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
+47
View File
@@ -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<ReplyShapesOverview> {
const q = days ? `?days=${days}` : "";
return apiGet<ReplyShapesOverview>(`/api/retrieval/reply-shapes${q}`);
}
+9
View File
@@ -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;
@@ -0,0 +1,226 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { apiErrorMessage } from "@/api/client";
import { getReplyShapes, type ReplyShape, type ReplyShapesOverview } from "@/api/replyShapes";
import type { RulePreset } from "@/api/rulebooks";
import RuleEditorSlideOver from "@/components/rules/RuleEditorSlideOver.vue";
/**
* The reply shapes Scribe ships, and the operator's adjustments to each
* (milestone 500 step 5). A shape is product, so its text is read-only here;
* what the operator owns is the preferences mounted on the shape's moment,
* which arrive beside it and win where they differ. "Adjust this" opens the
* preference editor already mounted there.
*/
const data = ref<ReplyShapesOverview | null>(null);
const loading = ref(true);
const error = ref("");
const open = ref<string | null>(null);
async function load() {
loading.value = true;
error.value = "";
try {
data.value = await getReplyShapes();
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Could not load the reply shapes");
} finally {
loading.value = false;
}
}
const shapes = computed(() => data.value?.shapes ?? []);
const adjusted = computed(() => shapes.value.filter((s) => s.preferences.length).length);
const delivered = computed(() =>
shapes.value.reduce((n, s) => n + (s.deliveries ? s.deliveries.full + s.deliveries.pointer : 0), 0),
);
// One line answering "what do my replies get shaped by?".
const verdict = computed(() => {
if (!data.value) return "";
const n = shapes.value.length;
const parts = [`${n} ${n === 1 ? "shape ships" : "shapes ship"} with Scribe`];
parts.push(adjusted.value
? `${adjusted.value} adjusted by your preferences`
: "none adjusted — the defaults stand");
if (!data.value.deliveries_failed) parts.push(`delivered ${delivered.value} times in ${data.value.days} days`);
return parts.join(" · ");
});
function deliveryLabel(s: ReplyShape): string {
if (!s.deliveries) return "—";
const { full, pointer } = s.deliveries;
if (!full && !pointer) return "not delivered";
return `${full} in full · ${pointer} as reminder`;
}
function toggle(key: string) {
open.value = open.value === key ? null : key;
}
// ── The editor: an existing preference, or a new one mounted here ─────────
const editingId = ref<number | null>(null);
const preset = ref<RulePreset | null>(null);
const editorOpen = computed(() => editingId.value !== null || preset.value !== null);
function editPreference(id: number) {
preset.value = null;
editingId.value = id;
}
function adjust(s: ReplyShape) {
editingId.value = null;
preset.value = {
kind: "preference",
moments: [s.moment],
// A starting trigger in the moment's own words; the operator sharpens it.
whenToApply: `When ${s.means}.`,
};
}
function closeEditor() {
editingId.value = null;
preset.value = null;
void load();
}
onMounted(load);
</script>
<template>
<div class="reply-shapes">
<p v-if="loading && !data" class="state">Loading…</p>
<p v-else-if="error" class="state state-error" role="alert">
{{ error }}
<button type="button" class="btn-text" @click="load">Retry</button>
</p>
<template v-else-if="data">
<p class="verdict">{{ verdict }}</p>
<p v-if="data.deliveries_failed" class="state state-error" role="alert">
Delivery counts could not be read, so they show as “—”.
<button type="button" class="btn-text" @click="load">Retry</button>
</p>
<ul class="shape-list">
<li v-for="s in shapes" :key="s.key" :class="['shape-row', { open: open === s.key }]">
<button
type="button"
class="shape-head"
:aria-expanded="open === s.key"
:aria-controls="`shape-${s.key}`"
@click="toggle(s.key)"
>
<span class="shape-name">
<strong>{{ s.title }}</strong>
<span class="shape-when">{{ s.delivered }}</span>
</span>
<span class="shape-stats">
<span :class="{ 'stat-none': !s.preferences.length }">
{{ s.preferences.length
? `${s.preferences.length} ${s.preferences.length === 1 ? "preference" : "preferences"}`
: "default" }}
</span>
<span :class="{ 'stat-none': deliveryLabel(s) === 'not delivered' }">{{ deliveryLabel(s) }}</span>
<span class="chevron" aria-hidden="true">{{ open === s.key ? "▾" : "▸" }}</span>
</span>
</button>
<div v-if="open === s.key" :id="`shape-${s.key}`" class="shape-body">
<h4 class="body-label">Ships with Scribe · at <code>{{ s.moment }}</code></h4>
<div class="shape-text">{{ s.text }}</div>
<h4 class="body-label">Your adjustments</h4>
<ul v-if="s.preferences.length" class="pref-list">
<li v-for="p in s.preferences" :key="p.id">
<button type="button" class="pref-row" @click="editPreference(p.id)">
<span class="pref-title">{{ p.title }}</span>
<span class="chevron" aria-hidden="true">›</span>
</button>
</li>
</ul>
<p v-else class="stat-none">None — this shape arrives as shipped.</p>
<button type="button" class="btn-primary adjust" @click="adjust(s)">Adjust this</button>
</div>
</li>
</ul>
</template>
<RuleEditorSlideOver
v-if="editorOpen"
:rule-id="editingId"
:topic-id="null"
:preset="preset"
@close="closeEditor"
/>
</div>
</template>
<style scoped>
.reply-shapes { display: flex; flex-direction: column; gap: var(--fs-space-2); }
.verdict { margin: 0; font-size: var(--fs-size-body-sm); color: var(--fs-text-primary); }
.state { margin: 0; font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); }
.state-error { color: var(--fs-error); }
.shape-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
.shape-row {
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
}
/* The whole row is the tap target (a settings row, not prose with a link). */
.shape-head {
width: 100%;
display: flex; align-items: baseline; gap: var(--fs-space-3);
padding: var(--fs-space-3);
background: none; border: 0; border-radius: var(--fs-radius-md);
color: inherit; font: inherit; text-align: left; cursor: pointer;
}
.shape-head:focus-visible { outline: 2px solid var(--fs-focus-ring); outline-offset: 2px; }
.shape-name { display: flex; flex-direction: column; gap: var(--fs-space-1); min-width: 0; }
.shape-name strong { font-size: var(--fs-size-body-sm); color: var(--fs-text-primary); }
.shape-when {
font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.shape-stats {
margin-left: auto;
display: flex; align-items: baseline; gap: var(--fs-space-3);
font-size: var(--fs-size-tiny); color: var(--fs-text-secondary);
font-variant-numeric: tabular-nums; white-space: nowrap;
}
.stat-none { color: var(--fs-text-tertiary); font-style: italic; }
.chevron { color: var(--fs-text-tertiary); }
.shape-body {
display: flex; flex-direction: column; align-items: flex-start; gap: var(--fs-space-2);
padding: 0 var(--fs-space-3) var(--fs-space-3);
}
.body-label {
margin: var(--fs-space-2) 0 0;
font-size: var(--fs-size-tiny); font-weight: normal;
text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny);
color: var(--fs-text-tertiary);
}
.body-label code { font-family: var(--fs-font-mono); text-transform: none; }
/* Product text, shown as it reaches a session. Read-only by design. */
.shape-text {
width: 100%;
padding: var(--fs-space-2) var(--fs-space-3);
border-left: 2px solid var(--fs-border-color);
font-size: var(--fs-size-body-sm); line-height: var(--fs-leading-body);
color: var(--fs-text-secondary); white-space: pre-wrap;
}
.pref-list { list-style: none; margin: 0; padding: 0; width: 100%; display: flex; flex-direction: column; gap: var(--fs-space-1); }
.pref-row {
width: 100%;
display: flex; align-items: baseline; gap: var(--fs-space-2);
padding: var(--fs-space-1) var(--fs-space-2);
background: none; border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-sm);
color: var(--fs-text-primary); font: inherit; font-size: var(--fs-size-body-sm);
text-align: left; cursor: pointer;
}
.pref-row:focus-visible { outline: 2px solid var(--fs-focus-ring); outline-offset: 2px; }
.pref-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pref-row .chevron { margin-left: auto; }
.adjust { margin-top: var(--fs-space-1); }
</style>
@@ -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); }
+10
View File
@@ -10,6 +10,7 @@ import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue";
import MomentsSettings from "@/components/MomentsSettings.vue";
import ReplyShapesSettings from "@/components/ReplyShapesSettings.vue";
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
import { fetchVersion, type VersionPayload } from "@/api/version";
@@ -2250,6 +2251,15 @@ async function deleteUser(userId: number) {
<MomentsSettings />
</section>
<section class="settings-section full-width">
<h2>Reply shapes</h2>
<p class="section-desc">
The default shape of each kind of reply, delivered to a session when it is due.
Adjust one with a preference; it arrives beside the shape and wins.
</p>
<ReplyShapesSettings />
</section>
</div>
<!-- ── Account ── -->