25 KiB
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.reasoningcontainer 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 = truefrom creation until the turn settles. A turn with no thinking and no tools creates NO container (byte-identical DOM —BOR_STREAM_THINKING=0and 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 — alongsidecloseAllRuns. A mid-rounddelta(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-bodyis ONE bounded scrollable live window —max-height: 320px(the phase-17 window convention) +overflow-y: auto— with pinned-tail autoscroll (the phase-17isThinkingNearBottompattern 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
segmentsrecord, theChatMessageschema, 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'ssegments). Records WITHOUTsegmentsrestore 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+ aspan.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(classreasoning) +summary(classreasoning-summary,textContent = "Reasoning"— D6, the ONLY new static literal; the span is empty until a frame sets it) +div(classreasoning-body). The caller owns open/fold + insertion.ensureReasoningWrap(wrap)— get-or-create per turn (module scopereasoningWrapEl, reset withsegRunsinrunTurn~L3304 — the pagehide partial reads the LIVE container, so the module scope is readable there likesegRuns): on the turn's first thinking/tool frame —createReasoningWrap(),open = true(D1), insertedbeforebody.querySelector(".bubble")(the container is the ONE new anchor; the per-run blocks move frominsertBefore(bubble)toreasoningWrapEl.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,segRunspush,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 alongsidecloseAllRuns(wrap)at the THREE settle sites (thedonebranch ~L3570, the STOP path ~L3752,finalizeFailedTurn~L3218). Thedeltabranch UNCHANGED (closeCurrentRun()— the active run folds; the container stays open — D2).- The live status (D6): the thinking frame branch sets
reasoningWrapEl's.reasoning-statustextContent = "Thinking…"; the tool frame branch sets"Calling <name>…"(thenametheappendToolLinecall 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 constantREASONING_NEAR_BOTTOM_PX = 32) andscrollReasoningToBottom(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: beforeappendToolLine; post: after it) —el=reasoningWrapEl?.querySelector(".reasoning-body")(a no-op when absent). The EXISTING per-blockisThinkingNearBottom(textEl)follow stays (harmless — the inner.thinking-textis no longer scrollable inside the container (task 03'smax-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-87armToolLineClock/settleToolLinewrap-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: eachthinkingentry starts a run (count+1 when the previous is not a thinking entry — the contiguous semantics matchingsegRuns.length); each CONTIGUOUS tool group is ONE run (count+1 at the group's first entry)); the container is insertedbeforethe bubble ONCE. Every per-run block appends into the container's.reasoning-body(theinsertBefore(block, bubble)lines become appends — the rest of the loop — the malformed-entry skips, the tOffset/tLimit/tPath guards, theappendTruncatedNotecall — is UNCHANGED).- The
renderStoredMessagedispatch (~L2292) is UNCHANGED: a record WITHOUTsegmentsnever reachesrestoreTimeline— 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 MIRRORaddToolLines— unchanged). The record dispatch (~L584) is UNCHANGED (nosegments→ the legacyaddThinkingBlock+addToolLinespath, byte-identical).
frontend/assets/styles.css (task 03)
details.reasoning— the family treatment ofdetails.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 thedetails.thinkingsummary uses);font-size: 0.9rem; cursor: pointer; list-style: none;; the webkit markerdisplay: none; the CSS chevron::before▸rotating 90° on[open](thedetails.thinkingconvention; the transition stills in the reduced-motion block — task 03 appends it to the L1499 still list);:focus-visible→ the house 3pxvar(--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::beforestill (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_flowdispatch (the more-specific-phrase-wins convention — the phrase CONTAINSthink 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 isthink out loud and use your tools— the new phrase shares onlythink 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 thetoolsparameter + 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-ROUNDdelta="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."→ adelta=" — confirmed by the listing."→ thefinish(thedoneconvention — the flow ends with a content finish; NO further tool call).
- request 1 (tools offered, no tool results yet):
- The mock stays mock-only by design (
E2E_REAL_LLMnote 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:
test_container_open_during_the_turn— during the turn (anexpect.pollpredicate over the mock's 0.1 s pacing window, before the answer bubble is complete): adetails.reasoningexists, isopen, contains ≥1details.thinkingordetails.tool-calls-disclosureINSIDE its.reasoning-body(the per-run blocks are NESTED — the phase-158 direct-children layout is superseded), and its.reasoning-statuscarries a live suffix (Thinking…orCalling …).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 notdone, the container is STILLopenwith 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).test_container_folds_at_done— afterdone: the container has NOopenattribute; its summary is visible; EVERY inner run has noopenattribute (the D2/D5 settle — the phase-158 fold-at-rest contract preserved inside the fold).test_restored_container_collapsed_with_step_count— RELOAD (same context): the restored message renders ONEdetails.reasoning(NOopenattribute) containing the same per-run timeline, and its.reasoning-statusis3 steps(the mid-round flow's run count — the D6 at-rest count: the flow has thinking → delta → tool → thinking → delta: the runs arethinking (Step 1),tool (ls),thinking (Step 2)= 3 contiguous runs — matchingsegRuns.length; the semantics is what's pinned: contiguous-run count).test_shared_page_container_collapsed— the phase-51 save + share flow, a FRESH context on/shared/<token>: ONEdetails.reasoning(collapsed) with the same timeline + step count (theshared.jsparity —addSegments).test_no_reasoning_turn_has_no_container— a plain answer question: ZEROdetails.reasoningin the brain wrap (D1/D4 — the byte-identical DOM); and a plainuse your toolsquestion (NO thinking trigger): the container EXISTS (the tool frames create it), wraps the ONE phase-117 disclosure, isopenlive, folds atdone(the no-thinking turn's contract — the phase-117 single-disclosure behavior preserved, now wrapped).
Tasks
01_live_container.md— the container's static builder + the get-or-create live wrapper (D1) + the re-anchored per-run inserts + thesettleReasoningfold at the three settle sites (D2) + the summary live status (D6) + the live follow (D3) + the JS source unit pins.02_restore_and_shared.md— the collapsed restore wrapper (D5/D6, the incremental step count) + theshared.jsaddSegmentsparity + the unit pins.03_container_css.md— thedetails.reasoningstyling family, the bounded live window, the inner-run compacting, the reduced-motion stills.04_mock_and_e2e.md— the mockREASONING_MIDROUND_TRIGGERflow + the dedicatedtest_reasoning_bubble.pystory suite, run in isolation.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 inrunTurn,open = trueat creation, the per-run inserts append into.reasoning-body(theinsertBefore(bubble)per-run anchor is GONE fromensureThinkingRun/ensureToolRun),settleReasoningdefined + called at the THREE settle sites (done / stop / finalizeFailedTurn) and NOT at thedeltabranch (D2 — thecloseCurrentRunthere is unchanged), the status literals (Reasoning/Thinking…/Calling/steps), thefollowReasoningBodypin + theREASONING_NEAR_BOTTOM_PXconstant, and theinnerHTMLABSENT 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 —restoreTimelineunreachable without segments), theshared.jsaddSegmentsparity (the collapsed wrapper + the step count + the unchanged line bytes), andinnerHTMLABSENT 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-elapsedbehavior, 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% onapp/(this phase is FRONTEND-ONLY —app/is untouched; the gate stays green by the suite). - Lint/types:
uv run ruff check . && uv run pyrightclean. - 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.mdandAGENTS.mdin 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.mdimmediately 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 atdone(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.pygreen in isolation.uv run pytestgreen (100% of the project suite);uv run pytest --cov=app --cov-report=term-missing> 90%;tests/e2e/test_reasoning_bubble.pygreen in isolation;uv run ruff check . && uv run pyrightclean.git diff --statlimited tofrontend/assets/app.js,frontend/assets/shared.js,frontend/assets/styles.css,tests/, and the phase files (NOapp/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.pypins update in task 05); the per-run open/fold behavior itself is UNCHANGED. - One atomic
--no-gpg-signcommit (below); the phase dir moved to.agents/phases/complete/.
Commit
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
segmentsfield 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-signcommit. - 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-runinsertBefore(bubble)→ the container's.reasoning-bodyappend. No other anchor changed.