diff --git a/src/ui/theme/content/message-components.css b/src/ui/theme/content/message-components.css index a9d8188..794b8b7 100644 --- a/src/ui/theme/content/message-components.css +++ b/src/ui/theme/content/message-components.css @@ -8,6 +8,46 @@ border-radius: var(--pill-radius); } +/* Group header — collapsible summary */ +.pi-tool-group__header { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 6px 10px; + border: none; + background: none; + cursor: pointer; + font-family: var(--font-sans); + font-size: var(--text-sm); + color: var(--muted-foreground); + text-align: left; + transition: color var(--duration-fast); +} + +.pi-tool-group__header:hover { + color: var(--foreground); +} + +.pi-tool-group__chevron { + font-size: 10px; + transition: transform var(--duration-fast); + flex-shrink: 0; +} + +.pi-tool-group:not(.pi-tool-group--collapsed) .pi-tool-group__chevron { + transform: rotate(90deg); +} + +.pi-tool-group__label { + font-weight: 500; +} + +/* Collapsed — hide individual tool cards */ +.pi-tool-group--collapsed > tool-message { + display: none; +} + /* Rows inside group */ .pi-tool-group > tool-message { padding: 1px 6px; diff --git a/src/ui/tool-grouping.ts b/src/ui/tool-grouping.ts index 48883e1..6f5a59d 100644 --- a/src/ui/tool-grouping.ts +++ b/src/ui/tool-grouping.ts @@ -1,8 +1,61 @@ /** * Tool card grouping — wraps consecutive same-tool calls in a single - * continuous container element. Groups are always expanded. + * collapsible container. Groups of 3+ start collapsed; groups of 2 start + * expanded (grouping with stripped card chrome only). */ +/* ── Group header summary ──────────────────────────────── */ + +/** Map tool-name → human-readable plural label for the group header. */ +const TOOL_GROUP_LABELS: Record = { + fill_formula: "fill operations", + write_cells: "edits", + read_range: "reads", + format_cells: "format operations", + conditional_format: "conditional formats", + view_settings: "view changes", + chart: "chart operations", + execute_office_js: "script executions", +}; + +function describeGroup(toolName: string, count: number): string { + const label = TOOL_GROUP_LABELS[toolName] ?? `${toolName} calls`; + return `${count} ${label}`; +} + +function buildGroupHeader(toolName: string, count: number, collapsed: boolean): HTMLButtonElement { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "pi-tool-group__header"; + btn.setAttribute("aria-expanded", collapsed ? "false" : "true"); + + const chevron = document.createElement("span"); + chevron.className = "pi-tool-group__chevron"; + chevron.textContent = "▸"; + + const label = document.createElement("span"); + label.className = "pi-tool-group__label"; + label.textContent = describeGroup(toolName, count); + + btn.append(chevron, label); + + btn.addEventListener("click", () => { + const wrapper = btn.parentElement; + if (!wrapper) return; + const isCollapsed = wrapper.classList.toggle("pi-tool-group--collapsed"); + btn.setAttribute("aria-expanded", isCollapsed ? "false" : "true"); + }); + + return btn; +} + +/* ── Collapse threshold ──────────────────────────────────── */ + +/** Groups with this many or more items start collapsed. */ +const COLLAPSE_THRESHOLD = 3; + +/* ── Main entry point ──────────────────────────────────── */ + /** * Initialise tool-card grouping on the given root element. * Returns a cleanup function that disconnects the observer and removes @@ -11,12 +64,32 @@ export function initToolGrouping(root: HTMLElement): () => void { let rafId = 0; + /** + * Preserve user toggle state across regrouping passes. Keyed on the + * first tool-message element of each group — if the user explicitly + * expanded or collapsed a group, we restore that state when the group + * is rebuilt rather than using the default. + */ + const userToggleState = new WeakMap(); + /* ── Unwrap existing groups ────────────────────────────── */ function unwrapAll() { for (const wrapper of root.querySelectorAll(".pi-tool-group")) { const parent = wrapper.parentNode; if (!parent) continue; + + // Snapshot user toggle state before unwrapping. + const firstMessage = wrapper.querySelector("tool-message"); + if (firstMessage) { + userToggleState.set( + firstMessage, + wrapper.classList.contains("pi-tool-group--collapsed"), + ); + } + + // Remove injected header before unwrapping. + wrapper.querySelector(".pi-tool-group__header")?.remove(); while (wrapper.firstChild) parent.insertBefore(wrapper.firstChild, wrapper); parent.removeChild(wrapper); } @@ -39,23 +112,26 @@ export function initToolGrouping(root: HTMLElement): () => void { } // Identify runs of 2+ consecutive same-name completed tools. - const runs: Element[][] = []; + const runs: { elements: Element[]; toolName: string }[] = []; let currentRun: Element[] = []; + let currentToolName = ""; for (const el of toolMessages) { const card = el.querySelector(".pi-tool-card"); if (!card) { - if (currentRun.length >= 2) runs.push(currentRun); + if (currentRun.length >= 2) runs.push({ elements: currentRun, toolName: currentToolName }); currentRun = []; + currentToolName = ""; continue; } - const toolName = card.getAttribute("data-tool-name"); + const toolName = card.getAttribute("data-tool-name") ?? ""; const cardState = card.getAttribute("data-state"); if (cardState !== "complete" || !toolName) { - if (currentRun.length >= 2) runs.push(currentRun); + if (currentRun.length >= 2) runs.push({ elements: currentRun, toolName: currentToolName }); currentRun = []; + currentToolName = ""; continue; } @@ -67,25 +143,36 @@ export function initToolGrouping(root: HTMLElement): () => void { if (prevName === toolName && areConsecutiveSiblings(prev, el)) { currentRun.push(el); } else { - if (currentRun.length >= 2) runs.push(currentRun); + if (currentRun.length >= 2) runs.push({ elements: currentRun, toolName: currentToolName }); currentRun = [el]; + currentToolName = toolName; } } else { currentRun.push(el); + currentToolName = toolName; } } - if (currentRun.length >= 2) runs.push(currentRun); + if (currentRun.length >= 2) runs.push({ elements: currentRun, toolName: currentToolName }); - // Wrap each run in a container element. + // Wrap each run in a container element with a collapsible header. for (const run of runs) { - const leader = run[0]; - const members = run.slice(1); + const leader = run.elements[0]; + const members = run.elements.slice(1); + const count = run.elements.length; + + // Restore user toggle state if available, otherwise use default. + const savedState = userToggleState.get(leader); + const collapsed = savedState ?? count >= COLLAPSE_THRESHOLD; const wrapper = document.createElement("div"); - wrapper.className = "pi-tool-group"; + wrapper.className = "pi-tool-group" + (collapsed ? " pi-tool-group--collapsed" : ""); + + // Inject group header with summary + expand/collapse toggle. + const header = buildGroupHeader(run.toolName, count, collapsed); + wrapper.appendChild(header); if (leader.parentNode) leader.parentNode.insertBefore(wrapper, leader); - for (const el of run) wrapper.appendChild(el); + for (const el of run.elements) wrapper.appendChild(el); for (const m of members) m.classList.add("pi-group-member"); }