# 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 `
` 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 `tool` frame appends a full-width bordered `.tool-call` card 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-call` is `display:flex; align-items:baseline`, so the label text node (`"📄 Reading "`) and the `` path are **two separate flex items**. The long mono path squeezes the label flex-item, and `overflow-wrap: anywhere` breaks 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 by `test_calling_tool_label_strings`), NOT the tool lines — so folding the tool lines does not remove any live feedback. Phase 87's ticking `.tool-elapsed` suffix, however, IS on a `.tool-call` line 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 `
/` disclosure in the same `.msg-body` wrap, mirroring `details.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 `tool` frame (the calls + phase 87's `.tool-elapsed` suffix stay visible during the turn); it folds when the answer's first `delta` arrives, on `done`, and on stop/abort (`closeToolCalls`, idempotent — the exact sites that call `closeThinkingBlock`). 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-call` line loses its card (no `display:flex` / background / border / left border / radius / padding) so the label + inline `` flow as one continuous run (fixes the mid-word label break + indented wrap); the `` loses its chip background but keeps `var(--mono)` + `var(--ink)`. The accent is carried by the line's `color` (`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-calls` list (role=list, aria-label "Tool calls"), the `.tool-call` listitems, and the `` `textContent` arguments stay **byte-identical** — so `test_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-child` query 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 `` (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; then `body.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 pinned `line.textContent` label literals, the `` with `code.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 `innerHTML` anywhere in the function** (house rule; pinned). The function stays flat (no nested function declaration) so the `js[fn : js.find("\n}\n", fn)]` body-extraction pins keep working. - **`closeToolCalls(wrap)`** (new, next to `closeThinkingBlock`): `const disc = wrap?.querySelector?.(".tool-calls-disclosure"); if (disc) disc.open = false;` — idempotent, no-op without a disclosure. Called at **every** existing `closeThinkingBlock(wrap)` site: the `delta` handler (≈L2514), the `done` handler (≈L2519), and the stop/abort settle (≈L2646). - **`renderStoredMessage`** — after the existing `if (Array.isArray(m.tools)) { …appendToolLine(wrap, t.name, arg)… }` loop, add `closeToolCalls(wrap);` so a restored turn renders folded (the space fix; mirrors the thinking restore rendering collapsed). - **`ensureThinkingBlock`** — the anchor becomes `body.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 = true` unchanged. ### `frontend/assets/shared.js` - **`addToolLines(wrap, tools)`** — parity with `appendToolLine`, but **created closed** (pure render, always folded): build the `details.tool-calls-disclosure` (`container.open = false`) + `summary.tool-calls-summary` + the existing `.tool-calls` list (role=list, aria-label) exactly as today; render each line byte-identical (the same four label literals + `` + the phase-95 `truncated-note`); set the summary count once at the end (`Tool call (N)` / `Tool calls (N)`). Keep the `code.textContent = argument` count at 3 and no `innerHTML` (the `test_shared_page_tool_lines_…` / `test_frontend_tool_states.py` shared pins). ### `frontend/assets/styles.css` - **`.tool-call`** — DEBOX: remove `display:flex`, `align-items:baseline`, `gap`, `background`, `border`, `border-left`, `border-radius`, `padding`. KEEP `color: var(--accent-ink)`, `font-size: 0.8rem`, `line-height: 1.4`, `overflow-wrap: anywhere`. As a flex item of the `.tool-calls` column it stays block-level per line, but its label + inline `` 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: remove `background: var(--brand-soft)`, `padding`, `border-radius`. KEEP `font-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 existing `details.thinking` styling (house AA palette, no new hue): the summary is a native focusable toggle that carries the house 3px `:focus-visible` ring (the `details.thinking summary` already 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: stays `display: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:** `appendToolLine` body contains `document.createElement("details")` + `className = "tool-calls-disclosure"` + `document.createElement("summary")`; `container.open = true` (the live default, D3); the count literal `Tool call${n === 1 ? "" : "s"} (${n})` (or the equivalent template) is built with `textContent` (no `innerHTML`); `closeToolCalls` is defined and called at **3** handler sites (delta/done/stop) + once in `renderStoredMessage` (pin: `closeToolCalls(wrap)` appears ≥4×); `ensureThinkingBlock`'s anchor includes `.tool-calls-disclosure`; the four pinned `line.textContent = "…"` label literals are STILL present (mirror the guard); `innerHTML` is NOT in the `appendToolLine` body. - **shared.js:** `addToolLines` body contains `document.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`; no `innerHTML`. - **styles.css:** the `.tool-call` rule has **NO** `display: flex` and **NO** `var(--accent-line)` (deboxed, D4) but still has `var(--accent-ink)`; the `.tool-call code` rule still has `var(--mono)` + `var(--ink)` and has **NO** `background`; `.tool-calls-disclosure` and `.tool-calls-summary` rules 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-call` rule is deboxed: REMOVE the `assert "display: flex" in row` and `assert "var(--accent-line)" in row` lines; KEEP `assert "var(--accent-ink)" in row` (the accent is now the line's color) and the `.tool-call code` `var(--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: 1. **Folds at rest:** after the turn completes, the `.tool-calls-summary` is visible with text `Tool calls (3)`; the disclosure is **not** open (`.tool-calls-disclosure` has no `[open]`); the `.tool-call` lines are present in the DOM (count 3) but hidden; the answer bubble is present. 2. **Expands on tap:** clicking the summary opens the disclosure; the three `.tool-call` lines become visible with the correct text (nth 0 "Listing documents", nth 1 "Listing documents in", nth 2 "Reading "). 3. **Deboxed inline flow:** on a visible (expanded) `.tool-call` line, `getComputedStyle(line).display !== "flex"` (the label + path are one inline run, not two flex items) and the line `to_contain_text("Reading ")` (label immediately followed by the path in the same run). 4. **Restored folded:** RELOAD (same context — the phase-14/50 persisted conversation restores); the restored brain message's `.tool-calls-disclosure` is present, closed, summary `Tool calls (3)`, `.tool-call` count 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 1. `01_disclosure_wrapper.md` — the `
/` wrapper + count + open-live/close-at-rest + restore-closed + shared.js parity (app.js + shared.js + the new unit module's JS pins). 2. `02_debox_and_inline_flow.md` — the CSS debox/dechip + disclosure/summary rules, and the one `test_frontend_tool_states.py` CSS-pin update (styles.css + unit CSS pins + the existing CSS test). 3. `03_e2e_story_suite.md` — `tests/e2e/test_tool_call_compact.py` (fold / expand / deboxed-flow / restored-folded). 4. `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), and `tests/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-call` line + the `.tool-elapsed` suffix 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/` — no `app/` 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-call` line 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-elapsed` suffix stay visible (the disclosure is open until the first delta) — `test_big_read_progress.py` green in isolation; the "calling tool" `#send-status`/aria state is byte-identical (`test_calling_tool_label_strings` green). - [ ] `uv run pytest` green (including `test_big_read_progress.py`, `test_shared_page.py`, and the updated `test_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.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/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 in `app/`. - [ ] One atomic `--no-gpg-sign` commit (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 `
` 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.textContent` labels, the `.tool-calls` list, the `.tool-call` listitems, and the `` `textContent` arguments are byte-identical; the emoji guard and phase 87's `.tool-calls .tool-call:last-child` query are unaffected. Only the ONE CSS assert in `test_frontend_tool_states.py` changes (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.