All verification complete. Every gate is green and the docs check out. Final report:
## Phase 158 final verification pass — COMPLETE
**Verified (all tasks pre-completed; this pass re-ran the full gate):**
- Unit + integration: `uv run pytest --cov=app --cov-report=term-missing` → **3618 passed, 99% coverage on `app/`** (>90% gate)
- Phase E2E in isolation: `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov` → **6/6 passed** (order pin, fold-at-rest, live-open, restore+`segments` array, shared page, no-thinking single disclosure)
- Regression E2E, each in isolation: `test_thinking_display` 5✓, `test_tool_call_compact` 4✓, `test_turn_progress_loader` 3✓ (per-run pin updated), `test_big_read_progress` 4✓, `test_agent_document_tools` 4✓, `test_share_chat` 4✓, `test_chat_persistence` 4✓, `test_smoke` 3✓
- Lint/types: `uv run ruff check . && uv run pyright` → **clean (0 errors)**
**Completion criteria:** all 9 met — timeline E2E pins ✓, restore/shared `segments` ✓, no-thinking single-disclosure + the three UNCHANGED regression suites green ✓, pre-phase byte-identical round-trip (integration pin: key absent, never null; >50 / >32 000 / empty text / unknown kind all 422) ✓, full suite + coverage + E2E + lint ✓, `git diff --stat` footprint limited to `app/schemas.py`, `frontend/assets/{app,shared}.js`, `tests/`, README, phase files (a pre-existing owner `TODO.md` edit is out of scope, not staged by the phase commit) ✓, D15→per-run supersession recorded in the updated pin ✓. (Commit + phase move are the harness's job; task dirs already in `complete/`.)
**Documentation:** README.md — Thinking section correctly describes the phase-158 timeline; config reference matches `.env.example` exactly; page table matches registered routes; no stale single-block wording. AGENTS.md — rules and quick-reference commands all accurate. **Verified consistent — no changes needed.**
**Decisions/deviations:** none. `test_tool_call_compact.py` carries a one-line docstring-only update (referenced the removed `closeToolCalls`); its test logic is byte-identical to the phase-117 contract and green.
**Next pending phase:** none — `todo/` contains only 158.
12 KiB
12 KiB
Task 02 — the per-run live timeline in app.js
Phase: 158_thinking_timeline · Story: n/a
Objective
Replace the single-block/single-disclosure turn rendering with the per-run
timeline: each contiguous thinking run streams into its own details.thinking
block, each contiguous tool-call run into its own
details.tool-calls-disclosure, all segments ABOVE the answer bubble in
arrival order (D2); the runs open live and fold at rest (D5); the phase-87
clock is re-targeted to a tracked line reference; the save payloads persist
the segments array (D4).
Work
frontend/assets/app.js— turn state (therunTurnlocals / module scope, ~L3065–L3080):segRuns = []— MODULE scope (besidethinkingAcc, which the pagehide partial reads), reset to[]inrunTurnwith the other turn locals. Entries:{ kind: "thinking", text: "", records: [] }/{ kind: "tool", text: "", records: [] }(the tool entry'srecordsholds thetoolAccrecord objects — the SAME objectstoolAccholds, so the phase-95tool_resultstamp (t.truncated = true; t.chars_shown = …; t.chars_total = …, ~L3398–L3404) flows into both automatically — NO new wiring).currentRun = null— turn-local (the active run's element + kind).
frontend/assets/app.js— the run builders (replaceensureThinkingBlock- the single-container logic in
appendToolLine):
ensureThinkingRun(wrap)— returns the CURRENT thinking run'sdetails.thinkingifcurrentRun?.kind === "thinking"; else closescurrentRun(if any —open = false), creates a NEWdetails.thinking(open = true,<summary>Thinking</summary>— the phase-17 literal, D3 — built via the existinginnerHTMLtemplate thatensureThinkingBlockuses, OR createElement/textContent — either is fine, but the RESTORE path's builder stays createElement/textContent (the house rule pinsinnerHTMLABSENT from the restore/shared builders — see task 03)), inserts itbefore body.querySelector(".bubble")(the bubble get-or-created first), pushes a fresh{kind:"thinking",…}tosegRuns, setscurrentRun.ensureToolRun(wrap)— the same, fordetails.tool-calls-disclosure(open = true,summary.tool-calls-summary+ thediv.tool-callslistrole=listaria-label="Tool calls"— the phase-117 structure, now per run).appendToolLine— the signature becomesappendToolLine(container, name, argument, offset = null, limit = null, scope = null)(the CALLER passes the run's disclosure — no morebody.querySelector(".tool-calls-disclosure")single-container derivation). EVERYTHING else in the function is BYTE-IDENTICAL: the.tool-callslist get-or-create, the line bytes, the four pinnedline.textContentlabel literals, the phase-139 range suffix, the phase-146 scope span, theTool call(s) (N)count.closeCurrentRun()— foldscurrentRun(if any,open = false); idempotent; no-op without a run.closeAllRuns(wrap)— closes EVERY opendetails.thinking+details.tool-calls-disclosurein the wrap (defensive; thedone+ stop/abort settle sites).
- the single-container logic in
frontend/assets/app.js— the turn-handler branches:- the
thinkingbranch (~L3148): keepsettleToolLine(),thinkingAcc += ev.text || "",sawThinking,clearTurnTimeout(),if (!wrap) wrap = addMessage("brain", ""),removeTyping()— all unchanged. REPLACE theensureThinkingBlock+block.open = true(phase-109 D15 re-open — SUPERSEDED by D1) withconst block = ensureThinkingRun(wrap);+ the run's text extension: the run'stextaccumulatesev.text; the pre-update pin-state / bottom-follow logic (theisThinkingNearBottomcapture +scrollTop = scrollHeight, ~L3166–L3189) moves VERBATIM into this branch, now operating on THIS block (per-block — the existing comment block stays, updated to name the per-run placement). - the
toolbranch (~L3193): keep the phase-139/146 guards,toolAcc.push(toolRecord),clearTurnTimeout(), the wrap get-or-create, and thetoolStatuslabel logic UNCHANGED. REPLACEappendToolLine(wrap, …)+armToolLineClock(wrap)with:const disc = ensureToolRun(wrap); const line = appendToolLine(disc, name, argument, frameOffset, frameLimit, framePath);(the function returns the appendedlineelement — a one-line change, the function already has it in scope) +armToolLineClock(wrap, line). The run record is the SAMEtoolRecordobject pushed totoolAcc(push it into the run'srecordstoo — or derive the run'srecordsfromsegRunsat save time via the call order — the executor picks ONE, but the phase-95 stamp must reach thesegmentspayload: the simplest is the run'srecords.push(toolRecord)alongside thetoolAcc.push(toolRecord)). - the
deltabranch (~L3300):settleToolLine()+acc += …+ thethinking→streamingtransition UNCHANGED; replacecloseThinkingBlock(wrap)+closeToolCalls(wrap)withcloseCurrentRun(). - the THREE remaining settle-pair sites —
finalizeFailedTurn'sif (wrap)block (L2990–L2991), the STOP path (L3477–L3478), and any othercloseThinkingBlock(wrap); closeToolCalls(wrap);pair (grep for the pair — the redesign removes both functions): replace the pair withcloseAllRuns(wrap);. removeTyping()/ the typing-indicator / the#send-statuscopy / the UI state machine / the 300 s guard / the stream-drop guard / thethinking-without-answerfallback: ALL UNCHANGED (§7.4 intact).
- the
frontend/assets/app.js— the clock (the phase-87 re-target, ~L1479):armToolLineClock(wrap, lineEl)— the NEW second param: storelineElin a module-scopetoolLineEl(reset tonullwithtoolLineWrapinstopToolLineClockand on re-arm); the interval body usestoolLineEl(null-guarded — the wrap-reset no-op stays) instead oftoolLineWrap?.querySelector?.(".tool-calls .tool-call:last-child"). The suffix element + the(Ns)text + theTOOL_LINE_ELAPSED_AFTER_MSgate are UNCHANGED.settleToolLine()— thequerySelectorAll(".tool-elapsed")removal stays (it clears every line in the wrap — correct with multiple disclosures); addtoolLineEl = nullalongside.- the comment block updated to name the re-target (the multi-disclosure fix, phase 158 D1).
frontend/assets/app.js— the save payloads (D4) — the FIVE sites:buildSegments(segRuns)— a helper (beside the turn state): maps the runs IN ORDER — a thinking run →{ kind: "thinking", text: run.text }ONLY whenrun.textis non-empty (an empty run stores nothing); a tool run → one{ kind: "tool", …record }per record in order (spread the record object — the additiveoffset/limit/path+ the phase-95 stamp fields ride through; NO re-validation on the client — the server'sChatMessageboundary validates).- the
donesave point (~L3355, therememberBrainTurnmeta):segments: buildSegments(segRuns).length ? buildSegments(segRuns) : undefined(theundefined-drops-the-key house pattern, D4) — thethinking: thinkingAcc || undefined+tools: toolAcc.length ? toolAcc : undefinedlines are UNCHANGED. finalizeFailedTurn(L2983): the parameter object gainssegments(the TWO call sites — the stream-drop site ~L3431 and the network-error site ~L3519 — passbuildSegments(segRuns)); the function'sif (wrap)saved record includessegmentsONLY when the passed value is a non-empty array (segments: segments?.length ? segments : undefined); the existingacc/thinking/toolsfields are UNCHANGED.- the STOP path's save point (L3487–L3491, the
stopped: truemeta): gains the samesegmentsfield (non-empty only) — a stopped partial restores its timeline. - the pagehide partial (L3642–L3650, the module-scope read —
segRunsis module scope, so it is readable there): gains the samesegmentsfield (non-empty only). The zero-frame fallback save (L3456–L3462) needs NOTHING (zero frames → no runs → no key). - the
thinking/toolskeys are UNCHANGED at every site.
tests/unit/test_thinking_timeline.py(NEW — the source-level house pattern, thejs[fn : js.find("\n}\n", fn)]body-extraction the existing pins use):ensureThinkingRunbody: createsdetails+className = "thinking"+open = true+ the plainThinkingsummary literal (D3 — the phase-17 literal, NOT a numbered variant) + inserts before.bubble(D2 — the anchor is the bubble, NOT the old.tool-calls-disclosure ?? …chain);ensureToolRunbody:className = "tool-calls-disclosure"+open = true+ the summary/list structure.appendToolLinesignature: the first param is the container (nobody.querySelector(".tool-calls-disclosure")in the body — the single-container derivation is GONE); the four label literals STILL present (mirror the guard); returns the line.armToolLineClock: the:last-childquery is GONE; atoolLineElreference is used;settleToolLineresets it.closeCurrentRun/closeAllRunsdefined; thedeltabranch callscloseCurrentRun(pin: appears in the delta-branch region);done+ the stop/abort settle callcloseAllRuns.buildSegmentspresent; thedonesave + thefinalizeFailedTurncall site + the pagehide partial all reference it (≥ 3 call sites).innerHTMLNOT inensureThinkingRun… — pin per the builder choice made in Work item 2 (if the live builder reuses the phase-17 innerHTML template, pin that; the RESTORE/SHARED builders (task 03) are pinned innerHTML-free there).segRunsis module scope + reset inrunTurn.- Builder style: MIRROR the function it replaces (the live thinking
builder keeps the phase-17 static-structure convention — a static
template with NO data — the data flows through
renderMarkdown/textContentexactly as today; the live tool builder keeps the phase-117 createElement/textContent style —innerHTMLABSENT fromappendToolLine, as pinned today). Pin the STRUCTURE + the no-data- through-innerHTML rule, not the mechanism.
Testing & Quality
- Unit:
tests/unit/test_thinking_timeline.py(this task's pins) + UPDATE the existing source pins THIS task's change breaks (inspect, in order:test_frontend_feedback.py,test_frontend_turn_loader.py— the phase-109 D15 re-open pin becomes the per-run pin (a NEW block, not a re-open),test_frontend_tool_states.py— theappendToolLinesingle-container pin,test_big_read_progress.py— the clock-query pin,test_frontend_failed_turn.py— thefinalizeFailedTurnpayload pin,test_thinking_scroll.py,test_chat_persistence.py— ONLY the pins this task touches; the restore-path pins are task 03's, the shared-page pins are task 03's). Every pin this task does NOT break (the tool-line label literals, the phase-117 deboxed CSS, the phase-139/146 suffix pins, the emoji guard) stays green UNCHANGED. - Coverage: >90% on this task's modified code (the pins).
Completion Criteria
- a live turn renders per-run blocks in arrival order (the code paths land exactly as designed — the unit pins)
- the clock targets the tracked line reference (the
:last-childquery is gone — the unit pin) - the
done/ failed / pagehide save payloads carrysegments(non-empty only) — the unit pins uv run pytest tests/unit -qgreen (every pin this task breaks is updated; every pin it doesn't touch stays green);uv run ruff check . && uv run pyrightcleangit diff --statlimited tofrontend/assets/app.js,tests/unit/test_thinking_timeline.py, and the unit pin files updated in this task