phase: 117_tool_call_compact
Build and Push Containers / build-and-push-app (push) Successful in 2m2s
Build and Push Containers / build-and-push-db (push) Successful in 12s

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
This commit is contained in:
2026-09-15 18:26:38 -04:00
parent 2ac3fc89c2
commit c851d1a1c5
27 changed files with 1682 additions and 34 deletions
@@ -0,0 +1,90 @@
# 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 `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 `<code>` 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 `<details>/<summary>` 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 `<code>` flow as one continuous run (fixes the mid-word label break + indented wrap); the `<code>` 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 `<code>` `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 `<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; 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 `<code>` 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 + `<code>` + 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 `<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: 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 `<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).
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 `<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.textContent` labels, the `.tool-calls` list, the `.tool-call` listitems, and the `<code>` `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.
@@ -0,0 +1,63 @@
# Task 01 — The `<details>/<summary>` disclosure wrapper + count + open-live/close-at-rest
**Phase:** `117_tool_call_compact` · **Source:** owner visual-glitch report (2026-09-15) — tool-call cards take too much space and wrap poorly.
**Story:** n/a (owner bug report)
## Objective
Wrap the existing `.tool-calls` list in a native `details.tool-calls-disclosure` with a `summary.tool-calls-summary` that carries a `Tool call (N)` / `Tool calls (N)` count. The disclosure is created **open** on the first live `tool` frame, **folds** when the answer starts / the turn ends / is stopped (`closeToolCalls`), and renders **closed** on the restore path and the shared page. The `.tool-calls` list, the `.tool-call` lines, the four label literals, and the `<code>` arguments stay byte-identical (D6) — only the wrapper is added.
## Work
1. `frontend/assets/app.js` — `appendToolLine` (house comment style, citing phase 117):
- `let container = body.querySelector(".tool-calls-disclosure");` (was `.tool-calls`).
- First-frame branch (the `if (!container)` block): create the disclosure + summary + the existing list, in this order:
```js
container = document.createElement("details");
container.className = "tool-calls-disclosure";
container.open = true; // D3: open while the turn is live; closeToolCalls folds it
const summary = document.createElement("summary");
summary.className = "tool-calls-summary";
container.appendChild(summary);
const listEl = document.createElement("div");
listEl.className = "tool-calls";
listEl.setAttribute("role", "list");
listEl.setAttribute("aria-label", "Tool calls");
container.appendChild(listEl);
body.insertBefore(container, body.querySelector(".bubble"));
```
- After the `if`: `const list = container.querySelector(".tool-calls");` then build the line EXACTLY as today (`line.className="tool-call"`, `role="listitem"`, the four pinned `line.textContent` label literals, the `<code>` with `code.textContent = argument`) and `list.appendChild(line)`.
- 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; keep it flat (no nested function declaration) so the `js[fn : js.find("\n}\n", fn)]` body-extraction pins keep working.
2. `frontend/assets/app.js` — `closeToolCalls(wrap)` (new, defined next to `closeThinkingBlock`):
```js
function closeToolCalls(wrap) {
const disc = wrap?.querySelector?.(".tool-calls-disclosure");
if (disc) disc.open = false; // idempotent; no-op without a disclosure
}
```
Add `closeToolCalls(wrap);` on the line next to each existing `closeThinkingBlock(wrap);` — the `delta` handler (≈L2514), the `done` handler (≈L2519), and the stop/abort settle (≈L2646). Comment each: the answer began / the turn ended / the turn was stopped — fold the record (mirrors the Thinking block settling closed).
3. `frontend/assets/app.js` — `renderStoredMessage`: immediately after the existing `if (Array.isArray(m.tools)) { …appendToolLine(wrap, t.name, arg)… }` loop, add `closeToolCalls(wrap);` (a restored turn renders folded — the space fix; mirrors the thinking restore rendering collapsed).
4. `frontend/assets/app.js` — `ensureThinkingBlock`: change the anchor to
`const anchor = body.querySelector(".tool-calls-disclosure") ?? body.querySelector(".tool-calls") ?? body.querySelector(".bubble");`
so the Thinking block lands above the whole disclosure. `block.open = true` and everything else unchanged.
5. `frontend/assets/shared.js` — `addToolLines` (parity, but **created closed** — pure render, always folded):
- Build the same `details.tool-calls-disclosure` with `container.open = false`, a `summary.tool-calls-summary` (first child), and the existing `.tool-calls` list (role=list, aria-label "Tool calls") — appended to the same `.msg-body` anchor as today.
- Render each line byte-identical (the same four label literals + `<code>` + the phase-95 `truncated-note`), appending to the list.
- Set the summary count once at the end: `const n = list.children.length; summary.textContent = \`Tool call${n === 1 ? "" : "s"} (${n})\`;`.
- Keep `body.count("code.textContent = argument") == 3` and no `innerHTML` (the shared-parity pins).
6. `tests/unit/test_tool_call_compact.py` (NEW module) — the source-level JS pins (house pattern, `_js()` / `_shared_js()` readers like `test_frontend_tool_states.py`):
- **app.js `appendToolLine`:** body contains `document.createElement("details")`, `className = "tool-calls-disclosure"`, `document.createElement("summary")`; `container.open = true`; the count template `Tool call${n === 1 ? "" : "s"} (${n})` (assert the template fragment, not the rendered value); `innerHTML` NOT in the body; the four pinned `line.textContent = "…"` label literals are still present.
- **app.js `closeToolCalls`:** defined (`function closeToolCalls`); `closeToolCalls(wrap)` appears **≥4×** (the 3 handler sites + `renderStoredMessage`).
- **app.js `ensureThinkingBlock`:** the body contains `.tool-calls-disclosure` (the new anchor term) AND still contains `querySelector(".tool-calls")` + `querySelector(".bubble")` + `block.open = true`.
- **shared.js `addToolLines`:** body contains `document.createElement("details")`, `className = "tool-calls-disclosure"`, `document.createElement("summary")`, `open = false` (closed on the shared page); the four label literals present; `body.count("code.textContent = argument") == 3`; no `innerHTML`.
## Testing & Quality
- Unit: `uv run pytest tests/unit/test_tool_call_compact.py -v` green (all JS pins). `uv run pytest tests/unit/test_frontend_tool_states.py tests/unit/test_big_read_progress.py tests/unit/test_shared_page.py -v` green **unchanged** (the CSS pin is task 02; the JS/DOM pins are preserved by D6).
- Quick live sanity (session log): dev server, trigger a tool turn — during the turn the disclosure is open (calls + any `.tool-elapsed` suffix visible); when the answer starts it folds to "Tool calls (N)"; a reload shows it folded. (The deterministic E2E is task 03 — this is a wiring smoke only.)
- Coverage: **>90%** on `app/` unaffected (no `app/` change).
## Completion Criteria
- [ ] `git diff frontend/assets/app.js` shows: the disclosure/summary/list creation in `appendToolLine`, the summary count line, the new `closeToolCalls`, the three handler call sites + the `renderStoredMessage` call, and the `ensureThinkingBlock` anchor — and NOTHING else; the four `line.textContent` label literals and the `<code>` `textContent` bytes byte-identical.
- [ ] `git diff frontend/assets/shared.js` shows the parallel closed-disclosure build + count + the unchanged line/literal bytes.
- [ ] `uv run pytest tests/unit/test_tool_call_compact.py -v` green; `tests/unit/test_frontend_tool_states.py` + `tests/unit/test_big_read_progress.py` + `tests/unit/test_shared_page.py` green unchanged (CSS test still asserts the OLD flex/border — that flips in task 02, so run it here to confirm only that one assert is the pending delta).
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] No behavior change in completed work (the fold/expand proof is task 03; the live-suffix regression is task 04).
@@ -0,0 +1,53 @@
# Task 02 — Debox + inline-flow CSS, and the one CSS-pin update
**Phase:** `117_tool_call_compact` · **Source:** owner visual-glitch report (2026-09-15) — the tool-call "cards" are too heavy and the label breaks mid-word.
**Story:** n/a (owner bug report)
## Objective
Debox the `.tool-call` line (no card, no flex) so the label + inline `<code>` flow as one continuous run (fixes the `Rea`/`ding` mid-word break and the indented 3-line wrap), dechip the `.tool-call code` (no background chip, keep mono + ink), and add the `.tool-calls-disclosure` / `.tool-calls-summary` rules modeled on the Thinking disclosure. Update the ONE existing CSS assert in `test_frontend_tool_states.py` to the deboxed contract, and add the CSS pins to the new unit module.
## Work
1. `frontend/assets/styles.css` — **`.tool-call`** (the debox, D4). Remove `display: flex;`, `align-items: baseline;`, `gap: 0.45rem;`, `background: var(--surface);`, `border: 1px solid var(--line);`, `border-left: 3px solid var(--accent-line);`, `border-radius: var(--radius-sm);`, `padding: 0.3rem 0.75rem;`. KEEP `color: var(--accent-ink);`, `font-size: 0.8rem;`, `line-height: 1.4;`, `overflow-wrap: anywhere;`. Update the rule's comment: phase 117 deboxed the line — the accent now rides the text color (≈10.4:1 on the surface) instead of a border; the label + inline `code` flow as one run so the path wraps to the left edge (no more mid-word label break).
2. `frontend/assets/styles.css` — **`.tool-call code`** (the dechip). Remove `background: var(--brand-soft);`, `padding: 0.05em 0.35em;`, `border-radius: 5px;`. KEEP `font-family: var(--mono);`, `font-size: 0.95em;`, `color: var(--ink);`, `overflow-wrap: anywhere;`. Comment: the path keeps the mono + ink treatment (≈11.5:1 AA) but loses the chip background — it is inline text in the line's run now.
3. `frontend/assets/styles.css` — **NEW `.tool-calls-disclosure` + `.tool-calls-summary`** rules, placed next to the `details.thinking` rules and modeled on them (house AA palette, no new hue — the phase-92 zero-literal invariant):
```css
/* Phase 117: the tool-call record folds into a single disclosure line,
reusing the Thinking block's native <details> convention. Open while
the turn is live (phase 87's elapsed suffix stays visible), folded at
rest / on restore. The summary is a native focusable toggle — it
inherits the house 3px :focus-visible ring (see details.thinking
summary). Accent rides the TEXT (var(--accent-ink), ≈10.4:1 on the
surface) — text + color, never color alone (B5). No new literal. */
.tool-calls-disclosure { margin: 0.25rem 0; }
.tool-calls-summary {
cursor: pointer;
color: var(--accent-ink);
font-size: 0.8rem;
line-height: 1.4;
padding: 0.15rem 0;
list-style: none; /* the native marker is redundant with the count text */
}
.tool-calls-summary::-webkit-details-marker { display: none; }
```
(If the `details.thinking summary` uses a chevron/marker rather than the native one, match that treatment so the two disclosures read as one family — the count text is the accessible label either way.)
4. `frontend/assets/styles.css` — **`.tool-calls`** (the list) — UNCHANGED: keep `display: flex; flex-direction: column; gap: 0.25rem;` (spacing between the now-deboxed lines). **`.tool-elapsed`** and **`.tool-call .truncated-note`** — UNCHANGED (phase 87 / phase 95).
5. `tests/unit/test_tool_call_compact.py` (extend the module from task 01) — the CSS pins:
- the `.tool-call` rule (regex `\.tool-call \{([^}]*)\}`) has **NO** `display: flex` and **NO** `var(--accent-line)` (deboxed), 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 (the summary carries `var(--accent-ink)`).
6. `tests/unit/test_frontend_tool_states.py` — **`test_tool_call_style_is_accent_and_contrast_safe`** (the ONE existing test that changes, D4/D6):
- REMOVE the two now-false asserts: `assert "display: flex" in row` and `assert "var(--accent-line)" in row`.
- 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 and the `.tool-calls` `gap` assert.
- Update the docstring: the line is now a deboxed inline-flow row (phase 117) — the accent rides the text color; the `code` is inline mono text (no chip). Everything else in this file is untouched.
## Testing & Quality
- Unit: `uv run pytest tests/unit/test_tool_call_compact.py -v` green (JS pins from task 01 + the new CSS pins); `uv run pytest tests/unit/test_frontend_tool_states.py -v` green with the updated CSS test; `tests/unit/test_big_read_progress.py` + `tests/unit/test_shared_page.py` green unchanged.
- Quick live sanity (session log): dev server, expand a completed tool turn at 390×844 — the lines are plain text rows (no card border/background), the path wraps to the left edge, and "Reading" never breaks as "Rea/ding". (The deterministic E2E is task 03.)
- Coverage: **>90%** on `app/` unaffected.
## Completion Criteria
- [ ] `git diff frontend/assets/styles.css` shows: the deboxed `.tool-call`, the dechipped `.tool-call code`, the two new disclosure/summary rules — and NOTHING else (`.tool-calls` gap, `.tool-elapsed`, `.truncated-note` unchanged).
- [ ] `git diff tests/unit/test_frontend_tool_states.py` shows ONLY the two removed asserts + the docstring in `test_tool_call_style_is_accent_and_contrast_safe`.
- [ ] `uv run pytest tests/unit/test_tool_call_compact.py tests/unit/test_frontend_tool_states.py tests/unit/test_big_read_progress.py tests/unit/test_shared_page.py -v` all green.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] No behavior change in completed work (E2E proof in task 03/04).
@@ -0,0 +1,41 @@
# Task 03 — The E2E story suite (fold / expand / deboxed-flow / restored-folded)
**Phase:** `117_tool_call_compact` · **Source:** owner visual-glitch report (2026-09-15).
**Story:** n/a (owner bug report)
## Objective
Prove the new behavior end-to-end against the deterministic mock: a completed 3-call tool turn folds to one "Tool calls (3)" line, expands on tap to the deboxed inline-flow lines, and renders folded on reload. Reuses the phase-37 admin login + the mock "use your tools" flow (exactly the 3 tool calls `test_agent_document_tools.py` drives: `ls` → `ls(scoped)` → `read`).
## Work
1. `tests/e2e/test_tool_call_compact.py` (NEW module) — header docstring cites phase 117 + the owner report; the conftest `app_url` + `page` fixtures; `from e2e.auth_helpers import login`. Constants:
- `MARKER_QUESTION = "Use your tools: what is the exact JSON shape of reeselink.json for my aws route53 hosted zone?"` (the SAME marker that drives the mock's 3-tool flow — mirror `test_agent_document_tools.py` so the flow is deterministic).
- `SUMMARY = "#messages .msg.brain .tool-calls-summary"` (the disclosure toggle).
- `DISCLOSURE = "#messages .msg.brain .tool-calls-disclosure"`.
- `LINES = "#messages .msg.brain .tool-call"`.
- A `_submit_tools_turn(page, app_url)` helper: `login(page, app_url, next="/")`; `page.fill("#message-input", MARKER_QUESTION)`; `page.click("#send-btn")`; wait for the answer bubble (`MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"`) — i.e. the turn is COMPLETE (the disclosure has already folded on the first delta).
2. **`test_tool_calls_fold_to_one_line_after_turn`** (pin 1): after `_submit_tools_turn`:
- `expect(page.locator(SUMMARY)).to_be_visible()` and `to_contain_text("Tool calls (3)")`.
- the disclosure is NOT open: `page.get_attribute(DISCLOSURE, "open")` is `None` (a closed `<details>` has no `open` attribute).
- the lines are present in the DOM but folded: `expect(page.locator(LINES)).to_have_count(3)` (DOM count) and the FIRST line is NOT visible (`expect(page.locator(LINES).first).not_to_be_visible()`).
- the answer is present: `expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)`.
3. **`test_summary_click_expands_the_calls`** (pin 2): after `_submit_tools_turn`:
- `page.locator(SUMMARY).click()` (native `<summary>` — a real focusable toggle, AA).
- `page.get_attribute(DISCLOSURE, "open")` is now `"true"` (or the attribute present).
- `expect(page.locator(LINES)).to_have_count(3)` and every line is now visible; assert the text (mirror `test_agent_document_tools.py`): nth 0 `to_contain_text("Listing documents")`, nth 1 `to_contain_text("Listing documents in")`, nth 2 `to_contain_text("Reading ")`.
4. **`test_expanded_line_is_deboxed_inline_flow`** (pin 3): after expanding (reuse the click from pin 2 in a fresh turn):
- take the visible `.tool-call` "Reading" line; assert `page.evaluate` on its computed style: `getComputedStyle(el).display !== "flex"` (the label + path are one inline run, not two flex items — the debox).
- `expect(line).to_contain_text("Reading ")` (the label is immediately followed by the path in the same run — no separate indented code column).
5. **`test_restored_turn_renders_folded`** (pin 4): after `_submit_tools_turn`:
- `page.reload()` (same context — the phase-14/50 persisted conversation restores).
- wait for the restored brain message's answer; then `expect(page.locator(SUMMARY)).to_be_visible()` + `to_contain_text("Tool calls (3)")`; the disclosure is NOT open (`get_attribute(...) is None`); `expect(page.locator(LINES)).to_have_count(3)` in the DOM; the FIRST line is NOT visible (folded on load — no auto-expand).
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_tool_call_compact.py -v --no-cov` green **in isolation** (DB up: `podman compose up -d db`). Mock LLM by default (no slow proxy — the fold is about a completed turn, not a live gap).
- No `app/` change — the conftest app-server + mock flow are used as-is.
- Coverage: **>90%** on `app/` unaffected.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_tool_call_compact.py -v --no-cov` green in isolation (all four pins).
- [ ] The suite proves: folded-after-turn (pin 1), expand-on-tap (pin 2), deboxed inline-flow (pin 3), restored-folded (pin 4).
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] No behavior change in completed work (the adjacent-suite regression is task 04).
@@ -0,0 +1,33 @@
# Task 04 — Full gate + atomic commit
**Phase:** `117_tool_call_compact` · **Source:** owner visual-glitch report (2026-09-15).
**Story:** n/a (owner bug report)
## Objective
Run the complete phase gate, land the phase as one atomic commit, and move the phase directory to `complete/`.
## Work
1. **Full regression gate** (AGENTS.md rule 9):
- `uv run pytest` — unit + integration green (including `tests/unit/test_tool_call_compact.py`, the updated `tests/unit/test_frontend_tool_states.py`, and the untouched `tests/unit/test_big_read_progress.py` + `tests/unit/test_shared_page.py`).
- `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage **>90%** (no `app/` change this phase — confirm the floor is held).
- `uv run pytest tests/e2e/test_tool_call_compact.py -v --no-cov` — green **in isolation** (this phase's E2E story — fold / expand / deboxed-flow / restored-folded).
- `uv run pytest tests/e2e/test_agent_document_tools.py -v --no-cov` — green in isolation (the tool-line count + text after a completed turn — the lines stay in the DOM inside the folded disclosure).
- `uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov` — green in isolation (the FIRST `.tool-call` line + the `.tool-elapsed` suffix are visible DURING the live gap — the disclosure is still open then; this is the key live-behavior regression proof for D3).
- `uv run pytest tests/e2e/test_thinking_display.py -v --no-cov` — green in isolation (the Thinking block ordering vs. the new disclosure anchor).
- `uv run pytest tests/e2e/test_smoke.py -v --no-cov` — green in isolation.
- `uv run ruff check . && uv run pyright` — clean.
2. **Manual live check** (keep the output in the session log): the live/dev app at a **mobile viewport (390×844)** — trigger a multi-call tool turn (e.g. "Generate a change log … last 5 phases"). Confirm: the 6+ stacked cards from the original report are now ONE collapsed "Tool calls (N)" line; tapping it reveals deboxed text rows whose paths wrap to the left edge with the label never breaking mid-word (`Reading` stays intact); during a live frameless gap the calls + any "(Ns)" suffix are visible (the disclosure is open); on reload the turn is folded.
3. **Commit** (AGENTS.md rule 8 — one atomic, Conventional-Commits commit, always `--no-gpg-sign`), staging `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:
`feat(ui): fold tool calls into a compact collapsible disclosure`
— body: owner visual-glitch report (2026-09-15) — each tool call rendered as a full-width bordered card, so a multi-call turn stacked N cards over the answer, and the flex label+code layout broke the label mid-word ("Reading" → "Rea/ding") with the path indented to 3 lines. Two changes, frontend-only: (1) the tool-call record now folds into a single native `<details>` "Tool calls (N)" line reusing the Thinking-block convention — open while the turn is live (phase 87's elapsed suffix stays visible), folded when the answer starts / on done / on stop / on restore, closed by default on the shared page; (2) the individual lines are deboxed (no card, no flex) and dechipped (no code background) so the label + path flow as one inline run that wraps to the left edge. The four pinned line literals, the `.tool-calls` list, the `<code>` textContent arguments, the emoji-guard set, the persistence format, and the server are byte-identical; only the ONE deboxed CSS assert in `test_frontend_tool_states.py` changes. New source-level unit pins + a dedicated fold/expand/debox/restore E2E story.
4. Move the phase directory: `mv .agents/phases/todo/117_tool_call_compact .agents/phases/complete/` and include the move in the same commit.
## Testing & Quality
- This task IS the phase-level gate — the commands above are the completion evidence.
- Coverage: >90% held.
## Completion Criteria
- [ ] All seven gate commands green (unit + integration, coverage >90%, the new E2E story + `test_agent_document_tools.py` + `test_big_read_progress.py` + `test_thinking_display.py` + smoke in isolation, ruff + pyright).
- [ ] The live mobile check shows the stacked cards replaced by one collapsed "Tool calls (N)" line that expands to deboxed inline-flow rows (path wraps to the left edge, label intact), live calls still visible during a gap, and restored turns folded (output kept in the session log).
- [ ] `git show --stat HEAD` lists the staged files above + the phase files (todo → complete move) — nothing else (in particular `app/`, `pyproject.toml` / `uv.lock`, `conftest.py`, `mock_llm.py`, `slow_llm.py` untouched).
- [ ] `.agents/phases/complete/117_tool_call_compact/` exists; `todo/` no longer contains it.