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
@@ -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>