Skip to content

Commit 64f4bf4

Browse files
feat(frontend): collapse Calls detail in analysis (#1226)
Long session call timelines can consume most of the analysis sidebar even when the aggregate count is all the user needs. The Calls header is now a keyboard-accessible disclosure that keeps the localized summary visible while hiding the time axis and individual rows. The expanded choice is stored globally in LocalStorage, so switching sessions or reloading preserves the user’s preferred density. Existing users retain the current expanded behavior until they collapse it, and the other analysis sections remain unchanged. <sup>generated by a clanker</sup> Co-authored-by: Marius van Niekerk <mariusvniekerk@users.noreply.github.com>
1 parent a2c1baa commit 64f4bf4

7 files changed

Lines changed: 801 additions & 96 deletions

File tree

Lines changed: 394 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,394 @@
1+
# Collapsible Calls Detail Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use
4+
> superpowers:subagent-driven-development (recommended) or
5+
> superpowers:executing-plans to implement this plan task-by-task. Steps use
6+
> checkbox (`- [ ]`) syntax for tracking.
7+
8+
**Goal:** Add an accessible disclosure control to the session analysis Calls
9+
section and persist its expanded state across sessions and reloads.
10+
11+
**Architecture:** Extend the existing global `UIStore` with one boolean
12+
preference, initialized and persisted through the store's guarded LocalStorage
13+
pattern. Bind the Calls header in `SessionVitals.svelte` to that preference and
14+
conditionally render only the detail body, leaving the localized section label
15+
and summary visible in both states.
16+
17+
**Tech Stack:** Svelte 5 runes, TypeScript, Vitest/Vite+, Testing Library,
18+
Paraglide messages, Lucide Svelte icons.
19+
20+
## Global Constraints
21+
22+
- The disclosure defaults to expanded when no valid preference exists.
23+
- The preference is global to the analysis sidebar, not scoped to a session.
24+
- The collapsed header retains the existing localized Calls summary.
25+
- No new user-facing copy or locale message keys are introduced.
26+
- The control is keyboard reachable, exposes `aria-expanded`, and has a visible
27+
focus state.
28+
- Tests assert rendered behavior and the owned LocalStorage boundary.
29+
30+
______________________________________________________________________
31+
32+
### Task 1: Persisted Calls Disclosure
33+
34+
**Files:**
35+
36+
- Modify: `frontend/src/lib/stores/ui.svelte.ts`
37+
- Modify: `frontend/src/lib/stores/ui.test.ts`
38+
- Modify: `frontend/src/lib/components/content/SessionVitals.svelte`
39+
- Modify: `frontend/src/lib/components/content/SessionVitals.test.ts`
40+
- Modify: `frontend/e2e/session-timing.spec.ts`
41+
42+
**Interfaces:**
43+
44+
- Consumes: existing `readStoredBool(key, fallback)`, `UIStore` persistence
45+
effects, `m.session_vitals_calls()`, and
46+
`m.session_vitals_calls_summary(...)`.
47+
48+
- Produces: `ui.vitalsCallsExpanded: boolean` and
49+
`ui.toggleVitalsCalls(): void`, consumed by `SessionVitals.svelte`.
50+
51+
- [ ] **Step 1: Add failing store tests for restoration and persistence**
52+
53+
Add a `describe("Calls detail preference", ...)` block to
54+
`frontend/src/lib/stores/ui.test.ts`. The restoration test imports a fresh store
55+
module with `agentsview-session-vitals-calls-expanded` seeded to `"false"` and
56+
expects `vitalsCallsExpanded` to be false. The persistence test toggles a fresh
57+
store instance and expects the key to be written as `"false"`.
58+
59+
```ts
60+
describe("Calls detail preference", () => {
61+
it("restores a collapsed Calls detail preference", async () => {
62+
const original = globalThis.localStorage;
63+
const setItem = vi.fn();
64+
Object.defineProperty(globalThis, "localStorage", {
65+
value: {
66+
getItem: vi.fn((key: string) =>
67+
key === "agentsview-session-vitals-calls-expanded" ? "false" : null,
68+
),
69+
setItem,
70+
},
71+
writable: true,
72+
configurable: true,
73+
});
74+
75+
try {
76+
// @ts-expect-error -- query string busts module cache
77+
const mod = await import("./ui.svelte.js?vitalsCallsCollapsed");
78+
expect(mod.ui.vitalsCallsExpanded).toBe(false);
79+
} finally {
80+
Object.defineProperty(globalThis, "localStorage", {
81+
value: original,
82+
writable: true,
83+
configurable: true,
84+
});
85+
}
86+
});
87+
88+
it("persists Calls detail changes", async () => {
89+
const original = globalThis.localStorage;
90+
const setItem = vi.fn();
91+
Object.defineProperty(globalThis, "localStorage", {
92+
value: { getItem: vi.fn(() => null), setItem },
93+
writable: true,
94+
configurable: true,
95+
});
96+
97+
try {
98+
// @ts-expect-error -- query string busts module cache
99+
const mod = await import("./ui.svelte.js?vitalsCallsPersist");
100+
setItem.mockClear();
101+
mod.ui.toggleVitalsCalls();
102+
await tick();
103+
expect(mod.ui.vitalsCallsExpanded).toBe(false);
104+
expect(setItem).toHaveBeenCalledWith(
105+
"agentsview-session-vitals-calls-expanded",
106+
"false",
107+
);
108+
} finally {
109+
Object.defineProperty(globalThis, "localStorage", {
110+
value: original,
111+
writable: true,
112+
configurable: true,
113+
});
114+
}
115+
});
116+
});
117+
```
118+
119+
- [ ] **Step 2: Run the store tests and verify the expected failure**
120+
121+
Run:
122+
123+
```bash
124+
cd frontend
125+
npm test -- src/lib/stores/ui.test.ts
126+
```
127+
128+
Expected: FAIL because `vitalsCallsExpanded` and `toggleVitalsCalls` do not yet
129+
exist.
130+
131+
- [ ] **Step 3: Add the failing component disclosure test**
132+
133+
In `frontend/src/lib/components/content/SessionVitals.test.ts`, reset
134+
`ui.vitalsCallsExpanded = true` in `beforeEach`. Add a concrete timing fixture
135+
with one Bash call, render the component, and assert the disclosure's observable
136+
behavior.
137+
138+
```ts
139+
it("collapses and restores the Calls detail while keeping its summary", async () => {
140+
mocks.fetchSessionTiming.mockResolvedValue(timingWithCall());
141+
component = mount(SessionVitals, {
142+
target: document.body,
143+
props: { sessionId: "sess-1" },
144+
});
145+
await tick();
146+
await tick();
147+
148+
const disclosure = document.querySelector<HTMLButtonElement>(
149+
`button[aria-expanded="true"]`,
150+
);
151+
expect(disclosure).not.toBeNull();
152+
expect(disclosure?.textContent).toContain(m.session_vitals_calls());
153+
expect(document.querySelector(".scale-axis")).not.toBeNull();
154+
expect(document.querySelector(".calls")).not.toBeNull();
155+
156+
disclosure!.click();
157+
await tick();
158+
159+
expect(disclosure?.getAttribute("aria-expanded")).toBe("false");
160+
expect(disclosure?.textContent).toContain(
161+
m.session_vitals_calls_summary({
162+
count: 1,
163+
countLabel: "1",
164+
runningCount: 0,
165+
}),
166+
);
167+
expect(document.querySelector(".scale-axis")).toBeNull();
168+
expect(document.querySelector(".calls")).toBeNull();
169+
170+
disclosure!.click();
171+
await tick();
172+
173+
expect(disclosure?.getAttribute("aria-expanded")).toBe("true");
174+
expect(document.querySelector(".scale-axis")).not.toBeNull();
175+
expect(document.querySelector(".calls")).not.toBeNull();
176+
});
177+
178+
function timingWithCall(): SessionTiming {
179+
return {
180+
...mocks.timing,
181+
tool_duration_ms: 400,
182+
tool_call_count: 1,
183+
turns: [
184+
{
185+
message_id: 1,
186+
ordinal: 1,
187+
started_at: "2026-07-14T12:00:00Z",
188+
duration_ms: 400,
189+
primary_category: "Bash",
190+
calls: [
191+
{
192+
tool_use_id: "call-1",
193+
tool_name: "Bash",
194+
category: "Bash",
195+
duration_ms: 400,
196+
is_parallel: false,
197+
input_preview: "go test ./...",
198+
},
199+
],
200+
},
201+
],
202+
};
203+
}
204+
```
205+
206+
- [ ] **Step 4: Run the component test and verify the expected failure**
207+
208+
Run:
209+
210+
```bash
211+
cd frontend
212+
npm test -- src/lib/components/content/SessionVitals.test.ts
213+
```
214+
215+
Expected: FAIL because the Calls header is not a disclosure button and the
216+
detail remains rendered after activation.
217+
218+
- [ ] **Step 5: Implement the persisted UI-store preference**
219+
220+
In `frontend/src/lib/stores/ui.svelte.ts`, add the storage key beside the other
221+
analysis-panel keys, initialize the state with an expanded fallback, persist it
222+
in the constructor, and expose the toggle.
223+
224+
```ts
225+
const VITALS_CALLS_EXPANDED_KEY =
226+
"agentsview-session-vitals-calls-expanded";
227+
228+
vitalsCallsExpanded: boolean = $state(
229+
readStoredBool(VITALS_CALLS_EXPANDED_KEY, true),
230+
);
231+
232+
$effect(() => {
233+
try {
234+
localStorage?.setItem(
235+
VITALS_CALLS_EXPANDED_KEY,
236+
String(this.vitalsCallsExpanded),
237+
);
238+
} catch {
239+
// ignore
240+
}
241+
});
242+
243+
toggleVitalsCalls() {
244+
this.vitalsCallsExpanded = !this.vitalsCallsExpanded;
245+
}
246+
```
247+
248+
- [ ] **Step 6: Implement the Calls disclosure UI**
249+
250+
Import `ChevronRightIcon` in `SessionVitals.svelte`. Replace the Calls header
251+
contents with a full-width button bound to the global preference, and render the
252+
axis and rows only while expanded.
253+
254+
```svelte
255+
<header class="v-h calls-header" class:expanded={ui.vitalsCallsExpanded}>
256+
<button
257+
type="button"
258+
class="calls-disclosure"
259+
aria-expanded={ui.vitalsCallsExpanded}
260+
onclick={() => ui.toggleVitalsCalls()}
261+
>
262+
<span class="calls-heading">
263+
<span class="calls-chevron" class:open={ui.vitalsCallsExpanded}>
264+
<ChevronRightIcon size="10" strokeWidth="2.4" aria-hidden="true" />
265+
</span>
266+
<span>{m.session_vitals_calls()}</span>
267+
</span>
268+
<span class="v-meta">
269+
{m.session_vitals_calls_summary({
270+
count: timing.tool_call_count,
271+
countLabel: formatNumber(timing.tool_call_count),
272+
runningCount: timing.running ? 1 : 0,
273+
})}
274+
</span>
275+
</button>
276+
</header>
277+
{#if ui.vitalsCallsExpanded}
278+
<!-- existing scale axis and calls list -->
279+
{/if}
280+
```
281+
282+
Add scoped styles that preserve the existing header layout while making the full
283+
row interactive.
284+
285+
```css
286+
.calls-header {
287+
margin-bottom: 0;
288+
}
289+
290+
.calls-disclosure {
291+
display: flex;
292+
align-items: center;
293+
justify-content: space-between;
294+
gap: 8px;
295+
width: calc(100% + 8px);
296+
padding: 2px 4px;
297+
margin: -2px -4px;
298+
border-radius: var(--radius-sm);
299+
color: inherit;
300+
text-align: left;
301+
transition: background 0.12s;
302+
}
303+
304+
.calls-header.expanded {
305+
margin-bottom: 9px;
306+
}
307+
308+
.calls-disclosure:hover {
309+
background: var(--bg-surface-hover);
310+
}
311+
312+
.calls-disclosure:focus-visible {
313+
outline: 2px solid var(--accent-blue);
314+
outline-offset: 2px;
315+
}
316+
317+
.calls-heading,
318+
.calls-chevron {
319+
display: inline-flex;
320+
align-items: center;
321+
}
322+
323+
.calls-heading {
324+
gap: 4px;
325+
}
326+
327+
.calls-chevron {
328+
transition: transform 0.15s ease-out;
329+
}
330+
331+
.calls-chevron.open {
332+
transform: rotate(90deg);
333+
}
334+
```
335+
336+
- [ ] **Step 7: Keep the existing section-count E2E assertion semantic**
337+
338+
Update `frontend/e2e/session-timing.spec.ts` so the section-header assertion
339+
selects the four `.v-h` elements instead of assuming every header's first child
340+
is a text-only span. This preserves the existing user-visible assertion after
341+
Calls becomes a button.
342+
343+
```ts
344+
const headers = page
345+
.locator(".v-section .v-h")
346+
.filter({ hasText: /(Session|Time spent|Timeline|Calls)/ });
347+
await expect(headers).toHaveCount(4);
348+
```
349+
350+
- [ ] **Step 8: Run the focused tests and verify they pass**
351+
352+
Run:
353+
354+
```bash
355+
cd frontend
356+
npm test -- src/lib/stores/ui.test.ts src/lib/components/content/SessionVitals.test.ts
357+
```
358+
359+
Expected: 2 test files pass with no failures.
360+
361+
- [ ] **Step 9: Run frontend validation**
362+
363+
Run:
364+
365+
```bash
366+
cd frontend
367+
npm run check
368+
npm run check:kit-ui
369+
npm test
370+
```
371+
372+
Expected: Svelte/TypeScript and kit-ui checks pass and all frontend tests pass.
373+
374+
- [ ] **Step 10: Run the focused Playwright spec when the local E2E harness is
375+
available**
376+
377+
Run:
378+
379+
```bash
380+
cd frontend
381+
npm run e2e -- session-timing.spec.ts
382+
```
383+
384+
Expected: the Session Vital Signs spec passes. If the local E2E harness cannot
385+
start, report the exact blocker instead of weakening or skipping assertions.
386+
387+
- [ ] **Step 11: Review and commit the implementation**
388+
389+
Review `git diff --check`, `git status --short`, `git diff --stat`, and
390+
`git diff HEAD`. Stage only the five implementation/test files and commit with:
391+
392+
```bash
393+
git commit -m "feat(frontend): collapse Calls detail in analysis"
394+
```

0 commit comments

Comments
 (0)