feat(500): Settings shows the shipped reply shapes, what adjusts each, and how often each arrives
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m19s
CI & Build / Python tests (push) Successful in 1m58s
CI & Build / Build & push image (push) Successful in 39s

Settings > General > Reply shapes. One menu row per shape (core, completion,
asks, plan): its title and when it arrives, how many of the operator's
preferences adjust it, and its deliveries over 30 days (in full / as a
reminder, "—" when the counts cannot be read). A verdict line leads. Opening a
row shows the shipped text, read-only because it is product, and the
preferences mounted on its moment; each opens in the rule editor, and "Adjust
this" opens a new preference already mounted on the shape's moment with a
starting trigger.

The rule editor takes an optional preset (kind, moments, trigger) and, opened
outside the Rules view, asks where a new record lives instead of dropping it.
GET /api/retrieval/reply-shapes now returns reply_shapes.overview: the
catalog plus mounted preferences (the same lookup that delivers them) and
counts from the reply_shape delivery rows. #5497 (step 5 of milestone 500).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 16:19:45 -04:00
co-authored by Claude Opus 5.5
parent b00dc7c4c2
commit 69dddb7551
8 changed files with 505 additions and 16 deletions
@@ -5,9 +5,14 @@ import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
import { useMomentsStore } from "@/stores/moments";
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
import RuleHomePicker from "@/components/rules/RuleHomePicker.vue";
import type { Rule, RuleKind } from "@/api/rulebooks";
import { apiErrorMessage } from "@/api/client";
import { listRulebooks, listTopics, type Rule, type RuleKind, type RulePreset } from "@/api/rulebooks";
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
const props = defineProps<{
ruleId: number | null;
topicId: number | null;
preset?: RulePreset | null;
}>();
const emit = defineEmits<{ close: [] }>();
const store = useRulebooksStore();
@@ -128,10 +133,10 @@ async function load() {
} else {
title.value = "";
statement.value = "";
whenToApply.value = "";
kind.value = "rule";
whenToApply.value = props.preset?.whenToApply ?? "";
kind.value = props.preset?.kind ?? "rule";
systemIds.value = [];
ruleMoments.value = [];
ruleMoments.value = [...(props.preset?.moments ?? [])];
why.value = "";
howToApply.value = "";
verifyWith.value = "";
@@ -139,7 +144,31 @@ async function load() {
}
procedureDraft.value = "";
procedureError.value = "";
await Promise.all([canon.fetchCatalog(), momentsStore.load()]);
await Promise.all([canon.fetchCatalog(), momentsStore.load(), loadHomes()]);
}
// ── Where a new record lives, when the opener did not say ──────────────────
// The rules view opens the editor inside a topic; Settings does not, so a new
// record opened there asks for its home. A rulebook topic is global — it
// applies in every project, as a reply shape does.
const homeChoices = ref<{ id: number; label: string }[]>([]);
const chosenTopic = ref<number | null>(null);
const homeError = ref("");
const needsHome = computed(() => isCreating.value && props.topicId === null);
async function loadHomes() {
if (!needsHome.value || homeChoices.value.length) return;
try {
const books = await listRulebooks();
const perBook = await Promise.all(books.map(async (rb) => {
const ts = await listTopics(rb.id);
return ts.map((t) => ({ id: t.id, label: `${rb.title} › ${t.title}` }));
}));
homeChoices.value = perBook.flat();
if (homeChoices.value.length === 1) chosenTopic.value = homeChoices.value[0].id;
} catch (e: unknown) {
homeError.value = apiErrorMessage(e, "Could not load where this could live");
}
}
async function save() {
@@ -166,8 +195,14 @@ async function save() {
verify_with: verifyWith.value,
expires_when: expiresWhen.value,
};
if (isCreating.value && props.topicId !== null) {
await store.createRule(props.topicId, fields);
const home = props.topicId ?? chosenTopic.value;
if (isCreating.value && home === null) {
// Written but homeless: keep the draft open rather than lose it on close.
homeError.value = "Choose where it lives before saving.";
return;
}
if (isCreating.value && home !== null) {
await store.createRule(home, fields);
} else if (props.ruleId !== null) {
await store.updateRule(props.ruleId, fields);
}
@@ -201,6 +236,14 @@ watch(() => props.ruleId, load);
<button v-if="!isCreating" class="trash" @click="remove" aria-label="Delete">🗑</button>
<button class="close" @click="save" aria-label="Close">×</button>
</header>
<label v-if="needsHome">
Where it lives
<select v-model="chosenTopic" class="fs-input">
<option :value="null" disabled>Choose a rulebook topic</option>
<option v-for="h in homeChoices" :key="h.id" :value="h.id">{{ h.label }}</option>
</select>
</label>
<p v-if="homeError" class="home-error" role="alert">{{ homeError }}</p>
<label>
Title
<input v-model="title" placeholder="e.g. dev is home" />
@@ -421,6 +464,7 @@ watch(() => props.ruleId, load);
one index everywhere (tests/test_frontend_shared_styles.py). -->
<style src="@/assets/rules-shared.css" />
<style scoped>
.home-error { margin: 0 0 var(--fs-space-2); color: var(--fs-error); font-size: var(--fs-size-body-sm); }
.kind { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: var(--fs-space-3); margin: var(--fs-space-3) 0; }
.kind legend { font-size: var(--fs-size-tiny); text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny); color: var(--fs-text-tertiary); padding: 0 var(--fs-space-2); }
.kind-opt { display: flex; align-items: flex-start; gap: var(--fs-space-2); margin-bottom: var(--fs-space-2); font-size: var(--fs-size-body-sm); line-height: var(--fs-leading-body); }