chore(agent): phase roadmap from TODO.md, 1 phase (161 reasoning bubble)

LLM-Generated: true
This commit is contained in:
2026-10-05 13:59:47 -04:00
parent aa297ea9b1
commit 4d7d0f6297
6 changed files with 912 additions and 0 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.
@@ -0,0 +1,99 @@
# Task 01 — The live reasoning container
**Phase:** `161_reasoning_bubble` · **Source:** `TODO.md:3 — "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
## Objective
One `details.reasoning` container per brain message wraps the phase-158
per-run timeline live: created on the turn's first thinking/tool frame,
`open` until the turn settles (done / stop / failed-finalize), condensed
in a bounded 320px live window with pinned-tail follow, the summary
carrying the D6 status literals; the per-run blocks are re-anchored into
the container (their structure and open/fold behavior are unchanged).
## Work
1. `frontend/assets/app.js` — `createReasoningWrap()` (beside
`createThinkingBlock` ~L1068): createElement only (innerHTML ABSENT)
— `details` (class `reasoning`) + `summary` (class
`reasoning-summary`, `textContent = "Reasoning"`, the ONLY new static
literal — D6) + `span` (class `reasoning-status`, empty until a frame
sets it) + `div` (class `reasoning-body`); return the `details`
element. The docstring cites this phase + D1/D6.
2. `frontend/assets/app.js` — module scope `reasoningWrapEl = null`
(beside `segRuns`/`currentRun` ~L1464–1465) + `ensureReasoningWrap
(wrap)` (get-or-create: on the turn's first thinking/tool frame —
`createReasoningWrap()`, `open = true` (D1), inserted `before`
`wrap.querySelector(".msg-body").querySelector(".bubble")`, stored in
`reasoningWrapEl`); reset in `runTurn` alongside `segRuns = []`
(~L3304 — the pagehide partial reads the live container, so the
module scope is readable there like `segRuns`).
3. `frontend/assets/app.js` — re-anchor the per-run inserts: in
`ensureThinkingRun` (~L1103) and `ensureToolRun` (~L1122), the
`body.insertBefore(block, body.querySelector(".bubble"))` line becomes
`ensureReasoningWrap(wrap).querySelector(".reasoning-body")
.appendChild(block)` (the `wrap` param — both functions already
receive it). EVERYTHING ELSE in both functions is UNCHANGED (the
`closeCurrentRun()` call, the `open = true`, the `segRuns.push`,
`currentRun`). The container is created by the FIRST run's
`ensureReasoningWrap` call — no other call site.
4. `frontend/assets/app.js` — `settleReasoning(wrap)` (beside
`closeAllRuns` ~L1149):
`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
(~L3553) is UNCHANGED — `closeCurrentRun()` folds the active run;
the container STAYS OPEN (D2 — the TODO's core contract).
5. `frontend/assets/app.js` — the D6 status: in the thinking frame
branch (~L3381, after `ensureThinkingRun`), set
`reasoningWrapEl?.querySelector(".reasoning-status")?.textContent =
"Thinking…"`; in the tool frame branch (~L3501, after
`ensureToolRun`), set `"Calling " + name + "…"` (the `name` the
`appendToolLine` call already receives — the phase-70 legacy-name
mapping already produced it); at the three settle sites (alongside
`settleReasoning`), set `"${segRuns.length} steps"` (the live `N` —
D6; the settle sites read `segRuns` — module scope).
6. `frontend/assets/app.js` — the D3 live follow: the
`REASONING_NEAR_BOTTOM_PX = 32` constant (beside the phase-17
`THINKING_NEAR_BOTTOM_PX`) + the follow helpers —
`isReasoningPinned(el)` (the pre-update capture — the phase-17
pre-measure convention: `el.scrollTop + el.clientHeight >=
el.scrollHeight - REASONING_NEAR_BOTTOM_PX`) and
`scrollReasoningToBottom(el)` (the post-append apply: `el.scrollTop
= el.scrollHeight`). Called from BOTH the thinking frame branch (pre:
after the existing `isThinkingNearBottom` capture; post: after the
block re-render) and the tool frame branch (pre: before
`appendToolLine`; post: after it) — `el` =
`reasoningWrapEl?.querySelector(".reasoning-body")` (a no-op when
absent — the plain-answer turn never creates one). The EXISTING
per-block `isThinkingNearBottom(textEl)` follow is UNCHANGED
(harmless — task 03 makes the inner `.thinking-text`
non-scrollable inside the container, so it no-ops there). The
phase-87 `armToolLineClock`/`settleToolLine` wrap-scoped queries are
DESCENDANT-scoped — UNCHANGED (they match the nested runs).
7. `tests/unit/test_reasoning_bubble.py` (new) — the source-level pins:
`createReasoningWrap`'s createElement shape (the `reasoning` /
`reasoning-summary` / `reasoning-status` / `reasoning-body` classes,
the `Reasoning` literal, innerHTML ABSENT), `ensureReasoningWrap`'s
get-or-create + `open = true` + the reset in `runTurn`, 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 and NOT
at the `delta` branch, the status literals (`Thinking…` / `Calling `
/ ` steps`), the `REASONING_NEAR_BOTTOM_PX = 32` constant + the
pre/post split, and the pagehide partial's readability (the module
scope reset pattern).
## Testing & Quality
- Unit: `tests/unit/test_reasoning_bubble.py` (above) — all pins green.
- Coverage: **>90%** on this task's new/modified code (frontend — the
`app/` gate is untouched; held by the suite).
## Completion Criteria
- [ ] `tests/unit/test_reasoning_bubble.py` green (`uv run pytest
tests/unit/test_reasoning_bubble.py -v --no-cov`)
- [ ] the full test suite green (`uv run pytest`)
- [ ] no behavior change in completed work beyond the documented
anchor supersession (the per-run blocks' structure + open/fold
behavior is byte-identical; only their parent changed)
@@ -0,0 +1,89 @@
# Task 02 — The collapsed restore + shared-page container
**Phase:** `161_reasoning_bubble` · **Source:** `TODO.md:3 — "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
## Objective
The restore path (`renderStoredMessage` → `restoreTimeline`) and the
shared page (`shared.js` → `addSegments`) render the SAME `details
.reasoning` container COLLAPSED around the per-run timeline, with the
D6 at-rest step-count summary; records without `segments` keep the
byte-identical legacy path (no container — D4).
## Work
1. `frontend/assets/app.js` — `restoreTimeline` (~L2195): BEFORE the
loop, create the container — `createReasoningWrap()` (the task-01
builder — the shared one), `open = false` (D5 — the restore renders
collapsed; explicit for the pin), the summary's
`.reasoning-status` filled with the at-rest count (step 2 below),
inserted ONCE `before` `body.querySelector(".bubble")`. Every
per-run block's `body.insertBefore(block, bubble)` /
`body.insertBefore(toolDisc, bubble)` line becomes
`container.querySelector(".reasoning-body").appendChild(…)` — the
rest of the loop is UNCHANGED (the malformed-entry skips, the
tOffset/tLimit/tPath well-formedness guards, the
`appendTruncatedNote` call, the thinking text's escape-first
`renderMarkdown` write, the UNKNOWN-`kind` skip).
2. `frontend/assets/app.js` — the D6 at-rest step count, computed
INCREMENTALLY inside the loop (matching the live `segRuns.length`
semantics — contiguous runs, NOT entry count): `runs = 0`; a
`thinking` entry: `if (the previous run was not a thinking run)
runs += 1` (track the previous entry's kind); a `tool` entry: the
first entry of a NEW contiguous group (i.e. `toolDisc` was null
before this entry created it) → `runs += 1`. After the loop, set
`.reasoning-status` `textContent = "${runs} steps"`. (A record with
only one run of one kind → `1 steps` — the D6 literal is UNIFORM:
no singular/plural special-case — the executor pins the literal
EXACTLY as `${runs} steps`.)
3. `frontend/assets/app.js` — the `renderStoredMessage` dispatch
(~L2292) is UNCHANGED: a record WITHOUT `segments` never reaches
`restoreTimeline` — the legacy path is byte-identical (no
container — D4). A comment update on the dispatch cites the
container (the segments path now wraps in the collapsed container).
4. `frontend/assets/shared.js` — `addSegments` (~L269): the SAME
wrapper parity copy — create the container (createElement only —
innerHTML ABSENT; the shared.js file has no `createReasoningWrap`
— a LOCAL parity builder mirroring the app.js one: the same
`reasoning` / `reasoning-summary` / `reasoning-status` /
`reasoning-body` classes, the `Reasoning` literal, the summary's
`textContent = "Reasoning"` + the span), created CLOSED (the
pure-render convention — D5), the SAME incremental step count (the
same two-line semantics as step 2 — a parity copy, not an import),
inserted ONCE before the bubble (the shared page's existing
insertion convention — mirror the existing `addSegments`/
`addToolLines` anchor), and every per-run block appends into the
container's `.reasoning-body` (the existing
`wrap.querySelector(".msg-body").appendChild(…)` lines of the loop
— verify the actual anchor in the file and mirror it EXACTLY). The
line bytes MIRROR `addToolLines` — UNCHANGED (the four label
literals, the phase-95 truncation note, the phase-139/146
suffixes). The record dispatch (~L584) is UNCHANGED (no
`segments` → the legacy `addThinkingBlock` + `addToolLines` path,
byte-identical).
5. `tests/unit/test_reasoning_bubble.py` (extended) — the restore
wrapper: created CLOSED (the `open = false` pin), the per-run
blocks append into `.reasoning-body` (the `insertBefore(…bubble)`
per-run anchor is GONE from `restoreTimeline`), the container
inserted ONCE (a single `details.reasoning` per message — the
get-or-create shape), the incremental step count (a thinking entry
after a tool group starts a NEW run; two adjacent thinking entries
are TWO runs; a tool group of N entries is ONE run — the
`segRuns.length` semantics), the legacy no-segments path is
untouched (`renderStoredMessage`'s dispatch unchanged — the source
pin), the `shared.js` `addSegments` parity (the collapsed wrapper +
the step count + the unchanged line bytes + the local builder's
classes/literal), and `innerHTML` ABSENT from BOTH wrappers.
## Testing & Quality
- Unit: `tests/unit/test_reasoning_bubble.py` (extended — above) — all
pins green.
- Coverage: **>90%** on this task's new/modified code (frontend — the
`app/` gate is untouched; held by the suite).
## Completion Criteria
- [ ] `tests/unit/test_reasoning_bubble.py` green (`uv run pytest
tests/unit/test_reasoning_bubble.py -v --no-cov`)
- [ ] the full test suite green (`uv run pytest`)
- [ ] no behavior change in completed work beyond the documented
restore/shared wrapper (a pre-phase record — no `segments` —
renders byte-identically; the per-run block bytes are unchanged)
@@ -0,0 +1,98 @@
# Task 03 — The container's CSS
**Phase:** `161_reasoning_bubble` · **Source:** `TODO.md:3 — "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
## Objective
The `details.reasoning` container's styling: the native
`details`/`summary` treatment of the house family (the
`details.thinking` convention — L633), the bounded 320px live window
(D3), the inner-run compacting (de-bordered, one scroll surface), the
reduced-motion stills — all AA-contrast, WCAG 2.1 AA basics
(focusable summary ≥44px, `:focus-visible`, chevron).
## Work
1. `frontend/assets/styles.css` — the `details.reasoning` block (a
NEW distinct comment block citing this phase + D3; placed
ADJACENT to the `details.thinking` family — after the
`details.thinking .thinking-text` margin rule ~L684, before the
phase-117 `.tool-calls-disclosure` comment ~L689 — verify the
actual layout in the file and keep the family blocks adjacent):
`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;` — the
family treatment of `details.thinking` (the same surface/line/
brand-soft/radius tokens; the comment documents the parity + the
D3 window).
2. `frontend/assets/styles.css` — `summary.reasoning-summary`:
`display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem
0.75rem; min-height: 44px;` (the WCAG ≥44px target — parity with
the `details.thinking` summary); `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;`;
`summary.reasoning-summary::-webkit-details-marker { display: none
}`; the CSS chevron `summary.reasoning-summary::before { content:
"▸"; display: inline-block; transition: transform 0.15s ease; }` +
`details.reasoning[open] summary.reasoning-summary::before {
transform: rotate(90deg); }` (the `details.thinking` convention);
`summary.reasoning-summary:focus-visible { outline: 3px solid
var(--brand); outline-offset: 2px; }` (the house 3px ring).
3. `frontend/assets/styles.css` — `.reasoning-status`: `color:
var(--ink-soft)` (6.9:1 on `--surface` — AA); `font-size: 0.8rem;
line-height: 1.4; margin-left: 0.25rem;` (D6 — text + color, never
color alone — B5; the status is a span INSIDE the summary — the
flex row lays it out after the literal).
4. `frontend/assets/styles.css` — `.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 pinned-tail
follow in app.js).
5. `frontend/assets/styles.css` — the inner-run compacting (D3 — the
nested-border weight removed; ONE scroll surface):
`details.reasoning details.thinking { border: 0; border-left: 2px
solid var(--line); border-radius: 0; margin: 0.25rem 0;
background: transparent; }` + `details.reasoning details.thinking
.thinking-text { max-height: none; overflow-y: visible; }` (the
inner 320px window disabled — the container's window is the single
scroll surface; the phase-17 per-block follow in app.js no-ops
accordingly — the task-01 comment) + `details.reasoning
.tool-calls-disclosure { margin: 0.25rem 0; }` (already compact —
the rule documents the intent, no visual change).
6. `frontend/assets/styles.css` — the reduced-motion block (L1499,
the existing `details.thinking summary::before { transition: none;
}` stills list): append `summary.reasoning-summary::before {
transition: none; }` (the chevron rotation stills — parity with
the existing stills).
7. No JS change in this task (the selectors the task-01/02 JS
creates — `reasoning` / `reasoning-summary` / `reasoning-status` /
`reasoning-body` — are the CSS contract).
## Testing & Quality
- Unit: `tests/unit/test_reasoning_bubble.py` (extended) — the source
pins: the `details.reasoning` rule exists with the family tokens +
`overflow: hidden`; the `summary.reasoning-summary` rule with
`min-height: 44px` + the `:focus-visible` ring + the webkit-marker
suppression + the chevron `::before` + the `[open]` rotation; the
`.reasoning-status` rule with the AA token; the `.reasoning-body`
rule with `max-height: 320px` + `overflow-y: auto`; the
inner-run compacting rules (the de-border + the
`max-height: none` on the inner `.thinking-text`); the
reduced-motion still appended.
- E2E (verified in task 04's suite): the computed-style pins —
`.reasoning-body`'s `max-height` ≤ 320px + `overflow-y: auto`
(the D3 contract — test 6 of `test_reasoning_bubble.py` per the
phase overview).
- Coverage: **>90%** on this task's new/modified code (frontend — the
`app/` gate is untouched; held by the suite).
## Completion Criteria
- [ ] `tests/unit/test_reasoning_bubble.py` green (`uv run pytest
tests/unit/test_reasoning_bubble.py -v --no-cov`)
- [ ] `uv run ruff check . && uv run pyright` clean (the CSS is
verified by the unit source pins — no CSS lint gate)
- [ ] the full test suite green (`uv run pytest`)
- [ ] no behavior change in completed work beyond the documented
inner-run compacting (the `details.thinking` /
`.tool-calls-disclosure` rules OUTSIDE the container are
byte-identical — the legacy no-container path renders
unchanged)
@@ -0,0 +1,117 @@
# Task 04 — The mock mid-round flow + the story E2E suite
**Phase:** `161_reasoning_bubble` · **Source:** `TODO.md:3 — "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
## Objective
The mock `REASONING_MIDROUND_TRIGGER` flow (a mid-round `delta` BEFORE
the tool call — the React-style reply the TODO's stays-open contract
needs) + the dedicated `test_reasoning_bubble.py` suite, green in
isolation (mock-only).
## Work
1. `tests/e2e/mock_llm.py` — `REASONING_MIDROUND_TRIGGER = "think out
loud, answer, then check your work"` (beside
`THINK_TOOLS_TRIGGER` ~L1067). BEFORE wiring, verify BOTH (the
phase-overview convention): (a) no existing trigger phrase is a
substring of the new one AND the new one is not 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` (they
share only `think out loud`; NEITHER is a substring of the other —
verified, re-verify at execution); and (b) no existing E2E
question carries the new phrase (grep the suite). Checked in the
streaming branch BEFORE the `_thinking_tools_flow` dispatch
(the more-specific-phrase-wins convention — the phase-158
precedent).
2. `tests/e2e/mock_llm.py` — `_reasoning_midround_flow(body)`
(stateless, shaped like `_thinking_tools_flow` ~L1983 — 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."` (the content stream — BEFORE the tool call — the
exact mid-round ordering the TODO describes) → the
`_tool_call_stream("ls", {}, "call_0", thinking="")` (the tool
call AFTER the delta).
- request 2 (a `tool`-role result): `thinking` = `"Step 2: the
listing confirms it."` → a `delta` = `" — confirmed by the
listing."` → the content `finish` (the flow ends with a content
finish; NO further tool call — the `done` convention).
3. `tests/e2e/mock_llm.py` — dispatch: in `chat_completions`
(the phase-158 site ~L4631, beside the `think_tools =
_thinking_tools_flow(body)` check), the `REASONING_MIDROUND_TRIGGER`
check BEFORE the phase-158 check; the docstring gains the new
trigger entry (the phase-161 flow). The mock stays mock-only by
design (`E2E_REAL_LLM` note unchanged).
4. `tests/e2e/test_reasoning_bubble.py` (new — the dedicated story
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`'s setup). The six tests (per
the phase overview):
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 turn settles): 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 settled, the
container is STILL `open` with a run visible (the D2 contract —
the phase-158 first-delta-fold is superseded at the container
level; the TODO's core: "only collapse once the final response
has been returned and the session has stopped").
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); the
`.reasoning-status` is `3 steps` (the flow's contiguous-run
count — `thinking (Step 1)`, `tool (ls)`, `thinking (Step 2)` —
the D6 live settle literal).
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
D6 at-rest count — the restore's incremental count matches the
live `segRuns.length` semantics).
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
(an `expect.poll` window), folds at `done` — and its
`.reasoning-status` is `1 steps` (the D6 uniform literal — the
no-thinking turn's single tool run; the phase-117
single-disclosure behavior preserved, now wrapped).
+ the D3 computed-style pin (the phase overview's test 6 — fold
into test 1 or a dedicated assertion there): the
`.reasoning-body`'s computed `max-height` ≤ 320px +
`overflow-y: auto` (the bounded-window contract — the CSS
verified in the browser, not just in source).
## Testing & Quality
- E2E: `tests/e2e/test_reasoning_bubble.py` green **in isolation**
(`uv run pytest tests/e2e/test_reasoning_bubble.py -v --no-cov`),
mock-only.
- Coverage: **>90%** on this task's new/modified code (the mock is
test-only — the `app/` gate is untouched; held by the suite).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_reasoning_bubble.py -v --no-cov`
green (all six tests)
- [ ] the full test suite green (`uv run pytest`) — the phase-158
suite's direct-children pins may be RED at this point (they
update in task 05 — the documented change); every OTHER suite
must stay green
- [ ] no behavior change in completed work beyond the documented
mock addition (the existing triggers dispatch unchanged — the
more-specific-phrase-wins verification)
@@ -0,0 +1,87 @@
# Task 05 — Regression pins, the full gate, docs, commit
**Phase:** `161_reasoning_bubble` · **Source:** `TODO.md:3 — "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
## Objective
The documented pin updates (the phase-158 "direct children before the
bubble" contract → the container-nested contract — the ONE
owner-directed exception), the remaining unit-pin updates, the full
gate (unit + integration, E2E in isolation, coverage >90%, lint/
types), the README/AGENTS documentation pass, the atomic commit, the
phase move.
## Work
1. `tests/e2e/test_thinking_timeline.py` — the pin updates (the
documented change — the per-run blocks are now NESTED in
`details.reasoning`): every assertion that the per-run blocks are
DIRECT children of `.msg-body` before the bubble → the blocks are
children of `.msg-body > details.reasoning .reasoning-body`
(arrival order preserved — the seven-element order pin
UNCHANGED in shape, the container is the one new parent);
`test_live_runs_open_while_streaming` — the inner runs'
open-while-streaming contract UNCHANGED (the container is also
open — no pin change); `test_no_thinking_turn_single_disclosure` —
the single-disclosure selector now scoped inside the container (the
phase-117 contract UNCHANGED — one disclosure, now wrapped).
2. `tests/unit/test_thinking_timeline.py` — the insertion-anchor pin
updates: `ensureThinkingRun`/`ensureToolRun`'s
"insert before the bubble" pins → "append into
`details.reasoning .reasoning-body`" (the phase-161 anchor — the
documented supersession); every OTHER pin (the per-run create, the
phase-17 template, the clock, `buildSegments`, the save-payload
sites, the restore guards) stays green UNCHANGED.
3. The remaining E2E regression suite — run each in isolation; fix
ONLY the pins broken by the nesting (descendant selectors like
`.msg-body details.thinking` still match — they are UNCHANGED;
`:first-child`/direct-children pins may break):
`test_thinking_display.py`, `test_tool_call_compact.py`,
`test_big_read_progress.py` (the phase-87 clock — the wrap-scoped
descendant queries are unchanged — must stay green UNCHANGED if no
pin breaks), `test_turn_progress_loader.py`, `test_share_chat.py`,
`test_chat_persistence.py`, `test_smoke.py`.
4. The full gate — `uv run pytest` (100% of the project suite
green); `uv run pytest --cov=app --cov-report=term-missing`
(>90% on `app/` — the phase is frontend-only, the gate stays
green by the suite); `uv run pytest
tests/e2e/test_reasoning_bubble.py -v --no-cov` (green in
isolation); `uv run ruff check . && uv run pyright` (clean).
5. The documentation pass — `README.md` + `AGENTS.md` reviewed in
full against the project as it stands after this phase; fix every
inconsistency found (including pre-existing ones). The
reasoning bubble is a visible UI change — name it where the
chat thinking/tool-call display is described, if anywhere (the
README's chat description, if it names the phase-158
per-run timeline; the AGENTS.md quick-reference is unchanged —
no new commands).
6. The atomic commit + the phase move:
`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)"`
— then move `.agents/phases/todo/161_reasoning_bubble/` →
`.agents/phases/complete/161_reasoning_bubble/` (the phase is
Complete per the overview's criteria).
## Testing & Quality
- The full gate (step 4) — every check green before the commit.
- Coverage: **>90%** on `app/` (the gate — frontend-only phase, held
by the suite).
## Completion Criteria
- [ ] `uv run pytest` green (100% of the project suite)
- [ ] `uv run pytest --cov=app --cov-report=term-missing` > 90%
- [ ] `uv run pytest tests/e2e/test_reasoning_bubble.py -v --no-cov`
green in isolation
- [ ] `uv run ruff check . && uv run pyright` clean
- [ ] `git diff --stat` (before the commit) 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 + AGENTS.md accurate and consistent (the
documentation pass)
- [ ] ONE owner-directed exception to "no behavior change in
completed phases": the phase-158 direct-children layout is
superseded by the container nesting (owner TODO direction
2026-10-05 — recorded in the overview); the per-run
open/fold behavior itself is UNCHANGED
- [ ] one atomic `--no-gpg-sign` commit; the phase dir moved to
`.agents/phases/complete/`