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
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:
@@ -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}`);
|
||||
}
|
||||
@@ -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,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 ── -->
|
||||
|
||||
Reference in New Issue
Block a user