chore(agent): phase roadmap from TODO.md, 1 phase (161 reasoning bubble)
LLM-Generated: true
This commit is contained in:
@@ -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/`
|
||||||
Reference in New Issue
Block a user