diff --git a/apps/frontend/src/renderer/components/github-issues/components/DependencyList.tsx b/apps/frontend/src/renderer/components/github-issues/components/DependencyList.tsx
new file mode 100644
index 00000000..35727b8b
--- /dev/null
+++ b/apps/frontend/src/renderer/components/github-issues/components/DependencyList.tsx
@@ -0,0 +1,119 @@
+import { useTranslation } from 'react-i18next';
+import type { IssueDependencies } from '../../../../shared/types/dependencies';
+import { hasDependencies, totalDependencyCount } from '../../../../shared/types/dependencies';
+
+interface DependencyListProps {
+ dependencies: IssueDependencies;
+ isLoading: boolean;
+ error: string | null;
+ onRefresh?: () => void;
+}
+
+export function DependencyList({
+ dependencies,
+ isLoading,
+ error,
+ onRefresh,
+}: DependencyListProps) {
+ const { t } = useTranslation('common');
+ const hasData = hasDependencies(dependencies);
+ const total = totalDependencyCount(dependencies);
+
+ if (isLoading) {
+ return (
+
+ {t('dependencies.loading')}
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
{error}
+ {onRefresh && (
+
+ )}
+
+ );
+ }
+
+ if (!hasData) {
+ return (
+
+ {t('dependencies.none')}
+
+ );
+ }
+
+ return (
+
+
+
+ {t('dependencies.title')}
+
+
+ {total}
+
+
+
+ {dependencies.tracks.length > 0 && (
+
+
+ {t('dependencies.tracks')}
+
+
+ {dependencies.tracks.map((dep) => (
+ -
+
+
+ {dep.repo ? `${dep.repo}#${dep.issueNumber}` : `#${dep.issueNumber}`}
+
+ {dep.title}
+
+ ))}
+
+
+ )}
+
+ {dependencies.trackedBy.length > 0 && (
+
+
+ {t('dependencies.trackedBy')}
+
+
+ {dependencies.trackedBy.map((dep) => (
+ -
+
+
+ {dep.repo ? `${dep.repo}#${dep.issueNumber}` : `#${dep.issueNumber}`}
+
+ {dep.title}
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/apps/frontend/src/renderer/components/github-issues/components/LabelSyncSettings.tsx b/apps/frontend/src/renderer/components/github-issues/components/LabelSyncSettings.tsx
new file mode 100644
index 00000000..d7c58cd4
--- /dev/null
+++ b/apps/frontend/src/renderer/components/github-issues/components/LabelSyncSettings.tsx
@@ -0,0 +1,76 @@
+import { useTranslation } from 'react-i18next';
+
+interface LabelSyncSettingsProps {
+ enabled: boolean;
+ isSyncing: boolean;
+ lastSyncedAt: string | null;
+ error: string | null;
+ onEnable: () => void;
+ onDisable: (cleanup: boolean) => void;
+}
+
+export function LabelSyncSettings({
+ enabled,
+ isSyncing,
+ lastSyncedAt,
+ error,
+ onEnable,
+ onDisable,
+}: LabelSyncSettingsProps) {
+ const { t } = useTranslation('common');
+
+ return (
+
+
+
+
+ {t('labelSync.title')}
+
+
+ {t('labelSync.description')}
+
+
+
+
+
+ {enabled && lastSyncedAt && (
+
+ {t('labelSync.lastSynced', { date: new Date(lastSyncedAt).toLocaleDateString() })}
+
+ )}
+
+ {enabled && (
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/apps/frontend/src/renderer/components/github-issues/components/MetricsDashboard.tsx b/apps/frontend/src/renderer/components/github-issues/components/MetricsDashboard.tsx
new file mode 100644
index 00000000..ba109d09
--- /dev/null
+++ b/apps/frontend/src/renderer/components/github-issues/components/MetricsDashboard.tsx
@@ -0,0 +1,172 @@
+import { useTranslation } from 'react-i18next';
+import type { TriageMetrics, MetricsTimeWindow } from '../../../../shared/types/metrics';
+import { formatDuration } from '../../../../shared/types/metrics';
+import {
+ WORKFLOW_STATE_COLORS,
+ WORKFLOW_STATE_LABELS,
+} from '../../../../shared/constants/enrichment';
+import type { WorkflowState } from '../../../../shared/types/enrichment';
+
+interface MetricsDashboardProps {
+ metrics: TriageMetrics;
+ timeWindow: MetricsTimeWindow;
+ isLoading: boolean;
+ error: string | null;
+ onTimeWindowChange: (window: MetricsTimeWindow) => void;
+ onRefresh: () => void;
+}
+
+const TIME_WINDOWS: { value: MetricsTimeWindow; label: string }[] = [
+ { value: '7d', label: '7 days' },
+ { value: '30d', label: '30 days' },
+ { value: 'all', label: 'All time' },
+];
+
+const STATE_ORDER: WorkflowState[] = ['new', 'triage', 'ready', 'in_progress', 'review', 'done', 'blocked'];
+
+export function MetricsDashboard({
+ metrics,
+ timeWindow,
+ isLoading,
+ error,
+ onTimeWindowChange,
+ onRefresh,
+}: MetricsDashboardProps) {
+ const { t } = useTranslation('common');
+
+ const totalIssues = STATE_ORDER.reduce((sum, s) => sum + (metrics.stateCounts[s] ?? 0), 0);
+
+ return (
+
+ {/* Header */}
+
+
+ {t('metrics.title')}
+
+
+
+ {TIME_WINDOWS.map((tw) => (
+
+ ))}
+
+
+
+
+
+ {error && (
+
{error}
+ )}
+
+ {/* State Distribution */}
+
+
+ {t('metrics.stateDistribution')}
+
+
+ {STATE_ORDER.map((state) => {
+ const count = metrics.stateCounts[state] ?? 0;
+ const pct = totalIssues > 0 ? (count / totalIssues) * 100 : 0;
+ if (pct === 0) return null;
+ return (
+
+ );
+ })}
+
+
+ {STATE_ORDER.map((state) => {
+ const count = metrics.stateCounts[state] ?? 0;
+ if (count === 0) return null;
+ return (
+
+
+ {WORKFLOW_STATE_LABELS[state]}
+ {count}
+
+ );
+ })}
+
+
+
+ {/* Completeness Distribution */}
+
+
+ {t('metrics.completeness')}
+
+
+ {(['low', 'medium', 'high', 'excellent'] as const).map((cat) => (
+
+
+ {metrics.completenessDistribution[cat]}
+
+
+ {t(`metrics.completeness_${cat}`)}
+
+
+ ))}
+
+
+
+ {/* Summary Stats */}
+
+
+
+ {metrics.totalTransitions}
+
+
+ {t('metrics.transitions')}
+
+
+
+
+ {metrics.avgBacklogAge > 0 ? formatDuration(metrics.avgBacklogAge) : '—'}
+
+
+ {t('metrics.avgBacklog')}
+
+
+
+
+ {metrics.weeklyThroughput.length > 0
+ ? metrics.weeklyThroughput[metrics.weeklyThroughput.length - 1].count
+ : 0}
+
+
+ {t('metrics.thisWeek')}
+
+
+
+
+ );
+}
diff --git a/apps/frontend/src/renderer/components/github-issues/components/__tests__/DependencyList.test.tsx b/apps/frontend/src/renderer/components/github-issues/components/__tests__/DependencyList.test.tsx
new file mode 100644
index 00000000..d9fce453
--- /dev/null
+++ b/apps/frontend/src/renderer/components/github-issues/components/__tests__/DependencyList.test.tsx
@@ -0,0 +1,112 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { DependencyList } from '../DependencyList';
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+describe('DependencyList', () => {
+ const emptyDeps = { tracks: [], trackedBy: [] };
+
+ it('shows loading state', () => {
+ render();
+ expect(screen.getByRole('status').textContent).toBe('dependencies.loading');
+ });
+
+ it('shows error with retry button', () => {
+ const onRefresh = vi.fn();
+ render(
+ ,
+ );
+ expect(screen.getByRole('alert').textContent).toBe('API unavailable');
+ fireEvent.click(screen.getByText('dependencies.retry'));
+ expect(onRefresh).toHaveBeenCalledOnce();
+ });
+
+ it('shows empty state when no dependencies', () => {
+ render();
+ expect(screen.getByText('dependencies.none')).toBeDefined();
+ });
+
+ it('renders tracks', () => {
+ const deps = {
+ tracks: [
+ { issueNumber: 10, title: 'Sub-task A', state: 'open' as const },
+ { issueNumber: 11, title: 'Sub-task B', state: 'closed' as const },
+ ],
+ trackedBy: [],
+ };
+ render();
+ expect(screen.getByText('#10')).toBeDefined();
+ expect(screen.getByText('#11')).toBeDefined();
+ expect(screen.getByText('Sub-task A')).toBeDefined();
+ expect(screen.getByText('Sub-task B')).toBeDefined();
+ });
+
+ it('renders trackedBy', () => {
+ const deps = {
+ tracks: [],
+ trackedBy: [
+ { issueNumber: 5, title: 'Parent Issue', state: 'open' as const },
+ ],
+ };
+ render();
+ expect(screen.getByText('#5')).toBeDefined();
+ expect(screen.getByText('Parent Issue')).toBeDefined();
+ });
+
+ it('shows cross-repo reference', () => {
+ const deps = {
+ tracks: [
+ { issueNumber: 10, title: 'Cross-repo', state: 'open' as const, repo: 'org/other' },
+ ],
+ trackedBy: [],
+ };
+ render();
+ expect(screen.getByText('org/other#10')).toBeDefined();
+ });
+
+ it('shows total count', () => {
+ const deps = {
+ tracks: [{ issueNumber: 1, title: 'A', state: 'open' as const }],
+ trackedBy: [{ issueNumber: 2, title: 'B', state: 'open' as const }],
+ };
+ render();
+ expect(screen.getByText('2')).toBeDefined();
+ });
+
+ it('has accessible region role', () => {
+ const deps = {
+ tracks: [{ issueNumber: 1, title: 'A', state: 'open' as const }],
+ trackedBy: [],
+ };
+ render();
+ expect(screen.getByRole('region')).toBeDefined();
+ });
+
+ it('shows state indicator dot with correct color', () => {
+ const deps = {
+ tracks: [
+ { issueNumber: 10, title: 'Open', state: 'open' as const },
+ { issueNumber: 11, title: 'Closed', state: 'closed' as const },
+ ],
+ trackedBy: [],
+ };
+ const { container } = render(
+ ,
+ );
+ const dots = container.querySelectorAll('.rounded-full');
+ expect(dots.length).toBeGreaterThanOrEqual(2);
+ });
+});
diff --git a/apps/frontend/src/renderer/components/github-issues/components/__tests__/LabelSyncSettings.test.tsx b/apps/frontend/src/renderer/components/github-issues/components/__tests__/LabelSyncSettings.test.tsx
new file mode 100644
index 00000000..6e218021
--- /dev/null
+++ b/apps/frontend/src/renderer/components/github-issues/components/__tests__/LabelSyncSettings.test.tsx
@@ -0,0 +1,92 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { LabelSyncSettings } from '../LabelSyncSettings';
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+describe('LabelSyncSettings', () => {
+ const defaultProps = {
+ enabled: false,
+ isSyncing: false,
+ lastSyncedAt: null,
+ error: null,
+ onEnable: vi.fn(),
+ onDisable: vi.fn(),
+ };
+
+ it('renders enable button when disabled', () => {
+ render();
+ expect(screen.getByText('labelSync.enable')).toBeDefined();
+ });
+
+ it('renders disable button when enabled', () => {
+ render();
+ expect(screen.getByText('labelSync.disable')).toBeDefined();
+ });
+
+ it('calls onEnable when enable clicked', () => {
+ const onEnable = vi.fn();
+ render();
+ fireEvent.click(screen.getByText('labelSync.enable'));
+ expect(onEnable).toHaveBeenCalledOnce();
+ });
+
+ it('calls onDisable(false) when disable clicked', () => {
+ const onDisable = vi.fn();
+ render();
+ fireEvent.click(screen.getByText('labelSync.disable'));
+ expect(onDisable).toHaveBeenCalledWith(false);
+ });
+
+ it('shows syncing state', () => {
+ render();
+ expect(screen.getByText('labelSync.syncing')).toBeDefined();
+ });
+
+ it('shows last synced date when enabled', () => {
+ render(
+ ,
+ );
+ // Translation key is used directly in tests
+ expect(screen.getByText(/labelSync\.lastSynced/)).toBeDefined();
+ });
+
+ it('shows cleanup option when enabled', () => {
+ render();
+ expect(screen.getByText('labelSync.disableAndCleanup')).toBeDefined();
+ });
+
+ it('calls onDisable(true) when cleanup clicked', () => {
+ const onDisable = vi.fn();
+ render();
+ fireEvent.click(screen.getByText('labelSync.disableAndCleanup'));
+ expect(onDisable).toHaveBeenCalledWith(true);
+ });
+
+ it('shows error message', () => {
+ render();
+ expect(screen.getByRole('alert').textContent).toBe('Rate limited');
+ });
+
+ it('has accessible region role', () => {
+ render();
+ expect(screen.getByRole('region')).toBeDefined();
+ });
+
+ it('disables buttons while syncing', () => {
+ render();
+ const button = screen.getByText('labelSync.syncing');
+ expect(button.getAttribute('disabled')).not.toBeNull();
+ });
+});
diff --git a/apps/frontend/src/renderer/components/github-issues/components/__tests__/MetricsDashboard.test.tsx b/apps/frontend/src/renderer/components/github-issues/components/__tests__/MetricsDashboard.test.tsx
new file mode 100644
index 00000000..25110b64
--- /dev/null
+++ b/apps/frontend/src/renderer/components/github-issues/components/__tests__/MetricsDashboard.test.tsx
@@ -0,0 +1,123 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { MetricsDashboard } from '../MetricsDashboard';
+import { createEmptyMetrics } from '../../../../../shared/types/metrics';
+import type { TriageMetrics } from '../../../../../shared/types/metrics';
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+function makeMetrics(overrides?: Partial): TriageMetrics {
+ return { ...createEmptyMetrics(), ...overrides };
+}
+
+describe('MetricsDashboard', () => {
+ const defaultProps = {
+ metrics: makeMetrics(),
+ timeWindow: '30d' as const,
+ isLoading: false,
+ error: null,
+ onTimeWindowChange: vi.fn(),
+ onRefresh: vi.fn(),
+ };
+
+ it('renders title', () => {
+ render();
+ expect(screen.getByText('metrics.title')).toBeDefined();
+ });
+
+ it('renders time window radio buttons', () => {
+ render();
+ const radios = screen.getAllByRole('radio');
+ expect(radios).toHaveLength(3);
+ });
+
+ it('highlights current time window', () => {
+ render();
+ const radio7d = screen.getAllByRole('radio').find((el) => el.textContent === '7 days');
+ expect(radio7d?.getAttribute('aria-checked')).toBe('true');
+ });
+
+ it('calls onTimeWindowChange', () => {
+ const onChange = vi.fn();
+ render();
+ fireEvent.click(screen.getByText('All time'));
+ expect(onChange).toHaveBeenCalledWith('all');
+ });
+
+ it('shows refresh button', () => {
+ render();
+ fireEvent.click(screen.getByText('metrics.refresh'));
+ expect(defaultProps.onRefresh).toHaveBeenCalledOnce();
+ });
+
+ it('shows computing state', () => {
+ render();
+ expect(screen.getByText('metrics.computing')).toBeDefined();
+ });
+
+ it('shows error message', () => {
+ render();
+ expect(screen.getByRole('alert').textContent).toBe('Failed');
+ });
+
+ it('renders state counts', () => {
+ const metrics = makeMetrics({
+ stateCounts: { new: 5, triage: 3, ready: 2, in_progress: 1, review: 0, done: 4, blocked: 0 },
+ });
+ render();
+ // State count badges should show values
+ expect(screen.getByText('5')).toBeDefined(); // new count
+ expect(screen.getByText('3')).toBeDefined(); // triage count
+ expect(screen.getByText('4')).toBeDefined(); // done count
+ });
+
+ it('renders completeness distribution', () => {
+ const metrics = makeMetrics({
+ completenessDistribution: { low: 2, medium: 3, high: 4, excellent: 1 },
+ });
+ render();
+ expect(screen.getByText('2')).toBeDefined(); // low
+ expect(screen.getByText('3')).toBeDefined(); // medium
+ expect(screen.getByText('4')).toBeDefined(); // high
+ });
+
+ it('renders total transitions', () => {
+ const metrics = makeMetrics({ totalTransitions: 42 });
+ render();
+ expect(screen.getByText('42')).toBeDefined();
+ });
+
+ it('shows dash for zero backlog age', () => {
+ render();
+ expect(screen.getByText('—')).toBeDefined();
+ });
+
+ it('formats backlog age', () => {
+ const metrics = makeMetrics({ avgBacklogAge: 86_400_000 }); // 1 day
+ render();
+ expect(screen.getByText('1d')).toBeDefined();
+ });
+
+ it('shows this week throughput', () => {
+ const metrics = makeMetrics({
+ weeklyThroughput: [
+ { week: '2026-02-03', count: 5 },
+ { week: '2026-02-10', count: 8 },
+ ],
+ });
+ render();
+ expect(screen.getByText('8')).toBeDefined(); // latest week
+ });
+
+ it('has accessible region role', () => {
+ render();
+ expect(screen.getByRole('region')).toBeDefined();
+ });
+});