All verification complete — every gate green. Final report: **Phase 117 final verification pass — all criteria verified** (tasks 01–04 were already in `complete/`; I re-verified the full gate myself, no code changes needed) - **Verified:** `appendToolLine` wraps lines in `details.tool-calls-disclosure` (open live, `closeToolCalls` at delta/done/stop + restore-closed, count via textContent); `addToolLines` parity (closed); `.tool-call` deboxed / `code` dechipped; new disclosure/summary CSS; only the one pinned CSS assert updated - `uv run pytest` → **2466 passed** - `uv run pytest --cov=app --cov-report=term-missing` → **99%** on `app/` (>90% floor) - E2E in isolation: `test_tool_call_compact.py` **4 passed** (fold/expand/deboxed-flow/restored-folded); `test_agent_document_tools.py` **4 passed**; `test_big_read_progress.py` **4 passed** (live disclosure open + `.tool-elapsed` visible); `test_thinking_display.py` **5 passed**; `test_smoke.py` **3 passed** - `uv run ruff check . && uv run pyright` → clean (pyright 0 errors, 0 warnings) - Completion criteria: pins 1–4 ✅ (E2E above); live "calling tool" state byte-identical ✅ (`test_frontend_tool_states.py` 10 passed incl. `test_calling_tool_label_strings`); scope ✅ — `git diff` limited to the 3 frontend assets + 2 unit tests + E2E story + phase files, **0 changes in `app/`** - Notable: `TODO.md` carries a **pre-existing uncommitted owner edit** (new retrieval-context idea) already in the tree before this pass — left untouched, will ride along in the harness commit; pre-existing committed quirk: the brain-wave commit's styles.css comment mislabels itself "Phase 117" (cosmetic, out of this diff) - No commit made (harness commits + moves the phase); **next pending phase: none** — `todo/` holds only phase 117
19 KiB
Phase 117 — Compact, well-wrapped tool-call lines
Source: owner visual-glitch report (live chat, 2026-09-15) — "how much space the tool calls take up, and the tool call text is spit and wrapped poorly."
Story: n/a (owner bug report — mobile viewport, https://brain.reeseapps.com, reproduced 2026-09-15)
Context: frontend/assets/app.js (appendToolLine — renders one .tool-call line per tool SSE frame into a .tool-calls list above the answer bubble; ensureThinkingBlock — places the Thinking <details> above the .tool-calls list via anchor = body.querySelector(".tool-calls") ?? body.querySelector(".bubble"); the delta/done/stop handlers each call closeThinkingBlock(wrap); renderStoredMessage re-renders persisted tools through the SAME appendToolLine); frontend/assets/shared.js (addToolLines — the shared page's local copy of the same renderer, pinned byte-parity with appendToolLine); frontend/assets/styles.css (.tool-calls = flex column + gap; .tool-call = display:flex; align-items:baseline + a full card: background + border + border-left:3px var(--accent-line) + border-radius + padding; .tool-call code = a chip: var(--brand-soft) background + padding + radius on top of var(--mono)/var(--ink)); tests/unit/test_frontend_tool_states.py (pins the exact tool-line template literals + the .tool-calls/.tool-call DOM shape + the .tool-call CSS — test_tool_call_style_is_accent_and_contrast_safe asserts display: flex + var(--accent-line) on .tool-call); tests/unit/test_big_read_progress.py (pins phase 87's .tool-elapsed clock, which queries .tool-calls .tool-call:last-child and appends a SIBLING suffix — the line's own text/literals stay byte-identical); tests/e2e/test_agent_document_tools.py + tests/e2e/test_big_read_progress.py (the tool-line E2E — the former asserts .tool-call count + to_contain_text after a completed turn, the latter asserts the FIRST .tool-call line and the .tool-elapsed suffix are to_be_visible DURING the live frameless gap, before the answer's delta).
Bug basis (code-traced + reproduced live, 2026-09-15)
Reproduced at 390×844 (mobile) on the live site. One answer to "Generate a change log … last 5 phases" rendered 6+ stacked full-width cards — one per tool frame — each a complete bordered card (accent left border + surface background + radius) holding a mono path chip inside it. Two distinct defects:
- Space: each
toolframe appends a full-width bordered.tool-callcard spanning the whole chat column. The agent loop is round-capped and fires several calls per turn (ls → read → read …), so a single answer stacks N cards above it. On a phone the tool process visually swamps the answer. - Wrapping:
.tool-callisdisplay:flex; align-items:baseline, so the label text node ("📄 Reading ") and the<code>path are two separate flex items. The long mono path squeezes the label flex-item, andoverflow-wrap: anywherebreaks the label mid-word (Reading→Rea/ding); the path wraps to 3 lines indented to the right of that narrow broken label. The "spit and wrapped poorly" look. - The live-feedback that must be preserved: the "calling tool" live state lives in
#send-status(aria-live) + the typing-indicator aria-label (pinned bytest_calling_tool_label_strings), NOT the tool lines — so folding the tool lines does not remove any live feedback. Phase 87's ticking.tool-elapsedsuffix, however, IS on a.tool-callline and its E2E asserts it is visible during the live gap — so the lines must stay visible while the turn is in flight and fold only once the answer begins.
Objective
Turn the per-call tool "cards" into a single collapsible disclosure — one compact "Tool calls (N)" line by default, expandable to the individual calls — and restyle the individual lines as deboxed, inline-flowing text so the path wraps to the left edge like a normal sentence and the label never breaks mid-word. Collapsed by default for completed/restored turns (the space fix), open during a live turn (keeps phase 87's live suffix visible), with the "calling tool" live state unchanged.
Owner decisions (chat, 2026-09-15 — recorded per AGENTS.md rule 3)
- D1 — Frontend-only. No
app/change, no new SSE frame, no persistence-format change. The stored record stays{name, argument}(+truncated); only the rendering changes. The server and the SSE event set are byte-identical. - D2 — Reuse the Thinking-block convention. The tool calls become a native
<details>/<summary>disclosure in the same.msg-bodywrap, mirroringdetails.thinking(open-while-active, closed-when-done). No new component, no new JS dependency. - D3 — Open live, folded at rest. The disclosure is created open on the first live
toolframe (the calls + phase 87's.tool-elapsedsuffix stay visible during the turn); it folds when the answer's firstdeltaarrives, ondone, and on stop/abort (closeToolCalls, idempotent — the exact sites that callcloseThinkingBlock). The restore path (renderStoredMessage) and the shared page (addToolLines) render it closed. This is what makes the user's screenshot (a completed turn) collapse to one line while keeping the live behavior green. - D4 — Debox + inline flow. The
.tool-callline loses its card (nodisplay:flex/ background / border / left border / radius / padding) so the label + inline<code>flow as one continuous run (fixes the mid-word label break + indented wrap); the<code>loses its chip background but keepsvar(--mono)+var(--ink). The accent is carried by the line'scolor(var(--accent-ink)), not a border. - D5 — Plain-text summary, no emoji. The summary reads
Tool call (1)/Tool calls (N)— plain text, matching the emoji-free chrome (the Thinking summary is just "Thinking") and the phase-08 emoji-free-chrome lean. The existing📄/🔎line glyphs stay (they are pinned literals + the emoji-guard strip set). - D6 — Keep the pinned literals + DOM shape. The four
line.textContent = "…"label literals, the.tool-callslist (role=list, aria-label "Tool calls"), the.tool-calllistitems, and the<code>textContentarguments stay byte-identical — sotest_frontend_tool_states.py(all but the one CSS assert),test_big_read_progress.py, the emoji guard, and phase 87's.tool-calls .tool-call:last-childquery all stay green unchanged.
Design (shared by all tasks — the executor reads this, not the chat)
frontend/assets/app.js
appendToolLine(wrap, name, argument)— wrap the list in a disclosure; keep everything else byte-identical:let container = body.querySelector(".tool-calls-disclosure");(idempotent per wrap — was.tool-calls).- On first frame:
container = document.createElement("details"); container.className = "tool-calls-disclosure"; container.open = true;then a<summary class="tool-calls-summary">(createElement, textContent only) appended first, then the existing list —const listEl = document.createElement("div"); listEl.className = "tool-calls"; listEl.setAttribute("role","list"); listEl.setAttribute("aria-label","Tool calls");— appended second; thenbody.insertBefore(container, body.querySelector(".bubble"))(the disclosure goes where the list did: above the answer, below an existing Thinking block). const list = container.querySelector(".tool-calls");— build the line EXACTLY as today (line.className="tool-call",role="listitem", the four pinnedline.textContentlabel literals, the<code>withcode.textContent = argument),list.appendChild(line).- Update the summary count on every append (live + restore):
const n = list.children.length; container.querySelector("summary").textContent = \Tool call${n === 1 ? "" : "s"} (${n})`;`. - No
innerHTMLanywhere in the function (house rule; pinned). The function stays flat (no nested function declaration) so thejs[fn : js.find("\n}\n", fn)]body-extraction pins keep working.
closeToolCalls(wrap)(new, next tocloseThinkingBlock):const disc = wrap?.querySelector?.(".tool-calls-disclosure"); if (disc) disc.open = false;— idempotent, no-op without a disclosure. Called at every existingcloseThinkingBlock(wrap)site: thedeltahandler (≈L2514), thedonehandler (≈L2519), and the stop/abort settle (≈L2646).renderStoredMessage— after the existingif (Array.isArray(m.tools)) { …appendToolLine(wrap, t.name, arg)… }loop, addcloseToolCalls(wrap);so a restored turn renders folded (the space fix; mirrors the thinking restore rendering collapsed).ensureThinkingBlock— the anchor becomesbody.querySelector(".tool-calls-disclosure") ?? body.querySelector(".tool-calls") ?? body.querySelector(".bubble")so the Thinking block lands above the WHOLE disclosure (not inside it).block.open = trueunchanged.
frontend/assets/shared.js
addToolLines(wrap, tools)— parity withappendToolLine, but created closed (pure render, always folded): build thedetails.tool-calls-disclosure(container.open = false) +summary.tool-calls-summary+ the existing.tool-callslist (role=list, aria-label) exactly as today; render each line byte-identical (the same four label literals +<code>+ the phase-95truncated-note); set the summary count once at the end (Tool call (N)/Tool calls (N)). Keep thecode.textContent = argumentcount at 3 and noinnerHTML(thetest_shared_page_tool_lines_…/test_frontend_tool_states.pyshared pins).
frontend/assets/styles.css
.tool-call— DEBOX: removedisplay:flex,align-items:baseline,gap,background,border,border-left,border-radius,padding. KEEPcolor: var(--accent-ink),font-size: 0.8rem,line-height: 1.4,overflow-wrap: anywhere. As a flex item of the.tool-callscolumn it stays block-level per line, but its label + inline<code>now flow as one continuous run → the path wraps to the left edge and the label no longer breaks mid-word..tool-call code— DECHIP: removebackground: var(--brand-soft),padding,border-radius. KEEPfont-family: var(--mono),font-size: 0.95em,color: var(--ink)(≈11.5:1, AA).- NEW
.tool-calls-disclosure+.tool-calls-summary— model the disclosure on the existingdetails.thinkingstyling (house AA palette, no new hue): the summary is a native focusable toggle that carries the house 3px:focus-visiblering (thedetails.thinking summaryalready has it — reuse that language), small status font,color: var(--accent-ink)(≈10.4:1 on the surface — same pairing the deboxed line uses). No new color literal (phase-92 zero-literal invariant; B5 text+color, never color alone — the count is text). .tool-calls(the list) — UNCHANGED: staysdisplay:flex; flex-direction:column; gap: 0.25rem(spacing between the now-deboxed lines)..tool-elapsed/.truncated-note— UNCHANGED (phase 87 / phase 95).
tests/unit/test_tool_call_compact.py (NEW — source-level house pattern)
- app.js:
appendToolLinebody containsdocument.createElement("details")+className = "tool-calls-disclosure"+document.createElement("summary");container.open = true(the live default, D3); the count literalTool call${n === 1 ? "" : "s"} (${n})(or the equivalent template) is built withtextContent(noinnerHTML);closeToolCallsis defined and called at 3 handler sites (delta/done/stop) + once inrenderStoredMessage(pin:closeToolCalls(wrap)appears ≥4×);ensureThinkingBlock's anchor includes.tool-calls-disclosure; the four pinnedline.textContent = "…"label literals are STILL present (mirror the guard);innerHTMLis NOT in theappendToolLinebody. - shared.js:
addToolLinesbody containsdocument.createElement("details")+className = "tool-calls-disclosure"+document.createElement("summary")+open = false(closed on the shared page, D3); the four label literals are still present;body.count("code.textContent = argument") == 3; noinnerHTML. - styles.css: the
.tool-callrule has NOdisplay: flexand NOvar(--accent-line)(deboxed, D4) but still hasvar(--accent-ink); the.tool-call coderule still hasvar(--mono)+var(--ink)and has NObackground;.tool-calls-disclosureand.tool-calls-summaryrules exist.
tests/unit/test_frontend_tool_states.py (UPDATE — the only existing test that changes)
test_tool_call_style_is_accent_and_contrast_safe— the.tool-callrule is deboxed: REMOVE theassert "display: flex" in rowandassert "var(--accent-line)" in rowlines; KEEPassert "var(--accent-ink)" in row(the accent is now the line's color) and the.tool-call codevar(--mono)+var(--ink)asserts. Update the docstring to describe the deboxed, inline-flow line (phase 117). Every OTHER test in this file stays green unchanged (the DOM-shape, literal, branch-order, persist, restore, shared-parity, and no-CDN pins are all preserved by D6).
tests/e2e/test_tool_call_compact.py (NEW story suite — mock "use your tools" flow, 3 tool calls)
The conftest app_server/page fixtures + the phase-37 admin login + the marker question that drives the mock's ls → ls(scoped) → read flow (mirror test_agent_document_tools.py). Four tests:
- Folds at rest: after the turn completes, the
.tool-calls-summaryis visible with textTool calls (3); the disclosure is not open (.tool-calls-disclosurehas no[open]); the.tool-calllines are present in the DOM (count 3) but hidden; the answer bubble is present. - Expands on tap: clicking the summary opens the disclosure; the three
.tool-calllines become visible with the correct text (nth 0 "Listing documents", nth 1 "Listing documents in", nth 2 "Reading "). - Deboxed inline flow: on a visible (expanded)
.tool-callline,getComputedStyle(line).display !== "flex"(the label + path are one inline run, not two flex items) and the lineto_contain_text("Reading ")(label immediately followed by the path in the same run). - Restored folded: RELOAD (same context — the phase-14/50 persisted conversation restores); the restored brain message's
.tool-calls-disclosureis present, closed, summaryTool calls (3),.tool-callcount 3 in the DOM — no auto-expand on load.
Dependencies
95_read_truncation_cap,87_big_read_progress,70_harness_aligned_tools,37(the tool-line rendering this restyles — complete). NO code dependency beyond the shared frontend files; the only pipeline predecessor is execution order (todo/ is empty — this is the next phase).
Tasks
01_disclosure_wrapper.md— the<details>/<summary>wrapper + count + open-live/close-at-rest + restore-closed + shared.js parity (app.js + shared.js + the new unit module's JS pins).02_debox_and_inline_flow.md— the CSS debox/dechip + disclosure/summary rules, and the onetest_frontend_tool_states.pyCSS-pin update (styles.css + unit CSS pins + the existing CSS test).03_e2e_story_suite.md—tests/e2e/test_tool_call_compact.py(fold / expand / deboxed-flow / restored-folded).04_verify_and_commit.md— full gate (unit + integration, coverage >90%, the new E2E story in isolation,test_agent_document_tools.py+test_big_read_progress.py+test_thinking_display.py+ smoke in isolation, ruff + pyright) + atomic commit + move to complete/.
Testing & Quality
- Unit —
tests/unit/test_tool_call_compact.py(new): the JS/CSS pins listed above (disclosure + summary + count + open-live, close-at-4-sites, restore-closed, shared parity, deboxed CSS, disclosure/summary rules, the pinned literals still present, no innerHTML). - Existing unit suites MUST stay green:
tests/unit/test_big_read_progress.py(phase-87 clock + the byte-identical tool-line literals),tests/unit/test_shared_page.py(shared-page tool-line parity), andtests/unit/test_frontend_tool_states.py(all tests except the ONE deboxed CSS assert updated in task 02). - E2E —
tests/e2e/test_tool_call_compact.py(new; isolation gate per AGENTS.md rule 9): the four tests above, mock LLM (no slow proxy needed — the fold is about a completed turn). - Regression E2E (run in isolation by task 04):
test_agent_document_tools.py(tool-line count + text after a completed turn — the lines stay in the DOM inside the folded disclosure),test_big_read_progress.py(the FIRST.tool-callline + the.tool-elapsedsuffix are visible DURING the live gap — the disclosure is still open then),test_thinking_display.py(the Thinking block ordering vs. the disclosure),test_smoke.py. - Coverage: >90% on
app/— noapp/code changes (the floor is held by the untouched suite).
Completion Criteria
- A completed tool turn renders as ONE collapsed "Tool calls (N)" line (E2E pin 1); tapping it reveals the individual calls (E2E pin 2); the restored/shared view is folded on load (E2E pin 4). The 6+ stacked cards from the screenshot are gone.
- An expanded
.tool-callline is deboxed inline-flow text (E2E pin 3 + the CSS unit pins): the path wraps to the left edge and the label never breaks mid-word — the "spit and wrapped poorly" glitch is fixed at 390×844 and at desktop. - Live behavior unchanged: during a live frameless gap the calls + phase-87
.tool-elapsedsuffix stay visible (the disclosure is open until the first delta) —test_big_read_progress.pygreen in isolation; the "calling tool"#send-status/aria state is byte-identical (test_calling_tool_label_stringsgreen). uv run pytestgreen (includingtest_big_read_progress.py,test_shared_page.py, and the updatedtest_frontend_tool_states.py);uv run pytest --cov=app --cov-report=term-missing>90%; the new E2E story +test_agent_document_tools.py+test_big_read_progress.py+test_thinking_display.py+test_smoke.pygreen in isolation;uv run ruff check . && uv run pyrightclean.git diff --statlimited tofrontend/assets/app.js,frontend/assets/shared.js,frontend/assets/styles.css,tests/unit/test_tool_call_compact.py,tests/unit/test_frontend_tool_states.py,tests/e2e/test_tool_call_compact.py, and the phase files — nothing inapp/.- One atomic
--no-gpg-signcommit (e.g.feat(ui): fold tool calls into a compact collapsible disclosure); phase dir moved to.agents/phases/complete/.
Locked decisions
- Frontend-only (D1) —
app/, the SSE event set, and the persistence format are byte-identical; only the rendering changes. - Reuse the native
<details>Thinking-block convention (D2/D3) — open while the turn is live, folded at rest and on restore; no new component or dependency. This is what keeps phase 87's live-suffix E2E and the tool-line count/text E2E green. - Pinned literals + DOM shape stay (D6) — the four
line.textContentlabels, the.tool-callslist, the.tool-calllistitems, and the<code>textContentarguments are byte-identical; the emoji guard and phase 87's.tool-calls .tool-call:last-childquery are unaffected. Only the ONE CSS assert intest_frontend_tool_states.pychanges (the debox). - Debox, don't restyle the accent away (D4) — the line keeps
var(--accent-ink)as its text color (≈10.4:1 AA on the surface); the accent moves off the border onto the text, so the line still reads as distinct from the brand-ink Thinking summary.