From 109e07cdbabc2b37597467383cfe4c41e4eae123 Mon Sep 17 00:00:00 2001 From: Monsky Date: Wed, 15 Jul 2026 19:14:06 -0400 Subject: [PATCH 01/14] docs: specify inline thread expansion --- ...6-07-15-threads-inline-expansion-design.md | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-15-threads-inline-expansion-design.md diff --git a/docs/superpowers/specs/2026-07-15-threads-inline-expansion-design.md b/docs/superpowers/specs/2026-07-15-threads-inline-expansion-design.md new file mode 100644 index 00000000..e132cfdd --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-threads-inline-expansion-design.md @@ -0,0 +1,153 @@ +# Threads Inline Expansion Design + +**Date:** 2026-07-15 +**Status:** Approved +**Scope:** React dashboard Threads workspace + +## Problem + +The Threads workspace currently separates the leaderboard from a large selected-thread inspector. A thread row selects that inspector, while row activation can open one representative call in Call Investigator. This makes the primary task—reviewing every call that belongs to a thread—feel indirect and makes an individual call appear to represent the whole thread. + +The redesigned workspace must make a thread itself the unit of interaction. Clicking its leaderboard row must reveal the calls belonging to that thread in place. Opening Call Investigator must remain an explicit action on an individual call. + +## Goals + +- Make thread-to-call exploration immediate and spatially clear. +- Let the user reach every aggregate call in the selected thread without leaving Threads. +- Preserve cross-thread comparison by keeping the leaderboard visible. +- Keep large threads responsive while their calls load. +- Preserve stable URL state, sorting, filtering, exports, privacy boundaries, and explicit Call Investigator navigation. +- Provide complete keyboard, screen-reader, narrow-screen, loading, empty, partial, and error behavior. + +## Non-goals + +- Changing `/api/threads` or `/api/thread-calls` response schemas. +- Exposing prompts, assistant text, tool output, or other raw context. +- Redesigning Call Investigator. +- Replacing Cache Frontier or Lifecycle visualizations. +- Supporting multiple simultaneously expanded threads. +- Changing thread identity or parent/subagent attachment semantics. + +## Chosen Interaction Model + +The default Table view becomes a single-open inline accordion. + +1. Clicking anywhere on a collapsed thread row expands a full-width detail region immediately beneath that row. +2. Clicking the expanded row again, or its explicit **Collapse** control, closes it. +3. Clicking another thread closes the previous region, cancels or ignores its obsolete in-flight work, and opens the new thread. +4. A parent thread row never opens Call Investigator, including on double-click or keyboard activation. +5. Each child call exposes an explicit **Open investigator** action and a secondary **Copy link** action. +6. The selected thread remains encoded as `thread=` in the URL. Closing the expansion removes the thread-only child-call paging state. +7. Legacy `detail`, `expand`, and `threads` URL forms continue to normalize into the single selected-thread state. + +This model was chosen over a persistent side inspector and a dedicated thread-detail route. It keeps calls attached to their source row, preserves comparison context, and removes ambiguous navigation without adding a new workspace. + +## Information Architecture + +### Leaderboard + +The leaderboard remains the default Threads surface and retains search, risk filtering, sorting, column preferences, export, focused API status, and pagination. Its most decision-useful columns remain visible by default: + +- thread identity; +- call count; +- total tokens; +- uncached input or cache ratio; +- Codex credits/cost confidence where available; +- peak context pressure; +- latest activity; +- cold-resume or attention risk. + +The row receives a visible disclosure affordance and `aria-expanded`. The row’s selected styling is replaced or supplemented by a clear expanded state so selection is not confused with navigation. + +### Expanded thread region + +The current oversized side inspector is removed from Table view. Its useful content moves into the expanded region in this order: + +1. A compact header with thread name, loaded/total call progress, and **Collapse**. +2. A concise summary strip for calls, total tokens, cached/uncached input, cache ratio, credits/cost, peak context, duration, and latest activity. +3. Sort controls for calls, retaining the existing sort keys and directions. +4. A dense call list showing time, model/effort, total tokens, cached/uncached balance, context pressure, duration/gap, cost or credits, and aggregate signals. +5. Explicit **Open investigator** and **Copy link** actions for every call. + +The current inspector's lifecycle, relationship, impact, donut, status, and secondary-field cards do not move into the accordion. Cache Frontier and Lifecycle already preserve the deeper analytical views; the expanded Table region stays focused on choosing a call. + +## Loading And Performance + +Expansion paints immediately with any calls already available in the boot payload. When the live focused endpoint is available, the client requests `/api/thread-calls` pages in the current sort order and progressively loads the remaining pages until all matched calls are available. + +- Progress is announced as `N of M calls loaded`. +- Switching or collapsing invalidates the old expansion. Late responses may populate the query cache but must not render into the new selection. +- A page failure preserves already loaded aggregate calls, labels the result partial, and provides **Retry loading calls**. +- An empty successful response shows a local empty state inside the expansion. +- The call list uses the repository's virtualization primitives so rendered call elements stay bounded for very large threads. +- The expanded region must not auto-scroll the page unexpectedly as pages arrive. +- Export continues to operate on the calls represented by the filtered Threads result, not only the currently expanded thread. + +No API schema change is required. The existing 100-row `/api/thread-calls` pagination contract is sufficient. + +## View Modes + +Table remains the practical default. Cache Frontier and Lifecycle remain secondary modes. Selecting evidence in those visual modes may update `thread=`, but inline expansion renders only after returning to Table. Visual-mode selections must never open a representative call implicitly. + +## Responsive Behavior + +On wide screens, the expanded region spans the full leaderboard width. The call list uses a compact tabular layout with horizontally safe column priorities. + +On narrow screens, each call reflows to a stacked summary card. Primary evidence and **Open investigator** remain visible; lower-priority metadata follows without horizontal scrolling. Interactive touch targets are at least 44 CSS pixels, and expansion does not create nested scroll traps. + +## Accessibility + +- The thread disclosure is keyboard reachable and responds consistently to Enter and Space. +- The parent row or disclosure exposes `aria-expanded` and an accessible name such as `Expand calls for ` or `Collapse calls for `. +- The expanded region is associated with its controlling row and has a useful accessible label. +- Loading, partial completion, retry, and completion are announced through a polite live region. +- Focus remains on the activating row/disclosure when expansion changes. +- Per-call buttons stop row propagation and have record-specific accessible names. +- Visible focus, non-color expanded state, semantic table/list structure, and reduced-motion preferences follow existing dashboard primitives. + +## State And URL Contract + +- `thread=` identifies the one expanded thread. +- Existing `thread_call_sort`, `thread_call_direction`, and child-call paging compatibility remain supported. +- The UI uses an explicit internal `loading-all` state and does not encode an unbounded numeric page count in the URL. +- Sorting calls resets visible positioning and begins or resumes progressive loading in the new order. +- Filtering or paging the leaderboard collapses a thread that is no longer in the displayed result while preserving unrelated global filters. +- Leaving Threads clears Threads-only state through the existing shell URL cleanup. + +## Error And Empty States + +- No matching threads: retain the leaderboard empty state and show no detached inspector. +- Thread with zero calls: show `No aggregate calls are available for this thread.` +- Live endpoint unavailable: show boot-payload calls when present and label the source as a stored snapshot. +- Later page fails: keep loaded calls, show `Partial result`, and offer retry. +- Retry succeeds: remove the partial label and continue until the total is loaded. + +## Implementation Boundaries + +The design should preserve existing focused query and URL-state modules while separating three responsibilities: + +- page orchestration: selected thread, query lifecycle, filters, sorting, and URL synchronization; +- expandable leaderboard presentation: parent rows and one full-width child region; +- expanded call evidence: summary, progress, sorting, virtualized rows, and explicit call actions. + +The implementation should reuse existing formatting, signal, copy-link, query, and design-system helpers. It should avoid a broad EvidenceGrid rewrite unless a narrowly tested expansion seam is required. + +## Testing And Acceptance Criteria + +The change is complete when automated tests prove: + +1. Clicking a collapsed parent row expands that thread’s calls inline and keeps the user on `view=threads`. +2. Clicking the same row collapses it; clicking another row leaves only the new thread open. +3. Parent-row click, double-click, Enter, and Space never open Call Investigator. +4. Every call retains explicit Open and Copy actions with correct return URLs. +5. Progressive pagination reaches all matched calls, reports progress, and does not duplicate rows. +6. Switching threads prevents stale responses from rendering under the new thread. +7. Partial failures preserve loaded rows and retry continues successfully. +8. Sorting, legacy URL hydration, direct `thread=` links, and shell state cleanup remain correct. +9. Large synthetic threads keep rendered call elements bounded. +10. Desktop and narrow-screen layouts preserve readable evidence and accessible actions. +11. The built plugin dashboard assets match the TypeScript source and pass source-budget, lint, typecheck, unit, browser-smoke, and release checks required for dashboard behavior changes. + +## Privacy + +All expanded content remains aggregate-only. The redesign must not call the raw-context endpoint, embed indexed snippets, or change static HTML privacy behavior. From 90a8f623a80086df10149f9752f3317854dea35f Mon Sep 17 00:00:00 2001 From: Monsky Date: Wed, 15 Jul 2026 20:04:11 -0400 Subject: [PATCH 02/14] docs: plan inline thread expansion --- .../2026-07-15-threads-inline-expansion.md | 947 ++++++++++++++++++ 1 file changed, 947 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-15-threads-inline-expansion.md diff --git a/docs/superpowers/plans/2026-07-15-threads-inline-expansion.md b/docs/superpowers/plans/2026-07-15-threads-inline-expansion.md new file mode 100644 index 00000000..e5c3b457 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-threads-inline-expansion.md @@ -0,0 +1,947 @@ +# Threads Inline Expansion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the Threads table's detached inspector and implicit latest-call navigation with a single-open inline accordion that progressively loads and virtualizes every aggregate call in the selected thread. + +**Architecture:** Keep query and URL orchestration in `ThreadsPage`, but replace Table mode's generic fixed-height grid with a thread-specific `ThreadAccordionGrid`. The new grid flattens parent thread rows, one expansion summary, child calls, and loading/error status into a single `@tanstack/react-virtual` sequence, so the entire interaction uses one scroll container and a bounded DOM. Existing `/api/threads` and `/api/thread-calls` contracts, Cache Frontier, Lifecycle, exports, and explicit Call Investigator routes remain unchanged. + +**Tech Stack:** React 19, TypeScript, TanStack React Table, TanStack React Query, TanStack React Virtual, CSS Modules, Vitest/Testing Library, Playwright, Vite, Python release checks. + +## Global Constraints + +- Keep the dashboard aggregate-only; do not call raw-context APIs or embed indexed snippets. +- Keep exactly one expanded thread; clicking the same row collapses it and clicking another row switches it. +- Parent thread click, double-click, Enter, and Space must never open Call Investigator. +- Load all `/api/thread-calls` pages progressively in the selected sort order, with visible progress, partial-error preservation, and retry. +- Keep rendered child-call elements bounded for very large threads and avoid nested vertical scroll containers. +- Preserve Threads filters, sorting, column preferences, exports, legacy URL hydration, direct `thread=` links, Cache Frontier, and Lifecycle. +- Keep per-call **Open investigator** and **Copy link** actions explicit and record-specific. +- Preserve synthetic-only fixtures, screenshots, and documentation assets. +- Do not change server schemas, thread attachment semantics, privacy semantics, or Call Investigator. + +--- + +## File Structure + +- Create `frontend/dashboard/src/features/threads/threadCallLoading.ts`: deduplicate paged call rows and decide when progressive loading should continue. +- Create `frontend/dashboard/src/features/threads/threadCallLoading.test.ts`: pure pagination/deduplication/error-stop tests. +- Create `frontend/dashboard/src/features/explore/EvidenceGridControls.tsx`: reusable density/column chooser extracted without changing grid behavior. +- Modify `frontend/dashboard/src/features/explore/EvidenceGrid.tsx`: consume the extracted controls; retain its fixed-height virtualization unchanged. +- Create `frontend/dashboard/src/features/threads/ThreadAccordionGrid.tsx`: table header, flattened virtual sequence, disclosure rows, child calls, status/retry row, and responsive roles. +- Create `frontend/dashboard/src/features/threads/ThreadAccordionGrid.test.tsx`: interaction, accessibility, virtualization, and mobile tests. +- Rename `frontend/dashboard/src/features/threads/ThreadInspector.tsx` to `frontend/dashboard/src/features/threads/ThreadAccordionRows.tsx`: retain only call controls and compact child-call presentation; remove detached inspector analytics. +- Modify `frontend/dashboard/src/features/threads/ThreadsPage.tsx`: nullable expansion identity, progressive query loop, stale-key isolation, toggle/collapse logic, and new grid props. +- Modify `frontend/dashboard/src/features/threads/ThreadsExplorerView.tsx`: render the accordion in Table mode and remove the detached inspector/row activation path. +- Modify `frontend/dashboard/src/features/threads/threadsUrlState.ts`: stop writing child-page counts while preserving legacy hydration and child sort state. +- Modify `frontend/dashboard/src/features/threads/threadsUrlState.test.ts`: pin collapse and legacy normalization behavior. +- Modify `frontend/dashboard/src/features/threads/ThreadsPage.module.css`: accordion hierarchy, responsive call cards, loading/error states, and 44px controls. +- Modify `frontend/dashboard/src/App.threads.test.tsx`: workspace-level toggle, progressive loading, stale result, partial retry, URL, and investigator-action coverage. +- Modify `tests/playwright/dashboard-react.spec.mjs`: browser flow for inline expansion and explicit child navigation at desktop and narrow widths. +- Modify `docs/dashboard-guide.md`: replace inspector/activation instructions with inline expansion behavior. +- Modify `scripts/capture_dashboard_screenshots.mjs`: ensure the synthetic Threads screenshot opens a real inline expansion. +- Regenerate `docs/assets/dashboard-threads.png`, packaged documentation copies, and `src/codex_usage_tracker/plugin_data/dashboard/react/assets/*` through the existing build/screenshot commands. + +--- + +### Task 1: Progressive Call Loading And URL Contract + +**Files:** +- Create: `frontend/dashboard/src/features/threads/threadCallLoading.ts` +- Create: `frontend/dashboard/src/features/threads/threadCallLoading.test.ts` +- Modify: `frontend/dashboard/src/features/threads/threadsUrlState.ts` +- Modify: `frontend/dashboard/src/features/threads/threadsUrlState.test.ts` + +**Interfaces:** +- Produces: `dedupeThreadCallPages(pages: ExploreCallsPage[], fallback: CallRow[]): CallRow[]` +- Produces: `shouldFetchNextThreadCallPage(state: ProgressiveThreadCallState): boolean` +- Preserves: `readInitialSelectedThreadParam`, `readThreadCallSortParam`, `readThreadCallSortDirectionParam` +- Changes: `ThreadsViewLinkState` no longer accepts `visibleThreadCallCount`; `buildThreadsViewLink` always deletes `thread_call_page` + +- [ ] **Step 1: Write failing progressive-loading tests** + +Create `threadCallLoading.test.ts` with concrete duplicate-boundary and error-stop cases: + +```ts +import { describe, expect, it } from 'vitest'; +import type { CallRow } from '../../api/types'; +import type { ExploreCallsPage } from '../../data/contracts/explore'; +import { dedupeThreadCallPages, shouldFetchNextThreadCallPage } from './threadCallLoading'; + +const call = (id: string): CallRow => ({ id } as CallRow); +const page = (rows: CallRow[]): ExploreCallsPage => ({ + schema: 'codex-usage-tracker-thread-calls-v1', + rows, + rowCount: rows.length, + totalMatchedRows: 3, + limit: 100, + offset: 0, + hasMore: false, + nextOffset: null, + rawContextIncluded: false, + threadKey: 'thread-alpha', +}); + +describe('thread call progressive loading', () => { + it('deduplicates page boundaries by record id while preserving first-seen order', () => { + expect(dedupeThreadCallPages([ + page([call('a'), call('b')]), + page([call('b'), call('c')]), + ], [])).map(row => row.id)).toEqual(['a', 'b', 'c']); + }); + + it('uses snapshot calls only before focused pages arrive', () => { + expect(dedupeThreadCallPages([], [call('snapshot')]).map(row => row.id)).toEqual(['snapshot']); + expect(dedupeThreadCallPages([page([call('live')])], [call('snapshot')]).map(row => row.id)).toEqual(['live']); + }); + + it('continues only for an expanded, healthy, idle query with another page', () => { + const ready = { + expanded: true, + enabled: true, + hasNextPage: true, + isFetchingNextPage: false, + isFetchNextPageError: false, + }; + expect(shouldFetchNextThreadCallPage(ready)).toBe(true); + expect(shouldFetchNextThreadCallPage({ ...ready, expanded: false })).toBe(false); + expect(shouldFetchNextThreadCallPage({ ...ready, isFetchingNextPage: true })).toBe(false); + expect(shouldFetchNextThreadCallPage({ ...ready, isFetchNextPageError: true })).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run the new tests and verify RED** + +Run: + +```bash +npm --workspace frontend/dashboard test -- src/features/threads/threadCallLoading.test.ts +``` + +Expected: FAIL because `threadCallLoading.ts` does not exist. + +- [ ] **Step 3: Implement the minimal loading helpers** + +Create `threadCallLoading.ts`: + +```ts +import type { CallRow } from '../../api/types'; +import type { ExploreCallsPage } from '../../data/contracts/explore'; + +export type ProgressiveThreadCallState = { + expanded: boolean; + enabled: boolean; + hasNextPage: boolean; + isFetchingNextPage: boolean; + isFetchNextPageError: boolean; +}; + +export function dedupeThreadCallPages(pages: ExploreCallsPage[], fallback: CallRow[]): CallRow[] { + const source = pages.length ? pages.flatMap(page => page.rows) : fallback; + const seen = new Set(); + return source.filter(row => { + if (seen.has(row.id)) return false; + seen.add(row.id); + return true; + }); +} + +export function shouldFetchNextThreadCallPage(state: ProgressiveThreadCallState): boolean { + return state.expanded + && state.enabled + && state.hasNextPage + && !state.isFetchingNextPage + && !state.isFetchNextPageError; +} +``` + +- [ ] **Step 4: Write failing URL normalization tests** + +Add cases to `threadsUrlState.test.ts` proving that direct and legacy expansion hydrate one name, while link building removes obsolete child paging on both expanded and collapsed URLs: + +```ts +it('normalizes legacy expansion while dropping obsolete child page state', () => { + expect(readInitialSelectedThreadParam('http://localhost/?view=threads&threads=alpha,beta')).toBe('alpha'); + expect(readInitialSelectedThreadParam('http://localhost/?view=threads&expand=first')).toBe(detailFirstSelectedThreadName); + + const url = buildThreadsViewLink({ + localQuery: 'cache', + riskFilter: 'High', + selectedThreadName: 'alpha', + sorting: [{ id: 'totalTokens', desc: true }], + visibleRowCount: threadsTablePageSize, + threadCallSort: 'tokens', + threadCallSortDirection: 'desc', + }, 'http://localhost/?view=threads&thread_call_page=9'); + + expect(url.searchParams.get('thread')).toBe('alpha'); + expect(url.searchParams.get('thread_call_sort')).toBe('tokens'); + expect(url.searchParams.has('thread_call_page')).toBe(false); +}); + +it('removes thread-only state when the accordion is collapsed', () => { + const url = buildThreadsViewLink({ + localQuery: '', + riskFilter: 'all', + selectedThreadName: null, + sorting: [], + visibleRowCount: threadsTablePageSize, + threadCallSort: 'newest', + threadCallSortDirection: 'desc', + }, 'http://localhost/?view=threads&thread=alpha&thread_call_page=4'); + + expect(url.searchParams.has('thread')).toBe(false); + expect(url.searchParams.has('thread_call_page')).toBe(false); +}); +``` + +- [ ] **Step 5: Run URL tests and verify RED** + +Run: + +```bash +npm --workspace frontend/dashboard test -- src/features/threads/threadsUrlState.test.ts +``` + +Expected: FAIL because `ThreadsViewLinkState` still requires and serializes `visibleThreadCallCount`. + +- [ ] **Step 6: Update URL state and verify GREEN** + +In `threadsUrlState.ts`: + +- remove `threadCallPageSize` and `readThreadCallPageVisibleRowsParam`; progressive loading supersedes five-row disclosure, and legacy `thread_call_page` is accepted as inert input then removed by canonical URL synchronization; +- remove `visibleThreadCallCount` from `ThreadsViewLinkState`; +- delete `thread_call_page` at the start of `buildThreadsViewLink`; +- preserve `thread_call_sort` and `thread_call_direction` only while `selectedThreadName` is non-null; +- normalize `detail`, `expand`, and `threads` exactly once into `thread`. + +Run: + +```bash +npm --workspace frontend/dashboard test -- src/features/threads/threadCallLoading.test.ts src/features/threads/threadsUrlState.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Commit Task 1** + +```bash +git add -- frontend/dashboard/src/features/threads/threadCallLoading.ts frontend/dashboard/src/features/threads/threadCallLoading.test.ts frontend/dashboard/src/features/threads/threadsUrlState.ts frontend/dashboard/src/features/threads/threadsUrlState.test.ts +git commit -m "feat: define progressive thread call state" +``` + +--- + +### Task 2: Virtualized Thread Accordion Grid + +**Files:** +- Create: `frontend/dashboard/src/features/explore/EvidenceGridControls.tsx` +- Modify: `frontend/dashboard/src/features/explore/EvidenceGrid.tsx` +- Create: `frontend/dashboard/src/features/threads/ThreadAccordionGrid.tsx` +- Create: `frontend/dashboard/src/features/threads/ThreadAccordionGrid.test.tsx` +- Rename: `frontend/dashboard/src/features/threads/ThreadInspector.tsx` to `frontend/dashboard/src/features/threads/ThreadAccordionRows.tsx` +- Modify: `frontend/dashboard/src/features/threads/ThreadsPage.module.css` + +**Interfaces:** +- Produces: `EvidenceGridControls` accepting a TanStack `Table`, locked column ids, density state, and restore callback. +- Produces: `ThreadAccordionGridProps` with threads, expanded calls, progress/error state, table/call sort state, preferences, and explicit call actions. +- Produces: `ThreadCallControls` and `ThreadCallEvidenceRow` from `ThreadAccordionRows.tsx`. +- Does not modify: `EvidenceGridProps`, `EvidenceGrid` virtualization model, or shared Cache Context usage. + +Use this exact accordion boundary: + +```ts +export type ThreadAccordionGridProps = { + ariaLabel: string; + threads: ThreadRow[]; + columns: Array>; + sorting: SortingState; + onSortingChange: OnChangeFn; + preferences: EvidenceGridPreferences; + expandedThreadName: string | null; + expandedCalls: CallRow[]; + totalCallCount: number; + loadingCalls: boolean; + loadingMoreCalls: boolean; + partialError: string | null; + callSort: ThreadCallSortKey; + callSortDirection: ThreadCallSortDirection; + viewportHeight?: number; + onToggleThread(threadName: string): void; + onRetryCalls(): void; + onCallSortChange(value: string): void; + onCallSortDirectionChange(value: string): void; + onOpenInvestigator(recordId: string): void; + onCopyCallLink(recordId: string): void; +}; +``` + +- [ ] **Step 1: Extract display controls under existing regression coverage** + +Move the existing density buttons, column chooser, Escape/outside-click behavior, locked-column handling, and restore button from `EvidenceGrid.tsx` into `EvidenceGridControls.tsx` with this complete implementation shape: + +```ts +import type { Table } from '@tanstack/react-table'; +import { useEffect, useRef, useState } from 'react'; +import type { EvidenceGridDensity } from './useEvidenceGridPreferences'; +import styles from './EvidenceGrid.module.css'; + +export type EvidenceGridControlsProps = { + ariaLabel: string; + table: Table; + lockedColumnIds: ReadonlySet; + density: EvidenceGridDensity; + onDensityChange(density: EvidenceGridDensity): void; + onRestoreDefaults(): void; +}; + +function headerText(header: unknown, fallback: string): string { + return typeof header === 'string' ? header : fallback; +} + +export function EvidenceGridControls({ + ariaLabel, + table, + lockedColumnIds, + density, + onDensityChange, + onRestoreDefaults, +}: EvidenceGridControlsProps) { + const chooserRef = useRef(null); + const [open, setOpen] = useState(false); + + useEffect(() => { + if (!open) return undefined; + const close = (event: KeyboardEvent | PointerEvent) => { + if (event instanceof KeyboardEvent && event.key === 'Escape') { + setOpen(false); + return; + } + if (event instanceof PointerEvent && !chooserRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('keydown', close); + document.addEventListener('pointerdown', close); + return () => { + document.removeEventListener('keydown', close); + document.removeEventListener('pointerdown', close); + }; + }, [open]); + + return
+
+ + +
+
+ + {open ?
+ Visible columns + {table.getAllLeafColumns().map(column => { + const locked = lockedColumnIds.has(column.id); + return ; + })} +
: null} +
+ +
; +} +``` + +Do not add a second column-visibility state; continue using `table.getAllLeafColumns()` and each column's existing toggle handler. + +- [ ] **Step 2: Run shared grid regression tests** + +Run: + +```bash +npm --workspace frontend/dashboard test -- src/features/explore/EvidenceGrid.test.tsx +``` + +Expected: PASS with the same eight grid/preference tests and no changed labels. + +- [ ] **Step 3: Write failing accordion component tests** + +Create `ThreadAccordionGrid.test.tsx` with a small harness and these concrete assertions: + +```ts +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi } from 'vitest'; +import type { CallRow } from '../../api/types'; +import { fixtureModel } from '../../test-fixtures/dashboardFixture'; +import { threadColumns } from '../shared/tables'; +import { ThreadAccordionGrid, type ThreadAccordionGridProps } from './ThreadAccordionGrid'; + +const fixtureThread = fixtureModel.threads[0]; +const fixtureCall = fixtureModel.calls[0]; +const callFixture = (id: string): CallRow => ({ ...fixtureCall, id }); +const defaultProps: ThreadAccordionGridProps = { + ariaLabel: 'Thread leaderboard', + threads: fixtureModel.threads, + columns: threadColumns, + sorting: [], + onSortingChange: vi.fn(), + preferences: { + density: 'compact', + setDensity: vi.fn(), + columnVisibility: {}, + setColumnVisibility: vi.fn(), + restoreDefaults: vi.fn(), + }, + expandedThreadName: null, + expandedCalls: [], + totalCallCount: 0, + loadingCalls: false, + loadingMoreCalls: false, + partialError: null, + callSort: 'newest', + callSortDirection: 'desc', + onToggleThread: vi.fn(), + onRetryCalls: vi.fn(), + onCallSortChange: vi.fn(), + onCallSortDirectionChange: vi.fn(), + onOpenInvestigator: vi.fn(), + onCopyCallLink: vi.fn(), +}; + +function renderGrid(overrides: Partial = {}) { + const props = { ...defaultProps, ...overrides }; + render(); + return props; +} + +it('toggles one inline thread without activating an investigator', () => { + const onToggleThread = vi.fn(); + const onOpenInvestigator = vi.fn(); + renderGrid({ expandedThreadName: null, onToggleThread, onOpenInvestigator }); + const row = screen.getByRole('row', { name: new RegExp(fixtureThread.name, 'i') }); + + fireEvent.click(row); + fireEvent.doubleClick(row); + fireEvent.keyDown(row, { key: 'Enter' }); + fireEvent.keyDown(row, { key: ' ' }); + + expect(onToggleThread).toHaveBeenCalledTimes(4); + expect(onToggleThread).toHaveBeenLastCalledWith(fixtureThread.name); + expect(onOpenInvestigator).not.toHaveBeenCalled(); +}); + +it('renders an associated expanded region and explicit child actions', () => { + const onOpenInvestigator = vi.fn(); + const onCopyCallLink = vi.fn(); + renderGrid({ + expandedThreadName: fixtureThread.name, + expandedCalls: [fixtureCall], + onOpenInvestigator, + onCopyCallLink, + }); + + expect(screen.getByRole('row', { name: new RegExp(fixtureThread.name, 'i') })).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByRole('region', { name: `Calls for ${fixtureThread.name}` })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Open investigator for thread call/i })); + fireEvent.click(screen.getByRole('button', { name: /Copy link for thread call/i })); + expect(onOpenInvestigator).toHaveBeenCalledWith(fixtureCall.id); + expect(onCopyCallLink).toHaveBeenCalledWith(fixtureCall.id); +}); + +it('keeps a thousand child calls bounded to one virtual window', () => { + renderGrid({ + expandedThreadName: fixtureThread.name, + expandedCalls: Array.from({ length: 1_000 }, (_, index) => callFixture(`call-${index}`)), + totalCallCount: 1_000, + }); + const scroller = screen.getByTestId('thread-accordion-scroller'); + expect(scroller).toHaveAttribute('data-virtualized', 'true'); + expect(scroller.querySelectorAll('[data-accordion-item]').length).toBeLessThan(60); +}); +``` + +Add cases for the progress label, partial error plus retry button, focus retention, and narrow-screen stacked call copy. + +- [ ] **Step 4: Run accordion tests and verify RED** + +Run: + +```bash +npm --workspace frontend/dashboard test -- src/features/threads/ThreadAccordionGrid.test.tsx +``` + +Expected: FAIL because the component does not exist. + +- [ ] **Step 5: Implement flattened accordion items and virtualization** + +Implement `ThreadAccordionGrid.tsx` around this discriminated union: + +```ts +type ThreadAccordionItem = + | { key: string; kind: 'thread'; thread: ThreadRow } + | { key: string; kind: 'summary'; thread: ThreadRow } + | { key: string; kind: 'call'; call: CallRow } + | { key: string; kind: 'status' }; + +function accordionItems( + threads: ThreadRow[], + expandedThreadName: string | null, + expandedCalls: CallRow[], +): ThreadAccordionItem[] { + return threads.flatMap(thread => { + const parent: ThreadAccordionItem = { key: `thread:${thread.name}`, kind: 'thread', thread }; + if (thread.name !== expandedThreadName) return [parent]; + return [ + parent, + { key: `summary:${thread.name}`, kind: 'summary', thread }, + ...expandedCalls.map(call => ({ key: `call:${call.id}`, kind: 'call' as const, call })), + { key: `status:${thread.name}`, kind: 'status' as const }, + ]; + }); +} +``` + +Use one `useVirtualizer`: + +```ts +const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollRef.current, + getItemKey: index => items[index]?.key ?? index, + estimateSize: index => items[index]?.kind === 'call' ? 112 : items[index]?.kind === 'summary' ? 132 : 52, + measureElement: element => element.getBoundingClientRect().height, + overscan: 8, + initialRect: { width: 1000, height: viewportHeight }, +}); +``` + +Render all items inside the same scroll container. Parent items use TanStack table cells and the persisted visible-column set. Summary, call, and status items span the full content width. Set `aria-expanded`, `aria-controls`, and a disclosure label on parent rows. Route click, double-click, Enter, and Space through `onToggleThread`; arrow keys retain row-to-row navigation. Attach `virtualizer.measureElement` to variable-height items. + +- [ ] **Step 6: Reduce the old inspector to reusable call rows** + +Rename the file with Git, then replace the detached `