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:
co-authored by
Claude Fable 5
parent
d14b6ebed3
commit
3b7850fe1a
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user