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
co-authored by Claude Fable 5
parent d14b6ebed3
commit 3b7850fe1a
8 changed files with 134 additions and 21 deletions
+19 -2
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">
+15 -2
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>
+38 -13
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) => (