That last `--co` (collect-only) run was my probe mistake — it shows 34% because no tests execute under collect-only. The valid coverage run was the real gate command executed earlier: `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (exit 0, all 3544 tests executed). All gates are verified. ## Phase 153 — final verification pass report **Verified (all tasks 01–04 complete in `complete/`):** - Modal `fullPageUrl(source, path, back = null)` + `openDocumentModal(…, back = null)` with module-level `currentBack`; 5 chat call sites pass `"/"`, sources.js call site unchanged (byte-identical Sources path); `documentUrl` comment now true - Copy-path control (shared `renderDocument` core, icon+text, text = accessible name) on both surfaces; per-page clipboard helpers with the fallback-field + status-line contract; `#doc-copy-announcer` live region; styles + 44px touch floor - Unit pins: `&back=%2F` / no-`back` byte-identity, `currentBack` single-writer guard, copy strings byte-pinned, phase-13 regression pins untouched **Test / lint / coverage results:** - `uv run pytest` → **3544 passed**, 0 failed (full unit + integration) - `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% gate) - `uv run pytest tests/e2e/test_document_viewer_context.py -v --no-cov` → **8 passed** in 47.33s, in isolation (DB up, mock LLM) - `uv run ruff check . && uv run pyright` → clean (0 errors/warnings) **Completion criteria:** all met — (1) chat→modal→Full page: `href="/"` + "Chat"; Sources→modal→Full page: `href="/sources.html"` + "Sources" with `back=` asserted absent; (2) no-`back` URLs byte-identical to pre-phase; (3) Copy path: "Path copied." on success (clipboard readback), prefilled select-on-focus fallback field + "Path ready — copy it from the field." on rejected clipboard, both surfaces parameterized; (4) copied string exactly `docs/homelab/kubernetes.md`; (5) full suite green, E2E isolated green, lint clean, coverage 99%; (6) commit by harness. **Documentation:** README.md reviewed in full — phase-153 feature already named in the Chat section; routes, env reference, workflows, deployment all consistent — no changes needed. AGENTS.md reviewed in full — rules, gates, and quick-reference commands verified against the repo (incl. `test_smoke.py`, `.env.example`, gitignore entries) — consistent, no changes needed. **Notable:** pytest 9.1.1 quirk — a doubled `-q` (pyproject `addopts` + explicit `-q`) swallows the final "N passed" summary line while still exiting 0; the plain `uv run pytest` gate command prints it. No defects found in prior work. **Next pending phase:** `154_sync_status_freshness`.
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