Skip to content

Commit 0942155

Browse files
committed
feat(ui): collapse consecutive same-tool card groups
Groups of 3+ consecutive same-tool completed cards now start collapsed behind a summary header ('5 fill operations', '3 edits', etc.) with a click-to-expand toggle. Groups of 2 remain expanded (header still shown for visual grouping). The header uses the tool name from data-tool-name and a human-readable label map, with a generic fallback. The collapse state toggles via a CSS class (.pi-tool-group--collapsed) that hides the individual cards. Closes #484
1 parent 227a996 commit 0942155

File tree

2 files changed

+139
-12
lines changed

2 files changed

+139
-12
lines changed

src/ui/theme/content/message-components.css

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,46 @@
88
border-radius: var(--pill-radius);
99
}
1010

11+
/* Group header — collapsible summary */
12+
.pi-tool-group__header {
13+
display: flex;
14+
align-items: center;
15+
gap: 6px;
16+
width: 100%;
17+
padding: 6px 10px;
18+
border: none;
19+
background: none;
20+
cursor: pointer;
21+
font-family: var(--font-sans);
22+
font-size: var(--text-sm);
23+
color: var(--muted-foreground);
24+
text-align: left;
25+
transition: color var(--duration-fast);
26+
}
27+
28+
.pi-tool-group__header:hover {
29+
color: var(--foreground);
30+
}
31+
32+
.pi-tool-group__chevron {
33+
font-size: 10px;
34+
transition: transform var(--duration-fast);
35+
flex-shrink: 0;
36+
}
37+
38+
.pi-tool-group:not(.pi-tool-group--collapsed) .pi-tool-group__chevron {
39+
transform: rotate(90deg);
40+
}
41+
42+
.pi-tool-group__label {
43+
font-weight: 500;
44+
}
45+
46+
/* Collapsed — hide individual tool cards */
47+
.pi-tool-group--collapsed > tool-message {
48+
display: none;
49+
}
50+
1151
/* Rows inside group */
1252
.pi-tool-group > tool-message {
1353
padding: 1px 6px;

src/ui/tool-grouping.ts

Lines changed: 99 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,61 @@
11
/**
22
* Tool card grouping — wraps consecutive same-tool calls in a single
3-
* continuous container element. Groups are always expanded.
3+
* collapsible container. Groups of 3+ start collapsed; groups of 2 start
4+
* expanded (grouping with stripped card chrome only).
45
*/
56

7+
/* ── Group header summary ──────────────────────────────── */
8+
9+
/** Map tool-name → human-readable plural label for the group header. */
10+
const TOOL_GROUP_LABELS: Record<string, string> = {
11+
fill_formula: "fill operations",
12+
write_cells: "edits",
13+
read_range: "reads",
14+
format_cells: "format operations",
15+
conditional_format: "conditional formats",
16+
view_settings: "view changes",
17+
chart: "chart operations",
18+
execute_office_js: "script executions",
19+
};
20+
21+
function describeGroup(toolName: string, count: number): string {
22+
const label = TOOL_GROUP_LABELS[toolName] ?? `${toolName} calls`;
23+
return `${count} ${label}`;
24+
}
25+
26+
function buildGroupHeader(toolName: string, count: number, collapsed: boolean): HTMLButtonElement {
27+
const btn = document.createElement("button");
28+
btn.type = "button";
29+
btn.className = "pi-tool-group__header";
30+
btn.setAttribute("aria-expanded", collapsed ? "false" : "true");
31+
32+
const chevron = document.createElement("span");
33+
chevron.className = "pi-tool-group__chevron";
34+
chevron.textContent = "▸";
35+
36+
const label = document.createElement("span");
37+
label.className = "pi-tool-group__label";
38+
label.textContent = describeGroup(toolName, count);
39+
40+
btn.append(chevron, label);
41+
42+
btn.addEventListener("click", () => {
43+
const wrapper = btn.parentElement;
44+
if (!wrapper) return;
45+
const isCollapsed = wrapper.classList.toggle("pi-tool-group--collapsed");
46+
btn.setAttribute("aria-expanded", isCollapsed ? "false" : "true");
47+
});
48+
49+
return btn;
50+
}
51+
52+
/* ── Collapse threshold ──────────────────────────────────── */
53+
54+
/** Groups with this many or more items start collapsed. */
55+
const COLLAPSE_THRESHOLD = 3;
56+
57+
/* ── Main entry point ──────────────────────────────────── */
58+
659
/**
760
* Initialise tool-card grouping on the given root element.
861
* Returns a cleanup function that disconnects the observer and removes
@@ -11,12 +64,32 @@
1164
export function initToolGrouping(root: HTMLElement): () => void {
1265
let rafId = 0;
1366

67+
/**
68+
* Preserve user toggle state across regrouping passes. Keyed on the
69+
* first tool-message element of each group — if the user explicitly
70+
* expanded or collapsed a group, we restore that state when the group
71+
* is rebuilt rather than using the default.
72+
*/
73+
const userToggleState = new WeakMap<Element, boolean>();
74+
1475
/* ── Unwrap existing groups ────────────────────────────── */
1576

1677
function unwrapAll() {
1778
for (const wrapper of root.querySelectorAll(".pi-tool-group")) {
1879
const parent = wrapper.parentNode;
1980
if (!parent) continue;
81+
82+
// Snapshot user toggle state before unwrapping.
83+
const firstMessage = wrapper.querySelector("tool-message");
84+
if (firstMessage) {
85+
userToggleState.set(
86+
firstMessage,
87+
wrapper.classList.contains("pi-tool-group--collapsed"),
88+
);
89+
}
90+
91+
// Remove injected header before unwrapping.
92+
wrapper.querySelector(".pi-tool-group__header")?.remove();
2093
while (wrapper.firstChild) parent.insertBefore(wrapper.firstChild, wrapper);
2194
parent.removeChild(wrapper);
2295
}
@@ -39,23 +112,26 @@ export function initToolGrouping(root: HTMLElement): () => void {
39112
}
40113

41114
// Identify runs of 2+ consecutive same-name completed tools.
42-
const runs: Element[][] = [];
115+
const runs: { elements: Element[]; toolName: string }[] = [];
43116
let currentRun: Element[] = [];
117+
let currentToolName = "";
44118

45119
for (const el of toolMessages) {
46120
const card = el.querySelector(".pi-tool-card");
47121
if (!card) {
48-
if (currentRun.length >= 2) runs.push(currentRun);
122+
if (currentRun.length >= 2) runs.push({ elements: currentRun, toolName: currentToolName });
49123
currentRun = [];
124+
currentToolName = "";
50125
continue;
51126
}
52127

53-
const toolName = card.getAttribute("data-tool-name");
128+
const toolName = card.getAttribute("data-tool-name") ?? "";
54129
const cardState = card.getAttribute("data-state");
55130

56131
if (cardState !== "complete" || !toolName) {
57-
if (currentRun.length >= 2) runs.push(currentRun);
132+
if (currentRun.length >= 2) runs.push({ elements: currentRun, toolName: currentToolName });
58133
currentRun = [];
134+
currentToolName = "";
59135
continue;
60136
}
61137

@@ -67,25 +143,36 @@ export function initToolGrouping(root: HTMLElement): () => void {
67143
if (prevName === toolName && areConsecutiveSiblings(prev, el)) {
68144
currentRun.push(el);
69145
} else {
70-
if (currentRun.length >= 2) runs.push(currentRun);
146+
if (currentRun.length >= 2) runs.push({ elements: currentRun, toolName: currentToolName });
71147
currentRun = [el];
148+
currentToolName = toolName;
72149
}
73150
} else {
74151
currentRun.push(el);
152+
currentToolName = toolName;
75153
}
76154
}
77-
if (currentRun.length >= 2) runs.push(currentRun);
155+
if (currentRun.length >= 2) runs.push({ elements: currentRun, toolName: currentToolName });
78156

79-
// Wrap each run in a container element.
157+
// Wrap each run in a container element with a collapsible header.
80158
for (const run of runs) {
81-
const leader = run[0];
82-
const members = run.slice(1);
159+
const leader = run.elements[0];
160+
const members = run.elements.slice(1);
161+
const count = run.elements.length;
162+
163+
// Restore user toggle state if available, otherwise use default.
164+
const savedState = userToggleState.get(leader);
165+
const collapsed = savedState ?? count >= COLLAPSE_THRESHOLD;
83166

84167
const wrapper = document.createElement("div");
85-
wrapper.className = "pi-tool-group";
168+
wrapper.className = "pi-tool-group" + (collapsed ? " pi-tool-group--collapsed" : "");
169+
170+
// Inject group header with summary + expand/collapse toggle.
171+
const header = buildGroupHeader(run.toolName, count, collapsed);
172+
wrapper.appendChild(header);
86173

87174
if (leader.parentNode) leader.parentNode.insertBefore(wrapper, leader);
88-
for (const el of run) wrapper.appendChild(el);
175+
for (const el of run.elements) wrapper.appendChild(el);
89176

90177
for (const m of members) m.classList.add("pi-group-member");
91178
}

0 commit comments

Comments
 (0)