React frontend: PR dashboard, per-check dropdowns, timeline, themes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgBEW6qAwgn2fcQbA2f4ZQ
This commit is contained in:
Joshua Coles 2026-07-28 14:28:59 +00:00
parent 1163357967
commit c9d810c8e5
20 changed files with 1560 additions and 0 deletions

49
server/index.ts Normal file
View File

@ -0,0 +1,49 @@
import { loadConfig } from './config.js';
import { Db } from './db.js';
import { runPoll } from './poller.js';
import { getLastRateLimit } from './github/client.js';
import { createApp, buildSyncPayload } from './routes.js';
import { broadcast } from './sse.js';
const config = loadConfig();
const db = new Db();
let polling = false;
async function poll(): Promise<void> {
if (polling) return;
polling = true;
try {
const result = await runPoll(config, db);
console.log(
`[poll] ${result.prCount} PRs, ${result.changedPrIds.length} changed, ${result.newEventCount} new events`,
);
broadcast(buildSyncPayload(db, result.changedPrIds, result.newEventCount));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[poll] failed: ${message}`);
db.setMeta('last_sync_error', message);
broadcast(buildSyncPayload(db));
} finally {
polling = false;
}
}
function scheduleNext(): void {
// Back off to 5 minutes when the rate-limit budget runs low.
const remaining = getLastRateLimit()?.remaining;
const intervalMs =
remaining !== undefined && remaining < 500 ? 5 * 60_000 : config.pollIntervalSeconds * 1000;
setTimeout(async () => {
await poll();
scheduleNext();
}, intervalMs);
}
const app = createApp({ db, triggerSync: poll });
app.listen(config.port, () => {
console.log(`pr-monitor listening on http://localhost:${config.port}`);
});
await poll();
scheduleNext();

86
server/routes.ts Normal file
View File

@ -0,0 +1,86 @@
import express, { type Express } from 'express';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import type { StatePayload, SyncEventPayload } from '../shared/types.js';
import type { Db } from './db.js';
import { rootDir } from './config.js';
import { addClient } from './sse.js';
export interface RouteDeps {
db: Db;
triggerSync: () => Promise<void>;
}
export function buildSyncPayload(db: Db, changedPrIds: string[] = [], newEventCount = 0): SyncEventPayload {
return {
lastSyncAt: db.getMeta('last_sync_at'),
error: db.getMeta('last_sync_error'),
changedPrIds,
newEventCount,
unreadCount: db.unreadCounts().total,
};
}
export function createApp({ db, triggerSync }: RouteDeps): Express {
const app = express();
app.use(express.json());
app.get('/api/state', (_req, res) => {
const { byPr, total } = db.unreadCounts();
const payload: StatePayload = {
prs: db.getOpenSnapshots(),
lastSyncAt: db.getMeta('last_sync_at'),
lastSyncError: db.getMeta('last_sync_error'),
unreadCountsByPr: byPr,
unreadTotal: total,
};
res.json(payload);
});
app.get('/api/timeline', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 50, 200);
const before = typeof req.query.before === 'string' ? req.query.before : undefined;
const unreadOnly = req.query.unread === '1';
res.json(db.timeline({ limit, before, unreadOnly }));
});
app.post('/api/timeline/:id/read', (req, res) => {
db.markRead(req.params.id, true, new Date().toISOString());
res.json({ ok: true });
});
app.post('/api/timeline/:id/unread', (req, res) => {
db.markRead(req.params.id, false, new Date().toISOString());
res.json({ ok: true });
});
app.post('/api/timeline/read-all', (req, res) => {
const before = typeof req.body?.before === 'string' ? req.body.before : undefined;
const changed = db.markAllRead(new Date().toISOString(), before);
res.json({ ok: true, changed });
});
app.post('/api/prs/:id/read-all', (req, res) => {
const changed = db.markAllReadForPr(req.params.id, new Date().toISOString());
res.json({ ok: true, changed });
});
app.post('/api/sync', (_req, res) => {
void triggerSync();
res.json({ ok: true });
});
app.get('/api/events', (_req, res) => {
addClient(res, buildSyncPayload(db));
});
const webDist = join(rootDir, 'web', 'dist');
if (existsSync(webDist)) {
app.use(express.static(webDist));
app.get(/^\/(?!api\/).*/, (_req, res) => {
res.sendFile(join(webDist, 'index.html'));
});
}
return app;
}

30
server/sse.ts Normal file
View File

@ -0,0 +1,30 @@
import type { Response } from 'express';
import type { SyncEventPayload } from '../shared/types.js';
const clients = new Set<Response>();
export function addClient(res: Response, initial: SyncEventPayload): void {
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive',
'x-accel-buffering': 'no',
});
res.write('retry: 3000\n\n');
res.write(frame(initial));
clients.add(res);
res.on('close', () => clients.delete(res));
}
export function broadcast(payload: SyncEventPayload): void {
const data = frame(payload);
for (const res of clients) res.write(data);
}
function frame(payload: SyncEventPayload): string {
return `event: sync\ndata: ${JSON.stringify(payload)}\n\n`;
}
setInterval(() => {
for (const res of clients) res.write(': ping\n\n');
}, 25_000).unref();

25
web/index.html Normal file
View File

@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>PR Monitor</title>
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='4' cy='3' r='2.2' fill='%232a78d6'/%3E%3Ccircle cx='4' cy='13' r='2.2' fill='%232a78d6'/%3E%3Ccircle cx='12' cy='8' r='2.2' fill='%230ca30c'/%3E%3Cpath d='M4 5v6M12 6V5a3 3 0 0 0-3-3' stroke='%23898781' stroke-width='1.6' fill='none'/%3E%3C/svg%3E"
/>
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f9f9f7" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0d0d0d" />
<script>
// Apply the stored theme before first paint to avoid a flash.
(function () {
var t = localStorage.getItem('theme');
if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

55
web/src/App.tsx Normal file
View File

@ -0,0 +1,55 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { fetchState } from './api';
import { useSSE } from './useSSE';
import Header from './components/Header';
import PrList from './components/PrList';
import Timeline from './components/Timeline';
type MobileView = 'prs' | 'timeline';
export default function App() {
const sse = useSSE();
const stateQuery = useQuery({ queryKey: ['state'], queryFn: fetchState });
const [mobileView, setMobileView] = useState<MobileView>('prs');
const state = stateQuery.data;
const lastSyncAt = sse.lastSync?.lastSyncAt ?? state?.lastSyncAt ?? null;
const error = sse.lastSync ? sse.lastSync.error : (state?.lastSyncError ?? null);
const unreadTotal = sse.lastSync?.unreadCount ?? state?.unreadTotal ?? 0;
return (
<div className="app">
<Header lastSyncAt={lastSyncAt} error={error} connected={sse.connected} />
<div className="columns">
<div className={`pane pane-prs${mobileView === 'prs' ? ' active' : ''}`}>
{state ? (
<PrList state={state} />
) : (
<div className="empty-note">
{stateQuery.isError ? 'Failed to load — is the server running?' : 'Loading…'}
</div>
)}
</div>
<div className={`pane pane-timeline${mobileView === 'timeline' ? ' active' : ''}`}>
<Timeline />
</div>
</div>
<nav className="mobile-tabs">
<button
className={mobileView === 'prs' ? 'active' : ''}
onClick={() => setMobileView('prs')}
>
Pull Requests
</button>
<button
className={mobileView === 'timeline' ? 'active' : ''}
onClick={() => setMobileView('timeline')}
>
{unreadTotal > 0 && <span className="tab-badge">{unreadTotal}</span>}
Activity
</button>
</nav>
</div>
);
}

33
web/src/api.ts Normal file
View File

@ -0,0 +1,33 @@
import type { StatePayload, TimelinePage } from '../../shared/types';
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, init);
if (!res.ok) throw new Error(`${path}: ${res.status}`);
return res.json() as Promise<T>;
}
export const fetchState = (): Promise<StatePayload> => request('/api/state');
export const fetchTimeline = (opts: {
cursor?: string;
unreadOnly: boolean;
}): Promise<TimelinePage> => {
const params = new URLSearchParams({ limit: '50' });
if (opts.cursor) params.set('before', opts.cursor);
if (opts.unreadOnly) params.set('unread', '1');
return request(`/api/timeline?${params}`);
};
export const markRead = (id: string, read: boolean): Promise<unknown> =>
request(`/api/timeline/${encodeURIComponent(id)}/${read ? 'read' : 'unread'}`, {
method: 'POST',
});
export const markAllRead = (): Promise<unknown> =>
request('/api/timeline/read-all', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{}',
});
export const triggerSync = (): Promise<unknown> => request('/api/sync', { method: 'POST' });

View File

@ -0,0 +1,104 @@
import { useEffect, useRef, useState } from 'react';
import type { CategoryStatus, CategoryState } from '../../../shared/types';
const ICONS: Record<CategoryState, string> = {
pass: '✓',
fail: '✗',
running: '◐',
pending: '○',
skipped: '',
none: '',
};
const LABELS: Record<string, string> = {
lint: 'lint',
specs: 'specs',
e2e: 'e2e',
other: 'other',
};
function Chip({ status }: { status: CategoryStatus }) {
const [open, setOpen] = useState(false);
const [alignRight, setAlignRight] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const updateAlignment = () => {
const rect = ref.current?.getBoundingClientRect();
if (rect) setAlignRight(rect.left + 300 > window.innerWidth);
};
useEffect(() => {
if (!open) return;
const close = (e: PointerEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('pointerdown', close);
return () => document.removeEventListener('pointerdown', close);
}, [open]);
const showCount = status.total > 0 && status.passed < status.total;
return (
<div
className={`chip-wrap${open ? ' open' : ''}${alignRight ? ' align-right' : ''}`}
ref={ref}
onMouseEnter={updateAlignment}
>
<button
className={`chip chip-${status.state}`}
onClick={() => {
updateAlignment();
setOpen((o) => !o);
}}
aria-expanded={open}
aria-label={`${LABELS[status.category]}: ${status.state}, ${status.passed} of ${status.total} passed`}
>
<span className="chip-icon">{ICONS[status.state]}</span>
{LABELS[status.category]}
{showCount && (
<span className="count">
{status.passed}/{status.total}
</span>
)}
</button>
<div className="check-dropdown" role="list">
{status.checks.map((check) =>
check.url ? (
<a
key={check.name}
className="check-row"
href={check.url}
target="_blank"
rel="noreferrer"
role="listitem"
>
<span className={`check-icon ${check.state}`}>{ICONS[check.state]}</span>
<span className="check-name" title={check.name}>
{check.name}
</span>
</a>
) : (
<div key={check.name} className="check-row" role="listitem">
<span className={`check-icon ${check.state}`}>{ICONS[check.state]}</span>
<span className="check-name" title={check.name}>
{check.name}
</span>
</div>
),
)}
</div>
</div>
);
}
export default function CategoryChips({ categories }: { categories: CategoryStatus[] }) {
return (
<>
{categories
.filter((c) => c.checks.length > 0)
.map((c) => (
<Chip key={c.category} status={c} />
))}
</>
);
}

View File

@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { getTheme, nextTheme, setTheme, type ThemeChoice } from '../theme';
import { relativeTime } from '../format';
import { triggerSync } from '../api';
const THEME_ICON: Record<ThemeChoice, string> = { light: '☀', dark: '☾', system: '◑' };
export default function Header({
lastSyncAt,
error,
connected,
}: {
lastSyncAt: string | null;
error: string | null;
connected: boolean;
}) {
const [theme, setThemeState] = useState<ThemeChoice>(getTheme);
// Re-render every 30s so relative times stay fresh.
const [, tick] = useState(0);
useEffect(() => {
const t = setInterval(() => tick((n) => n + 1), 30_000);
return () => clearInterval(t);
}, []);
const stale = lastSyncAt !== null && Date.now() - Date.parse(lastSyncAt) > 3 * 60_000;
const dotClass = error ? 'error' : stale || !connected ? 'stale' : '';
return (
<>
<header className="header">
<h1>PR Monitor</h1>
<div className="spacer" />
<button
className="sync-status"
onClick={() => void triggerSync()}
title="Trigger sync now"
>
<span className={`sync-dot ${dotClass}`} />
{connected ? `synced ${relativeTime(lastSyncAt)}` : 'reconnecting…'}
</button>
<button
className="icon-btn"
onClick={() => {
const next = nextTheme(theme);
setTheme(next);
setThemeState(next);
}}
title={`Theme: ${theme}`}
aria-label={`Theme: ${theme}. Click to change.`}
>
{THEME_ICON[theme]}
</button>
</header>
{error && <div className="error-banner">Sync error: {error}</div>}
</>
);
}

View File

@ -0,0 +1,30 @@
import type { PrSnapshot } from '../../../shared/types';
import CategoryChips from './CategoryChips';
import ReviewBadge from './ReviewBadge';
import { relativeTime } from '../format';
export default function PrCard({ pr, unread }: { pr: PrSnapshot; unread: number }) {
return (
<div className="pr-card">
<div className="title-row">
<span className="pr-number">#{pr.number}</span>
<a className="pr-title" href={pr.url} target="_blank" rel="noreferrer">
{pr.title}
</a>
{unread > 0 && <span className="unread-pill">{unread}</span>}
</div>
<div className="meta-row">
{pr.isDraft && <span className="draft-tag">draft</span>}
<span>{pr.author}</span>
<span className="branch" title={pr.branch}>
{pr.branch}
</span>
<span>{relativeTime(pr.updatedAt)}</span>
</div>
<div className="chips-row">
<ReviewBadge pr={pr} />
<CategoryChips categories={pr.categories} />
</div>
</div>
);
}

View File

@ -0,0 +1,58 @@
import { useState } from 'react';
import type { StatePayload } from '../../../shared/types';
import PrCard from './PrCard';
import { shortRepo } from '../format';
type Filter = 'all' | 'unread' | string;
export default function PrList({ state }: { state: StatePayload }) {
const [filter, setFilter] = useState<Filter>('all');
const repos = [...new Set(state.prs.map((p) => p.repo))];
const filtered = state.prs.filter((pr) => {
if (filter === 'all') return true;
if (filter === 'unread') return (state.unreadCountsByPr[pr.id] ?? 0) > 0;
return pr.repo === filter;
});
const byRepo = new Map<string, typeof filtered>();
for (const pr of filtered) {
if (!byRepo.has(pr.repo)) byRepo.set(pr.repo, []);
byRepo.get(pr.repo)!.push(pr);
}
return (
<>
<div className="filter-tabs">
<button className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>
All ({state.prs.length})
</button>
<button className={filter === 'unread' ? 'active' : ''} onClick={() => setFilter('unread')}>
Unread{state.unreadTotal > 0 ? ` (${state.unreadTotal})` : ''}
</button>
{repos.map((repo) => (
<button
key={repo}
className={filter === repo ? 'active' : ''}
onClick={() => setFilter(repo)}
>
{shortRepo(repo)}
</button>
))}
</div>
<div className="pr-list">
{filtered.length === 0 && <div className="empty-note">No PRs match.</div>}
{[...byRepo.entries()].map(([repo, prs]) => (
<div key={repo}>
<div className="repo-heading">{repo}</div>
{prs.map((pr) => (
<div key={pr.id} style={{ marginBottom: 8 }}>
<PrCard pr={pr} unread={state.unreadCountsByPr[pr.id] ?? 0} />
</div>
))}
</div>
))}
</div>
</>
);
}

View File

@ -0,0 +1,36 @@
import type { PrSnapshot } from '../../../shared/types';
const STATE_LABEL = {
no_review: { icon: '◌', text: 'no review', cls: '' },
outstanding: { icon: '●', text: 'review outstanding', cls: 'outstanding' },
addressed: { icon: '✓', text: 'review addressed', cls: 'addressed' },
} as const;
export default function ReviewBadge({ pr }: { pr: PrSnapshot }) {
const s = STATE_LABEL[pr.reviewState];
return (
<>
<span className={`review-badge ${s.cls}`}>
<span aria-hidden>{s.icon}</span>
{s.text}
{pr.reviewState === 'outstanding' && pr.unresolvedThreads > 0 && (
<span>
· {pr.unresolvedThreads} thread{pr.unresolvedThreads > 1 ? 's' : ''}
</span>
)}
</span>
{pr.verdicts.map((v) => (
<a
key={v.reviewer}
className={`verdict ${v.state === 'APPROVED' ? 'approved' : 'changes'}`}
href={v.url}
target="_blank"
rel="noreferrer"
title={`${v.reviewer}: ${v.state === 'APPROVED' ? 'approved' : 'requested changes'}`}
>
{v.state === 'APPROVED' ? '✓' : '±'} {v.reviewer}
</a>
))}
</>
);
}

View File

@ -0,0 +1,169 @@
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import type { TimelineEvent } from '../../../shared/types';
import { fetchTimeline, markAllRead, markRead } from '../api';
import { relativeTime, shortRepo } from '../format';
const GROUP_WINDOW_MS = 10 * 60 * 1000;
interface Group {
key: string;
actor: string;
repo: string;
prNumber: number;
prTitle: string;
events: TimelineEvent[];
}
function groupEvents(events: TimelineEvent[]): Group[] {
const groups: Group[] = [];
for (const e of events) {
const last = groups[groups.length - 1];
const lastEvent = last?.events[last.events.length - 1];
if (
last &&
lastEvent &&
last.actor === e.actor &&
last.repo === e.repo &&
last.prNumber === e.prNumber &&
Date.parse(lastEvent.createdAt) - Date.parse(e.createdAt) < GROUP_WINDOW_MS
) {
last.events.push(e);
} else {
groups.push({
key: e.id,
actor: e.actor,
repo: e.repo,
prNumber: e.prNumber,
prTitle: e.prTitle,
events: [e],
});
}
}
return groups;
}
function KindLabel({ event }: { event: TimelineEvent }) {
if (event.kind === 'review') {
const cls =
event.reviewState === 'APPROVED'
? 'approved'
: event.reviewState === 'CHANGES_REQUESTED'
? 'changes'
: '';
const label =
event.reviewState === 'APPROVED'
? 'approved'
: event.reviewState === 'CHANGES_REQUESTED'
? 'requested changes'
: 'reviewed';
return <span className={`tl-review-state ${cls}`}>{label}</span>;
}
if (event.kind === 'review_comment') {
return (
<span>
commented{event.path ? ' on ' : ''}
{event.path && <span className="path">{event.path.split('/').pop()}</span>}
</span>
);
}
return <span>commented</span>;
}
export default function Timeline() {
const [unreadOnly, setUnreadOnly] = useState(false);
const queryClient = useQueryClient();
const query = useInfiniteQuery({
queryKey: ['timeline', unreadOnly],
queryFn: ({ pageParam }) => fetchTimeline({ cursor: pageParam, unreadOnly }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (page) => page.nextCursor ?? undefined,
});
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ['timeline'] });
void queryClient.invalidateQueries({ queryKey: ['state'] });
};
const readMutation = useMutation({
mutationFn: ({ id, read }: { id: string; read: boolean }) => markRead(id, read),
onSettled: invalidate,
});
const readAllMutation = useMutation({ mutationFn: markAllRead, onSettled: invalidate });
const events = query.data?.pages.flatMap((p) => p.events) ?? [];
const groups = groupEvents(events);
const openEvent = (e: TimelineEvent) => {
if (!e.read) readMutation.mutate({ id: e.id, read: true });
window.open(e.url, '_blank', 'noopener');
};
return (
<>
<div className="timeline-head">
<h2>Activity</h2>
<button className={unreadOnly ? 'active' : ''} onClick={() => setUnreadOnly((v) => !v)}>
Unread only
</button>
<button onClick={() => readAllMutation.mutate()}>Mark all read</button>
</div>
<div className="timeline-list">
{query.isLoading && <div className="empty-note">Loading</div>}
{!query.isLoading && groups.length === 0 && (
<div className="empty-note">{unreadOnly ? 'Nothing unread.' : 'No activity yet.'}</div>
)}
{groups.map((group) => (
<div className="tl-group" key={group.key}>
<div className="tl-group-head">
<span className="actor">{group.actor}</span>
<a
href={`https://github.com/${group.repo}/pull/${group.prNumber}`}
target="_blank"
rel="noreferrer"
title={group.prTitle}
>
{shortRepo(group.repo)}#{group.prNumber} · {group.prTitle}
</a>
<span>{relativeTime(group.events[0]!.createdAt)}</span>
</div>
{group.events.map((e) => (
<div
key={e.id}
className={`tl-item${e.read ? '' : ' unread'}`}
onClick={() => openEvent(e)}
>
<button
className="dot"
aria-label={e.read ? 'mark unread' : 'mark read'}
title={e.read ? 'Mark unread' : 'Mark read'}
onClick={(ev) => {
ev.stopPropagation();
readMutation.mutate({ id: e.id, read: !e.read });
}}
/>
<div>
<div className="tl-kind">
<KindLabel event={e} />
</div>
{e.bodyExcerpt && <div className="body">{e.bodyExcerpt}</div>}
</div>
</div>
))}
</div>
))}
{query.hasNextPage && (
<button
className="load-more"
onClick={() => query.fetchNextPage()}
disabled={query.isFetchingNextPage}
>
{query.isFetchingNextPage ? 'Loading…' : 'Load older'}
</button>
)}
</div>
</>
);
}

16
web/src/format.ts Normal file
View File

@ -0,0 +1,16 @@
export function relativeTime(iso: string | null): string {
if (!iso) return 'never';
const seconds = Math.round((Date.now() - Date.parse(iso)) / 1000);
if (seconds < 45) return 'just now';
if (seconds < 90) return '1 min ago';
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
return `${days}d ago`;
}
export function shortRepo(repo: string): string {
return repo.split('/')[1] ?? repo;
}

20
web/src/main.tsx Normal file
View File

@ -0,0 +1,20 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './styles/tokens.css';
import './styles/app.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, refetchOnWindowFocus: true },
},
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);

629
web/src/styles/app.css Normal file
View File

@ -0,0 +1,629 @@
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
height: 100%;
}
body {
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
background: var(--page);
color: var(--ink);
font-size: 14px;
line-height: 1.45;
}
a {
color: inherit;
}
button {
font: inherit;
color: inherit;
background: none;
border: none;
padding: 0;
cursor: pointer;
}
.app {
display: flex;
flex-direction: column;
height: 100%;
}
/* ---------- header ---------- */
.header {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--hairline);
background: var(--surface);
position: sticky;
top: 0;
z-index: 20;
}
.header h1 {
font-size: 15px;
font-weight: 650;
margin: 0;
}
.header .spacer {
flex: 1;
}
.sync-status {
display: flex;
align-items: center;
gap: 6px;
color: var(--ink-secondary);
font-size: 12px;
white-space: nowrap;
}
.sync-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--status-pass);
}
.sync-dot.stale {
background: var(--status-running);
}
.sync-dot.error {
background: var(--status-fail);
}
.error-banner {
background: var(--chip-fail-bg);
color: var(--status-fail-text);
padding: 6px 16px;
font-size: 12px;
border-bottom: 1px solid var(--hairline);
overflow-wrap: anywhere;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 7px;
border: 1px solid var(--border);
background: var(--surface-raised);
font-size: 14px;
}
.icon-btn:hover {
border-color: var(--ink-muted);
}
/* ---------- layout ---------- */
.columns {
display: flex;
flex: 1;
min-height: 0;
}
.pane {
overflow-y: auto;
overflow-x: hidden;
min-width: 0;
-webkit-overflow-scrolling: touch;
}
.pane-prs {
flex: 3;
border-right: 1px solid var(--hairline);
}
.pane-timeline {
flex: 2;
background: var(--surface);
}
.mobile-tabs {
display: none;
}
@media (max-width: 899px) {
.columns .pane {
display: none;
flex: 1;
}
.columns .pane.active {
display: block;
}
.pane-prs {
border-right: none;
}
.mobile-tabs {
display: flex;
border-top: 1px solid var(--hairline);
background: var(--surface);
padding-bottom: env(safe-area-inset-bottom);
}
.mobile-tabs button {
flex: 1;
padding: 10px 0 8px;
font-size: 13px;
color: var(--ink-muted);
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.mobile-tabs button.active {
color: var(--accent);
font-weight: 600;
}
.tab-badge {
position: absolute;
transform: translate(14px, -2px);
background: var(--unread);
color: #fff;
font-size: 10px;
font-weight: 600;
border-radius: 8px;
min-width: 16px;
height: 16px;
line-height: 16px;
padding: 0 4px;
}
}
/* ---------- filter tabs ---------- */
.filter-tabs {
display: flex;
gap: 6px;
padding: 10px 16px 4px;
flex-wrap: wrap;
}
.filter-tabs button {
padding: 4px 10px;
border-radius: 14px;
border: 1px solid var(--border);
color: var(--ink-secondary);
font-size: 12px;
background: var(--surface);
}
.filter-tabs button.active {
background: var(--ink);
color: var(--page);
border-color: var(--ink);
}
/* ---------- PR cards ---------- */
.pr-list {
padding: 8px 16px 24px;
display: flex;
flex-direction: column;
gap: 8px;
}
.repo-heading {
margin: 10px 0 2px;
font-size: 11px;
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ink-muted);
}
.pr-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
}
.pr-card .title-row {
display: flex;
align-items: baseline;
gap: 8px;
}
.pr-card .pr-number {
color: var(--ink-muted);
font-size: 12px;
white-space: nowrap;
}
.pr-card .pr-title {
font-weight: 600;
text-decoration: none;
overflow-wrap: anywhere;
}
.pr-card .pr-title:hover {
text-decoration: underline;
}
.pr-card .meta-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
color: var(--ink-muted);
font-size: 12px;
flex-wrap: wrap;
}
.draft-tag {
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 5px;
font-size: 11px;
color: var(--ink-secondary);
}
.branch {
font-family: ui-monospace, monospace;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
white-space: nowrap;
}
.unread-pill {
background: var(--unread);
color: #fff;
border-radius: 9px;
font-size: 11px;
font-weight: 600;
padding: 0 6px;
line-height: 17px;
}
.chips-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
flex-wrap: wrap;
}
/* ---------- category chips + dropdown ---------- */
.chip-wrap {
position: relative;
}
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 9px;
border-radius: 13px;
font-size: 12px;
font-weight: 600;
border: 1px solid transparent;
}
.chip .count {
font-weight: 500;
opacity: 0.85;
}
.chip-pass {
background: var(--chip-pass-bg);
color: var(--status-pass-text);
}
.chip-fail {
background: var(--chip-fail-bg);
color: var(--status-fail-text);
}
.chip-running {
background: var(--chip-running-bg);
color: var(--status-running-text);
}
.chip-running .chip-icon {
animation: pulse 1.4s ease-in-out infinite;
}
.chip-pending {
background: var(--chip-pending-bg);
color: var(--ink-secondary);
}
.chip-skipped {
background: var(--chip-skipped-bg);
color: var(--ink-muted);
}
@keyframes pulse {
50% {
opacity: 0.35;
}
}
.check-dropdown {
display: none;
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 30;
min-width: 260px;
max-width: min(340px, 90vw);
max-height: 300px;
overflow-y: auto;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow);
padding: 6px;
}
.chip-wrap.align-right .check-dropdown {
left: auto;
right: 0;
}
.chip-wrap.open .check-dropdown {
display: block;
}
@media (hover: hover) {
.chip-wrap:hover .check-dropdown {
display: block;
}
}
.check-row {
display: flex;
align-items: center;
gap: 7px;
padding: 4px 6px;
border-radius: 6px;
font-size: 12px;
color: var(--ink);
text-decoration: none;
}
a.check-row:hover {
background: var(--chip-pending-bg);
}
.check-row .check-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.check-icon {
font-size: 11px;
width: 14px;
text-align: center;
flex: none;
}
.check-icon.pass {
color: var(--status-pass-text);
}
.check-icon.fail {
color: var(--status-fail-text);
}
.check-icon.running {
color: var(--status-running-text);
animation: pulse 1.4s ease-in-out infinite;
}
.check-icon.pending,
.check-icon.skipped {
color: var(--ink-muted);
}
/* ---------- review badge ---------- */
.review-badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
padding: 3px 9px;
border-radius: 13px;
border: 1px solid var(--border);
color: var(--ink-secondary);
}
.review-badge.outstanding {
border-color: var(--status-running);
color: var(--status-running-text);
}
.review-badge.addressed {
border-color: var(--status-pass);
color: var(--status-pass-text);
}
.verdict {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
padding: 2px 7px;
border-radius: 11px;
}
.verdict.approved {
background: var(--chip-pass-bg);
color: var(--status-pass-text);
}
.verdict.changes {
background: var(--chip-fail-bg);
color: var(--status-fail-text);
}
/* ---------- timeline ---------- */
.timeline-head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px 6px;
position: sticky;
top: 0;
background: var(--surface);
z-index: 10;
}
.timeline-head h2 {
font-size: 13px;
font-weight: 650;
margin: 0;
flex: 1;
}
.timeline-head button {
font-size: 12px;
color: var(--ink-secondary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 3px 9px;
}
.timeline-head button.active {
background: var(--ink);
color: var(--page);
border-color: var(--ink);
}
.timeline-list {
padding: 0 8px 24px;
}
.tl-group {
margin: 10px 6px 0;
}
.tl-group-head {
display: flex;
align-items: baseline;
gap: 6px;
font-size: 12px;
color: var(--ink-secondary);
padding: 0 6px 2px;
flex-wrap: wrap;
}
.tl-group-head .actor {
font-weight: 650;
color: var(--ink);
}
.tl-group-head a {
color: var(--ink-secondary);
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.tl-group-head a:hover {
text-decoration: underline;
}
.tl-item {
display: flex;
gap: 8px;
padding: 6px 6px;
border-radius: 8px;
cursor: pointer;
align-items: flex-start;
}
.tl-item:hover {
background: var(--chip-pending-bg);
}
.tl-item .dot {
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 6px;
background: transparent;
border: 1px solid transparent;
}
.tl-item.unread .dot {
background: var(--unread);
}
.tl-item .body {
color: var(--ink-secondary);
font-size: 13px;
overflow-wrap: anywhere;
}
.tl-item.unread .body {
color: var(--ink);
}
.tl-kind {
font-size: 11px;
color: var(--ink-muted);
}
.tl-kind .path {
font-family: ui-monospace, monospace;
}
.tl-review-state {
font-weight: 600;
}
.tl-review-state.approved {
color: var(--status-pass-text);
}
.tl-review-state.changes {
color: var(--status-fail-text);
}
.load-more {
display: block;
margin: 12px auto;
padding: 5px 14px;
border: 1px solid var(--border);
border-radius: 14px;
color: var(--ink-secondary);
font-size: 12px;
}
.empty-note {
color: var(--ink-muted);
text-align: center;
padding: 32px 16px;
font-size: 13px;
}

87
web/src/styles/tokens.css Normal file
View File

@ -0,0 +1,87 @@
:root {
color-scheme: light;
--page: #f9f9f7;
--surface: #fcfcfb;
--surface-raised: #ffffff;
--ink: #0b0b0b;
--ink-secondary: #52514e;
--ink-muted: #898781;
--hairline: #e1e0d9;
--border: rgba(11, 11, 11, 0.1);
--accent: #2a78d6;
/* Status palette (mode-invariant hues; always paired with icon + label). */
--status-pass: #0ca30c;
--status-pass-text: #006300;
--status-fail: #d03b3b;
--status-fail-text: #b02f2f;
--status-running: #fab219;
--status-running-text: #8a5f00;
--status-pending: #898781;
--status-skipped: #b8b6ae;
--chip-pass-bg: rgba(12, 163, 12, 0.1);
--chip-fail-bg: rgba(208, 59, 59, 0.12);
--chip-running-bg: rgba(250, 178, 25, 0.16);
--chip-pending-bg: rgba(137, 135, 129, 0.12);
--chip-skipped-bg: rgba(137, 135, 129, 0.07);
--unread: #2a78d6;
--shadow: 0 4px 16px rgba(11, 11, 11, 0.12);
}
@media (prefers-color-scheme: dark) {
:root:where(:not([data-theme='light'])) {
color-scheme: dark;
--page: #0d0d0d;
--surface: #1a1a19;
--surface-raised: #242423;
--ink: #ffffff;
--ink-secondary: #c3c2b7;
--ink-muted: #898781;
--hairline: #2c2c2a;
--border: rgba(255, 255, 255, 0.1);
--accent: #3987e5;
--status-pass-text: #4cc24c;
--status-fail-text: #e66767;
--status-running-text: #fab219;
--status-skipped: #55544f;
--chip-pass-bg: rgba(12, 163, 12, 0.16);
--chip-fail-bg: rgba(208, 59, 59, 0.2);
--chip-running-bg: rgba(250, 178, 25, 0.16);
--chip-pending-bg: rgba(137, 135, 129, 0.18);
--chip-skipped-bg: rgba(137, 135, 129, 0.1);
--unread: #3987e5;
--shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
}
}
:root[data-theme='dark'] {
color-scheme: dark;
--page: #0d0d0d;
--surface: #1a1a19;
--surface-raised: #242423;
--ink: #ffffff;
--ink-secondary: #c3c2b7;
--ink-muted: #898781;
--hairline: #2c2c2a;
--border: rgba(255, 255, 255, 0.1);
--accent: #3987e5;
--status-pass-text: #4cc24c;
--status-fail-text: #e66767;
--status-running-text: #fab219;
--status-skipped: #55544f;
--chip-pass-bg: rgba(12, 163, 12, 0.16);
--chip-fail-bg: rgba(208, 59, 59, 0.2);
--chip-running-bg: rgba(250, 178, 25, 0.16);
--chip-pending-bg: rgba(137, 135, 129, 0.18);
--chip-skipped-bg: rgba(137, 135, 129, 0.1);
--unread: #3987e5;
--shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
}

20
web/src/theme.ts Normal file
View File

@ -0,0 +1,20 @@
export type ThemeChoice = 'light' | 'dark' | 'system';
export function getTheme(): ThemeChoice {
const stored = localStorage.getItem('theme');
return stored === 'light' || stored === 'dark' ? stored : 'system';
}
export function setTheme(choice: ThemeChoice): void {
if (choice === 'system') {
localStorage.removeItem('theme');
delete document.documentElement.dataset.theme;
} else {
localStorage.setItem('theme', choice);
document.documentElement.dataset.theme = choice;
}
}
export function nextTheme(current: ThemeChoice): ThemeChoice {
return current === 'system' ? 'light' : current === 'light' ? 'dark' : 'system';
}

30
web/src/useSSE.ts Normal file
View File

@ -0,0 +1,30 @@
import { useEffect, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import type { SyncEventPayload } from '../../shared/types';
export interface ConnectionState {
connected: boolean;
lastSync: SyncEventPayload | null;
}
export function useSSE(): ConnectionState {
const queryClient = useQueryClient();
const [state, setState] = useState<ConnectionState>({ connected: false, lastSync: null });
useEffect(() => {
const source = new EventSource('/api/events');
source.onopen = () => setState((s) => ({ ...s, connected: true }));
source.onerror = () => setState((s) => ({ ...s, connected: false }));
source.addEventListener('sync', (e) => {
const payload = JSON.parse((e as MessageEvent).data) as SyncEventPayload;
setState({ connected: true, lastSync: payload });
if (payload.changedPrIds.length > 0 || payload.newEventCount > 0) {
void queryClient.invalidateQueries({ queryKey: ['state'] });
void queryClient.invalidateQueries({ queryKey: ['timeline'] });
}
});
return () => source.close();
}, [queryClient]);
return state;
}

14
web/tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src", "../shared"]
}

12
web/vite.config.ts Normal file
View File

@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': { target: 'http://localhost:4000' },
},
},
});