From 178995c9871d6a22560759db2e0128c3de1940b3 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Fri, 25 Sep 2026 19:00:11 +0300 Subject: [PATCH] Agent prompts section: re-read on write, dedupe, owner-ruled layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompts area's five owner-reported symptoms, root causes found in code and verified live before fixing: 1. STATE ECHO — the toggle's truth (SegmentInfo.enabled) derives from the settings record's agent switches, but the segment view was read ONCE per section mount: the switch write landed (PUT /api/settings/agent) yet the toggle painted its old state until a remount re-fetched. Root-cause fix in the settings-saved family: the section now re-reads /api/settings/prompts when the 'lh:settings-saved' signal lands for the agent section — the reader the signal law was missing. Toggle flips verify at ~40ms, no navigation. 2. DUPLICATION — the server contract returns the shared segments in BOTH per-agent lists; the UI rendered both lists back to back, so every shared chunk appeared twice (18 rows, 5 dupes). sectionedSegments (pure, exported, pinned) dedupes: the agents' own chunks plus the shared chunks ONCE. 3. ORDERING — Teacher first, then Prober, then Shared last (owner-ruled), each section headed, chunk order within a section preserved from assembly order (the server lists ARE assembly order: base, shared sorted, own numeric-prefix sorted). 4. GRAYING — the whole-row dim died; only the TOGGLE dims now when there is no override file (a wrapper span on the control; the file name is always full-contrast). 5. DESCRIPTION LOGIC — the 'No changes yet' line died. No description when there's no override (the empty state is clean: toggle + name); 'Your version is saved' (PROMPT_SAVED_HINT, pinned) appears ONLY when an override exists. The redundant 'Shared base — appears in both agents' editor note died too — the Shared section header carries 'Used by both agents'. Also: saveSegment/deleteSegment reuse the readPrompts callback (keeps last-good on a failed re-read; the mount effect owns the loading state). Live-verified on :3001: sections in the ruled order with 13 unique rows, clean-row and overridden-row states per the rulings, toggle flip instant (view + record agree), save/delete round-trips, and a shared-segment save + toggle through the single Shared control. Open finding (NOT fixed here — needs a ruling): an ON switch whose override file is later deleted orphans the record, and the agent-section write validation 400s every subsequent write (an ON switch must have an existing file) while the UI's disabled toggle cannot reach the OFF escape hatch the validation anticipates. Repro: Save + switch on, Delete the override, flip any tool. Server validation + row semantics are Platform's / owner's call. --- components/settings/agent-section.tsx | 128 +++++++++++++++++++++----- tests/settings-pure.test.ts | 49 ++++++++++ 2 files changed, 152 insertions(+), 25 deletions(-) diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx index 6f5d99e..a253fb4 100644 --- a/components/settings/agent-section.tsx +++ b/components/settings/agent-section.tsx @@ -35,10 +35,20 @@ * Absent key = enabled in the schema; the UI writes explicit booleans for * all four (simpler to reason about, sanitize-equivalent). * - * Prompts: GET /api/settings/prompts segments; the switch requires an - * override FILE (route-enforced), so a file-less row dims whole-row - * (graph-view graying) until one is written. Switch state lives in this - * section's PUT; text writes through the segment route. + * Prompts: GET /api/settings/prompts segments — THREE SECTIONS in the + * owner's ruled order (2026-09-26 overhaul): Teacher first, Prober, then + * Shared LAST. The server returns per-agent lists that each INCLUDE the + * shared segments; sectionedSegments deduplicates them (shared chunks + * once, in prompt-assembly order). The row rulings: only the TOGGLE + * grays when there's no override file (the file name is always + * full-contrast); NO description line when there's no override (the empty + * state is clean — toggle + name); the indicator ("Your version is + * saved") appears ONLY when an override exists. The switch requires an + * override FILE (route-enforced), so a file-less toggle is disabled. + * The segments view re-reads on the 'lh:settings-saved' signal for the + * agent section — the view's enabled flags derive from the record's + * switches, and the 2026-09-26 state-echo bug was exactly this reader + * missing (the toggle painted its old state until a remount re-fetched). */ import { useCallback, useEffect, useRef, useState } from "react"; @@ -160,6 +170,45 @@ export function composeSearchEngine( return { ...agent, search: { engine } }; } +/** The indicator under a row that HAS an override file (owner ruling + * 2026-09-26: the empty state carries NO description line; the + * indicator exists only when the user's version is on disk). Exported + * for the pin. */ +export const PROMPT_SAVED_HINT = "Your version is saved"; + +/** One section of the prompt view — the owner's ruled order: Teacher, + * Prober, then Shared last. */ +export interface PromptSection { + id: "teacher" | "prober" | "shared"; + label: string; + /** The one header that earns a hint: "Shared" alone is not + * self-evident. Teacher/Prober are. */ + hint?: string; + segments: SegmentInfo[]; +} + +/** Split the server's per-agent segment lists into the owner's three + * sections. The server contract returns the shared segments in BOTH + * lists (same paths, same content) — the shared chunks are listed ONCE + * here (taken from the teacher list; identical by construction), in + * assembly order: the base file, then the shared fragments sorted. The + * agents' own chunks keep their lists' order — which IS the prompt + * assembly order (numeric-prefix filename sort). Pure — exported for + * tests. */ +export function sectionedSegments(view: { prober: SegmentInfo[]; teacher: SegmentInfo[] }): PromptSection[] { + const own = (who: "prober" | "teacher") => view[who].filter((s) => !s.shared); + return [ + { id: "teacher", label: "Teacher", segments: own("teacher") }, + { id: "prober", label: "Prober", segments: own("prober") }, + { + id: "shared", + label: "Shared", + hint: "Used by both agents", + segments: view.teacher.filter((s) => s.shared), + }, + ]; +} + /** Compose: one prompt switch. on = explicit true; off = key REMOVED * (absent = off is the schema's resting state; explicit false is the * same thing but noisier). Pure — tests. */ @@ -267,6 +316,20 @@ export function AgentSection({ .then((body: { available: boolean; engine: string | null; reason: string }) => setSearch(body)) .catch(() => setSearch(null)); + /** Re-read the prompt-segment view — the signal seam for the + * 2026-09-26 state-echo fix: the view's `enabled` flags DERIVE from + * the settings record's agent switches (the toggle's truth lives + * here, not in the `agent` prop), so an agent-section write must + * re-read it or the toggle keeps painting its pre-write state until + * a remount. Keeps the last-good view on a failed re-read (the + * initial mount fetch below owns the loading/null state). */ + const readPrompts = useCallback(() => { + fetch("/api/settings/prompts") + .then((r) => r.json()) + .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)) + .catch(() => {}); + }, []); + // The catalog (federated: keyed providers only), the resolved slots, // the prompt segments, and the search engines' key presence — one // fetch each per section mount. The models read ALSO re-runs on the @@ -284,6 +347,11 @@ export function AgentSection({ useSettingsSaved((section) => { if (section === "models") readModels(); + // THE state-echo root cause: the prompt switches write through the + // agent section, but this view (the toggles' truth) never re-read — + // the settings-saved law (every reader re-reads) was missing this + // one reader. + if (section === "agent") readPrompts(); }); useEffect(() => { @@ -354,9 +422,7 @@ export function AgentSection({ return; } // Refetch: file presence gates the switch, so the view must track it. - fetch("/api/settings/prompts") - .then((r) => r.json()) - .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)); + readPrompts(); setSegmentStatus(`Saved ${path}`); } @@ -367,9 +433,7 @@ export function AgentSection({ setSegmentStatus(`Delete failed: ${res.status}`); return; } - fetch("/api/settings/prompts") - .then((r) => r.json()) - .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)); + readPrompts(); } /** The search-engine menu row — label + the quiet live-key mark. */ @@ -403,16 +467,24 @@ export function AgentSection({
- onSectionChange(composePromptSwitch(agent, info.path, on))} - /> + {/* Graying ruling: ONLY the toggle dims without an + override file — the file name above stays + full-contrast (the whole-row dim is dead). */} + + onSectionChange(composePromptSwitch(agent, info.path, on))} + /> +