From 06b713304b70a53841fa1aa3f639d3d00bf8ea26 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Fri, 27 Feb 2026 06:13:14 -0800 Subject: [PATCH] factory|feat: Add RunList component with clickable run list in sidebar Add a RunList component to the sidebar that displays all runs in a flat, scrollable list sorted by most recent. Each item shows a color-coded status indicator (CGA-16 palette), is clickable to select the run, and has a dismiss button with "Clear all" support. Lift fetchProjects from RunSelector to App.tsx so both components share the same ProjectIndex data. RunSelector now accepts the index as a prop. New files: FlatRunInfo type, flattenProjectIndex helper, useDismissedRuns hook, RunList component with CSS, and full test coverage for all new code. --- packages/factory/src/client/App.css | 9 + packages/factory/src/client/App.tsx | 70 ++++- .../factory/src/client/__tests__/App.test.tsx | 250 ++++++++++++++- .../factory/src/client/components/RunList.css | 110 +++++++ .../factory/src/client/components/RunList.tsx | 98 ++++++ .../src/client/components/RunSelector.css | 9 - .../src/client/components/RunSelector.tsx | 17 +- .../components/__tests__/RunList.test.tsx | 288 ++++++++++++++++++ .../components/__tests__/RunSelector.test.tsx | 196 ++++-------- .../__tests__/flatten-project-index.test.ts | 129 ++++++++ .../client/helpers/__tests__/run-key.test.ts | 13 + .../client/helpers/flatten-project-index.ts | 26 ++ .../factory/src/client/helpers/run-key.ts | 4 + .../hooks/__tests__/useDismissedRuns.test.ts | 82 +++++ .../src/client/hooks/useDismissedRuns.ts | 33 ++ packages/factory/src/shared/types/api.ts | 5 + 16 files changed, 1169 insertions(+), 170 deletions(-) create mode 100644 packages/factory/src/client/components/RunList.css create mode 100644 packages/factory/src/client/components/RunList.tsx create mode 100644 packages/factory/src/client/components/__tests__/RunList.test.tsx create mode 100644 packages/factory/src/client/helpers/__tests__/flatten-project-index.test.ts create mode 100644 packages/factory/src/client/helpers/__tests__/run-key.test.ts create mode 100644 packages/factory/src/client/helpers/flatten-project-index.ts create mode 100644 packages/factory/src/client/helpers/run-key.ts create mode 100644 packages/factory/src/client/hooks/__tests__/useDismissedRuns.test.ts create mode 100644 packages/factory/src/client/hooks/useDismissedRuns.ts diff --git a/packages/factory/src/client/App.css b/packages/factory/src/client/App.css index 6fa2a656..e1acb6b0 100644 --- a/packages/factory/src/client/App.css +++ b/packages/factory/src/client/App.css @@ -44,3 +44,12 @@ body { justify-content: center; padding: 16px; } + +.fetch-error { + color: #ff5555; + font-family: 'Courier New', monospace; + font-size: 12px; + padding: 6px 8px; + border: 1px solid #aa0000; + background-color: #1a0000; +} diff --git a/packages/factory/src/client/App.tsx b/packages/factory/src/client/App.tsx index d972cf0f..6d219bd3 100644 --- a/packages/factory/src/client/App.tsx +++ b/packages/factory/src/client/App.tsx @@ -1,26 +1,84 @@ -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import type { ProjectIndex } from '../shared/types/api.js'; +import { fetchProjects } from './api/client.js'; import { GameCanvas } from './components/GameCanvas.js'; +import { RunList } from './components/RunList.js'; import { RunSelector } from './components/RunSelector.js'; import { StatusBar } from './components/StatusBar.js'; +import { flattenProjectIndex } from './helpers/flatten-project-index.js'; +import { toRunKey } from './helpers/run-key.js'; +import { useDismissedRuns } from './hooks/useDismissedRuns.js'; import { useRunStatus } from './hooks/useRunStatus.js'; import './App.css'; export function App(): React.JSX.Element { + const [index, setIndex] = useState(null); + const [fetchError, setFetchError] = useState(null); + + /** + * Currently selected project slug. Used together with `selectedRun` to look up the full run + * context. The `selectedRunKey` derivation assumes that `runId` values are unique within a + * project (across all its tickets). This invariant is guaranteed by the directory-based data + * source: each run directory name is a unique timestamp-based identifier scoped to its project. + */ const [selectedProject, setSelectedProject] = useState(null); + + /** Currently selected run ID within `selectedProject`. See `selectedProject` for uniqueness assumption. */ const [selectedRun, setSelectedRun] = useState(null); const { data: runStatus, isLoading, error } = useRunStatus(selectedProject, selectedRun); + const { dismissed, dismiss, dismissAll } = useDismissedRuns(); + + useEffect(() => { + fetchProjects() + .then(setIndex) + .catch((error_: unknown) => { + console.error('Failed to fetch projects:', error_); + setFetchError(error_ instanceof Error ? error_.message : 'Failed to load projects'); + }); + }, []); + + const allRuns = useMemo(() => flattenProjectIndex(index), [index]); + + const visibleRuns = useMemo( + () => allRuns.filter((run) => !dismissed.has(toRunKey(run.projectSlug, run.ticketId, run.runId))), + [allRuns, dismissed], + ); + + const selectedRunKey = useMemo(() => { + if (!selectedProject || !selectedRun) return null; + const match = allRuns.find((r) => r.projectSlug === selectedProject && r.runId === selectedRun); + return match ? toRunKey(match.projectSlug, match.ticketId, match.runId) : null; + }, [selectedProject, selectedRun, allRuns]); + + /** + * Selects a run by project slug and run ID. The ticket is not tracked because `runId` is + * assumed to be unique within a project -- the `selectedRunKey` derivation resolves the + * ticket by finding the matching run in the already-flattened `allRuns` array. + */ + function handleSelectRun(projectSlug: string, runId: string): void { + setSelectedProject(projectSlug); + setSelectedRun(runId); + } + + function handleDismissAll(): void { + dismissAll(visibleRuns.map((r) => toRunKey(r.projectSlug, r.ticketId, r.runId))); + } + return (
diff --git a/packages/factory/src/client/__tests__/App.test.tsx b/packages/factory/src/client/__tests__/App.test.tsx index bad632b2..ea06ae09 100644 --- a/packages/factory/src/client/__tests__/App.test.tsx +++ b/packages/factory/src/client/__tests__/App.test.tsx @@ -1,15 +1,29 @@ -import { cleanup, fireEvent, render, within } from '@testing-library/react'; +import { cleanup, fireEvent, render, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createMockRunStatus } from '../../__test-helpers__/fixtures.js'; +import type { FlatRunInfo, ProjectIndex } from '../../shared/types/api.js'; import type { CanonicalRunStatus } from '../../shared/types/canonical.js'; -const { mockUseRunStatus, mockRunSelector, mockStatusBar, mockGameCanvas } = vi.hoisted(() => { +const { + mockUseRunStatus, + mockRunSelector, + mockStatusBar, + mockGameCanvas, + mockFetchProjects, + mockFlattenProjectIndex, + mockUseDismissedRuns, + mockRunList, +} = vi.hoisted(() => { return { mockUseRunStatus: vi.fn(), mockRunSelector: vi.fn(), mockStatusBar: vi.fn(), mockGameCanvas: vi.fn(), + mockFetchProjects: vi.fn<() => Promise>(), + mockFlattenProjectIndex: vi.fn<(index: ProjectIndex | null) => FlatRunInfo[]>(), + mockUseDismissedRuns: vi.fn(), + mockRunList: vi.fn(), }; }); @@ -29,28 +43,60 @@ vi.mock('../components/GameCanvas.js', () => ({ GameCanvas: mockGameCanvas, })); +vi.mock('../api/client.js', () => ({ + fetchProjects: mockFetchProjects, +})); + +vi.mock('../helpers/flatten-project-index.js', () => ({ + flattenProjectIndex: mockFlattenProjectIndex, +})); + +vi.mock('../hooks/useDismissedRuns.js', () => ({ + useDismissedRuns: mockUseDismissedRuns, +})); + +vi.mock('../components/RunList.js', () => ({ + RunList: mockRunList, +})); + // Stub CSS import vi.mock('../App.css', () => ({})); const { App } = await import('../App.js'); describe('App', () => { + const mockDismiss = vi.fn(); + const mockDismissAll = vi.fn(); + let mockDismissedSet: Set; + afterEach(() => { cleanup(); }); beforeEach(() => { - mockRunSelector.mockImplementation(({ onSelectRun }: { onSelectRun: (slug: string, runId: string) => void }) => ( - - )); + vi.clearAllMocks(); + mockDismissedSet = new Set(); + mockFetchProjects.mockResolvedValue({ projects: [] }); + mockFlattenProjectIndex.mockReturnValue([]); + mockUseDismissedRuns.mockReturnValue({ + dismissed: mockDismissedSet, + dismiss: mockDismiss, + dismissAll: mockDismissAll, + }); + mockRunSelector.mockImplementation( + ({ onSelectRun }: { index: ProjectIndex | null; onSelectRun: (slug: string, runId: string) => void }) => ( + + ), + ); mockStatusBar.mockImplementation(({ status }: { status: CanonicalRunStatus }) => (
{status.runId}
)); mockGameCanvas.mockImplementation(({ status }: { status: CanonicalRunStatus }) => (
{status.runId}
)); + mockRunList.mockImplementation(() =>
); }); it('displays loading state during useRunStatus loading', () => { @@ -99,7 +145,6 @@ describe('App', () => { fireEvent.click(view.getByTestId('run-selector')); // After clicking, useRunStatus should have been called with the new project/run values. - // The most recent call should have the values passed by the mock RunSelector's onClick. expect(mockUseRunStatus).toHaveBeenLastCalledWith('proj-a', 'run-1'); }); @@ -110,4 +155,193 @@ describe('App', () => { expect(mockUseRunStatus).toHaveBeenCalledWith(null, null); }); + + it('fetches projects on mount and passes index to RunSelector', async () => { + mockUseRunStatus.mockReturnValue({ data: null, isLoading: false, error: null }); + + const projectIndex: ProjectIndex = { projects: [{ slug: 'alpha', tickets: [] }] }; + mockFetchProjects.mockResolvedValue(projectIndex); + + render(); + + await waitFor(() => { + const lastCall = mockRunSelector.mock.lastCall; + expect(lastCall?.[0]).toEqual(expect.objectContaining({ index: projectIndex })); + }); + }); + + it('renders RunList component', () => { + mockUseRunStatus.mockReturnValue({ data: null, isLoading: false, error: null }); + + const { container } = render(); + const view = within(container); + + expect(view.getByTestId('run-list')).toBeInTheDocument(); + }); + + it('passes visible runs to RunList after filtering dismissed', async () => { + mockUseRunStatus.mockReturnValue({ data: null, isLoading: false, error: null }); + + const runs: FlatRunInfo[] = [ + { projectSlug: 'alpha', ticketId: 'T-1', runId: 'run-a', status: 'completed', startedAt: '2026-01-01T00:00:00Z' }, + { projectSlug: 'alpha', ticketId: 'T-1', runId: 'run-b', status: 'failed', startedAt: '2026-01-02T00:00:00Z' }, + ]; + mockFlattenProjectIndex.mockReturnValue(runs); + mockDismissedSet = new Set(['alpha/T-1/run-a']); + mockUseDismissedRuns.mockReturnValue({ + dismissed: mockDismissedSet, + dismiss: mockDismiss, + dismissAll: mockDismissAll, + }); + + render(); + + await waitFor(() => { + const lastCall = mockRunList.mock.lastCall; + expect(lastCall?.[0]).toEqual(expect.objectContaining({ runs: [runs[1]] })); + }); + }); + + it('handles fetchProjects error', async () => { + mockUseRunStatus.mockReturnValue({ data: null, isLoading: false, error: null }); + mockFetchProjects.mockRejectedValue(new Error('Server down')); + + const { container } = render(); + const view = within(container); + + await waitFor(() => { + expect(view.getByText('Server down')).toBeInTheDocument(); + }); + }); + + it('RunList onSelectRun updates state and triggers useRunStatus', () => { + mockUseRunStatus.mockReturnValue({ data: null, isLoading: false, error: null }); + mockRunList.mockImplementation(({ onSelectRun }: { onSelectRun: (projectSlug: string, runId: string) => void }) => ( + + )); + + const { container } = render(); + const view = within(container); + + fireEvent.click(view.getByTestId('run-list-select')); + + expect(mockUseRunStatus).toHaveBeenLastCalledWith('beta', 'run-x'); + }); + + it('handles fetchProjects rejection with non-Error value', async () => { + mockUseRunStatus.mockReturnValue({ data: null, isLoading: false, error: null }); + mockFetchProjects.mockRejectedValue('connection refused'); + + const { container } = render(); + const view = within(container); + + await waitFor(() => { + expect(view.getByText('Failed to load projects')).toBeInTheDocument(); + }); + }); + + it('selectedRunKey is null when selectedRun does not match any run in the project', async () => { + mockUseRunStatus.mockReturnValue({ data: null, isLoading: false, error: null }); + + const projectIndex: ProjectIndex = { + projects: [ + { + slug: 'alpha', + tickets: [ + { + ticketId: 'T-1', + runs: [{ runId: 'run-a', path: '/a', status: 'completed', startedAt: '2026-01-01T00:00:00Z' }], + }, + ], + }, + ], + }; + mockFetchProjects.mockResolvedValue(projectIndex); + + // Mock RunSelector to select a non-existent run in the 'alpha' project + mockRunSelector.mockImplementation( + ({ onSelectRun }: { index: ProjectIndex | null; onSelectRun: (slug: string, runId: string) => void }) => ( + + ), + ); + + const { container } = render(); + const view = within(container); + + // Wait for the project index to load + await waitFor(() => { + const lastSelectorCall = mockRunSelector.mock.lastCall; + expect(lastSelectorCall?.[0]).toEqual(expect.objectContaining({ index: projectIndex })); + }); + + // Select a run that does not exist in the project + fireEvent.click(view.getByTestId('run-selector')); + + // RunList should receive null for selectedRunKey + await waitFor(() => { + const lastRunListCall = mockRunList.mock.lastCall; + expect(lastRunListCall?.[0]).toEqual(expect.objectContaining({ selectedRunKey: null })); + }); + }); + + it('dismiss callback wired to RunList onDismissRun', () => { + mockUseRunStatus.mockReturnValue({ data: null, isLoading: false, error: null }); + mockRunList.mockImplementation(({ onDismissRun }: { onDismissRun: (key: string) => void }) => ( + + )); + + const { container } = render(); + const view = within(container); + + fireEvent.click(view.getByTestId('dismiss-btn')); + + expect(mockDismiss).toHaveBeenCalledWith('alpha/T-1/run-a'); + }); + + it('handleDismissAll calls dismissAll with all visible run keys', () => { + mockUseRunStatus.mockReturnValue({ data: null, isLoading: false, error: null }); + + const runs: FlatRunInfo[] = [ + { projectSlug: 'alpha', ticketId: 'T-1', runId: 'run-a', status: 'completed', startedAt: '2026-01-01T00:00:00Z' }, + { projectSlug: 'beta', ticketId: 'T-2', runId: 'run-b', status: 'failed', startedAt: '2026-01-02T00:00:00Z' }, + ]; + mockFlattenProjectIndex.mockReturnValue(runs); + + mockRunList.mockImplementation(({ onDismissAll }: { onDismissAll: () => void }) => ( + + )); + + const { container } = render(); + const view = within(container); + + fireEvent.click(view.getByTestId('dismiss-all-btn')); + + expect(mockDismissAll).toHaveBeenCalledWith(['alpha/T-1/run-a', 'beta/T-2/run-b']); + }); + + it('handleDismissAll with empty visibleRuns calls dismissAll with empty array', () => { + mockUseRunStatus.mockReturnValue({ data: null, isLoading: false, error: null }); + mockFlattenProjectIndex.mockReturnValue([]); + + mockRunList.mockImplementation(({ onDismissAll }: { onDismissAll: () => void }) => ( + + )); + + const { container } = render(); + const view = within(container); + + fireEvent.click(view.getByTestId('dismiss-all-btn')); + + expect(mockDismissAll).toHaveBeenCalledWith([]); + }); }); diff --git a/packages/factory/src/client/components/RunList.css b/packages/factory/src/client/components/RunList.css new file mode 100644 index 00000000..74710e93 --- /dev/null +++ b/packages/factory/src/client/components/RunList.css @@ -0,0 +1,110 @@ +.run-list { + display: flex; + flex-direction: column; + margin-top: 16px; +} + +.run-list-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.run-list-header-label { + font-size: 12px; + color: #aaaaaa; + text-transform: uppercase; + letter-spacing: 1px; +} + +.run-list-clear-btn { + background: none; + border: none; + color: #aaaaaa; + font-family: 'Courier New', monospace; + font-size: 11px; + cursor: pointer; + padding: 2px 6px; +} + +.run-list-clear-btn:hover { + color: #ff5555; +} + +.run-list-items { + display: flex; + flex-direction: column; + gap: 2px; +} + +.run-list-empty { + color: #555555; + font-style: italic; + font-size: 12px; + padding: 8px 0; +} + +.run-list-item { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 6px 8px; + cursor: pointer; + border-left: 3px solid transparent; + background-color: transparent; +} + +.run-list-item:hover { + background-color: #111111; +} + +.run-list-item--selected { + border-left-color: #5555ff; + background-color: #00001a; +} + +.run-list-item-status { + flex-shrink: 0; + font-size: 13px; + line-height: 1; +} + +.run-list-item-content { + flex: 1; + min-width: 0; +} + +.run-list-item-run-id { + color: #ffffff; + font-family: 'Courier New', monospace; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.run-list-item-context { + color: #555555; + font-family: 'Courier New', monospace; + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.run-list-item-dismiss { + flex-shrink: 0; + background: none; + border: none; + color: #aaaaaa; + font-family: 'Courier New', monospace; + font-size: 14px; + cursor: pointer; + padding: 0 2px; + line-height: 1; +} + +.run-list-item-dismiss:hover { + color: #ff5555; +} diff --git a/packages/factory/src/client/components/RunList.tsx b/packages/factory/src/client/components/RunList.tsx new file mode 100644 index 00000000..de2e117b --- /dev/null +++ b/packages/factory/src/client/components/RunList.tsx @@ -0,0 +1,98 @@ +import React from 'react'; + +import { PALETTE } from '../../shared/constants/palette.js'; +import type { FlatRunInfo } from '../../shared/types/api.js'; +import type { RunStatus } from '../../shared/types/canonical.js'; +import { toRunKey } from '../helpers/run-key.js'; + +import './RunList.css'; + +interface RunListProps { + runs: FlatRunInfo[]; + selectedRunKey: string | null; + onSelectRun: (projectSlug: string, runId: string) => void; + onDismissRun: (key: string) => void; + onDismissAll: () => void; +} + +interface StatusIndicator { + symbol: string; + color: string; +} + +const DEFAULT_INDICATOR: StatusIndicator = { symbol: '?', color: PALETTE.white }; + +const STATUS_INDICATORS: Record = { + in_progress: { symbol: '\u25B6', color: PALETTE.cyan }, + completed: { symbol: '\u2714', color: PALETTE.green }, + failed: { symbol: '\u2718', color: PALETTE.red }, + needs_manual_review: { symbol: '\u26A0', color: PALETTE.yellow }, +} satisfies Record; + +export function RunList({ + runs, + selectedRunKey, + onSelectRun, + onDismissRun, + onDismissAll, +}: RunListProps): React.JSX.Element { + return ( +
+
+ Runs + {runs.length > 0 && ( + + )} +
+ {runs.length === 0 ? ( +
No runs
+ ) : ( +
+ {runs.map((run) => { + const key = toRunKey(run.projectSlug, run.ticketId, run.runId); + const isSelected = key === selectedRunKey; + const indicator = STATUS_INDICATORS[run.status] ?? DEFAULT_INDICATOR; + + return ( +
onSelectRun(run.projectSlug, run.runId)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelectRun(run.projectSlug, run.runId); + } + }} + > + + {indicator.symbol} + +
+
{run.runId}
+
+ {run.projectSlug} / {run.ticketId} +
+
+ +
+ ); + })} +
+ )} +
+ ); +} diff --git a/packages/factory/src/client/components/RunSelector.css b/packages/factory/src/client/components/RunSelector.css index bd022f7a..0d2fb39e 100644 --- a/packages/factory/src/client/components/RunSelector.css +++ b/packages/factory/src/client/components/RunSelector.css @@ -27,12 +27,3 @@ outline: 1px solid #5555ff; border-color: #5555ff; } - -.run-selector-error { - color: #ff5555; - font-family: 'Courier New', monospace; - font-size: 12px; - padding: 6px 8px; - border: 1px solid #aa0000; - background-color: #1a0000; -} diff --git a/packages/factory/src/client/components/RunSelector.tsx b/packages/factory/src/client/components/RunSelector.tsx index bf5ea3fb..d13900e0 100644 --- a/packages/factory/src/client/components/RunSelector.tsx +++ b/packages/factory/src/client/components/RunSelector.tsx @@ -1,36 +1,24 @@ import React, { useEffect, useRef, useState } from 'react'; import type { ProjectIndex } from '../../shared/types/api.js'; -import { fetchProjects } from '../api/client.js'; import { useSelectionParams } from '../hooks/useSelectionParams.js'; import './RunSelector.css'; interface RunSelectorProps { + index: ProjectIndex | null; onSelectRun: (projectSlug: string, runId: string) => void; } -export function RunSelector({ onSelectRun }: RunSelectorProps): React.JSX.Element { +export function RunSelector({ index, onSelectRun }: RunSelectorProps): React.JSX.Element { const { initialParams, setParams } = useSelectionParams(); - const [index, setIndex] = useState(null); - const [fetchError, setFetchError] = useState(null); const [selectedProject, setSelectedProject] = useState(initialParams.project); const [selectedTicket, setSelectedTicket] = useState(initialParams.ticket); const [selectedRun, setSelectedRun] = useState(initialParams.run); const hasValidated = useRef(false); - useEffect(() => { - fetchProjects() - .then(setIndex) - .catch((error: unknown) => { - const message = error instanceof Error ? error.message : 'Failed to load projects'; - setFetchError(message); - console.error('Failed to fetch projects:', error); - }); - }, []); - // Validate URL params against loaded data useEffect(() => { if (!index || hasValidated.current) return; @@ -82,7 +70,6 @@ export function RunSelector({ onSelectRun }: RunSelectorProps): React.JSX.Elemen return (
- {fetchError &&
{fetchError}
}