Prompt editor affordances: version-in-use hint, dirty-gated Save, auto-on
Owner-confirmed semantics (verified in composeInstructions first: switch ON = the user's override text is what the agent reads; OFF = the default composes, the saved file stays on disk unused). Four refinements: 1. The small text under a file name states WHICH VERSION IS IN USE: 'Your version is in use' (PROMPT_IN_USE_HINT, pinned) when the switch is on; NOTHING when the default composes — even with a saved override sitting on disk. The old presence-based 'Your version is saved' (shown whenever a file existed, regardless of the switch) dies; the switch's meaning becomes legible through the line it visibly controls. 2. Save is clickable only when there's something to save: grayed and inert while the draft equals what's on disk (including the untouched editor just opened), full-contrast the moment the text differs. The always-active same-color button was the 'doesn't feel like it does anything' half of the report. 3. Saving auto-turns the switch ON — writing an override and putting it to work collapse into one action (a save with the switch off was the did-something-changed-nothing trap). Fires whenever the record genuinely has the path off, so a re-save after toggling off re-on's too. Order is the safe one (file first, then the switch the validation requires it). 4. A quiet inline 'Saved' / 'Deleted' confirmation rides next to the buttons (the old status line echoed the file path at the section's bottom — production-copy violation and easy to miss). Plus the mirror-symmetry the auto-on needs: deleting an override while its switch is on now composes the switch OFF in the same action — not just redundant with Platform's 643a693 server-side clear: the dialog's record mirror is what composes the next section write, so a stale ON would silently defeat the auto-on after a save-following-delete (the mirror would think the switch is already on and skip the write the server record needs). Live-verified on :3001: untouched editor grays Save; typing activates it; reverting re-grays it; save-with-switch-off lands toggle ON + hint + 'Saved' + Save re-grayed in one click; toggle-off drops the hint with the file retained on disk; re-save while off re-on's; delete while on clears switch + file + hint with record left clean and a subsequent agent-section write landing 200.
This commit is contained in:
@@ -170,11 +170,13 @@ export function composeSearchEngine(
|
|||||||
return { ...agent, search: { engine } };
|
return { ...agent, search: { engine } };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The indicator under a row that HAS an override file (owner ruling
|
/** The indicator under a row whose override IS the version in use (owner
|
||||||
* 2026-09-26: the empty state carries NO description line; the
|
* ruling, refined 2026-09-26: the small text states WHICH VERSION IS
|
||||||
* indicator exists only when the user's version is on disk). Exported
|
* IN USE — "Your version is in use" when the switch is on, and NOTHING
|
||||||
* for the pin. */
|
* when the default composes, even if a saved override sits on disk
|
||||||
export const PROMPT_SAVED_HINT = "Your version is saved";
|
* unused. The switch's meaning becomes legible: it visibly controls
|
||||||
|
* this line). Exported for the pin. */
|
||||||
|
export const PROMPT_IN_USE_HINT = "Your version is in use";
|
||||||
|
|
||||||
/** One section of the prompt view — the owner's ruled order: Teacher,
|
/** One section of the prompt view — the owner's ruled order: Teacher,
|
||||||
* Prober, then Shared last. */
|
* Prober, then Shared last. */
|
||||||
@@ -411,7 +413,8 @@ export function AgentSection({
|
|||||||
};
|
};
|
||||||
}, [openMenu]);
|
}, [openMenu]);
|
||||||
|
|
||||||
async function saveSegment(path: string, text: string) { setSegmentStatus(null);
|
async function saveSegment(path: string, text: string) {
|
||||||
|
setSegmentStatus(null);
|
||||||
const res = await fetch(`/api/settings/prompts/${path}`, {
|
const res = await fetch(`/api/settings/prompts/${path}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
@@ -421,9 +424,18 @@ export function AgentSection({
|
|||||||
setSegmentStatus(`Save failed: ${(await res.json().catch(() => ({}))).error ?? res.status}`);
|
setSegmentStatus(`Save failed: ${(await res.json().catch(() => ({}))).error ?? res.status}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Refetch: file presence gates the switch, so the view must track it.
|
|
||||||
readPrompts();
|
readPrompts();
|
||||||
setSegmentStatus(`Saved ${path}`);
|
setSegmentStatus("Saved");
|
||||||
|
// Auto-on (owner ruling, refined 2026-09-26): writing an override and
|
||||||
|
// putting it to work are ONE action — a save with the switch off was
|
||||||
|
// the "did something, changed nothing" trap (the composed prompt
|
||||||
|
// stayed the default). The file exists now, so the ON switch the
|
||||||
|
// agent-section validation requires is legal; the order (file, then
|
||||||
|
// switch) is the safe one. Saving an edit while already on rides
|
||||||
|
// through — the switch write is idempotent at the record level.
|
||||||
|
if (agent?.prompts?.[path] !== true) {
|
||||||
|
onSectionChange(composePromptSwitch(agent, path, true));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSegment(path: string) {
|
async function deleteSegment(path: string) {
|
||||||
@@ -433,7 +445,16 @@ export function AgentSection({
|
|||||||
setSegmentStatus(`Delete failed: ${res.status}`);
|
setSegmentStatus(`Delete failed: ${res.status}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Auto-off — the mirror of save's auto-on: deleting the file while
|
||||||
|
// the switch is on would orphan an ON record switch (the agent-section
|
||||||
|
// validation 400s every later write while the UI's toggle can no
|
||||||
|
// longer reach the OFF escape hatch — the trap found in live verify
|
||||||
|
// 2026-09-26). Composing the OFF here keeps the record clean.
|
||||||
|
if (agent?.prompts?.[path] === true) {
|
||||||
|
onSectionChange(composePromptSwitch(agent, path, false));
|
||||||
|
}
|
||||||
readPrompts();
|
readPrompts();
|
||||||
|
setSegmentStatus("Deleted");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The search-engine menu row — label + the quiet live-key mark. */
|
/** The search-engine menu row — label + the quiet live-key mark. */
|
||||||
@@ -463,15 +484,24 @@ export function AgentSection({
|
|||||||
const segmentRow = (info: SegmentInfo) => {
|
const segmentRow = (info: SegmentInfo) => {
|
||||||
const fileExists = info.overrideText !== null;
|
const fileExists = info.overrideText !== null;
|
||||||
const open = openSegment === info.path;
|
const open = openSegment === info.path;
|
||||||
|
// The Save affordance (owner ruling, refined 2026-09-26): clickable
|
||||||
|
// only when there's something to save — the draft differs from
|
||||||
|
// what's on disk. An unchanged editor shows a grayed, inert Save
|
||||||
|
// (the always-active button was the "doesn't feel like it does
|
||||||
|
// anything" half of the report).
|
||||||
|
const draft = segmentDraft[info.path] ?? info.overrideText ?? "";
|
||||||
|
const dirty = draft !== (info.overrideText ?? "");
|
||||||
return (
|
return (
|
||||||
<div key={info.path} data-testid={`prompt-segment-${info.path}`}>
|
<div key={info.path} data-testid={`prompt-segment-${info.path}`}>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label={info.path}
|
label={info.path}
|
||||||
// Overhaul rulings (2026-09-26): NO description when
|
// Overhaul rulings (2026-09-26, refined): the small text
|
||||||
// there's no override (the empty state is clean —
|
// states WHICH VERSION IS IN USE — nothing when the
|
||||||
// toggle + name); the indicator appears ONLY when the
|
// default composes (even with a saved override sitting
|
||||||
// user's version is on disk.
|
// on disk unused), "Your version is in use" when the
|
||||||
hint={fileExists ? PROMPT_SAVED_HINT : undefined}
|
// switch is on. The switch's meaning becomes legible
|
||||||
|
// through this line.
|
||||||
|
hint={info.enabled ? PROMPT_IN_USE_HINT : undefined}
|
||||||
testid={`prompt-row-${info.path}`}
|
testid={`prompt-row-${info.path}`}
|
||||||
>
|
>
|
||||||
{/* Graying ruling: ONLY the toggle dims without an
|
{/* Graying ruling: ONLY the toggle dims without an
|
||||||
@@ -520,8 +550,13 @@ export function AgentSection({
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={!dirty}
|
||||||
onClick={() => void saveSegment(info.path, segmentDraft[info.path] ?? "")}
|
onClick={() => void saveSegment(info.path, segmentDraft[info.path] ?? "")}
|
||||||
className="text-xs text-foreground underline-offset-2 hover:underline"
|
className={
|
||||||
|
dirty
|
||||||
|
? "text-xs text-foreground underline-offset-2 hover:underline"
|
||||||
|
: "cursor-not-allowed text-xs text-muted-foreground/40 underline-offset-2"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
@@ -534,6 +569,17 @@ export function AgentSection({
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* The quiet inline confirmation — next to the
|
||||||
|
action, not the old path-echoing line at the
|
||||||
|
section's bottom (production-copy: no file
|
||||||
|
paths in user-facing status). One editor is
|
||||||
|
open at a time (openSegment), so one shared
|
||||||
|
status reads unambiguously. */}
|
||||||
|
{segmentStatus && (
|
||||||
|
<p className="text-xs text-muted-foreground/70" data-testid="segment-status">
|
||||||
|
{segmentStatus}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -770,7 +816,6 @@ export function AgentSection({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{segmentStatus && <p className="text-xs text-muted-foreground/70">{segmentStatus}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import assert from "node:assert/strict";
|
|||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { appearanceVars, derivedPalette, resolveAppearance, staleVarNames } from "../components/settings/appearance.tsx";
|
import { appearanceVars, derivedPalette, resolveAppearance, staleVarNames } from "../components/settings/appearance.tsx";
|
||||||
import {
|
import {
|
||||||
PROMPT_SAVED_HINT,
|
PROMPT_IN_USE_HINT,
|
||||||
composePromptSwitch,
|
composePromptSwitch,
|
||||||
composeSearchEngine,
|
composeSearchEngine,
|
||||||
composeSlotModel,
|
composeSlotModel,
|
||||||
@@ -384,7 +384,7 @@ describe("sectionedSegments — the prompts overhaul (2026-09-26)", () => {
|
|||||||
assert.equal(teacher.hint, undefined);
|
assert.equal(teacher.hint, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the indicator copy is pinned (override present = the row earns its line)", () => {
|
it("the indicator copy is pinned — it states WHICH VERSION IS IN USE (refined 2026-09-26)", () => {
|
||||||
assert.equal(PROMPT_SAVED_HINT, "Your version is saved");
|
assert.equal(PROMPT_IN_USE_HINT, "Your version is in use");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user