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

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

**Verification complete — full results:**

- **Verified** (implemented by prior task passes, all in working tree): `createReasoningWrap`/`ensureReasoningWrap` live container (D1), `settleReasoning` fold at the 3 settle sites + not at delta (D2), 320px `.reasoning-body` window + pinned-tail follow (D3), zero persistence change (D4), collapsed restore + shared-page parity with D6 step counts, `styles.css` family treatment + reduced-motion still, mock `REASONING_MIDROUND_TRIGGER` flow, 6-test story suite, phase-158 pin updates
- **Full suite:** `uv run pytest` → **3738 passed, 0 failed** · `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99% (>90%)** · `uv run ruff check .` → all checks passed · `uv run pyright` → **0 errors, 0 warnings**
- **E2E in isolation** (DB up; port-8123 collision with an unrelated `undercooked` service → `E2E_APP_PORT=8125`/`E2E_MOCK_PORT=8902`, no live change made): `test_reasoning_bubble.py` **6/6** · `test_thinking_timeline.py` ✓ · `test_thinking_display.py` ✓ · `test_tool_call_compact.py` ✓ · `test_big_read_progress.py` ✓ · `test_turn_progress_loader.py` ✓ · `test_share_chat.py` ✓ · `test_chat_persistence.py` ✓ · `test_smoke.py` ✓ (all exit 0, zero failures)
- **Completion criteria:** all 9 checked against the code + test outcomes — container open/live/nested ✓, stays open past mid-round delta ✓, folds at done ✓, restore + shared collapsed with "3 steps" ✓, plain turn = no container ✓, regression suites green ✓, suite/coverage/lint green ✓, diff scope limited to `frontend/assets/{app.js,shared.js,styles.css}` + `tests/` + `README.md` + phase files (no `app/` — D4) ✓
- **Documentation:** README.md "Thinking" section names the Reasoning bubble accurately (320px window, open-until-settle incl. mid-round deltas, "N steps", byte-identical no-container, collapsed on restore/shared) — verified consistent, no changes needed; AGENTS.md — verified consistent, no changes needed; all `.env.example` vars documented
- **Decisions:** commit + phase-dir move left to the harness (per pass rules — no git index/history commands executed); `undercooked` dev server on port 8123 untouched, E2E used the env port overrides
- **Next pending phase:** none — `161_reasoning_bubble` is the last in `todo/`
This commit is contained in:
2026-10-06 06:36:09 -04:00
parent 8172956c15
commit 051f62deb7
48 changed files with 4194 additions and 166 deletions
+56 -3
View File
@@ -247,6 +247,33 @@ function addToolLines(wrap, tools) {
body.insertBefore(disclosure, body.querySelector(".bubble"));
}
/* Phase 161 (task 02, D1/D6): the SHARED page's parity copy of
* app.js's createReasoningWrap — the SAME details.reasoning
* container (summary.reasoning-summary with the "Reasoning" literal
* — D6's ONLY new static literal — + the EMPTY span.reasoning-status
* the at-rest count fills, and the .reasoning-body window), built
* with createElement only (innerHTML ABSENT — the house rule; the
* "Reasoning" literal is a static text node, no data flows through
* it). The caller owns open/fold + insertion: the shared page
* renders it COLLAPSED around the per-run timeline (D5 — the
* pure-render convention) with the D6 "N steps" summary. A parity
* copy, not an import (the shared page has no import of app.js). */
function createReasoningWrap() {
const cont = document.createElement("details");
cont.className = "reasoning";
const summary = document.createElement("summary");
summary.className = "reasoning-summary";
summary.textContent = "Reasoning"; // D6: the ONLY new static literal (parity with app.js)
const status = document.createElement("span");
status.className = "reasoning-status"; // empty until the at-rest count fills it
summary.appendChild(status);
cont.appendChild(summary);
const body = document.createElement("div");
body.className = "reasoning-body";
cont.appendChild(body);
return cont;
}
/* Phase 158 (task 03, D4/D5): the SHARED page's per-run timeline —
* the parity copy of the chat page's restoreTimeline: the record's
* `segments` array (the per-run order, task 01's ChatMessage field)
@@ -255,7 +282,18 @@ function addToolLines(wrap, tools) {
* details.tool-calls-disclosure ("Tool call(s) (N)" per run), in
* arrival order, ABOVE the answer — a pure render, so everything is
* created CLOSED (D5; the same collapsed convention as
* addThinkingBlock / addToolLines). The structure is
* addThinkingBlock / addToolLines). Phase 161 (task 02, D5/D6): the
* per-run timeline renders inside ONE details.reasoning container —
* the local parity builder above, created CLOSED (the pure-render
* convention) and inserted ONCE before the bubble (the shared
* page's existing insertion convention); every per-run block
* appends into the container's .reasoning-body (the per-run bubble
* anchor is superseded — arrival order preserved). The D6 at-rest
* step count uses the SAME two-increment semantics as app.js's
* restoreTimeline (a parity copy, not an import — contiguous runs:
* each thinking entry is ONE run; the first entry of a new
* contiguous tool group is ONE run), with the same uniform
* `${runs} steps` literal. The structure is
* createElement/textContent; the line bytes mirror addToolLines
* EXACTLY (the four pinned label literals + the phase-70 legacy
* names + the phase-139 readRangeSuffix + the phase-95 truncation
@@ -270,7 +308,17 @@ function addSegments(wrap, segments) {
const body = wrap.querySelector(".msg-body");
if (!body) return;
const bubble = body.querySelector(".bubble");
// Phase 161 (task 02, D5/D6): the container — the local parity
// builder, created CLOSED (D5 — the pure-render convention;
// explicit for the pin) and inserted ONCE before the bubble (the
// shared page's existing insertion convention — the per-run blocks
// nest into its .reasoning-body, arrival order preserved).
const cont = createReasoningWrap();
cont.open = false; // D5: collapsed (the pure-render convention; explicit for the pin)
const runsEl = cont.querySelector(".reasoning-body");
body.insertBefore(cont, bubble); // the shared page's existing insertion convention
let toolDisc = null; // the CURRENT run of consecutive tool entries (one disclosure per run, D1)
let runs = 0; // D6: the at-rest step count — contiguous runs, not entry count
for (const entry of segments || []) {
// a malformed entry (or one with an unknown `kind`) is SKIPPED —
// never render from storage (textContent only, the house rule).
@@ -278,6 +326,7 @@ function addSegments(wrap, segments) {
if (entry.kind === "thinking") {
if (typeof entry.text !== "string") continue; // malformed: skipped
if (toolDisc) toolDisc = null; // the run is sealed — the count is final
runs += 1; // D6: each thinking entry is ONE run (parity with app.js — a thinking entry after a tool group starts a run; two adjacent thinking entries are TWO runs)
const block = document.createElement("details");
block.className = "thinking";
block.open = false; // D5: collapsed (the addThinkingBlock convention)
@@ -287,13 +336,14 @@ function addSegments(wrap, segments) {
textEl.className = "thinking-text";
textEl.innerHTML = renderMarkdown(entry.text); // escape-first, XSS-safe (parity with addThinkingBlock)
block.append(summary, textEl);
body.insertBefore(block, bubble); // D2: above the bubble, in arrival order
runsEl.appendChild(block); // Phase 161 D5: inside the container, in arrival order
} else if (entry.kind === "tool") {
if (typeof entry.name !== "string") continue; // malformed: skipped
if (!toolDisc) {
toolDisc = document.createElement("details");
toolDisc.className = "tool-calls-disclosure";
toolDisc.open = false; // D5: collapsed (parity with addToolLines — phase 117 D3)
runs += 1; // D6: the first entry of a NEW contiguous group is ONE run (parity with app.js — a group of N entries is ONE run)
const summary = document.createElement("summary");
summary.className = "tool-calls-summary";
toolDisc.appendChild(summary);
@@ -302,7 +352,7 @@ function addSegments(wrap, segments) {
container.setAttribute("role", "list");
container.setAttribute("aria-label", "Tool calls");
toolDisc.appendChild(container);
body.insertBefore(toolDisc, bubble); // D2: above the bubble, in arrival order
runsEl.appendChild(toolDisc); // Phase 161 D5: inside the container, in arrival order
}
const container = toolDisc.querySelector(".tool-calls");
const line = document.createElement("span");
@@ -364,6 +414,9 @@ function addSegments(wrap, segments) {
// an entry with an unknown `kind` (or a malformed shape) is
// SKIPPED — never render from storage (textContent only, the
// house rule); a guard comment names the skip.
// Phase 161 (task 02, D6): the at-rest step count — the UNIFORM
// literal (parity with app.js — no singular/plural special-case).
cont.querySelector(".reasoning-status").textContent = `${runs} steps`;
}
/* Phase 139 (task 01): the shared page's parity copy of app.js's