Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/dashboard-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,13 @@ 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.
- Click a thread row to expand all of its aggregate calls directly beneath it.
- Click a thread row to load the first 100 aggregate calls directly beneath it.
- Only one thread stays expanded at a time.
- Remaining call pages load progressively with visible progress and retry.
- The compact default keeps the most useful comparison columns visible; use
`Columns` to reveal duration, model/effort mix, token breakdowns, and other
advanced fields without making the default leaderboard excessively wide.
- Use `Load 100 more` to request each additional page; visible progress and retry
keep large threads bounded and recoverable.
- Parent rows never open a representative call.
- Explicit child `Open` and `Copy` actions reach Call Investigator.
- Cache Frontier and Lifecycle remain secondary analysis modes.
Expand Down
112 changes: 112 additions & 0 deletions docs/superpowers/plans/2026-07-17-threads-table-compact-redesign.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Threads Table Compact Redesign 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:** Make the Threads leaderboard a polished scan-first table without changing its data, virtualization, expansion, or privacy contracts.

**Architecture:** Keep the existing `ThreadAccordionGrid` and TanStack table model. Define presentation defaults alongside Threads orchestration, give shared thread columns intentional sizes and human-readable cells, and fix alignment/styling inside the Threads CSS module. Version the local preference key so the compact defaults reach existing installations.

**Tech Stack:** React 19, TypeScript, TanStack React Table, TanStack React Virtual, CSS Modules, Vitest/Testing Library, Playwright, Vite.

## Global Constraints

- Keep every advanced column available from **Columns**.
- Preserve aggregate-only data, API schemas, URL state, pagination, exports, and inline call expansion.
- Preserve dense/roomy modes, keyboard interaction, accessible names, and narrow-screen behavior.
- Rebuild packaged dashboard assets instead of editing generated JavaScript.
- Use only synthetic test data and screenshots.

---

### Task 1: Pin The Scan-First Presentation Contract

**Files:**
- Modify: `frontend/dashboard/src/features/shared/tables.test.ts`
- Modify: `frontend/dashboard/src/features/threads/ThreadAccordionGrid.test.tsx`
- Modify: `frontend/dashboard/src/App.threads.test.tsx`

**Interfaces:**
- Consumes: `threadColumns`, `ThreadAccordionGrid`, and the Threads workspace.
- Produces: regression coverage for column widths, default visibility, humanized unloaded metadata, and full thread identity access.

- [ ] **Step 1: Write failing tests**

Add assertions equivalent to:

```ts
expect(threadColumns.find(column => column.id === 'name')?.size).toBe(280);
expect(screen.getByRole('columnheader', { name: /Thread/i })).toBeInTheDocument();
expect(screen.queryByRole('columnheader', { name: /Models/i })).not.toBeInTheDocument();
expect(screen.getByTitle(fixtureThread.name)).toBeInTheDocument();
expect(screen.getByText('Mostly user')).toBeInTheDocument();
```

- [ ] **Step 2: Verify RED**

Run:

```bash
npm --workspace frontend/dashboard test -- src/features/shared/tables.test.ts src/features/threads/ThreadAccordionGrid.test.tsx src/App.threads.test.tsx
```

Expected: FAIL because the columns have default widths, all columns are visible, identifiers lack a title, and initiator text is raw.

### Task 2: Implement The Compact Grid

**Files:**
- Modify: `frontend/dashboard/src/features/shared/tables.tsx`
- Modify: `frontend/dashboard/src/features/threads/ThreadsPage.tsx`
- Modify: `frontend/dashboard/src/features/threads/ThreadAccordionGrid.tsx`
- Modify: `frontend/dashboard/src/features/threads/ThreadsPage.module.css`
- Modify: `docs/dashboard-guide.md`
- Regenerate: `src/codex_usage_tracker/plugin_data/dashboard/react/assets/ThreadsPage.js`

**Interfaces:**
- Produces: `threadDefaultColumnVisibility: VisibilityState` in Threads orchestration.
- Preserves: `ThreadAccordionGridProps`, query paging, row virtualization, and explicit child-call actions.

- [ ] **Step 1: Add intentional column definitions and defaults**

Use explicit sizes and cells, for example:

```tsx
{
accessorKey: 'name',
header: 'Thread',
size: 280,
minSize: 220,
cell: info => <span className="thread-name-cell" title={String(info.getValue())}>{String(info.getValue())}</span>,
}
```

Set nonessential column visibility to `false`, pass it to `useEvidenceGridPreferences`, and use a versioned Threads preference key.

- [ ] **Step 2: Align and style the custom grid**

Move the disclosure chevron into the first visible grid cell. Add CSS that resets header buttons, truncates cells, freezes the identity column, preserves hover/expanded backgrounds, and provides `:focus-visible` outlines.

- [ ] **Step 3: Verify GREEN and rebuild**

Run:

```bash
npm --workspace frontend/dashboard test -- src/features/shared/tables.test.ts src/features/threads/ThreadAccordionGrid.test.tsx src/App.threads.test.tsx
npm --workspace frontend/dashboard run typecheck
npm --workspace frontend/dashboard run build
```

Expected: all commands PASS and only Threads packaged assets change.

- [ ] **Step 4: Run browser and full gates**

Verify the branch dashboard at desktop and narrow widths, then run:

```bash
/Users/Monsky/.codex/bin/codex-task dashboard-verify --json
```

Expected: PASS with no browser console errors and explicit 100-call pagination retained.

- [ ] **Step 5: Publish and merge**

Stage only intended files, commit with Conventional Commit style, push the branch, open a ready PR, wait for required checks, and squash merge after they pass.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Threads Table Compact Redesign

**Date:** 2026-07-17
**Status:** Approved
**Scope:** React dashboard Threads table presentation

## Problem

The custom virtualized Threads grid does not inherit the dashboard's normal sortable-header styling. Native browser buttons therefore appear in the header, the disclosure chevron shifts row cells out of alignment, all twenty columns render by default, and long thread identifiers wrap across multiple lines. Summary-only API rows also show unloaded model and effort values as prominent action-like labels. Together these defects make the table read like an unfinished debug surface.

## Chosen Design

The table remains a virtualized, single-open thread accordion. Its default presentation becomes scan-first:

- show Thread, Latest, Turns, Total Tokens, Cache %, Context %, Est. Cost, Codex Credits, and Cold Resume Risk by default;
- keep every other metric available through the existing **Columns** control;
- reset sortable header buttons to the dashboard visual language, including hover and focus-visible states;
- place the disclosure chevron inside the identity cell so headers and rows share the same column boundaries;
- keep the identity column frozen while horizontally scrolling advanced columns;
- truncate long identifiers to one line and expose the full value through the title tooltip;
- render unavailable model, effort, and initiator metadata as quiet, human-readable placeholders instead of blue action-like pills;
- retain dense/roomy modes, keyboard disclosure, virtualization, URL state, and explicit call actions.

The preference storage key is versioned for this redesign so existing all-columns-visible state does not prevent the new default from taking effect. Users can still restore or customize visibility normally.

## Accessibility And Responsive Contract

Sortable headers retain specific accessible names and `aria-sort`. The frozen identity cell, disclosure state, and full thread-name tooltip do not alter keyboard order. Focus indicators remain visible, color is not the only state signal, and narrow screens continue collapsing the leaderboard to the identity column while expanded call evidence stacks vertically.

## Verification

Synthetic tests must cover the new default visibility, column sizing, humanized metadata, full-name access, and disclosure alignment structure. Browser verification must confirm that native gray controls are gone, only the scan-first columns appear initially, long IDs no longer wrap, Columns can reveal advanced metrics, expansion loads 100 calls at a time, horizontal scrolling retains identity, and the console has no errors.
12 changes: 6 additions & 6 deletions frontend/dashboard/src/App.calls-detail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,16 +217,16 @@ it('sorts table columns through accessible header controls', () => {
fireEvent.click(within(screen.getByRole('navigation', { name: 'Primary' })).getByRole('button', { name: /^Threads$/i }));
const threadsTable = screen.getByRole('treegrid', { name: 'Thread leaderboard' });
expect(within(threadsTable).getByRole('button', { name: /Sort by Latest/i })).toBeInTheDocument();
expect(within(threadsTable).getByRole('button', { name: /Sort by Avg Gap/i })).toBeInTheDocument();
expect(within(threadsTable).getByRole('button', { name: /Sort by Initiated/i })).toBeInTheDocument();
expect(within(threadsTable).getByRole('button', { name: /Sort by Reasoning Output/i })).toBeInTheDocument();
expect(within(threadsTable).queryByRole('button', { name: /Sort by Avg Gap/i })).not.toBeInTheDocument();
expect(within(threadsTable).queryByRole('button', { name: /Sort by Initiated/i })).not.toBeInTheDocument();
expect(within(threadsTable).queryByRole('button', { name: /Sort by Reasoning Output/i })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Columns/i }));
expect(screen.getByRole('checkbox', { name: 'Thread' })).toBeDisabled();
expect(screen.getByRole('checkbox', { name: 'Reasoning Output' })).toBeChecked();
expect(screen.getByRole('checkbox', { name: 'Reasoning Output' })).not.toBeChecked();
fireEvent.click(screen.getByRole('checkbox', { name: 'Reasoning Output' }));
expect(screen.queryByRole('columnheader', { name: /Reasoning Output/i })).not.toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /Reasoning Output/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: 'Productivity' }));
expect(screen.queryByRole('columnheader', { name: /Productivity/i })).not.toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /Productivity/i })).toBeInTheDocument();
});

it('keeps selected-call thread context modular in the calls drill-down', () => {
Expand Down
8 changes: 5 additions & 3 deletions frontend/dashboard/src/App.threads-live.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ describe('React dashboard threads live queries', () => {
expect(screen.queryByRole('region', { name: /Calls for never-present-thread/i })).not.toBeInTheDocument();
});

it('isolates progressive thread call pages and retries a partial result', async () => {
it('loads additional thread calls only on request and retries a partial result', async () => {
let resolveOldThread: ((response: Response) => void) | undefined;
let newThreadPageTwoAttempts = 0;
const callRow = (id: string, thread: string, model: string) => ({
Expand Down Expand Up @@ -212,12 +212,14 @@ describe('React dashboard threads live queries', () => {
const grid = await screen.findByRole('treegrid', { name: 'Thread leaderboard' });
await within(grid).findByRole('row', { name: /Collapse calls for old-thread/i });
fireEvent.click(await within(grid).findByRole('row', { name: /new-thread/i }));
await screen.findByText(/Partial result:/i, {}, { timeout: 3_000 });
expect(screen.getByText('2 of 3 calls loaded')).toBeInTheDocument();
expect(await screen.findByText('2 of 3 calls loaded')).toBeInTheDocument();
expect(newThreadPageTwoAttempts).toBe(0);
resolveOldThread?.(callsPage('old-thread', [callRow('old-1', 'old-thread', 'old-model')], 0, false));
await waitFor(() => expect(screen.queryByText('old-model / high')).not.toBeInTheDocument());
expect(screen.getAllByText('new-model-boundary / high')).toHaveLength(1);

fireEvent.click(screen.getByRole('button', { name: 'Load 100 more thread calls' }));
await screen.findByText(/Partial result:/i, {}, { timeout: 3_000 });
fireEvent.click(await screen.findByRole('button', { name: 'Retry loading thread calls' }));
await screen.findByText('3 of 3 calls loaded');
expect(screen.getAllByText('new-model-boundary / high')).toHaveLength(1);
Expand Down
16 changes: 16 additions & 0 deletions frontend/dashboard/src/App.threads.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,22 @@ describe('React dashboard threads workspace', () => {
});
});

it('starts with scan-first columns while keeping advanced metrics available', () => {
render(<App />);
fireEvent.click(screen.getByRole('button', { name: /^Threads$/i }));

const table = screen.getByRole('treegrid', { name: 'Thread leaderboard' });
expect(within(table).getByRole('columnheader', { name: 'Thread' })).toBeInTheDocument();
expect(within(table).getByRole('columnheader', { name: 'Total Tokens' })).toBeInTheDocument();
expect(within(table).queryByRole('columnheader', { name: 'Models' })).not.toBeInTheDocument();

fireEvent.click(screen.getByRole('button', { name: 'Columns' }));
const modelsToggle = screen.getByRole('checkbox', { name: 'Models' });
expect(modelsToggle).not.toBeChecked();
fireEvent.click(modelsToggle);
expect(within(table).getByRole('columnheader', { name: 'Models' })).toBeInTheDocument();
});

it('hydrates legacy expanded thread URL state', async () => {
window.history.replaceState(null, '', '/?view=threads&threads=thread-7c2b,thread-9f3a');

Expand Down
16 changes: 15 additions & 1 deletion frontend/dashboard/src/features/shared/tables.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';

import type { CallRow } from '../../api/types';
import { callActionColumn, callColumns, callCsvColumns, callSignalPucks } from './tables';
import { callActionColumn, callColumns, callCsvColumns, callSignalPucks, threadColumns } from './tables';
import { rowsToCsv } from './exportCsv';

describe('call CSV columns', () => {
Expand Down Expand Up @@ -188,3 +188,17 @@ describe('call signal pucks', () => {
expect(pucks.visible.map(puck => puck.label)).toEqual(['Cache Risk', 'Context Heavy', 'High Cost']);
});
});

describe('thread table columns', () => {
it('uses scan-friendly widths for the compact leaderboard', () => {
const column = (accessorKey: string) => threadColumns.find(candidate => (
'accessorKey' in candidate && candidate.accessorKey === accessorKey
));

expect(column('name')).toMatchObject({ size: 280, minSize: 220 });
expect(column('latestActivity')).toMatchObject({ size: 130 });
expect(column('turns')).toMatchObject({ size: 82 });
expect(column('totalTokens')).toMatchObject({ size: 118 });
expect(column('cachePct')).toMatchObject({ size: 92 });
});
});
Loading
Loading