Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/ui/theme/content/message-components.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
111 changes: 99 additions & 12 deletions src/ui/tool-grouping.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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
Expand All @@ -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<Element, boolean>();

/* ── 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);
}
Expand All @@ -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;
}

Expand All @@ -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");
}
Expand Down
Loading