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
+47 -6
View File
@@ -942,7 +942,10 @@ function ensureThinkingBlock(wrap) {
// Phase 37: the scratchpad stays the TOP row of the wrap — if tool
// lines are already there (a `tool` frame preceded the first
// `thinking` frame), the block lands above them, not below.
// Phase 117: the anchor is the WHOLE disclosure (the bare .tool-calls
// list term stays as the pre-117 fallback).
const anchor =
body.querySelector(".tool-calls-disclosure") ??
body.querySelector(".tool-calls") ?? body.querySelector(".bubble");
body.insertBefore(block, anchor);
}
@@ -954,6 +957,17 @@ function closeThinkingBlock(wrap) {
if (block) block.open = false; // idempotent; no-op without a block
}
/* Phase 117 (D3): the tool-calls disclosure settles FOLDED at rest —
* the same sites that settle the Thinking block: the answer began
* (`delta`), the turn ended (`done`), or the turn was stopped (the
* abort settle). The N-line record collapses to one compact
* "Tool call(s) (N)" line. Idempotent; a no-op without a disclosure
* (deflected answers, pre-tool turns, …). */
function closeToolCalls(wrap) {
const disc = wrap?.querySelector?.(".tool-calls-disclosure");
if (disc) disc.open = false; // idempotent; no-op without a disclosure
}
/* ---------- tool-call lines (phase 37, PLAN §4 extension) ----------
* One visible "calling tool" row per `tool` SSE frame, in the same wrap
* the Thinking block uses — above the answer, below the Thinking
@@ -967,6 +981,14 @@ function closeThinkingBlock(wrap) {
* HTML-shaped can come from storage. Lines are not interactive (no
* focus targets).
*
* Phase 117 (owner report 2026-09-15): the list rides in a native
* <details> disclosure (details.tool-calls-disclosure + a plain-text
* "Tool call(s) (N)" summary) — OPEN while the turn is live, folded by
* closeToolCalls (delta/done/stop) and by the restore path (a restored
* turn is one compact line, not N stacked cards). The line bytes, the
* four label literals, and the <code> textContent arguments are
* untouched (phase-87 clock + emoji-guard pins stay green).
*
* Phase 70 (owner permission 2026-09-03): the server tools were remapped
* to the harness-aligned surface — ls / read(path) / grep(pattern,
* path?) — so the NEW names get their own lines (read → the Reading
@@ -981,16 +1003,26 @@ function closeThinkingBlock(wrap) {
function appendToolLine(wrap, name, argument) {
const body = wrap?.querySelector?.(".msg-body");
if (!body) return;
let container = body.querySelector(".tool-calls");
// Phase 117 (D2): the lines ride in a native <details> disclosure —
// one compact "Tool call(s) (N)" line instead of N stacked cards.
let container = body.querySelector(".tool-calls-disclosure");
if (!container) {
container = document.createElement("div");
container.className = "tool-calls";
container.setAttribute("role", "list");
container.setAttribute("aria-label", "Tool calls");
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);
// Before the bubble; below an existing Thinking block (both insert
// before the bubble, so document order is preserved).
body.insertBefore(container, body.querySelector(".bubble"));
}
const list = container.querySelector(".tool-calls");
const line = document.createElement("span");
line.className = "tool-call";
line.setAttribute("role", "listitem");
@@ -1015,7 +1047,12 @@ function appendToolLine(wrap, name, argument) {
} else {
line.textContent = "🔎 Listing documents";
}
container.appendChild(line);
list.appendChild(line);
// Phase 117 (D5): the count rides the summary — every append (live
// frames and the restore path) re-renders through here.
const n = list.children.length;
container.querySelector("summary").textContent =
`Tool call${n === 1 ? "" : "s"} (${n})`;
}
/* Phase 95 (task 02): the truncation marker on a Reading line. The
@@ -1636,6 +1673,7 @@ function renderStoredMessage(m) {
appendTruncatedNote(wrap, arg, Number(t.chars_shown) || 0, Number(t.chars_total) || 0);
}
}
closeToolCalls(wrap); // phase 117 (D3): a restored turn renders folded
}
if (m.deflected) {
wrap.classList.add("is-deflected");
@@ -2512,11 +2550,13 @@ async function runTurn(text, { reask = false } = {}) {
if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming);
if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in
closeThinkingBlock(wrap); // auto-collapse; idempotent — the next `thinking` frame re-opens it (phase 109, D15)
closeToolCalls(wrap); // the answer began — fold the tool record (phase 117, D3)
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
// No page scroll (phase 42): the answer never follows the viewport.
} else if (ev.type === "done") {
sawDone = true;
closeThinkingBlock(wrap); // the turn is over: settle the block closed
closeToolCalls(wrap); // the turn ended — settle the record folded (phase 117, D3)
if (!wrap) {
setUiState(UI_STATE.streaming);
wrap = addMessage("brain", "…");
@@ -2644,6 +2684,7 @@ async function runTurn(text, { reask = false } = {}) {
// which aborts the same fetch — from saving the partial twice.
if (wrap && acc && !persistedOnLeave) {
closeThinkingBlock(wrap); // settle the block closed, like `done`
closeToolCalls(wrap); // the turn was stopped — fold the record (phase 117, D3)
persistedOnLeave = true;
appendTuneButton(wrap); // admin-only; parity with the restore path
appendStoppedNote(wrap);
+22 -2
View File
@@ -163,15 +163,32 @@ function addThinkingBlock(wrap, thinking) {
* page shows the truncation pixel-identically to the chat page (the
* phase-50 restore contract). A record saved before phase 95 (no
* fields) renders exactly as before (no marker, no migration).
* createElement + textContent only — nothing HTML-shaped from storage. */
* createElement + textContent only — nothing HTML-shaped from storage.
*
* Phase 117 (D2/D3): the lines ride the SAME native <details>
* disclosure as the chat page (details.tool-calls-disclosure + a
* plain-text "Tool call(s) (N)" summary) — but a pure render has no
* live turn, so it is created CLOSED: the shared page shows one
* compact line, never an auto-expanded record. The line bytes, the
* four label literals, and the <code> textContent arguments are
* untouched (the parity pins stay green). */
function addToolLines(wrap, tools) {
if (!Array.isArray(tools) || !tools.length) return;
const body = wrap.querySelector(".msg-body");
if (!body) return;
// Phase 117 (D3): the SAME disclosure as the chat page — created
// CLOSED here (pure render, always folded; no live turn to open it).
const disclosure = document.createElement("details");
disclosure.className = "tool-calls-disclosure";
disclosure.open = false; // D3: closed on the shared page (pure render)
const summary = document.createElement("summary");
summary.className = "tool-calls-summary";
disclosure.appendChild(summary);
const container = document.createElement("div");
container.className = "tool-calls";
container.setAttribute("role", "list");
container.setAttribute("aria-label", "Tool calls");
disclosure.appendChild(container);
for (const t of tools) {
if (!t || typeof t.name !== "string") continue;
const line = document.createElement("span");
@@ -213,7 +230,10 @@ function addToolLines(wrap, tools) {
line.appendChild(note);
}
}
body.insertBefore(container, body.querySelector(".bubble"));
// Phase 117 (D5): the count once, after every line is in.
const n = container.children.length;
summary.textContent = `Tool call${n === 1 ? "" : "s"} (${n})`;
body.insertBefore(disclosure, body.querySelector(".bubble"));
}
/* "Maybe try:" chips under a deflected bubble (honesty gate, phase
+55 -21
View File
@@ -615,42 +615,74 @@ details.thinking .thinking-text {
details.thinking .thinking-text p,
details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
/* Agent tool-call lines (phase 37): one visible "calling tool" row per
`tool` SSE frame — in the same wrap as the Thinking block, above the
answer, below the Thinking summary. Deliberately distinct from the
scratchpad: accent palette (--accent-ink) vs the brand-ink summary,
own icon, own accent left border. Contrast: --accent-ink on the row's
--surface ≈10.4:1 (11.6:1 on the page bg), and --ink on --brand-soft
in the path `code` ≈11.5:1 — all comfortably AA in the (single dark)
theme. Inline rows only: appending lines never shifts the chat
column (72rem — the .container width at every viewport; no new
container), and the rows are not interactive — no focus targets. */
/* 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. The
chevron matches details.thinking summary::before so the two
disclosures read as one family — the count text is the accessible
label either way. */
.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; }
/* CSS chevron: ▸ rotates 90° when open — the same family treatment as
details.thinking summary::before (the transition stills under
prefers-reduced-motion — see the reduced-motion block below). */
.tool-calls-summary::before {
content: "▸";
display: inline-block;
margin-right: 0.5rem;
transition: transform 0.15s ease;
}
.tool-calls-disclosure[open] .tool-calls-summary::before {
transform: rotate(90deg);
}
/* Agent tool-call lines (phase 37, deboxed in phase 117): one compact
inline-flow row per `tool` SSE frame — in the same wrap as the
Thinking block, above the answer, below the Thinking summary, inside
the phase-117 disclosure. Deliberately distinct from the scratchpad:
accent text (--accent-ink) vs the brand-ink summary. Contrast:
--accent-ink on the surface ≈10.4:1 (11.6:1 on the page bg), and
--ink on the surface in the path `code` ≈11.5:1 — all comfortably AA
in the (single dark) theme. Inline rows only: appending lines never
shifts the chat column (72rem — the .container width at every
viewport; no new container), and the rows are not interactive — no
focus targets (the fold is the disclosure's native summary). */
.tool-calls {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* Phase 117 deboxed the line (owner visual-glitch report 2026-09-15):
no card, no flex — the label + the inline `code` flow as ONE
continuous run, so a long path wraps to the left edge like a normal
sentence and the label no longer breaks mid-word ("Rea/ding" is
gone). The accent now rides the TEXT color (var(--accent-ink))
instead of a border. */
.tool-call {
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;
color: var(--accent-ink);
font-size: 0.8rem;
line-height: 1.4;
overflow-wrap: anywhere;
}
/* Phase 117 dechipped the path: it keeps the mono + ink treatment
(var(--ink) on the surface ≈11.5:1 — AA) but loses the chip
background — it is inline text in the line's run now. */
.tool-call code {
font-family: var(--mono);
font-size: 0.95em;
background: var(--brand-soft);
color: var(--ink);
padding: 0.05em 0.35em;
border-radius: 5px;
overflow-wrap: anywhere;
}
/* Phase 87 (TODO.md L5): the latest tool line's visible "processing"
@@ -1253,6 +1285,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.typing span.typing-elapsed { opacity: 1; }
/* Phase 17 thinking block: the chevron stills (no rotation motion). */
details.thinking summary::before { transition: none; }
/* Phase 117 tool-calls disclosure: the same chevron, the same still. */
.tool-calls-summary::before { transition: none; }
}
/* Phase 117 (owner request 2026-09-15, live-mockup-confirmed — the