diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md new file mode 100644 index 00000000..05695101 --- /dev/null +++ b/.superpowers/sdd/task-4-report.md @@ -0,0 +1,95 @@ +# Task 4 Report: Browser QA, Documentation, Generated Assets, And Release Gates + +## Status + +DONE. Task 4 implementation, generated assets, synthetic screenshot proof, source-budget corrections, release readiness, and targeted Playwright smoke are complete. The earlier gate failures below are retained as resolved historical RED evidence; the appended final-fix section records the green branch state. + +Commits: `ae21c6f docs: update threads expansion workflow`; `1d324fb fix: resolve threads final review gates` + +## RED / GREEN Evidence + +- RED: `npm run dashboard:smoke -- --grep "threads expand inline"` initially failed 4/4 because the new flow used the legacy full call-thread key `thread-9f3a1c`, while the fixture's aggregate parent row is canonically labeled `thread-9f3a`. +- Intermediate browser run: the 390px case passed, while the desktop round-trip exposed an overly broad parent-row locator after expansion. +- GREEN: after targeting the canonical aggregate parent with an anchored accessible row name, the focused browser run passed 4/4 across `chromium-desktop` and `chromium-mobile`. +- Post-build GREEN: the same targeted Playwright command passed 4/4 again. + +The desktop flow proves that a parent click stays in Threads, the labeled inline expansion appears, an explicit child Open reaches Call Investigator, Back returns to Threads, and the same thread remains expanded. The 390px flow measures vertically stacked identity/signal evidence, a visible Open action at least 44px high, and no page-level horizontal overflow. + +## Files And Generated Assets + +- Added browser behavior and responsive assertions in `tests/playwright/dashboard-react.spec.mjs`. +- Updated the visible Threads workflow in `docs/dashboard-guide.md`. +- Updated `scripts/capture_dashboard_screenshots.mjs` to expand the canonical synthetic parent deterministically, wait for its labeled Calls region, and fail closed unless the page has no API token or embedded payload and contains the known static synthetic markers. +- Regenerated the matching 1600x900 synthetic Threads screenshot pair: + - `docs/assets/dashboard-threads.png` + - `src/codex_usage_tracker/plugin_data/docs/assets/dashboard-threads.png` +- Rebuilt `src/codex_usage_tracker/plugin_data/dashboard/react/` through `npm run dashboard:build`, including the extracted Evidence Grid controls chunks. +- Restored the eight other screenshot pairs that the all-routes capture command regenerated incidentally; they are outside Task 4 scope. + +The two Threads PNGs are byte-identical (SHA-256 `4cf6f369795c1a3fd66c7800ccacd8e4441ba3c6916fc7134394128d1bb7344a`) and were visually reviewed. They show one inline aggregate expansion with explicit Open and Copy actions. + +## Validation Results + +- Focused Vitest selection: PASS, 5 files / 51 tests. +- `npm run dashboard:typecheck`: PASS. +- `npm run dashboard:lint`: PASS. +- `npm run dashboard:stylelint`: PASS. +- `npm run dashboard:build`: PASS (TypeScript and Vite production build). +- `npm run dashboard:screenshots`: PASS, all nine routes captured from the synthetic Vite fixture; only the scoped Threads pair retained. +- `npm run dashboard:smoke -- --grep "threads expand inline"`: PASS, 4/4 post-build. +- `python3 scripts/check_dashboard_source_budgets.py`: FAIL with the exact packet: + - `new oversized tests file: frontend/dashboard/src/App.threads.test.tsx` + - `new oversized source file: frontend/dashboard/src/features/threads/ThreadsPage.tsx` +- `/Users/Monsky/.codex/bin/codex-task dashboard-verify --json`: FAIL after 96/97 files and 462/463 tests passed. `src/App.calls-detail.test.tsx:218` queries a Threads table by `getByRole('table', { name: 'Thread leaderboard' })`, but Tasks 1-3 changed that surface to a `treegrid`. Full private log: `/var/folders/8b/wt4vy9ld3_v82nb4hsqpvw880000gp/T/codex-task-logs/965fc4bf9644/20260716T010926.039184Z-dashboard-verify.log`. +- `python3 scripts/check_release.py`: PASS (`python` and `.venv/bin/python` were unavailable in this worktree, so the available Python 3 interpreter was used). +- `git diff --check`: PASS. + +## Synthetic / Privacy Confirmation + +The capture ran only against the repository Vite fixture on localhost. The capture script rejects an API token, rejects an embedded usage payload, and requires `Stored snapshot`, `8 calls analyzed`, and `Local data only` markers before writing an image. No real Codex session data, prompts, context snippets, databases, local HTML dashboards, secrets, credentials, or `.env` files are in the Task 4 change set. + +## Final Diff Review + +The final review covered status, diff stat, the complete human-authored Task 4 diff, generated asset names, screenshot dimensions/hashes, and the packaged React output. Exact staging excludes the pre-existing `.superpowers/sdd/task-3-report.md` modification plus untracked `.idea/` and `.serena/logs/` artifacts. + +## Concerns + +1. The source-budget failure belongs to the Tasks 1-3 implementation and is intentionally not refactored in Task 4. +2. The full dashboard gate has one stale pre-existing table-role assertion in `App.calls-detail.test.tsx`; Task 4's browser and focused suites pass. +3. The preserved documentation query uses the full call thread key `thread-9f3a1c`, while the aggregate fixture canonicalizes it to parent `thread-9f3a`. The capture therefore clicks the canonical parent and waits for the actual accessible `Calls for thread-9f3a` region before capture. + +## Final Fix RED Evidence + +Before the review fixes, `python3 scripts/check_dashboard_source_budgets.py` failed with: + +- `new oversized tests file: frontend/dashboard/src/App.threads.test.tsx` (667 physical lines; limit 600). +- `new oversized source file: frontend/dashboard/src/features/threads/ThreadsPage.tsx` (458 physical lines and over the 400-nonblank-line source limit). + +The existing focused Threads tests are the characterization gate for the behavior-preserving split and orchestration extraction below. + +## Final Fix Results + +Status: PASS. The complete Task 4 review list is fixed without changing server schemas, privacy behavior, or unrelated product behavior. + +Files: + +- Split focused endpoint and progressive-loading characterizations into `frontend/dashboard/src/App.threads-live.test.tsx`; `App.threads.test.tsx` is now 447 physical lines. +- Extracted typed URL/control-state orchestration into `features/threads/useThreadsPageControls.ts`; `ThreadsPage.tsx` is now 384 physical / 366 nonblank lines. +- Updated the calls-detail Threads assertion to the `treegrid` contract and removed stale legacy column-copy assertions. +- Added exact Call Investigator URL-state assertions to the desktop/mobile Playwright flow. + +Exact validation: + +- `npm run dashboard:test -- --run src/App.threads.test.tsx src/App.threads-live.test.tsx src/App.calls-detail.test.tsx`: PASS, 3 files / 31 tests. +- `npm run dashboard:typecheck`: PASS. +- `npm run dashboard:lint`: PASS. +- `npm run dashboard:stylelint`: PASS. +- `python3 scripts/check_dashboard_source_budgets.py`: PASS (`13 ratcheted exceptions`). +- `/Users/Monsky/.codex/bin/codex-task dashboard-verify --json`: PASS (exit 0). +- `python3 scripts/check_release.py`: PASS. +- `npm run dashboard:smoke -- --grep "threads expand inline"`: PASS, 4/4 across `chromium-desktop` and `chromium-mobile`. +- `git diff --check`: PASS. + +Self-review: verified status, diff stat, the complete scoped human-authored diff, source line counts, and exact staging. The extracted hook preserves initial URL hydration, page resets, clear-filter URL replacement, selected-thread toggling, and thread-call sort defaults. The moved live-query tests retain their original assertions without duplication or weakening. + +Privacy check: tests and browser smoke use only repository synthetic fixtures. No real Codex session content, prompts, private records, secrets, credentials, `.env` files, databases, screenshots, or production data are included. diff --git a/docs/assets/dashboard-call-investigator-evidence.png b/docs/assets/dashboard-call-investigator-evidence.png index a2d9487d..e38636fa 100644 Binary files a/docs/assets/dashboard-call-investigator-evidence.png and b/docs/assets/dashboard-call-investigator-evidence.png differ diff --git a/docs/assets/dashboard-call-investigator-preview.png b/docs/assets/dashboard-call-investigator-preview.png index 6f837086..1d79fa95 100644 Binary files a/docs/assets/dashboard-call-investigator-preview.png and b/docs/assets/dashboard-call-investigator-preview.png differ diff --git a/docs/assets/dashboard-call-investigator.png b/docs/assets/dashboard-call-investigator.png index 6f837086..1d79fa95 100644 Binary files a/docs/assets/dashboard-call-investigator.png and b/docs/assets/dashboard-call-investigator.png differ diff --git a/docs/assets/dashboard-calls-preview.png b/docs/assets/dashboard-calls-preview.png index d4e13cff..e8b9a404 100644 Binary files a/docs/assets/dashboard-calls-preview.png and b/docs/assets/dashboard-calls-preview.png differ diff --git a/docs/assets/dashboard-calls.png b/docs/assets/dashboard-calls.png index 2bd1766e..ece10bf2 100644 Binary files a/docs/assets/dashboard-calls.png and b/docs/assets/dashboard-calls.png differ diff --git a/docs/assets/dashboard-details.png b/docs/assets/dashboard-details.png index d4e13cff..e8b9a404 100644 Binary files a/docs/assets/dashboard-details.png and b/docs/assets/dashboard-details.png differ diff --git a/docs/assets/dashboard-diagnostics.png b/docs/assets/dashboard-diagnostics.png index 5dba2330..50371d99 100644 Binary files a/docs/assets/dashboard-diagnostics.png and b/docs/assets/dashboard-diagnostics.png differ diff --git a/docs/assets/dashboard-insights.png b/docs/assets/dashboard-insights.png index cbda273e..83a35b5d 100644 Binary files a/docs/assets/dashboard-insights.png and b/docs/assets/dashboard-insights.png differ diff --git a/docs/assets/dashboard-threads.png b/docs/assets/dashboard-threads.png index cedce486..ac8e116b 100644 Binary files a/docs/assets/dashboard-threads.png and b/docs/assets/dashboard-threads.png differ diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 0fe4ee68..59fde34e 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -147,16 +147,15 @@ Use `Threads` view when you want to understand a work session as a group instead - Threads pages through `/api/threads` on localhost and hydrates the selected thread's supporting calls through `/api/thread-calls`; loaded snapshot rows remain the fallback. -- Switch between the virtualized table, cache-efficiency frontier, and lifecycle - view. Chart selections update the same selected-thread evidence used by the - inspector. +- Click a thread row to expand all of its aggregate calls directly beneath it. +- Only one thread stays expanded at a time. +- Remaining call pages load progressively with visible progress and retry. +- Parent rows never open a representative call. +- Explicit child `Open` and `Copy` actions reach Call Investigator. +- Cache Frontier and Lifecycle remain secondary analysis modes. - Each thread row groups the filtered model calls by thread name, falling back to session id when no name is available. - Thread rows show latest activity, call count, model mix, effort mix, total tokens, estimated cost, Codex credits, cache ratio, and signal count. - Mixed model summaries prefer the primary non-review model; `codex-auto-review` appears as the thread model only for review-only threads. -- Hover or keyboard-select a thread to scan its aggregate inspector. Activating - a row opens the latest supporting call in Call Investigator. -- The inspector's call timeline supports independent sorting, incremental - disclosure, copy-link actions, and direct Call Investigator navigation. - Subagents with logged parent session ids are shown under the parent thread. Auto-review sessions without explicit parent ids may be attached by cwd and nearby activity and are marked as attached or inferred in the details. ### Tools And Files Explorers 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 `