# 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 1. `frontend/assets/app.js` — turn state (the `runTurn` locals / module scope, ~L3065–L3080): - `segRuns = []` — MODULE scope (beside `thinkingAcc`, which the pagehide partial reads), reset to `[]` in `runTurn` with the other turn locals. Entries: `{ kind: "thinking", text: "", records: [] }` / `{ kind: "tool", text: "", records: [] }` (the tool entry's `records` holds the `toolAcc` record objects — the SAME objects `toolAcc` holds, so the phase-95 `tool_result` stamp (`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). 2. `frontend/assets/app.js` — the run builders (replace `ensureThinkingBlock` + the single-container logic in `appendToolLine`): - `ensureThinkingRun(wrap)` — returns the CURRENT thinking run's `details.thinking` if `currentRun?.kind === "thinking"`; else closes `currentRun` (if any — `open = false`), creates a NEW `details.thinking` (`open = true`, `Thinking` — the phase-17 literal, D3 — built via the existing `innerHTML` template that `ensureThinkingBlock` uses, OR createElement/textContent — either is fine, but the RESTORE path's builder stays createElement/textContent (the house rule pins `innerHTML` ABSENT from the restore/shared builders — see task 03)), inserts it `before body.querySelector(".bubble")` (the bubble get-or-created first), pushes a fresh `{kind:"thinking",…}` to `segRuns`, sets `currentRun`. - `ensureToolRun(wrap)` — the same, for `details.tool-calls-disclosure` (`open = true`, `summary.tool-calls-summary` + the `div.tool-calls` list `role=list` `aria-label="Tool calls"` — the phase-117 structure, now per run). - `appendToolLine` — the signature becomes `appendToolLine(container, name, argument, offset = null, limit = null, scope = null)` (the CALLER passes the run's disclosure — no more `body.querySelector(".tool-calls-disclosure")` single-container derivation). EVERYTHING else in the function is BYTE-IDENTICAL: the `.tool-calls` list get-or-create, the line bytes, the four pinned `line.textContent` label literals, the phase-139 range suffix, the phase-146 scope span, the `Tool call(s) (N)` count. - `closeCurrentRun()` — folds `currentRun` (if any, `open = false`); idempotent; no-op without a run. - `closeAllRuns(wrap)` — closes EVERY open `details.thinking` + `details.tool-calls-disclosure` in the wrap (defensive; the `done` + stop/abort settle sites). 3. `frontend/assets/app.js` — the turn-handler branches: - the `thinking` branch (~L3148): keep `settleToolLine()`, `thinkingAcc += ev.text || ""`, `sawThinking`, `clearTurnTimeout()`, `if (!wrap) wrap = addMessage("brain", "")`, `removeTyping()` — all unchanged. REPLACE the `ensureThinkingBlock` + `block.open = true` (phase-109 D15 re-open — SUPERSEDED by D1) with `const block = ensureThinkingRun(wrap);` + the run's text extension: the run's `text` accumulates `ev.text`; the pre-update pin-state / bottom-follow logic (the `isThinkingNearBottom` capture + `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 `tool` branch (~L3193): keep the phase-139/146 guards, `toolAcc.push(toolRecord)`, `clearTurnTimeout()`, the wrap get-or-create, and the `toolStatus` label logic UNCHANGED. REPLACE `appendToolLine(wrap, …)` + `armToolLineClock(wrap)` with: `const disc = ensureToolRun(wrap); const line = appendToolLine(disc, name, argument, frameOffset, frameLimit, framePath);` (the function returns the appended `line` element — a one-line change, the function already has it in scope) + `armToolLineClock(wrap, line)`. The run record is the SAME `toolRecord` object pushed to `toolAcc` (push it into the run's `records` too — or derive the run's `records` from `segRuns` at save time via the call order — the executor picks ONE, but the phase-95 stamp must reach the `segments` payload: the simplest is the run's `records.push(toolRecord)` alongside the `toolAcc.push(toolRecord)`). - the `delta` branch (~L3300): `settleToolLine()` + `acc += …` + the `thinking→streaming` transition UNCHANGED; replace `closeThinkingBlock(wrap)` + `closeToolCalls(wrap)` with `closeCurrentRun()`. - the THREE remaining settle-pair sites — `finalizeFailedTurn`'s `if (wrap)` block (L2990–L2991), the STOP path (L3477–L3478), and any other `closeThinkingBlock(wrap); closeToolCalls(wrap);` pair (grep for the pair — the redesign removes both functions): replace the pair with `closeAllRuns(wrap);`. - `removeTyping()` / the typing-indicator / the `#send-status` copy / the UI state machine / the 300 s guard / the stream-drop guard / the `thinking-without-answer` fallback: ALL UNCHANGED (§7.4 intact). 4. `frontend/assets/app.js` — the clock (the phase-87 re-target, ~L1479): - `armToolLineClock(wrap, lineEl)` — the NEW second param: store `lineEl` in a module-scope `toolLineEl` (reset to `null` with `toolLineWrap` in `stopToolLineClock` and on re-arm); the interval body uses `toolLineEl` (null-guarded — the wrap-reset no-op stays) instead of `toolLineWrap?.querySelector?.(".tool-calls .tool-call:last-child")`. The suffix element + the `(Ns)` text + the `TOOL_LINE_ELAPSED_AFTER_MS` gate are UNCHANGED. - `settleToolLine()` — the `querySelectorAll(".tool-elapsed")` removal stays (it clears every line in the wrap — correct with multiple disclosures); add `toolLineEl = null` alongside. - the comment block updated to name the re-target (the multi-disclosure fix, phase 158 D1). 5. `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 when `run.text` is non-empty (an empty run stores nothing); a tool run → one `{ kind: "tool", …record }` per record in order (spread the record object — the additive `offset/limit/path` + the phase-95 stamp fields ride through; NO re-validation on the client — the server's `ChatMessage` boundary validates). - the `done` save point (~L3355, the `rememberBrainTurn` meta): `segments: buildSegments(segRuns).length ? buildSegments(segRuns) : undefined` (the `undefined`-drops-the-key house pattern, D4) — the `thinking: thinkingAcc || undefined` + `tools: toolAcc.length ? toolAcc : undefined` lines are UNCHANGED. - `finalizeFailedTurn` (L2983): the parameter object gains `segments` (the TWO call sites — the stream-drop site ~L3431 and the network-error site ~L3519 — pass `buildSegments(segRuns)`); the function's `if (wrap)` saved record includes `segments` ONLY when the passed value is a non-empty array (`segments: segments?.length ? segments : undefined`); the existing `acc`/`thinking`/`tools` fields are UNCHANGED. - the STOP path's save point (L3487–L3491, the `stopped: true` meta): gains the same `segments` field (non-empty only) — a stopped partial restores its timeline. - the pagehide partial (L3642–L3650, the module-scope read — `segRuns` is module scope, so it is readable there): gains the same `segments` field (non-empty only). The zero-frame fallback save (L3456–L3462) needs NOTHING (zero frames → no runs → no key). - the `thinking`/`tools` keys are UNCHANGED at every site. 6. `tests/unit/test_thinking_timeline.py` (NEW — the source-level house pattern, the `js[fn : js.find("\n}\n", fn)]` body-extraction the existing pins use): - `ensureThinkingRun` body: creates `details` + `className = "thinking"` + `open = true` + the plain `Thinking` summary 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); `ensureToolRun` body: `className = "tool-calls-disclosure"` + `open = true` + the summary/list structure. - `appendToolLine` signature: the first param is the container (no `body.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-child` query is GONE; a `toolLineEl` reference is used; `settleToolLine` resets it. - `closeCurrentRun` / `closeAllRuns` defined; the `delta` branch calls `closeCurrentRun` (pin: appears in the delta-branch region); `done` + the stop/abort settle call `closeAllRuns`. - `buildSegments` present; the `done` save + the `finalizeFailedTurn` call site + the pagehide partial all reference it (≥ 3 call sites). - `innerHTML` NOT in `ensureThinkingRun`… — 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). - `segRuns` is module scope + reset in `runTurn`. - 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` / `textContent` exactly as today; the live tool builder keeps the phase-117 createElement/textContent style — `innerHTML` ABSENT from `appendToolLine`, 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` — the `appendToolLine` single-container pin, `test_big_read_progress.py` — the clock-query pin, `test_frontend_failed_turn.py` — the `finalizeFailedTurn` payload 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-child` query is gone — the unit pin) - [ ] the `done` / failed / pagehide save payloads carry `segments` (non-empty only) — the unit pins - [ ] `uv run pytest tests/unit -q` green (every pin this task breaks is updated; every pin it doesn't touch stays green); `uv run ruff check . && uv run pyright` clean - [ ] `git diff --stat` limited to `frontend/assets/app.js`, `tests/unit/test_thinking_timeline.py`, and the unit pin files updated in this task