chore(agent): track .agent/ planning tree in git
Build and Push Containers / build-and-push-app (push) Successful in 12s
Build and Push Containers / build-and-push-db (push) Successful in 10s

Remove the blanket .agent/ gitignore so the phase roadmap, user
stories, reports, and PLAN.md are versioned with the code. Only
runtime artifacts (.agent/phase-sessions/, .agent/pipeline.log)
remain ignored. Update AGENTS.md git protocol rule to match.
This commit is contained in:
2026-09-01 10:18:22 -04:00
parent 5fa620fde5
commit 4971e2859d
818 changed files with 23964 additions and 4 deletions
@@ -0,0 +1,38 @@
# 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.