Per-PR timeline focus: click a card to filter activity, mark-PR-read, clear chip

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 15:12:29 +00:00
parent d14b6ebed3
commit 3b7850fe1a
8 changed files with 134 additions and 21 deletions

View File

@ -195,9 +195,18 @@ export class Db {
return this.db.prepare('SELECT 1 FROM events WHERE id = ?').get(id) !== undefined;
}
timeline(opts: { limit: number; before?: string; unreadOnly?: boolean }): TimelinePage {
timeline(opts: {
limit: number;
before?: string;
unreadOnly?: boolean;
prId?: string;
}): TimelinePage {
const clauses = ["p.state != ''"];
const params: (string | number)[] = [];
if (opts.prId) {
clauses.push('e.pr_id = ?');
params.push(opts.prId);
}
if (opts.before) {
clauses.push('(e.created_at < ? OR (e.created_at = ? AND e.id < ?))');
const [createdAt, id] = splitCursor(opts.before);

View File

@ -41,7 +41,8 @@ export function createApp({ db, triggerSync }: RouteDeps): Express {
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 }));
const prId = typeof req.query.pr === 'string' ? req.query.pr : undefined;
res.json(db.timeline({ limit, before, unreadOnly, prId }));
});
app.post('/api/timeline/:id/read', (req, res) => {

View File

@ -12,8 +12,15 @@ export default function App() {
const sse = useSSE();
const stateQuery = useQuery({ queryKey: ['state'], queryFn: fetchState });
const [mobileView, setMobileView] = useState<MobileView>('prs');
const [selectedPrId, setSelectedPrId] = useState<string | null>(null);
const state = stateQuery.data;
const selectedPr = state?.prs.find((p) => p.id === selectedPrId) ?? null;
const selectPr = (id: string) => {
setSelectedPrId((cur) => (cur === id ? null : id));
setMobileView('timeline');
};
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;
@ -24,7 +31,7 @@ export default function App() {
<div className="columns">
<div className={`pane pane-prs${mobileView === 'prs' ? ' active' : ''}`}>
{state ? (
<PrList state={state} />
<PrList state={state} selectedPrId={selectedPrId} onSelect={selectPr} />
) : (
<div className="empty-note">
{stateQuery.isError ? 'Failed to load — is the server running?' : 'Loading…'}
@ -32,7 +39,7 @@ export default function App() {
)}
</div>
<div className={`pane pane-timeline${mobileView === 'timeline' ? ' active' : ''}`}>
<Timeline />
<Timeline selectedPr={selectedPr} onClearSelection={() => setSelectedPrId(null)} />
</div>
</div>
<nav className="mobile-tabs">

View File

@ -11,13 +11,18 @@ export const fetchState = (): Promise<StatePayload> => request('/api/state');
export const fetchTimeline = (opts: {
cursor?: string;
unreadOnly: boolean;
prId?: string;
}): Promise<TimelinePage> => {
const params = new URLSearchParams({ limit: '50' });
if (opts.cursor) params.set('before', opts.cursor);
if (opts.unreadOnly) params.set('unread', '1');
if (opts.prId) params.set('pr', opts.prId);
return request(`/api/timeline?${params}`);
};
export const markPrRead = (prId: string): Promise<unknown> =>
request(`/api/prs/${encodeURIComponent(prId)}/read-all`, { method: 'POST' });
export const markRead = (id: string, read: boolean): Promise<unknown> =>
request(`/api/timeline/${encodeURIComponent(id)}/${read ? 'read' : 'unread'}`, {
method: 'POST',

View File

@ -3,9 +3,26 @@ import CategoryChips from './CategoryChips';
import ReviewBadge from './ReviewBadge';
import { relativeTime } from '../format';
export default function PrCard({ pr, unread }: { pr: PrSnapshot; unread: number }) {
export default function PrCard({
pr,
unread,
selected,
onSelect,
}: {
pr: PrSnapshot;
unread: number;
selected: boolean;
onSelect: () => void;
}) {
return (
<div className="pr-card">
<div
className={`pr-card${selected ? ' selected' : ''}`}
onClick={(e) => {
// Links, chips, and dropdown contents keep their own behavior.
if ((e.target as HTMLElement).closest('a, button, .check-dropdown')) return;
onSelect();
}}
>
<div className="title-row">
<span className="pr-number">#{pr.number}</span>
<a className="pr-title" href={pr.url} target="_blank" rel="noreferrer">

View File

@ -5,7 +5,15 @@ import { shortRepo } from '../format';
type Filter = 'all' | 'unread' | string;
export default function PrList({ state }: { state: StatePayload }) {
export default function PrList({
state,
selectedPrId,
onSelect,
}: {
state: StatePayload;
selectedPrId: string | null;
onSelect: (id: string) => void;
}) {
const [filter, setFilter] = useState<Filter>('all');
const repos = [...new Set(state.prs.map((p) => p.repo))];
@ -47,7 +55,12 @@ export default function PrList({ state }: { state: StatePayload }) {
<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} />
<PrCard
pr={pr}
unread={state.unreadCountsByPr[pr.id] ?? 0}
selected={pr.id === selectedPrId}
onSelect={() => onSelect(pr.id)}
/>
</div>
))}
</div>

View File

@ -1,7 +1,8 @@
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 type { PrSnapshot } from '../../../shared/types';
import { fetchTimeline, markAllRead, markPrRead, markRead } from '../api';
import { relativeTime, shortRepo } from '../format';
const GROUP_WINDOW_MS = 10 * 60 * 1000;
@ -99,13 +100,20 @@ function DiffHunk({ hunk }: { hunk: string }) {
);
}
export default function Timeline() {
export default function Timeline({
selectedPr,
onClearSelection,
}: {
selectedPr: PrSnapshot | null;
onClearSelection: () => void;
}) {
const [unreadOnly, setUnreadOnly] = useState(false);
const queryClient = useQueryClient();
const prId = selectedPr?.id;
const query = useInfiniteQuery({
queryKey: ['timeline', unreadOnly],
queryFn: ({ pageParam }) => fetchTimeline({ cursor: pageParam, unreadOnly }),
queryKey: ['timeline', unreadOnly, prId ?? null],
queryFn: ({ pageParam }) => fetchTimeline({ cursor: pageParam, unreadOnly, prId }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (page) => page.nextCursor ?? undefined,
});
@ -121,6 +129,7 @@ export default function Timeline() {
});
const readAllMutation = useMutation({ mutationFn: markAllRead, onSettled: invalidate });
const prReadMutation = useMutation({ mutationFn: markPrRead, onSettled: invalidate });
const events = query.data?.pages.flatMap((p) => p.events) ?? [];
const groups = groupEvents(events);
@ -137,8 +146,22 @@ export default function Timeline() {
<button className={unreadOnly ? 'active' : ''} onClick={() => setUnreadOnly((v) => !v)}>
Unread only
</button>
<button onClick={() => readAllMutation.mutate()}>Mark all read</button>
{selectedPr ? (
<button onClick={() => prReadMutation.mutate(selectedPr.id)}>Mark PR read</button>
) : (
<button onClick={() => readAllMutation.mutate()}>Mark all read</button>
)}
</div>
{selectedPr && (
<div className="pr-filter-bar">
<span className="pr-filter-label" title={selectedPr.title}>
{shortRepo(selectedPr.repo)}#{selectedPr.number} · {selectedPr.title}
</span>
<button onClick={onClearSelection} aria-label="Show all activity">
all activity
</button>
</div>
)}
<div className="timeline-list">
{query.isLoading && <div className="empty-note">Loading</div>}
{!query.isLoading && groups.length === 0 && (
@ -148,14 +171,16 @@ export default function Timeline() {
<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>
{!selectedPr && (
<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) => (

View File

@ -237,6 +237,16 @@ button {
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
cursor: pointer;
}
.pr-card:hover {
border-color: var(--ink-muted);
}
.pr-card.selected {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.pr-card .title-row {
@ -530,6 +540,32 @@ a.check-row:hover {
border-color: var(--ink);
}
.pr-filter-bar {
display: flex;
align-items: center;
gap: 8px;
margin: 0 14px 4px;
padding: 5px 10px;
background: var(--chip-pending-bg);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 12px;
}
.pr-filter-bar .pr-filter-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.pr-filter-bar button {
color: var(--accent);
font-size: 12px;
white-space: nowrap;
}
.timeline-list {
padding: 0 8px 24px;
}