diff --git a/frontend/src/api/replyShapes.ts b/frontend/src/api/replyShapes.ts new file mode 100644 index 00000000..27b4832b --- /dev/null +++ b/frontend/src/api/replyShapes.ts @@ -0,0 +1,47 @@ +import { apiGet } from "@/api/client"; + +/** + * The reply shapes Scribe ships (milestone 500) — `GET /api/retrieval/reply-shapes`. + * The same catalog the `list_reply_shapes` MCP tool returns, plus what only + * the operator's view needs: the preferences mounted on each shape's moment, + * and how often each shape was delivered. + */ +export interface ShapePreference { + id: number; + title: string; + statement: string; +} + +/** How often a shape went out: whole, or as its one-line reminder. */ +export interface ShapeDeliveries { + full: number; + pointer: number; +} + +export interface ReplyShape { + key: string; + title: string; + /** The moment it rides — a preference mounted here adjusts it. */ + moment: string; + /** What is happening at that moment. */ + means: string; + /** When the shape arrives, in words. */ + delivered: string; + /** The shipped text. Product, so read-only. */ + text: string; + preferences: ShapePreference[]; + /** null when the counts could not be read — unknown, not zero. */ + deliveries: ShapeDeliveries | null; +} + +export interface ReplyShapesOverview { + shapes: ReplyShape[]; + total: number; + days: number; + deliveries_failed: boolean; +} + +export async function getReplyShapes(days?: number): Promise { + const q = days ? `?days=${days}` : ""; + return apiGet(`/api/retrieval/reply-shapes${q}`); +} diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts index e84f10c0..f4552855 100644 --- a/frontend/src/api/rulebooks.ts +++ b/frontend/src/api/rulebooks.ts @@ -54,6 +54,15 @@ export interface RulebookTopic { */ export type RuleKind = "rule" | "preference"; +/** A new record opened from somewhere that already knows what it is for — + * "Adjust this" on a reply shape opens a preference mounted on that shape's + * moment (milestone 500 step 5). The editor applies it once, when creating. */ +export interface RulePreset { + kind?: RuleKind; + moments?: string[]; + whenToApply?: string; +} + export interface Rule { id: number; topic_id: number | null; diff --git a/frontend/src/components/ReplyShapesSettings.vue b/frontend/src/components/ReplyShapesSettings.vue new file mode 100644 index 00000000..1ba91beb --- /dev/null +++ b/frontend/src/components/ReplyShapesSettings.vue @@ -0,0 +1,226 @@ + + + + + diff --git a/frontend/src/components/rules/RuleEditorSlideOver.vue b/frontend/src/components/rules/RuleEditorSlideOver.vue index 530cfd9a..fe43f691 100644 --- a/frontend/src/components/rules/RuleEditorSlideOver.vue +++ b/frontend/src/components/rules/RuleEditorSlideOver.vue @@ -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(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); + +