phase: 153_document_viewer_context
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`.
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
# 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`, `<summary>Thinking</summary>` — 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
|
||||
Reference in New Issue
Block a user