+
+ Automated
+ ·
+ {props.finding.severity}
+ ·
+ {props.finding.category}
+
+
+
+
+
+ {props.finding.source}
+ /
+ {props.finding.ruleId}
+ ·
+ {formatQualityFindingLocation(props.finding)}
+
+
+
+ {props.finding.explanation}
+
+
+ );
+}
diff --git a/src/components/QualityFindingSidebarItem.tsx b/src/components/QualityFindingSidebarItem.tsx
new file mode 100644
index 00000000..4b465348
--- /dev/null
+++ b/src/components/QualityFindingSidebarItem.tsx
@@ -0,0 +1,136 @@
+import { Show } from 'solid-js';
+import { theme } from '../lib/theme';
+import { sf } from '../lib/fontScale';
+import { formatQualityFindingLocation, type QualityFinding } from '../lib/quality-findings';
+import { CloseIcon } from './icons';
+import { qualityFindingSeverityColor } from './quality-finding-colors';
+
+interface QualityFindingSidebarItemProps {
+ finding: QualityFinding;
+ selected: boolean;
+ onSelected: (selected: boolean) => void;
+ onDismiss: () => void;
+ onScrollTo: () => void;
+}
+
+export function QualityFindingSidebarItem(props: QualityFindingSidebarItemProps) {
+ const color = () => qualityFindingSeverityColor(props.finding.severity);
+ const isActionable = () => props.finding.freshness === 'current';
+ const freshnessLabel = () => {
+ if (props.finding.freshness === 'pending') return 'Pending';
+ if (props.finding.freshness === 'stale') return 'Stale';
+ return null;
+ };
+
+ return (
+ {
+ if (isActionable()) props.onScrollTo();
+ }}
+ style={{
+ padding: '8px 10px',
+ 'margin-bottom': '6px',
+ 'border-left': `3px solid ${isActionable() ? color() : theme.fgSubtle}`,
+ 'border-radius': '0 4px 4px 0',
+ background: 'color-mix(in srgb, var(--fg) 3%, transparent)',
+ cursor: isActionable() ? 'pointer' : 'default',
+ opacity: isActionable() ? '1' : '0.65',
+ }}
+ >
+
+ event.stopPropagation()}
+ onChange={(event) => props.onSelected(event.currentTarget.checked)}
+ />
+
+ Automated · {props.finding.severity} · {props.finding.category}
+
+
+
+ {(label) => (
+
+ {label()}
+
+ )}
+
+
+
+
+
+ {formatQualityFindingLocation(props.finding)}
+
+
+
+ {props.finding.source}/{props.finding.ruleId}
+
+
+
+ {props.finding.explanation.length > 180
+ ? `${props.finding.explanation.slice(0, 180)}...`
+ : props.finding.explanation}
+
+
+ );
+}
diff --git a/src/components/ReviewProvider.client.test.tsx b/src/components/ReviewProvider.client.test.tsx
new file mode 100644
index 00000000..8559314b
--- /dev/null
+++ b/src/components/ReviewProvider.client.test.tsx
@@ -0,0 +1,385 @@
+import { Show, createEffect, createSignal, onCleanup, type Setter } from 'solid-js';
+import { render } from 'solid-js/web';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import {
+ createRequestGenerationGuard,
+ type RequestGenerationGuard,
+} from '../lib/diff-review-lifecycle';
+import { sendPrompt } from '../store/tasks';
+import type {
+ QualityFinding,
+ QualityFindingLoadContext,
+ QualityFindingProvider,
+} from '../lib/quality-findings';
+import type { FileDiff } from '../lib/unified-diff-parser';
+import { ReviewProvider, useReview, type ReviewContextValue } from './ReviewProvider';
+
+vi.mock('../store/tasks', () => ({
+ sendPrompt: vi.fn(),
+}));
+
+const disposers: Array<() => void> = [];
+
+afterEach(() => {
+ while (disposers.length > 0) disposers.pop()?.();
+ document.body.replaceChildren();
+ vi.mocked(sendPrompt).mockReset();
+});
+
+function finding(id: string): QualityFinding {
+ return {
+ id,
+ source: 'fixture',
+ ruleId: 'no-floating-promises',
+ category: 'reliability',
+ severity: 'warning',
+ location: { filePath: 'src/app.ts', startLine: 10 },
+ explanation: 'Await this promise.',
+ state: 'open',
+ freshness: 'current',
+ };
+}
+
+function renderedDiff(content = 'runAsync();'): FileDiff[] {
+ return [
+ {
+ path: 'src/app.ts',
+ status: 'M',
+ binary: false,
+ hunks: [
+ {
+ oldStart: 9,
+ oldCount: 2,
+ newStart: 9,
+ newCount: 3,
+ lines: [
+ { type: 'context', content: 'before', oldLine: 9, newLine: 9 },
+ { type: 'add', content, oldLine: null, newLine: 10 },
+ { type: 'context', content: 'after', oldLine: 10, newLine: 11 },
+ ],
+ },
+ ],
+ },
+ ];
+}
+
+interface MountedReview {
+ review: ReviewContextValue;
+ setOpen: Setter