Files
brain-of-reese/.agents/phases/complete/48_stop_generation/02_stop_button.md
T
ducoterra dbf2af26c6 refactor(agents): migrate .agent/ planning tree to .agents/
Standardize on the .agents/ directory (shared with project skills):
phases/, user_stories/, reports/, screenshots/, validate.sh, and
phase-sessions/ + pipeline.log all move to .agents/ (git mv preserves
history; runtime artifacts move alongside).

Updates every reference in AGENTS.md, README.md, .gitignore, app
docstrings, and test story headers. Historical KB content in data/
and the runtime pipeline.log transcript are left untouched.
2026-09-05 10:57:07 -04:00

6.8 KiB
Raw Blame History

Task 02 — The Stop button (Send↔Stop morph) + partial keep

Phase: 48_stop_generation · Source: TODO.md:3 — "Need a way to stop or cancel generation of text in the chat" Story: n/a (TODO-derived)

Objective

One-button control on the chat page: while a turn is in flight #send-btn becomes Stop (click or Enter aborts the turn); the partial answer is kept on screen and persisted (with the stopped marker), the UI settles to idle with a live-region confirmation and no error banner.

Work

  1. frontend/assets/app.js:
    • Abort plumbing: at the start of a turn create const ac = new AbortController() and store it in module scope (let turnAbort = null, reset per turn, cleared in the finally); pass signal: ac.signal to the fetch("/api/chat", …) call. The 120s guard keeps cancelStream(res) as the backstop and additionally calls turnAbort?.abort() — same outcome, one owner.
    • stopTurn(): new module function — a no-op unless uiState is thinking/streaming; sets a module-scope stoppedByUser = true (reset at turn start, next to aborted), then turnAbort.abort(). The abort makes the in-flight fetch/await readSSE(...) throw (AbortError) into handleSend's catch, where the stop finalizes:
      • in catch (err): if stoppedByUser (or err?.name === "AbortError") → stop path: no showErrorBanner. When wrap exists and acc is non-empty: closeThinkingBlock(wrap), appendTuneButton(wrap) (parity with the restore path — admin-only, anonymous gets nothing), appendStoppedNote(wrap), then rememberBrainTurn(acc, { thinking: thinkingAcc || undefined, tools: toolAcc.length ? toolAcc : undefined, stopped: true }) (owner-locked optional stopped marker — phase-14 convention, STORAGE_VERSION stays 1). When there is no answer text yet (pre-token / thinking-only stop): persist nothing brain-side (phase-20 convention — the question is already saved on send). Set sendStatus.textContent = "Answer stopped." before the finally settles idle (the existing finally's setUiState(idle) + focus-back remains the single settle path — the stop path must not double-settle, and it never scrolls: no scrollReveal on stop, phase-42 contract).
    • One-button morph in setUiState: in-flight states (thinking/streaming) keep #send-btn enabled (it is the Stop button now — owner-locked), sendLabel.textContent = "Stop", and sendBtn.classList.toggle("is-stop", inFlight). The spinner is hidden while in flight (sendBtn.querySelector(".spinner").hidden = inFlight — the label alone reads "Stop"; the CSS treatment carries the state). Idle/error: existing behavior (label "Send", class removed, spinner hidden as today).
    • Tool frames: the phase-37 tool branch no longer writes sendLabel.textContent = "Calling tool…" — the button stays "Stop" while in flight (owner-locked); the "calling tool" status stays exactly where it is today in #send-status + the typing indicator's aria-label.
    • Submit-while-in-flight: handleSend's first guard becomes if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) { stopTurn(); return; } (the button is enabled while in flight, so both the click and Enter-to-submit land here; the !text guard for idle stays).
    • Restore: renderStoredMessage — when m.stopped is true, call the same appendStoppedNote(wrap) helper. New small helper: creates/reuses the .msg-meta row exactly like appendTuneButton does (including the role=list → button/span role=listitem rule) and appends a .stopped-note span — an inline stop-glyph SVG (a small filled square, aria-hidden="true") + the text "Stopped" (the text carries the accessible meaning).
    • Header comment: update the file's top comment — the loading-feedback machine gains a user-stop terminal (stop → idle, no banner) and the button's dual role (2026-08-29, TODO.md L3).
  2. frontend/assets/styles.css:
    • .send-btn.is-stop — the stop treatment in the phase-08 dark-tech palette: a rose-family background derived from the brand rose #f43f5e but darkened so the near-white label keeps ≥4.5:1 (check the contrast — e.g. a #be123c-range token with #fff label), the same border-radius/height/focus-visible ring as .send-btn, a darker hover step, hit target ≥44px unchanged.
    • .stopped-note — the meta-row note: muted ink-soft color (≥4.5:1 on the bubble surface), 10–12px glyph baseline-aligned with the Tune button, non-interactive (no hover/focus).
  3. frontend/index.html — no markup change (#send-btn already carries spinner + #send-label); update the composer comment block to document the Send↔Stop morph + the stopped-note meta row.
  4. Revise the old contract in place — tests/e2e/test_loading_feedback.py: its pins of the in-flight button (disabled, "Thinking…" label, visible spinner) are updated to the new contract (enabled "Stop" button, is-stop class, spinner hidden); its 120s-guard, live-region, and typing-dots pins stay intact. The unit pins in tests/unit/test_frontend_feedback.py that assert the same old button state are updated in place the same way (they live with the SEND_STATUS/setUiState pins — keep the file's structure).
  5. Frontend source pins (house pattern, tests/unit/test_frontend_feedback.py style — extend that file): the in-flight is-stop toggle + enabled button + "Stop" label; the AbortController + signal in the fetch; the stoppedByUser stop branch (no showErrorBanner); the stopped: true key in the persisted record; the appendStoppedNote restore path; the tool branch no longer writing sendLabel.
  • ASSUMPTION (owner-locked 2026-08-29): one-button morph — click or Enter while in flight stops the turn.
  • ASSUMPTION (owner-locked 2026-08-29): the partial answer is kept, persisted with the optional stopped marker (no sources/suggestions), "Stopped" note, no error banner; a pre-token stop persists nothing brain-side; the live stopped bubble gets the Tune button for admin (parity with the restore path).
  • ASSUMPTION (owner-locked 2026-08-29): the server side of a stop is the task-01 teardown — this task never calls a cancel endpoint (there is none — A10).

Testing & Quality

  • Unit: source pins as above; full suite green.
  • Coverage: >90% on app/ (unchanged by this frontend task — the phase's app coverage came from task 01).

Completion Criteria

  • In flight: button enabled, label "Stop", is-stop class; idle: "Send" (state machine otherwise unchanged — the revised test_loading_feedback.py green).
  • Click/Enter while in flight aborts; the partial is kept + persisted (stopped: true); no error banner; the live region confirms "Answer stopped.".
  • A reload restores the stopped answer with the Stopped note (unit-pinned now, E2E in task 03).
  • uv run pytest green; uv run ruff check . && uv run pyright clean.