feat(chat): stop an in-flight answer — Send becomes Stop, the partial is kept and persisted, the model stream is torn down

This commit is contained in:
2026-08-29 17:27:04 -04:00
parent 6bf7f456d4
commit 1a60ecbd8b
186 changed files with 1738 additions and 7796 deletions
+160 -37
View File
@@ -7,22 +7,37 @@
* Loading feedback (PLAN §7.4 "never stale" contract, loading-feedback
* story) is one explicit state machine with a single entry point —
* setUiState(state) — driving the typing indicator, the send button
* (disabled/spinner/label), and the #send-status live region:
* (one button, two roles: "Send" when idle, the enabled "Stop" control
* while a turn is in flight — phase 48, 2026-08-29, TODO.md L3), and
* the #send-status live region:
*
* idle → thinking → streaming → done | error → idle
* stop → idle (no banner — phase 48)
*
* • thinking — pre-token: typing dots + disabled "Thinking…" button;
* after 10s the indicator's aria-label shows elapsed
* seconds so screen-reader users are never left guessing.
* • thinking — pre-token: typing dots; the button is the enabled
* "Stop" control (it stays so until `done`). After 10s
* the indicator's aria-label shows elapsed seconds so
* screen-reader users are never left guessing.
* Phase 17: while the model streams reasoning (`thinking`
* SSE events), the live collapsible Thinking block IS the
* visible feedback (it replaces the typing dots; the UI
* state stays "thinking" — button still disabled,
* "Thinking…") and the 120s guard clears on the first
* state stays "thinking" — the button stays the "Stop"
* control) and the 120s guard clears on the first
* thinking *or* delta event.
* • streaming — the first delta removes the dots and appends live into
* the answer bubble (auto-collapsing the Thinking block,
* phase 17); the button stays busy until `done`.
* phase 17); the button stays the "Stop" control until
* `done`.
* • stop — phase 48: clicking the in-flight button (or pressing
* Enter) aborts the fetch (AbortController, the task-01
* server teardown closes the model's stream on
* disconnect). The partial answer is kept on screen and
* persisted with the optional `stopped` marker (a
* pre-token stop persists nothing brain-side — phase-20
* convention), the live region confirms "Answer
* stopped.", and the turn settles to idle through the
* SAME finally path — no error banner, no scroll
* (phase 42).
* • error — red banner (role="alert") with an actionable retry hint;
* the 120s guard (TURN_TIMEOUT_MS) catches hung pre-token
* streams and the sawDone guard (phase 17) catches a
@@ -44,14 +59,16 @@
* call the two server-side document tools (list_documents /
* read_document, budgeted server-side). Each call streams a `tool` SSE
* frame, and the UI shows the "calling tool" state IN ADDITION to
* "thinking": the UI state itself stays "thinking" (button stays
* disabled — never stale, PLAN §7.4) while the LABELS change — the
* button says "Calling tool…", the #send-status + typing-indicator
* labels say what Brain is doing ("…is listing documents" /
* "thinking": the UI state itself stays "thinking" (the button stays
* the enabled "Stop" control — phase 48 — never stale, PLAN §7.4) while
* the STATUS LABELS change — the #send-status + typing-indicator labels
* say what Brain is doing ("…is listing documents" /
* "…is reading source/path" — the name prefix resolves from
* window.BOR_BRAND at call time, phase 39), and a visible `.tool-call`
* line (own icon + accent color, distinct from the brand-ink Thinking
* block) is appended above the answer, one per call, in order.
* window.BOR_BRAND at call time, phase 39) — the button no longer
* relabels to "Calling tool…" (phase 48, owner-locked: it stays "Stop"
* for the whole turn) — and a visible `.tool-call` line (own icon +
* accent color, distinct from the brand-ink Thinking block) is appended
* above the answer, one per call, in order.
* Append-only like thinking: frames are tolerated in any interleaving
* (a frame after the first delta just appends — the agent loop never
* emits one, but it must not crash). The turn record persists an
@@ -268,6 +285,36 @@ function appendTuneButton(wrap) {
meta.appendChild(btn);
}
/* Phase 48 (owner-locked 2026-08-29): the "Stopped" note in the meta row
* of a user-stopped brain bubble. The live stop path (handleSend's stop
* branch) and the phase-14 restore path (a record with `m.stopped`) share
* this helper, so a restored bubble reads exactly like the stopped one.
* Reuses the .msg-meta row the way appendTuneButton does (role=list → the
* span joins as a listitem so ARIA stays valid). Non-interactive (no
* hover/focus — pointer-events: none in the CSS): the small filled-square
* stop glyph is aria-hidden decoration; the "Stopped" text carries the
* accessible meaning. */
function appendStoppedNote(wrap) {
const body = wrap?.querySelector?.(".msg-body");
if (!body) return;
let meta = body.querySelector(".msg-meta");
if (!meta) {
meta = document.createElement("div");
meta.className = "msg-meta";
body.appendChild(meta);
}
if (meta.querySelector(".stopped-note")) return; // one per bubble
const note = document.createElement("span");
note.className = "stopped-note";
if (meta.getAttribute("role") === "list") note.setAttribute("role", "listitem");
note.innerHTML =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><rect x="6.5" y="6.5" width="11" height="11" rx="2"/></svg>';
const label = document.createElement("span");
label.textContent = "Stopped";
note.appendChild(label);
meta.appendChild(note);
}
/* Inline tuning form under the bubble: labeled textarea (maxlength 2000)
+ Save / Cancel. Success replaces the form with the .tune-saved status
(role=status); failure keeps the form and shows an inline error
@@ -552,6 +599,15 @@ let turnTimeout = 0; // setTimeout id — 120s pre-token guard
let acc = ""; // accumulated answer text this turn
let thinkingAcc = ""; // accumulated thinking text (persisted with the turn)
let persistedOnLeave = false; // pagehide partial-persist at most once
/* Phase 48 (2026-08-29, TODO.md L3): the user-stop machinery.
* `turnAbort` owns the in-flight fetch (the Stop button aborts it; the
* 120s guard aborts the same controller as its backstop); `stoppedByUser`
* marks a turn the Stop button took, so the catch's AbortError can tell a
* user stop from the guard's own abort (the guard sets `aborted` first).
* Both are turn-scoped: created/reset at the top of handleSend, cleared
* in its finally. */
let turnAbort = null; // AbortController of the in-flight turn (null idle)
let stoppedByUser = false; // the Stop button took this turn (not the guard)
function stopThinkingClock() {
if (thinkingClock) {
@@ -585,16 +641,22 @@ function clearTurnTimeout() {
}
/* The single entry point for chat feedback. Every in-flight state has a
* visible indicator; every terminal state re-enables the button. */
* visible indicator; every terminal state returns the button to "Send".
* Phase 48 (owner-locked 2026-08-29): the button is ONE control with two
* roles — enabled "Send" when idle, the enabled "Stop" control while a
* turn is in flight (click or Enter aborts it). It is never disabled
* anymore, and the spinner never shows: the "Stop" label + the .is-stop
* treatment carry the in-flight state. */
export function setUiState(state, errorDetail = "") {
uiState = state;
stopThinkingClock();
clearTurnTimeout(); // the guard only owns the pre-token window
const inFlight = state === UI_STATE.thinking || state === UI_STATE.streaming;
sendBtn.disabled = inFlight;
sendBtn.querySelector(".spinner").hidden = !inFlight;
sendLabel.textContent = inFlight ? "Thinking…" : "Send";
sendBtn.disabled = false; // enabled in every state — Stop is a control
sendBtn.classList.toggle("is-stop", inFlight);
sendBtn.querySelector(".spinner").hidden = true; // the Stop label carries it
sendLabel.textContent = inFlight ? "Stop" : "Send";
sendStatus.textContent = SEND_STATUS[state]?.() ?? "";
if (state === UI_STATE.thinking) {
@@ -715,15 +777,17 @@ function appendMaybeTry(wrap, suggestions) {
*
* bor.chat.v1 → { v: 1, messages: [{ who: "user"|"brain", text,
* sources?, deflected?, suggestions?,
* thinking?, tools? }] }
* thinking?, tools?, stopped? }] }
*
* Only RAW TEXT is stored — restore re-renders it through the escape-first
* markdown renderer, so no HTML is ever persisted. Save points: the user
* message on send (a failed turn keeps the question), the brain message on
* `done` (with sources/deflected/suggestions), and the PARTIAL brain
* message on navigate-away (`pagehide`, phase 20 — an in-flight turn keeps
* whatever had already streamed; thinking-only turns persist nothing
* brain-side). Every localStorage access
* `done` (with sources/deflected/suggestions), the PARTIAL brain message
* when the user stops the turn (phase 48 — the partial is kept, with the
* optional `stopped` marker; a pre-token stop persists nothing
* brain-side), and the PARTIAL brain message on navigate-away (`pagehide`,
* phase 20 — an in-flight turn keeps whatever had already streamed;
* thinking-only turns persist nothing brain-side). Every localStorage access
* is try/catch'd — private mode or quota exhaustion degrades silently to
* in-memory-only chat. If the serialized state outgrows the budget (~700k
* chars, far under the ~5MB quota) the oldest messages are dropped first.
@@ -822,6 +886,7 @@ function renderStoredMessage(m) {
}
appendSources(wrap, m.sources);
appendTuneButton(wrap); // restored brain answers are tunable too
if (m.stopped) appendStoppedNote(wrap); // phase 48: the stop marker restores
}
/* On load: re-render the stored conversation (markdown, source chips,
@@ -915,8 +980,29 @@ function clearErrorBanner() {
}
}
/* Phase 48 (owner-locked 2026-08-29): the user stop. No-op unless a turn
* is in flight; marks the turn as user-stopped and aborts the fetch — the
* in-flight fetch / readSSE throw (AbortError) into handleSend's catch,
* where the stop finalizes (partial kept + persisted with `stopped: true`,
* the "Answer stopped." live-region confirmation, no error banner). A
* click on the in-flight button and an Enter-to-submit both land in
* handleSend's in-flight guard, which calls this — there is no separate
* click binding. */
function stopTurn() {
if (uiState !== UI_STATE.thinking && uiState !== UI_STATE.streaming) return;
stoppedByUser = true;
turnAbort?.abort();
}
async function handleSend(e) {
e.preventDefault();
// Phase 48: while a turn is in flight the Send button IS the Stop
// button (setUiState keeps it enabled) — a click or an Enter-to-submit
// aborts the in-flight turn instead of starting a new one.
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) {
stopTurn();
return;
}
const text = input.value.trim();
if (!text || sendBtn.disabled) return;
@@ -938,6 +1024,10 @@ async function handleSend(e) {
acc = "";
thinkingAcc = "";
persistedOnLeave = false;
// Phase 48: a fresh abort owner per turn (cleared in the finally); the
// stop flag resets with the rest of the turn locals.
turnAbort = new AbortController();
stoppedByUser = false;
let sawThinking = false; // did any `thinking` frame arrive this turn?
let sawDone = false; // did the stream end with a `done` event?
let toolAcc = []; // phase 37: {name, argument} per `tool` frame —
@@ -952,6 +1042,9 @@ async function handleSend(e) {
armTurnTimeout(() => {
aborted = true;
cancelStream(res); // best-effort: the reader may still hold the lock
turnAbort?.abort(); // phase 48: same outcome, one owner — aborted is
// set first, so the catch never reads the guard's
// abort as a user stop
setUiState(UI_STATE.error, "That's taking a long time — the answer may be stuck.");
});
@@ -959,6 +1052,7 @@ async function handleSend(e) {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
signal: turnAbort.signal, // phase 48: the Stop button aborts the fetch
});
if (!res.ok || !res.body) {
let detail = `Brain's API answered with HTTP ${res.status}.`;
@@ -973,9 +1067,9 @@ async function handleSend(e) {
if (ev.type === "thinking") {
// Phase 17: model reasoning — stream it live into the collapsible
// Thinking block. No setUiState here: the UI state stays
// "thinking" (button still disabled with "Thinking…", #send-status
// unchanged) — the live block simply replaces the typing dots as
// the visible feedback.
// "thinking" (the button stays the enabled "Stop" control —
// phase 48 — #send-status unchanged) — the live block simply
// replaces the typing dots as the visible feedback.
thinkingAcc += ev.text || "";
sawThinking = true;
clearTurnTimeout(); // the stream is alive — as the first delta says
@@ -1002,13 +1096,14 @@ async function handleSend(e) {
}
} else if (ev.type === "tool") {
// Phase 37 (PLAN §4 extension): an agent tool call. The UI
// state stays "thinking" — the button remains disabled (never
// stale, PLAN §7.4); what changes are the LABELS: the button
// carries the "calling tool" text, #send-status + the typing
// indicator (if still visible) say what Brain is doing, and a
// .tool-call line lands above the answer (append-only, in
// order). The elapsed-seconds hint (thinkingClock) keeps
// running through tool frames — no clock changes here.
// state stays "thinking" — the button stays the enabled "Stop"
// control (phase 48, owner-locked: it no longer relabels to
// "Calling tool…"); what changes are the STATUS LABELS:
// #send-status + the typing indicator (if still visible) say
// what Brain is doing, and a .tool-call line lands above the
// answer (append-only, in order). The elapsed-seconds hint
// (thinkingClock) keeps running through tool frames — no clock
// changes here.
const name = typeof ev.name === "string" ? ev.name : "";
const argument =
typeof ev.argument === "string" && ev.argument ? ev.argument : null;
@@ -1020,7 +1115,6 @@ async function handleSend(e) {
? `${brand()} is reading ${argument}`
: `${brand()} is listing documents`;
if (uiState === UI_STATE.thinking) {
sendLabel.textContent = "Calling tool…";
sendStatus.textContent = toolStatus;
document
.querySelector("#typing-indicator .bubble")
@@ -1087,7 +1181,32 @@ async function handleSend(e) {
rememberBrainTurn(fallback, {}); // persist what the user actually saw
}
} catch (err) {
if (!aborted) {
if (aborted) {
// The 120s guard already took the turn to the error state — its
// own abort surfaces here, and it is never read as a user stop.
} else if (stoppedByUser || err?.name === "AbortError") {
// Phase 48 (owner-locked): the STOP path — no error banner. When
// answer text streamed, keep the partial on screen and persist it
// with the optional `stopped` marker (phase-14 no-version-bump
// convention; no sources/suggestions — the turn never settled).
// The persistedOnLeave guard (phase 20) keeps a navigate-away —
// which aborts the same fetch — from saving the partial twice.
if (wrap && acc && !persistedOnLeave) {
closeThinkingBlock(wrap); // settle the block closed, like `done`
persistedOnLeave = true;
appendTuneButton(wrap); // admin-only; parity with the restore path
appendStoppedNote(wrap);
rememberBrainTurn(acc, {
thinking: thinkingAcc || undefined,
tools: toolAcc.length ? toolAcc : undefined,
stopped: true,
});
}
// Pre-token / thinking-only stop: persist NOTHING brain-side
// (phase-20 convention — the question is already saved on send).
// The "Answer stopped." confirmation is set in the finally, AFTER
// the single settle, so setUiState(idle) can't overwrite it.
} else {
const detail =
err instanceof Error && err.message
? err.message
@@ -1095,12 +1214,16 @@ async function handleSend(e) {
setUiState(UI_STATE.error, detail);
}
} finally {
// done | error → idle: always settle, always focus back. State is
// turn-local, so a page reload mid-stream leaves a usable composer.
// done | error | stop → idle: always settle, always focus back.
// State is turn-local, so a page reload mid-stream leaves a usable
// composer. The stop path must not double-settle — this is the one
// settle, and it never scrolls (phase 42: no scrollReveal on stop).
clearTurnTimeout();
stopThinkingClock();
cancelStream(res); // the reader lock is released — no unhandled rejection
turnAbort = null; // phase 48: the turn's abort owner is spent
if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);
if (stoppedByUser) sendStatus.textContent = "Answer stopped.";
// Focus back for the next question, but never move the viewport —
// the page never auto-scrolls (phase 42), so a user reading earlier
// content stays where they are.
+26
View File
@@ -623,6 +623,23 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.tune-btn svg { width: 14px; height: 14px; display: block; }
.tune-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
/* Phase 48: the "Stopped" note in a stopped brain bubble's meta row:
ink-soft on the surface bubble ≈6.9:1, the 10px filled-square glyph
centered with the row (the Tune button shares the row), and
non-interactive — no hover, no focus (pointer-events: none). The text
carries the accessible meaning; the glyph is aria-hidden in the JS. */
.stopped-note {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--ink-soft);
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
pointer-events: none;
}
.stopped-note svg { width: 10px; height: 10px; display: block; fill: currentColor; }
/* Inline tuning form under the bubble: labeled textarea + Save/Cancel
(both ≥44px). Save = brand button (dark ink 5.2:1), Cancel = ghost. */
.tune-form {
@@ -1065,6 +1082,15 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.send-btn:hover:not(:disabled) { background: #7d88f5; }
.send-btn:disabled { background: #a5b4fc; cursor: not-allowed; }
/* Phase 48 (2026-08-29, TODO.md L3): the in-flight Stop treatment — one
button, two roles. Rose-700 #be123c (the brand rose #f43f5e darkened)
with a #fff label = 6.3:1 (WCAG AA); the hover step #9f1239 holds
8.0:1. Same radius/height/hit target as the Send state and the shared
:focus-visible ring — the .is-stop class rides the same .send-btn
element, and the later rules win the hover specificity tie. */
.send-btn.is-stop { background: #be123c; color: #fff; }
.send-btn.is-stop:hover { background: #9f1239; }
/* Busy spinner: dark arc (--bg) on the #a5b4fc busy button = 9.7:1. */
.spinner {
width: 16px; height: 16px;
+17 -2
View File
@@ -125,7 +125,23 @@
</div>
</section>
<form class="composer" id="composer">
<!-- Composer (phase 48, 2026-08-29, TODO.md L3): one button, two
roles — #send-btn reads "Send" when idle and morphs into the
enabled "Stop" control (.is-stop, rose treatment) while a turn
is in flight; a click or Enter in flight aborts the fetch
(AbortController in app.js) and the partial is kept + persisted
with the optional `stopped` marker, rendered as the .stopped-note
meta-row note by app.js — live and on restore. The spinner span
stays in the markup (contract marker, reduced-motion pin) but
never shows: the Stop label + treatment carry the in-flight
state.
`novalidate`: the input is cleared after send, so a `required`
constraint would silently block the Stop click/Enter — the
browser's constraint validation runs before the `submit` event
and would never reach handleSend's in-flight guard. The
`!text` guard in app.js is the real empty-input check (same
precedent as the tuning form's noValidate). -->
<form class="composer" id="composer" novalidate>
<label class="visually-hidden" for="message-input">Ask Brain of Reese a question</label>
<textarea
id="message-input"
@@ -133,7 +149,6 @@
rows="1"
placeholder="Ask me about the homelab…"
autocomplete="off"
required
></textarea>
<button type="submit" class="send-btn" id="send-btn">
<span class="spinner" aria-hidden="true" hidden></span>