|
| 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