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:
+160
-37
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user