Files
brain-of-reese/.agents/phases/todo/161_reasoning_bubble/00_phase.md
T

25 KiB
Raw Blame History

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

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.