phase: 161_reasoning_bubble
Build and Push Containers / build-and-push-app (push) Successful in 1m55s
Build and Push Containers / build-and-push-db (push) Successful in 14s

All gates verified. Final check — AGENTS.md rules against the project (commands, gates, protocols) — the quick-reference commands all exist and the rules (PLAN §2/§7/§9 references, phase protocol, No CDN, Debugpy, git gates) accurately describe the project; the reasoning bubble is a pure UI change with no new commands or env vars, so no AGENTS.md change is needed.

**Verification complete — full results:**

- **Verified** (implemented by prior task passes, all in working tree): `createReasoningWrap`/`ensureReasoningWrap` live container (D1), `settleReasoning` fold at the 3 settle sites + not at delta (D2), 320px `.reasoning-body` window + pinned-tail follow (D3), zero persistence change (D4), collapsed restore + shared-page parity with D6 step counts, `styles.css` family treatment + reduced-motion still, mock `REASONING_MIDROUND_TRIGGER` flow, 6-test story suite, phase-158 pin updates
- **Full suite:** `uv run pytest` → **3738 passed, 0 failed** · `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99% (>90%)** · `uv run ruff check .` → all checks passed · `uv run pyright` → **0 errors, 0 warnings**
- **E2E in isolation** (DB up; port-8123 collision with an unrelated `undercooked` service → `E2E_APP_PORT=8125`/`E2E_MOCK_PORT=8902`, no live change made): `test_reasoning_bubble.py` **6/6** · `test_thinking_timeline.py` ✓ · `test_thinking_display.py` ✓ · `test_tool_call_compact.py` ✓ · `test_big_read_progress.py` ✓ · `test_turn_progress_loader.py` ✓ · `test_share_chat.py` ✓ · `test_chat_persistence.py` ✓ · `test_smoke.py` ✓ (all exit 0, zero failures)
- **Completion criteria:** all 9 checked against the code + test outcomes — container open/live/nested ✓, stays open past mid-round delta ✓, folds at done ✓, restore + shared collapsed with "3 steps" ✓, plain turn = no container ✓, regression suites green ✓, suite/coverage/lint green ✓, diff scope limited to `frontend/assets/{app.js,shared.js,styles.css}` + `tests/` + `README.md` + phase files (no `app/` — D4) ✓
- **Documentation:** README.md "Thinking" section names the Reasoning bubble accurately (320px window, open-until-settle incl. mid-round deltas, "N steps", byte-identical no-container, collapsed on restore/shared) — verified consistent, no changes needed; AGENTS.md — verified consistent, no changes needed; all `.env.example` vars documented
- **Decisions:** commit + phase-dir move left to the harness (per pass rules — no git index/history commands executed); `undercooked` dev server on port 8123 untouched, E2E used the env port overrides
- **Next pending phase:** none — `161_reasoning_bubble` is the last in `todo/`
This commit is contained in:
2026-10-06 06:36:09 -04:00
parent 8172956c15
commit 051f62deb7
48 changed files with 4194 additions and 166 deletions
@@ -0,0 +1,422 @@
# Phase 161 — One live-open, condensed reasoning bubble
**Source:** `TODO.md` L3 — "The reasoning process should be one collapsible
section that **stays open** during the reasoning process and shows all
thinking and tool calls in order. Right now the thinking, tool calling,
thinking, tool calling blocks take up a ton of space and obscure the answer.
We need to condense this while still letting the user watch the thinking
process happen so the latency doesn't feel super low. This new thinking/tool
calling bubble should only collapse once the final response has been
returned and the session has stopped."
**Story:** n/a (owner TODO — 2026-10-05)
**Context:** the DIRECT follow-up to completed `158_thinking_timeline`
(the per-run `details.thinking` / `details.tool-calls-disclosure`
stack, open-live/fold-at-rest) and `117_tool_call_compact` (the
disclosure convention) + `17_thinking_display` (the 320px live-tail
window + pinned-follow pattern). `frontend/assets/app.js` —
`ensureThinkingRun` (~L1103, `body.insertBefore(block,
body.querySelector(".bubble"))` — the per-run anchor),
`ensureToolRun` (~L1122, the same anchor), `closeCurrentRun` (~L1140, the
delta fold), `closeAllRuns` (~L1149, the turn's terminal settle),
`segRuns` (~L1464, module scope, reset in `runTurn` ~L3304), the thinking
frame branch (~L3381: `ensureThinkingRun` + the per-block
`isThinkingNearBottom` pinned follow), the tool frame branch (~L3501:
`ensureToolRun` + `appendToolLine` + `armToolLineClock`), the `delta`
branch (~L3553: `closeCurrentRun()`), the `done` branch (~L3570:
`closeAllRuns(wrap)`), `finalizeFailedTurn` (~L3218: `closeAllRuns(wrap)`),
the STOP path (~L3752: `closeAllRuns(wrap)`); `restoreTimeline`
(~L2195, the collapsed per-run restore, `insertBefore(block, bubble)`),
the `renderStoredMessage` dispatch (~L2292: segments vs the byte-identical
legacy path); `frontend/assets/shared.js` — `addSegments` (~L269, the
collapsed parity copy), the record dispatch (~L584);
`frontend/assets/styles.css` — the `details.thinking` block (L633, the
320px window + the phase-17 pinned-follow convention), the
`.tool-calls-disclosure` (L689), the reduced-motion still block (L1499);
`tests/e2e/mock_llm.py` (`THINK_TOOLS_TRIGGER` L1067 +
`_thinking_tools_flow` L1983 + `_tool_call_stream` L3956 — the sequential
flow, extended by task 04 with the mid-round-delta flow);
`tests/e2e/test_thinking_timeline.py` (the six phase-158 pins — the
"direct children before the bubble" pins update in task 05);
`tests/unit/test_thinking_timeline.py` (the source pins — the
insertion-anchor pins update in task 05; the new container pins are
task 01's).
## Owner decisions (chat, 2026-10-05 — confirmed 2026-10-05 — recorded per AGENTS.md rule 3)
- **D1 — One wrapper, runs unchanged.** ONE `details.reasoning` container
per brain message wraps the ENTIRE per-run timeline (the phase-158
blocks keep their structure, order, and open/fold behavior). Created on
the turn's FIRST thinking/tool frame, inserted before the bubble,
`open = true` from creation until the turn settles. A turn with no
thinking and no tools creates NO container (byte-identical DOM —
`BOR_STREAM_THINKING=0` and plain-answer turns are untouched).
- **D2 — Stays open through mid-round deltas.** The container folds ONLY
at the existing settle points — `done`, stop/abort, failed-turn
finalize — alongside `closeAllRuns`. A mid-round `delta` (the React
style reply before the next round) still folds the ACTIVE RUN
(`closeCurrentRun`, phase 158 D5) but the CONTAINER STAYS OPEN
(the TODO's "only collapse once the final response has been returned
and the session has stopped").
- **D3 — Condensed while open (A2).** The container's `.reasoning-body`
is ONE bounded scrollable live window — `max-height: 320px` (the
phase-17 window convention) + `overflow-y: auto` — with
pinned-tail autoscroll (the phase-17 `isThinkingNearBottom` pattern
reused, extended to the container window; the inner per-run blocks are
de-bordered inside the container and their OWN 320px scroll windows
are disabled — `max-height: none` — so there is ONE scroll surface,
not nested boxes; the user watches the live tail move without the
timeline pushing the answer off-screen).
- **D4 — Zero persistence change (A4).** The `segments` record, the
`ChatMessage` schema, the SSE contract, and the history wire are ALL
untouched (the container is a pure client render decision — the run
order already persists through phase 158's `segments`). Records
WITHOUT `segments` restore byte-identically through the untouched
legacy path (no container, no new keys). No migration.
- **D5 — Collapsed at rest (A5).** The restore path and the shared page
render the SAME container COLLAPSED around the per-run timeline, with
the at-rest step-count summary (D6). The live open state is
exclusively the live turn's.
- **D6 — The summary literals (A1).** The summary is the static literal
`Reasoning` + a `span.reasoning-status`: live — `Thinking…` while a
thinking frame streams, `Calling <name>…` on a tool frame (the
display name the tool line already shows — the phase-70 legacy-name
mapping); settled — `N steps` (N = the turn's RUN count —
`segRuns.length`; restore/shared — the restored run count, the SAME
semantics: consecutive thinking entries + one per contiguous tool
group).
## Design (shared by all tasks — the executor reads this, not the chat)
### The container's STATIC structure (task 01's builder)
```
details.reasoning[open] (live) / details.reasoning (rest)
summary.reasoning-summary
(CSS chevron ::before — the family treatment, task 03)
Reasoning
span.reasoning-status ("Thinking…" / "Calling <name>…" / "N steps")
div.reasoning-body (the bounded live window, task 03)
details.thinking … (the phase-158 per-run blocks — UNCHANGED
details.tool-calls-disclosure … inside, arrival order preserved)
```
- `createReasoningWrap()` — createElement only (innerHTML ABSENT — the
house rule): `details` (class `reasoning`) + `summary` (class
`reasoning-summary`, `textContent = "Reasoning"` — D6, the ONLY new
static literal; the span is empty until a frame sets it) + `div`
(class `reasoning-body`). The caller owns open/fold + insertion.
- `ensureReasoningWrap(wrap)` — get-or-create per turn (module scope
`reasoningWrapEl`, reset with `segRuns` in `runTurn` ~L3304 — the
pagehide partial reads the LIVE container, so the module scope is
readable there like `segRuns`): on the turn's first thinking/tool
frame — `createReasoningWrap()`, `open = true` (D1), inserted
`before` `body.querySelector(".bubble")` (the container is the ONE
new anchor; the per-run blocks move from `insertBefore(bubble)` to
`reasoningWrapEl.querySelector(".reasoning-body").appendChild(…)` —
arrival order preserved: a new run is appended after the previous
run's block).
- `ensureThinkingRun` / `ensureToolRun` — the insertion line ONLY
changes (append into the container's body); everything else (the
per-run create, `open = true`, `segRuns` push, `currentRun`) is
UNCHANGED.
- `settleReasoning(wrap)` — the container fold:
`wrap?.querySelector("details.reasoning")?.removeAttribute("open")`
(idempotent; a no-op without a container — the plain-answer turn
settles byte-identically). Called alongside `closeAllRuns(wrap)` at
the THREE settle sites (the `done` branch ~L3570, the STOP path
~L3752, `finalizeFailedTurn` ~L3218). The `delta` branch UNCHANGED
(`closeCurrentRun()` — the active run folds; the container stays
open — D2).
- The live status (D6): the thinking frame branch sets
`reasoningWrapEl`'s `.reasoning-status` `textContent = "Thinking…"`;
the tool frame branch sets `"Calling <name>…"` (the `name` the
`appendToolLine` call already receives); the settle sites set
`"${segRuns.length} steps"`. The restore/shared builders set
`"${N} steps"` directly (D5/D6 — no live state there).
- The live follow (D3): the NEW small helpers — `isReasoningPinned(el)`
(the pre-update capture — the phase-17 pre-measure convention:
`el.scrollTop + el.clientHeight >= el.scrollHeight -
REASONING_NEAR_BOTTOM_PX`, the same 32px band — a new constant
`REASONING_NEAR_BOTTOM_PX = 32`) and `scrollReasoningToBottom(el)`
(the post-append apply: `el.scrollTop = el.scrollHeight`). Called
from BOTH the thinking frame branch (pre: before the block
re-render; post: after it) and the tool frame branch (pre: before
`appendToolLine`; post: after it) — `el` =
`reasoningWrapEl?.querySelector(".reasoning-body")` (a no-op when
absent). The EXISTING per-block `isThinkingNearBottom(textEl)`
follow stays (harmless — the inner `.thinking-text` is no longer
scrollable inside the container (task 03's `max-height: none`), so
it's a no-op there; it keeps the legacy no-container path's
behavior conceptually intact and costs nothing). The phase-87
`armToolLineClock` / `settleToolLine` wrap-scoped queries are
DESCENDANT-scoped — they work unchanged with the runs nested in
the container (no change).
- `closeAllRuns`'s selector (`details.thinking,
details.tool-calls-disclosure`) is descendant-scoped to the wrap —
the nested runs still match. UNCHANGED.
### `frontend/assets/app.js` — the restore path (task 02)
- `restoreTimeline`: BEFORE the loop, get-or-create the container —
`createReasoningWrap()`, `open = false` (D5 — the restore renders
collapsed), the summary's `.reasoning-status` = `"${runs} steps"`
(D6 — computed INCREMENTALLY: each `thinking` entry starts a run
(count+1 when the previous is not a thinking entry — the contiguous
semantics matching `segRuns.length`); each CONTIGUOUS tool group is
ONE run (count+1 at the group's first entry)); the container is
inserted `before` the bubble ONCE. Every per-run block appends into
the container's `.reasoning-body` (the `insertBefore(block, bubble)`
lines become appends — the rest of the loop — the malformed-entry
skips, the tOffset/tLimit/tPath guards, the
`appendTruncatedNote` call — is UNCHANGED).
- The `renderStoredMessage` dispatch (~L2292) is UNCHANGED: a record
WITHOUT `segments` never reaches `restoreTimeline` — the legacy path
is byte-identical (no container — D4).
- `frontend/assets/shared.js` — `addSegments`: the SAME wrapper parity
copy (created CLOSED — the pure-render convention; the SAME
incremental step count; the line bytes MIRROR `addToolLines` —
unchanged). The record dispatch (~L584) is UNCHANGED (no `segments`
→ the legacy `addThinkingBlock` + `addToolLines` path, byte-identical).
### `frontend/assets/styles.css` (task 03)
- `details.reasoning` — the family treatment of `details.thinking`
(L633): `background: var(--surface); border: 1px solid var(--line);
border-left: 3px solid var(--brand-soft); border-radius:
var(--radius-sm); margin: 0 0 0.5rem; overflow: hidden;` — a
distinct comment block citing this phase.
- `summary.reasoning-summary` — `display: flex; align-items: center;
gap: 0.5rem; padding: 0.5rem 0.75rem; min-height: 44px;` (the
WCAG ≥44px target); `color: var(--brand-ink)` (8.7:1 on
`--surface` — AA, the token the `details.thinking` summary uses);
`font-size: 0.9rem; cursor: pointer; list-style: none;`; the
webkit marker `display: none`; the CSS chevron `::before` `▸`
rotating 90° on `[open]` (the `details.thinking` convention; the
transition stills in the reduced-motion block — task 03 appends it
to the L1499 still list); `:focus-visible` → the house 3px
`var(--brand)` ring + `outline-offset: 2px`.
- `.reasoning-status` — `color: var(--ink-soft)` (6.9:1 on
`--surface` — AA); `font-size: 0.8rem; line-height: 1.4; margin-left:
0.25rem;` — text + color, never color alone (B5).
- `.reasoning-body` — `padding: 0 0.75rem 0.75rem; max-height: 320px;
overflow-y: auto;` (D3 — the ONE bounded live window; a
user-scrollable window — the phase-17 convention, the comment
cites this phase + D3).
- The inner-run compacting (D3): `details.reasoning details.thinking`
→ `border: 0; border-left: 2px solid var(--line);
border-radius: 0; margin: 0.25rem 0; background: transparent;`
(deboxed — the nested-border weight is removed);
`details.reasoning details.thinking .thinking-text` → `max-height:
none; overflow-y: visible;` (the inner scroll window is disabled —
the container's window is the single scroll surface);
`details.reasoning .tool-calls-disclosure` → `margin: 0.25rem 0;`
(already compact — the rule documents the intent, no visual change).
- The reduced-motion block (L1499) gains the `details.reasoning
summary::before` / `.reasoning-summary::before` still (the
chevron rotation stills — parity with the existing stills).
- All colors are EXISTING AA-verified tokens (no new literal colors —
the B5 token discipline; contrast ≥4.5:1 by construction).
### `tests/e2e/mock_llm.py` (task 04)
- `REASONING_MIDROUND_TRIGGER = "think out loud, answer, then check
your work"` — checked in the streaming branch BEFORE the
`_thinking_tools_flow` dispatch (the more-specific-phrase-wins
convention — the phrase CONTAINS `think out loud` + `check your
work`… the executor MUST verify: (a) no existing trigger phrase is a
substring of the new one AND no new phrase is a substring of an
existing one in a way that steals an existing question's dispatch
(the existing trigger is `think out loud and use your tools` — the
new phrase shares only `think out loud`, and NEITHER is a substring
of the other — verified: the existing continues `…and use your
tools`, the new continues `…answer, then check…`), and (b) no
existing E2E question carries the new phrase — verify both before
wiring).
- `_reasoning_midround_flow(body)` — stateless, shaped like
`_thinking_tools_flow` (gated on the `tools` parameter + the
`<tools>` section, like the other marker flows):
- request 1 (tools offered, no tool results yet): `thinking` =
`"Step 1: I think I know the answer — let me check the
documents."` → a MID-ROUND `delta` = `"The reference server is
ref-server-1."` (content stream — BEFORE the tool call — the
React-style reply) → the `_tool_call_stream("ls", {}, "call_0",
thinking="")` (the tool call AFTER the delta — the exact
mid-round ordering the TODO describes).
- request 2 (a `tool`-role result): `thinking` = `"Step 2: the
listing confirms it."` → a `delta` = `" — confirmed by the
listing."` → the `finish` (the `done` convention — the flow ends
with a content finish; NO further tool call).
- The mock stays mock-only by design (`E2E_REAL_LLM` note unchanged);
the docstring gains the new trigger entry (the phase-161 flow).
### `tests/e2e/test_reasoning_bubble.py` (task 04 — dedicated suite, run in isolation)
The conftest `app_server`/`page`/`seeded_kb` fixtures + the phase-37
admin login + the mid-round trigger question (the KB must be seeded so
the `ls` result exists — mirror `test_thinking_timeline.py`). Six
tests:
1. **`test_container_open_during_the_turn`** — during the turn (an
`expect.poll` predicate over the mock's 0.1 s pacing window, before
the answer bubble is complete): a `details.reasoning` exists, is
`open`, contains ≥1 `details.thinking` or
`details.tool-calls-disclosure` INSIDE its `.reasoning-body` (the
per-run blocks are NESTED — the phase-158 direct-children layout is
superseded), and its `.reasoning-status` carries a live suffix
(`Thinking…` or `Calling …`).
2. **`test_container_stays_open_past_a_mid_round_delta`** — the
mid-round trigger: while the answer bubble is NON-EMPTY (the
mid-round delta rendered) AND the turn is not `done`, the container
is STILL `open` with a run visible (the D2 contract — the TODO's
"only collapse once the final response has been returned" — the
phase-158 first-delta-fold is superseded at the container level).
3. **`test_container_folds_at_done`** — after `done`: the container
has NO `open` attribute; its summary is visible; EVERY inner run
has no `open` attribute (the D2/D5 settle — the phase-158
fold-at-rest contract preserved inside the fold).
4. **`test_restored_container_collapsed_with_step_count`** — RELOAD
(same context): the restored message renders ONE `details.reasoning`
(NO `open` attribute) containing the same per-run timeline, and its
`.reasoning-status` is `3 steps` (the mid-round flow's run count —
the D6 at-rest count: the flow has thinking → delta → tool →
thinking → delta: the runs are `thinking (Step 1)`, `tool (ls)`,
`thinking (Step 2)` = 3 contiguous runs — matching
`segRuns.length`; the semantics is what's pinned: contiguous-run
count).
5. **`test_shared_page_container_collapsed`** — the phase-51 save +
share flow, a FRESH context on `/shared/<token>`: ONE
`details.reasoning` (collapsed) with the same timeline + step count
(the `shared.js` parity — `addSegments`).
6. **`test_no_reasoning_turn_has_no_container`** — a plain answer
question: ZERO `details.reasoning` in the brain wrap (D1/D4 — the
byte-identical DOM); and a plain `use your tools` question (NO
thinking trigger): the container EXISTS (the tool frames create it),
wraps the ONE phase-117 disclosure, is `open` live, folds at `done`
(the no-thinking turn's contract — the phase-117 single-disclosure
behavior preserved, now wrapped).
## Tasks
1. `01_live_container.md` — the container's static builder + the
get-or-create live wrapper (D1) + the re-anchored per-run inserts +
the `settleReasoning` fold at the three settle sites (D2) + the
summary live status (D6) + the live follow (D3) + the JS source
unit pins.
2. `02_restore_and_shared.md` — the collapsed restore wrapper (D5/D6,
the incremental step count) + the `shared.js` `addSegments` parity +
the unit pins.
3. `03_container_css.md` — the `details.reasoning` styling family, the
bounded live window, the inner-run compacting, the reduced-motion
stills.
4. `04_mock_and_e2e.md` — the mock `REASONING_MIDROUND_TRIGGER` flow +
the dedicated `test_reasoning_bubble.py` story suite, run in
isolation.
5. `05_regression_update_and_commit.md` — the phase-158 suite pin
updates (the "direct children before the bubble" pins → the
container-nested contract), the remaining unit-pin updates, the full
gate, the documentation pass, the atomic commit, the phase move.
## Testing & Quality
- Unit (new, task 01): `tests/unit/test_reasoning_bubble.py` — the
source-level pins: `createReasoningWrap`'s createElement shape
(innerHTML ABSENT), `ensureReasoningWrap`'s get-or-create + reset in
`runTurn`, `open = true` at creation, the per-run inserts append
into `.reasoning-body` (the `insertBefore(bubble)` per-run anchor is
GONE from `ensureThinkingRun`/`ensureToolRun`), `settleReasoning`
defined + called at the THREE settle sites (done / stop /
finalizeFailedTurn) and NOT at the `delta` branch (D2 — the
`closeCurrentRun` there is unchanged), the status literals
(`Reasoning` / `Thinking…` / `Calling ` / ` steps`), the
`followReasoningBody` pin + the `REASONING_NEAR_BOTTOM_PX` constant,
and the `innerHTML` ABSENT from the wrapper builder.
- Unit (task 02): `tests/unit/test_reasoning_bubble.py` (extended) — the
restore wrapper (created CLOSED, the incremental step count — a
thinking entry + a contiguous tool group = the run semantics; the
legacy no-segments path is byte-identical — `restoreTimeline`
unreachable without segments), the `shared.js` `addSegments` parity
(the collapsed wrapper + the step count + the unchanged line bytes),
and `innerHTML` ABSENT from the restore/shared wrappers.
- E2E: `tests/e2e/test_reasoning_bubble.py` (new — the six tests
above), green **in isolation** (`uv run pytest
tests/e2e/test_reasoning_bubble.py -v --no-cov`), mock-only.
- Regression E2E (task 05, in isolation): `test_thinking_timeline.py`
(UPDATED in task 05 — the direct-children pins → the container-nested
contract; the open/fold + the per-run distinctness contracts
unchanged), `test_thinking_display.py`, `test_tool_call_compact.py`
(the no-thinking single-disclosure contract — now inside the
container), `test_big_read_progress.py` (the phase-87 clock — the
wrap-scoped descendant queries are unchanged — must stay green
UNCHANGED), `test_turn_progress_loader.py`, `test_share_chat.py`,
`test_chat_persistence.py`, `test_smoke.py`.
- Existing unit suites: every pin NOT broken by the wrapper (the
tool-line label literals, the phase-117 deboxed CSS, the phase-87
`.tool-elapsed` behavior, the phase-139/146 suffix pins, the emoji
guard) stays green; the phase-158 insertion-anchor pins update in
task 05 (the documented change — the per-run anchor moved into the
container).
- Coverage: `uv run pytest --cov=app --cov-report=term-missing`
**>90%** on `app/` (this phase is FRONTEND-ONLY — `app/` is
untouched; the gate stays green by the suite).
- Lint/types: `uv run ruff check . && uv run pyright` clean.
- The phase is "Complete" only when the **entire** project test suite
passes 100% (every test in the project, not just this phase's).
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against
the project as it stands after this phase — fixing every
inconsistency found, **including pre-existing ones not caused by the
phase**.
- Tasks update `README.md`/`AGENTS.md` immediately when their own
changes are user-visible (the reasoning bubble is a visible UI
change — name it where the chat thinking/tool-call display is
described, if anywhere).
## Completion Criteria
- [ ] A live reasoning turn renders ONE `details.reasoning` (open)
wrapping the ordered per-run timeline, condensed in the bounded
320px live window with pinned-tail follow (E2E tests 1 + 6 —
task 04); it STAYS OPEN through a mid-round delta (test 2 — the
TODO's core contract) and folds at `done` (test 3).
- [ ] A reload restores the collapsed container with the step-count
summary (test 4); a fresh context on `/shared/<token>` renders
the same (test 5).
- [ ] A plain answer turn creates NO container (test 6) — the
byte-identical DOM; a pre-phase saved record (no `segments`)
restores byte-identically through the untouched legacy path (the
regression suites + the unit pin).
- [ ] `test_thinking_timeline.py` (updated pins) +
`test_thinking_display.py` + `test_tool_call_compact.py` +
`test_big_read_progress.py` + `test_turn_progress_loader.py`
green in isolation.
- [ ] `uv run pytest` green (100% of the project suite);
`uv run pytest --cov=app --cov-report=term-missing` > 90%;
`tests/e2e/test_reasoning_bubble.py` green in isolation;
`uv run ruff check . && uv run pyright` clean.
- [ ] `git diff --stat` limited to `frontend/assets/app.js`,
`frontend/assets/shared.js`, `frontend/assets/styles.css`,
`tests/`, and the phase files (NO `app/` change — D4).
- [ ] README.md and AGENTS.md are accurate and consistent with the
whole project (verified in the final pass).
- [ ] ONE owner-directed exception to "no behavior change in completed
phases": the phase-158 per-run blocks' direct-children layout is
SUPERSeded by the container nesting (owner TODO direction
2026-10-05 — the `test_thinking_timeline.py` pins update in task
05); the per-run open/fold behavior itself is UNCHANGED.
- [ ] One atomic `--no-gpg-sign` commit (below); the phase dir moved
to `.agents/phases/complete/`.
## Commit
```bash
git add -A .agents/ frontend/ tests/ README.md && git commit --no-gpg-sign -m "feat(ui): condense the reasoning timeline into one live-open collapsible bubble (folds at turn end, collapsed on restore/shared)"
```
## Locked decisions
- **A15 UNCHANGED — the SSE contract is untouched.** No new event type,
no frame change; the container is pure client grouping/render.
`app/` is untouched (D4).
- **D1–D6 (owner, 2026-10-05 — confirmed 2026-10-05)** — the one
wrapper with unchanged per-run blocks, the stays-open-past-mid-round
deltas, the condensed live window (320px, one scroll surface,
pinned-tail follow), the zero persistence change, the collapsed-at
rest, and the summary literals.
- **A13 UNCHANGED** — no migration (the record is opaque JSONB; no new
key — the `segments` field already carries the run order).
- **A16 honoured** — one new story E2E suite + the task-05 regression
updates; mock-only by design. **A17 honoured** — one atomic
`--no-gpg-sign` commit.
- **The phase-158 `closeAllRuns` / `closeCurrentRun` / the phase-87
clock are UNCHANGED** (descendant-scoped queries — they work with
the nesting); the ONLY superseded anchor is the per-run
`insertBefore(bubble)` → the container's `.reasoning-body` append.
No other anchor changed.