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.
6.8 KiB
6.8 KiB
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
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 thefinally); passsignal: ac.signalto thefetch("/api/chat", …)call. The 120s guard keepscancelStream(res)as the backstop and additionally callsturnAbort?.abort()— same outcome, one owner. stopTurn(): new module function — a no-op unlessuiStateis thinking/streaming; sets a module-scopestoppedByUser = true(reset at turn start, next toaborted), thenturnAbort.abort(). The abort makes the in-flightfetch/await readSSE(...)throw (AbortError) intohandleSend'scatch, where the stop finalizes:- in
catch (err): ifstoppedByUser(orerr?.name === "AbortError") → stop path: noshowErrorBanner. Whenwrapexists andaccis non-empty:closeThinkingBlock(wrap),appendTuneButton(wrap)(parity with the restore path — admin-only, anonymous gets nothing),appendStoppedNote(wrap), thenrememberBrainTurn(acc, { thinking: thinkingAcc || undefined, tools: toolAcc.length ? toolAcc : undefined, stopped: true })(owner-locked optionalstoppedmarker — phase-14 convention,STORAGE_VERSIONstays 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). SetsendStatus.textContent = "Answer stopped."before thefinallysettlesidle(the existingfinally'ssetUiState(idle)+ focus-back remains the single settle path — the stop path must not double-settle, and it never scrolls: noscrollRevealon stop, phase-42 contract).
- in
- One-button morph in
setUiState: in-flight states (thinking/streaming) keep#send-btnenabled (it is the Stop button now — owner-locked),sendLabel.textContent = "Stop", andsendBtn.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
toolbranch no longer writessendLabel.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'saria-label. - Submit-while-in-flight:
handleSend's first guard becomesif (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!textguard for idle stays). - Restore:
renderStoredMessage— whenm.stoppedis true, call the sameappendStoppedNote(wrap)helper. New small helper: creates/reuses the.msg-metarow exactly likeappendTuneButtondoes (including therole=list→ button/spanrole=listitemrule) and appends a.stopped-notespan — 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.mdL3).
- Abort plumbing: at the start of a turn create
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#f43f5ebut darkened so the near-white label keeps ≥4.5:1 (check the contrast — e.g. a#be123c-range token with#ffflabel), 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).
frontend/index.html— no markup change (#send-btnalready carries spinner +#send-label); update the composer comment block to document the Send↔Stop morph + the stopped-note meta row.- 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-stopclass, spinner hidden); its 120s-guard, live-region, and typing-dots pins stay intact. The unit pins intests/unit/test_frontend_feedback.pythat assert the same old button state are updated in place the same way (they live with theSEND_STATUS/setUiStatepins — keep the file's structure). - Frontend source pins (house pattern,
tests/unit/test_frontend_feedback.pystyle — extend that file): the in-flightis-stoptoggle + enabled button + "Stop" label; theAbortController+signalin the fetch; thestoppedByUserstop branch (noshowErrorBanner); thestopped: truekey in the persisted record; theappendStoppedNoterestore path; the tool branch no longer writingsendLabel.
- 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
stoppedmarker (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-stopclass; idle: "Send" (state machine otherwise unchanged — the revisedtest_loading_feedback.pygreen). - 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 pytestgreen;uv run ruff check . && uv run pyrightclean.