@anthusai/papyrus 5.45.0 → 5.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/amplify/data/resource.js +6 -0
  2. package/amplify/data/resource.ts +7 -0
  3. package/amplify/data/schema.js +6 -0
  4. package/amplify/data/schema.ts +7 -0
  5. package/amplify/functions/content-actions/handler.py +8 -0
  6. package/bin/papyrus-app.mjs +12 -1
  7. package/components/news-desk-client-provider.tsx +2 -2
  8. package/components/newsroom-ops-overview.tsx +63 -60
  9. package/components/newsroom-ops-shell.tsx +3 -1
  10. package/components/newsroom-organizer.css +5 -2
  11. package/components/newsroom-references-view.tsx +120 -8
  12. package/components/notification-cards.tsx +364 -0
  13. package/components/notification-center.tsx +319 -331
  14. package/components/reference-capture-client.ts +110 -0
  15. package/components/reference-capture-panel.tsx +180 -0
  16. package/components/reference-feed.tsx +94 -60
  17. package/components/reference-review-card.tsx +21 -66
  18. package/components/references-prefetch.ts +5 -0
  19. package/components/review-rating-grid.tsx +31 -15
  20. package/components/topic-steering-workspace.tsx +12 -2
  21. package/components/use-reference-ratings.ts +132 -0
  22. package/lib/category-repository.ts +2 -0
  23. package/lib/content-actions-client.ts +18 -0
  24. package/lib/local-preview-feed-fixture.ts +20 -0
  25. package/lib/newsroom-nav.ts +1 -1
  26. package/lib/newsroom-organizer-nav.ts +1 -1
  27. package/lib/notification-feed.ts +250 -0
  28. package/lib/reference-capture.ts +293 -0
  29. package/lib/reference-feed-cache.ts +37 -0
  30. package/lib/reference-feed-ranking.ts +44 -0
  31. package/lib/review-drafts.ts +8 -3
  32. package/lib/site-brand.ts +2 -0
  33. package/package.json +7 -1
  34. package/routes.manifest.json +1 -1
@@ -1,37 +1,64 @@
1
1
  "use client";
2
2
 
3
- import { CheckIcon, CircleCheckIcon, CircleDotIcon, CircleXIcon, ClockIcon, ExternalLinkIcon, FlagIcon, HourglassIcon, LockIcon } from "lucide-react";
3
+ import { CheckIcon, ChevronDownIcon, CircleCheckBigIcon } from "lucide-react";
4
4
  import Link from "next/link";
5
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
- import { Badge } from "./ui/badge";
7
- import { Button } from "./ui/button";
5
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
6
+ import { cn } from "../lib/utils";
8
7
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
9
8
  import { proposalReceiptMessage, stateLabel, type ProposalReceipt } from "../lib/proposals-admin";
10
9
  import type { DraftLoad, ReviewLoad } from "../lib/review-client";
11
- import { acceptPath, batchAnswer, cannotMoveReason, draftDecisionOf, draftsToDecide, draftView, rejectPath, undecidedDraftIds, withDecision, type DraftChoice, type DraftView } from "../lib/review-drafts";
10
+ import { acceptPath, batchAnswer, draftView, rejectPath, undecidedDraftIds, withDecision, draftDecisionUpdate, type DraftChoice, type DraftView } from "../lib/review-drafts";
12
11
  import {
13
- cyclotronAutoDecided, cyclotronHeldForReview, draftIdsOf, formatWhen, isDraftReview, isUnread, modeLabel, nextText, proposalHref, sectionReviews,
14
- steeringText, subjectHref, subjectLabel, summarize, summaryText,
15
- type AnswerInput, type CyclotronEntry, type DraftDecision, type ReviewRequest,
16
- } from "../lib/review-requests";
12
+ FILTER_LABELS, NOTIFICATION_FILTERS, buildNotificationItems, filterCount, previewsBySourceAddress, recentDecisions, showsSection, waitingText,
13
+ type NotificationFilter,
14
+ } from "../lib/notification-feed";
15
+ import { answerUpdate, cyclotronAutoDecided, draftIdsOf, isDraftReview, sectionReviews, type AnswerInput, type CyclotronEntry, type DraftDecision, type ReviewRequest } from "../lib/review-requests";
17
16
  import type { ReferenceRecord } from "../lib/category-repository";
18
17
  import type { RelevanceDecisionView } from "../lib/relevance-decisions";
18
+ import { partitionReferenceFeed } from "../lib/reference-feed-ranking";
19
+ import { normalizeReferenceCurationStatus, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
20
+ import type { ReferencePreview } from "../lib/reference-preview";
21
+ import { loadNotificationSnapshot, saveNotificationSnapshot } from "../lib/reference-feed-cache";
22
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
23
+ import { SITE_BRAND } from "../lib/site-brand";
19
24
  import { FIXTURE_DRAFTS, FIXTURE_REVIEW_REQUESTS } from "../lib/review-fixture";
20
- import { ChoiceButtons } from "./reference-review-card";
25
+ import { DraftCard, FlagCard, NotificationSkeleton, RecentDecisions, RequestCard, type DecisionExtras } from "./notification-cards";
26
+ import { FEED_SURFACE_PROPS, FeedCard, PILL, ReferenceFeedSkeleton } from "./reference-feed";
27
+ import type { ReviewChoice } from "./reference-review-card";
21
28
 
22
29
  export type NotificationLoadState = "loading" | "ready" | "forbidden" | "unavailable";
23
30
  export type DraftsLoadState = "loading" | "ready" | "failed";
24
31
  export type DraftExtras = { why: string; notThisKind: boolean };
25
32
 
33
+ /** What a real-data preview shows instead of the invented sample (scripts/export-notification-center-fixture.py). */
34
+ export type NotificationFixture = { requests: ReviewRequest[]; drafts: Parameters<typeof draftView>[0][] };
35
+
36
+ const GRID = "grid gap-5 sm:grid-cols-2 xl:grid-cols-3";
37
+ /** References are shown a screenful at a time, ranked as in the References feed; the rest are one tap away. */
38
+ const REFERENCE_PAGE = 6;
39
+ const CONFIRMING = "Confirming your sign-in. You can answer in a moment.";
40
+ const NO_PREVIEWS: ReadonlyMap<string, ReferencePreview> = new Map();
41
+ const NO_VISIT: ReadonlySet<string> = new Set();
42
+
26
43
  type ViewProps = {
27
44
  loadState: NotificationLoadState;
28
45
  requests: ReviewRequest[];
29
- readIds: ReadonlySet<string>;
30
- held: CyclotronEntry[];
31
- autoDecided: CyclotronEntry[];
46
+ /** Canonical References (any status) and the Cyclotron's decisions on them. */
47
+ references?: ReferenceRecord[];
48
+ decisions?: ReadonlyMap<string, RelevanceDecisionView>;
49
+ previews?: ReadonlyMap<string, ReferencePreview>;
50
+ referencesLoading?: boolean;
51
+ predictionLoading?: boolean;
52
+ referenceDisabledReason?: string | null;
53
+ /** What the Cyclotron decided on its own confidence: read only, collapsed at the foot of the page. */
54
+ autoDecided?: CyclotronEntry[];
55
+ now?: number;
56
+ visitRated?: ReadonlySet<string>;
32
57
  storyTitles?: Readonly<Record<string, string>>;
33
58
  deletedStories?: ReadonlySet<string>;
34
59
  demo?: boolean;
60
+ /** True while the cards are a saved copy that is being refreshed. */
61
+ stale?: boolean;
35
62
  busyId?: string | null;
36
63
  error?: string | null;
37
64
  /** The AI drafts that draft reviews are about, by proposal id, and which ids have no proposal on this site. */
@@ -41,324 +68,216 @@ type ViewProps = {
41
68
  /** Keyed `<request id>#<draft id>`. */
42
69
  draftErrors?: Readonly<Record<string, string>>;
43
70
  draftBusyId?: string | null;
71
+ /** Draft keys and request ids decided in this visit: their cards stay in place showing the answer. */
72
+ stay?: ReadonlySet<string>;
73
+ initialFilter?: NotificationFilter;
44
74
  onAnswer?: (request: ReviewRequest, input: AnswerInput) => void;
45
75
  onDecideDraft?: (request: ReviewRequest, draftId: string, choice: DraftChoice, extras: DraftExtras) => void;
46
76
  onRetryDrafts?: () => void;
47
77
  onMarkRead?: (requestId: string) => void;
48
- onMarkAllRead?: () => void;
78
+ onOpenReference?: (lineageId: string) => void;
79
+ onRateReference?: (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) => Promise<void> | void;
80
+ onVisitRated?: (lineageId: string) => void;
49
81
  };
50
82
 
51
- const CONTROL_ICON = { hard_gate: LockIcon, soft_review: ClockIcon, flag: FlagIcon } as const;
52
- const NOT_THIS_KIND = { request: "Not this kind of thing", draft: "Not this kind of story" } as const;
53
-
54
- function WhyBox({ busy, onChange, placeholder, value }: { busy: boolean; onChange: (value: string) => void; placeholder: string; value: string }) {
83
+ function Section({ children, count, id, subtitle, title }: { children: ReactNode; count?: number; id: string; subtitle?: ReactNode; title: string }) {
55
84
  return (
56
- <label className="block text-sm font-medium">
57
- Why? (optional)
58
- <textarea
59
- aria-label="Why? (optional)"
60
- className="mt-1 block min-h-20 w-full rounded-md border bg-background p-3 text-base font-normal"
61
- onChange={(event) => onChange(event.target.value)}
62
- placeholder={placeholder}
63
- readOnly={busy}
64
- value={value}
65
- />
66
- </label>
85
+ <section aria-labelledby={`notification-${id}`} data-notification-section={id}>
86
+ <h2 className="mb-1 text-3xl font-bold tracking-tight text-primary sm:text-4xl" id={`notification-${id}`}>
87
+ {title}{typeof count === "number" ? <span className="ml-2 text-xl font-semibold tabular-nums text-muted-foreground" data-section-count>{count}</span> : null}
88
+ </h2>
89
+ {subtitle ? <p className="mb-5 text-sm text-muted-foreground">{subtitle}</p> : <div className="mb-5" />}
90
+ {children}
91
+ </section>
67
92
  );
68
93
  }
69
94
 
70
- function NotThisKind({ busy, label, onToggle, pressed }: { busy: boolean; label: string; onToggle: () => void; pressed: boolean }) {
71
- return (
72
- <div className="space-y-1" data-review-reasons>
73
- <p className="text-sm font-medium">If you reject it</p>
74
- <Button aria-disabled={busy || undefined} aria-pressed={pressed} className="min-h-10" data-review-reason="not_this_kind" onClick={() => { if (!busy) onToggle(); }} type="button" variant={pressed ? "default" : "outline"}>
75
- {pressed ? <CheckIcon aria-hidden="true" className="size-4" /> : null}
76
- {label}
77
- </Button>
78
- </div>
79
- );
95
+ function Note({ children, ...rest }: { children: ReactNode } & Record<string, unknown>) {
96
+ return <div className="rounded-[20px] bg-[var(--feed-card)] p-6 text-sm text-muted-foreground" {...rest}>{children}</div>;
80
97
  }
81
98
 
82
- /** Accept or Reject on the request itself: a hard gate, a soft review without drafts, or a draft review whose drafts could not be loaded. */
83
- function RequestControls({ request, busy, unread, onAnswer, onMarkRead }: { request: ReviewRequest; busy: boolean; unread: boolean; onAnswer?: ViewProps["onAnswer"]; onMarkRead?: ViewProps["onMarkRead"] }) {
84
- const [note, setNote] = useState("");
85
- const [notThisKind, setNotThisKind] = useState(false);
86
- const flag = request.mode === "flag";
87
- const act = (input: AnswerInput) => { if (!busy) onAnswer?.(request, { ...input, feedback: note.trim() || null }); };
88
- return (
89
- <div className="space-y-3" data-review-controls>
90
- {flag ? (
91
- <Button aria-disabled={busy || undefined} className="min-h-12 w-full text-base" onClick={() => act({ decision: "acknowledge" })} type="button" variant="outline">Acknowledge</Button>
92
- ) : (
93
- <ChoiceButtons chosen={null} disabled={busy} onChoose={(choice) => act(choice === "accept" ? { decision: "approve" } : { decision: "reject", steer: notThisKind ? "not_this_kind" : null })} />
94
- )}
95
- <div aria-live="polite" className="min-h-5 text-sm" role="status">{busy ? "Saving…" : ""}</div>
96
- <WhyBox busy={busy} onChange={setNote} placeholder={flag ? "Optional. Tell the step what to do differently." : "Optional. Write it before you tap Accept or Reject; it is saved with your answer."} value={note} />
97
- {flag ? null : <NotThisKind busy={busy} label={NOT_THIS_KIND.request} onToggle={() => setNotThisKind((current) => !current)} pressed={notThisKind} />}
98
- {unread ? <Button aria-disabled={busy || undefined} onClick={() => { if (!busy) onMarkRead?.(request.id); }} size="sm" variant="ghost">Mark read</Button> : null}
99
- </div>
100
- );
101
- }
99
+ /** The CMS console home. It answers one question, "What needs me?": cards for AI drafts, References and anything else, one tap to decide each. */
100
+ export function NotificationCenterView({
101
+ loadState, requests, references = [], decisions, previews = NO_PREVIEWS, referencesLoading = false, predictionLoading = false, referenceDisabledReason = null,
102
+ autoDecided = [], now = 0, visitRated = NO_VISIT, demo = false, stale = false, storyTitles = {}, deletedStories = new Set<string>(), busyId = null, error = null,
103
+ drafts = {}, missingDrafts = new Set<string>(), draftsState = "ready", draftErrors = {}, draftBusyId = null, stay = new Set<string>(), initialFilter = "all",
104
+ onAnswer, onDecideDraft, onRetryDrafts, onMarkRead, onOpenReference, onRateReference, onVisitRated,
105
+ }: ViewProps) {
106
+ const [filter, setFilter] = useState<NotificationFilter>(initialFilter);
107
+ const [shown, setShown] = useState(REFERENCE_PAGE);
108
+ const session = stale ? CONFIRMING : null;
102
109
 
103
- function DecidedLine({ choice, notThisKind, why }: { choice: "accept" | "reject"; notThisKind?: boolean; why?: string | null }) {
104
- const Icon = choice === "accept" ? CircleCheckIcon : CircleXIcon;
105
- return (
106
- <p className="flex items-start gap-1.5 text-sm font-medium" data-draft-decided={choice}>
107
- <Icon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
108
- <span className="break-words">
109
- {choice === "accept" ? "Accepted" : "Rejected"}{choice === "reject" && notThisKind ? " as not this kind of story" : ""}
110
- {why ? <span className="font-normal text-muted-foreground">: {why}</span> : null}
111
- </span>
112
- </p>
110
+ const partition = useMemo(() => partitionReferenceFeed(references, decisions, { now, visitRated }), [references, decisions, now, visitRated]);
111
+ const referenceItems = partition.feed;
112
+ const waitingReferences = useMemo(() => referenceItems.filter((item) => normalizeReferenceCurationStatus(item.reference.curationStatus) === "pending").length, [referenceItems]);
113
+ const items = useMemo(
114
+ () => buildNotificationItems({ requests, drafts, missingDrafts, draftsState, stay, referenceCount: waitingReferences }),
115
+ [requests, drafts, missingDrafts, draftsState, stay, waitingReferences],
113
116
  );
114
- }
117
+ const bySource = useMemo(() => previewsBySourceAddress(references, previews), [references, previews]);
118
+ const recent = useMemo(() => recentDecisions(requests, references, demo), [requests, references, demo]);
119
+ const openDraftRequests = useMemo(() => sectionReviews(requests).draftBatches, [requests]);
120
+ const missingCount = openDraftRequests.reduce((total, request) => total + draftIdsOf(request).filter((id) => missingDrafts.has(id)).length, 0);
121
+
122
+ if (loadState === "loading") return <NotificationSkeleton />;
123
+ if (loadState === "forbidden") return <div data-notification-state="forbidden" role="alert"><strong>The Notification Center is for editors and admins</strong></div>;
124
+ if (loadState === "unavailable") return <div data-notification-state="unavailable" role="alert"><strong>Review requests could not be read right now</strong><p>Try again in a minute. The current request state is unknown.</p></div>;
125
+
126
+ const counts = items.counts;
127
+ const nothing = counts.drafts + counts.references + counts.other === 0;
128
+ const waitingText_ = waitingText(counts);
129
+ const anyCards = items.drafts.length + items.reviews.length + items.flags.length + referenceItems.length > 0;
130
+ const visibleReferences = referenceItems.slice(0, shown);
131
+ const moreReferences = referenceItems.length - visibleReferences.length;
132
+ const sectionGrid = (children: ReactNode, grid = GRID) => <div className={grid} data-notification-grid>{children}</div>;
133
+
134
+ const draftCards = items.drafts.map((item) => (
135
+ <DraftCard
136
+ busy={draftBusyId === item.key} error={draftErrors[item.key] ?? null} item={item} key={item.key} previews={bySource} session={session}
137
+ onDecide={(draftId, choice, extras) => onDecideDraft?.(item.request, draftId, choice, extras)}
138
+ />
139
+ ));
140
+ const requestAnswer = (request: ReviewRequest, choice: DraftChoice, extras: DecisionExtras) =>
141
+ onAnswer?.(request, choice === "accept" ? { decision: "approve", feedback: extras.why || null } : { decision: "reject", feedback: extras.why || null, steer: extras.notThisKind ? "not_this_kind" : null });
115
142
 
116
- /** One AI draft, shown so it can be judged in place: headline, dek, section, evidence, sources and the way to the full draft. */
117
- function DraftCard({ id, view, request, busy, error, onDecide }: {
118
- id: string; view: DraftView | undefined; request: ReviewRequest; busy: boolean; error: string | null;
119
- onDecide?: (draftId: string, choice: DraftChoice, extras: { why: string; notThisKind: boolean }) => void;
120
- }) {
121
- const [why, setWhy] = useState("");
122
- const [notThisKind, setNotThisKind] = useState(false);
123
- const link = request.links.find((entry) => entry.id === id);
124
- const decision = draftDecisionOf(link, view, { by: "", at: "" });
125
- const hold = view ? cannotMoveReason(view) : null;
126
- if (!view) {
127
- return (
128
- <li className="rounded-lg border p-3 text-sm" data-review-draft={id} data-draft-state="loading" role="status">
129
- <p className="font-medium">{link?.label ?? "Draft"}</p>
130
- <p className="text-muted-foreground">Loading the draft…</p>
131
- </li>
132
- );
133
- }
134
143
  return (
135
- <li className="space-y-3 rounded-lg border p-3" data-review-draft={id} data-draft-state={decision ? "decided" : "open"}>
136
- <header className="space-y-1">
137
- <h3 className="break-words text-base font-semibold leading-snug">{view.headline}</h3>
138
- <p className="text-xs text-muted-foreground">
139
- {view.kindLabel}{view.section ? ` · Section: ${view.section}` : ""} · Proposed by AI · {stateLabel(view.state, false)}
144
+ <div className="space-y-10" data-notification-center {...FEED_SURFACE_PROPS}>
145
+ <header className="space-y-4">
146
+ <div className="space-y-1.5">
147
+ <h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl" data-notification-title>What needs me?</h1>
148
+ <p className="text-lg text-muted-foreground" data-notification-summary role="status">{waitingText_}</p>
149
+ </div>
150
+ <div aria-label="Show" className="flex flex-wrap gap-1" data-notification-filters role="group">
151
+ <div className="inline-flex max-w-full rounded-full bg-[var(--feed-card)] p-1">
152
+ {NOTIFICATION_FILTERS.map((key) => {
153
+ const on = filter === key;
154
+ return (
155
+ <button
156
+ aria-pressed={on}
157
+ className={cn(PILL, "min-h-9 gap-1 px-2 text-[0.8rem] sm:gap-1.5 sm:px-3.5 sm:text-sm", on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "text-muted-foreground hover:text-foreground")}
158
+ data-notification-filter={key}
159
+ key={key}
160
+ onClick={() => setFilter(key)}
161
+ type="button"
162
+ >
163
+ {on ? <CheckIcon aria-hidden="true" className="hidden size-3.5 sm:block" /> : null}
164
+ {FILTER_LABELS[key]}
165
+ <span className="text-xs tabular-nums opacity-70" data-notification-filter-count>{filterCount(key, counts)}</span>
166
+ </button>
167
+ );
168
+ })}
169
+ </div>
170
+ </div>
171
+ <p className="max-w-3xl text-sm text-muted-foreground" data-notification-ignore>
172
+ Decide what you like and leave the rest. A draft you leave alone stays in Proposals, and nothing is published without an editor. Only a hard gate waits for you.
140
173
  </p>
174
+ {stale ? <p className="text-xs text-muted-foreground" data-notification-stale role="status">Showing your last view while it updates.</p> : null}
175
+ {demo ? <p className="text-xs text-muted-foreground" data-notification-demo>Preview: what you decide here is not saved.</p> : null}
176
+ {error ? <p className="text-sm text-destructive" data-notification-error role="alert">{error}</p> : null}
141
177
  </header>
142
- {view.dek ? <p className="break-words text-sm" data-draft-dek>{view.dek}</p> : null}
143
- {view.evidence.length ? (
144
- <ul aria-label="Evidence" className="space-y-0.5 text-sm" data-draft-evidence>
145
- {view.evidence.map((line) => <li className="break-words" key={line}><span className="font-medium">Evidence:</span> {line}</li>)}
146
- </ul>
147
- ) : null}
148
- {view.sources.length ? (
149
- <div className="space-y-1">
150
- <p className="text-sm font-medium">Sources</p>
151
- <ul aria-label="Sources" className="space-y-0.5 text-sm" data-draft-sources>
152
- {view.sources.map((source, index) => (
153
- <li className="break-words" key={`${source.title}-${index}`}>
154
- {source.url ? (
155
- <a className="inline-flex max-w-full items-center gap-1 text-primary underline underline-offset-4" href={source.url} rel="noreferrer" target="_blank">
156
- <span className="break-words">{source.title}</span><ExternalLinkIcon aria-hidden="true" className="size-3.5 shrink-0" />
157
- </a>
158
- ) : source.title}
159
- {source.date ? <span className="text-muted-foreground"> ({source.date})</span> : null}
160
- </li>
161
- ))}
162
- {view.moreSources ? <li className="text-muted-foreground">and {view.moreSources} more in the full draft</li> : null}
163
- </ul>
178
+
179
+ {nothing && !anyCards && !referencesLoading ? (
180
+ <div className="flex flex-col items-center gap-2 rounded-[20px] bg-[var(--feed-card)] px-6 py-12 text-center" data-notification-caught-up>
181
+ <CircleCheckBigIcon aria-hidden="true" className="size-10 text-primary" />
182
+ <h2 className="text-2xl font-bold tracking-tight">All caught up</h2>
183
+ <p className="max-w-md text-sm text-muted-foreground">Nothing needs you right now. New drafts, references and reviews will appear here as they arrive.</p>
164
184
  </div>
165
185
  ) : null}
166
- <Link className="inline-flex items-center gap-1 text-sm text-primary underline underline-offset-4" data-draft-open href={view.href}>
167
- Open the full draft in Proposals<ExternalLinkIcon aria-hidden="true" className="size-3.5 shrink-0" />
168
- </Link>
169
- {decision ? (
170
- <DecidedLine choice={decision.choice} notThisKind={decision.notThisKind} why={decision.why} />
171
- ) : (
172
- <div className="space-y-3" data-review-controls>
173
- <ChoiceButtons chosen={null} disabled={busy || Boolean(hold)} onChoose={(choice) => { if (!busy && !hold) onDecide?.(id, choice, { why: why.trim(), notThisKind }); }} />
174
- <div aria-live="polite" className="min-h-5 text-sm" role="status">{busy ? "Saving…" : ""}</div>
175
- {hold ? <p className="text-sm" data-review-disabled-reason>{hold}</p> : null}
176
- {error ? <p className="text-sm text-destructive" role="alert">{error}</p> : null}
177
- <WhyBox busy={busy} onChange={setWhy} placeholder="Optional. Write it before you tap Accept or Reject; it is saved with your answer and teaches the AI." value={why} />
178
- <NotThisKind busy={busy} label={NOT_THIS_KIND.draft} onToggle={() => setNotThisKind((current) => !current)} pressed={notThisKind} />
179
- </div>
180
- )}
181
- </li>
182
- );
183
- }
184
186
 
185
- function Entry({ request, unread, demo, busy, titles, deleted, drafts, missingDrafts, draftsState, draftErrors, draftBusyId, onAnswer, onMarkRead, onDecideDraft, onRetryDrafts }: {
186
- request: ReviewRequest; unread: boolean; demo: boolean; busy: boolean; titles: Readonly<Record<string, string>>; deleted: ReadonlySet<string>;
187
- drafts: Readonly<Record<string, DraftView>>; missingDrafts: ReadonlySet<string>; draftsState: DraftsLoadState; draftErrors: Readonly<Record<string, string>>; draftBusyId: string | null;
188
- onAnswer?: ViewProps["onAnswer"]; onMarkRead?: ViewProps["onMarkRead"]; onDecideDraft?: ViewProps["onDecideDraft"]; onRetryDrafts?: () => void;
189
- }) {
190
- const href = subjectHref(request.subject, demo);
191
- const open = request.state === "open";
192
- const steering = steeringText(request);
193
- const draftIds = draftIdsOf(request);
194
- const draftReview = isDraftReview(request);
195
- const available = draftIds.filter((id) => !missingDrafts.has(id));
196
- const Icon = CONTROL_ICON[request.mode];
197
- // A draft review is decided draft by draft. Only when none of its drafts can be opened here (an idea kept outside Proposals) does the request itself take Accept or Reject.
198
- const requestLevel = open && (!draftReview || (draftsState === "ready" && available.length === 0));
199
- const recorded = request.answer?.drafts ?? [];
200
- return (
201
- <li className="space-y-3 rounded-lg border p-3" data-review-request={request.id} data-review-mode={request.mode} data-review-state={request.state} data-review-unread={unread || undefined}>
202
- <div className="flex flex-wrap items-center gap-2">
203
- <Badge variant="outline"><Icon aria-hidden="true" className="mr-1 size-3" />{modeLabel(request.mode)}</Badge>
204
- {unread ? <Badge variant="default"><CircleDotIcon aria-hidden="true" className="mr-1 size-3" />New</Badge> : <span className="text-xs text-muted-foreground">Read</span>}
205
- <strong className="text-sm">{request.reason}</strong>
206
- </div>
207
- <p className="text-xs text-muted-foreground">
208
- Raised by {request.raisedBy} on {formatWhen(request.raisedAt)}
209
- {draftReview ? null : <>{" · "}About: {href ? <Link href={href} onClick={() => onMarkRead?.(request.id)}>{subjectLabel(request.subject, titles, deleted)}</Link> : <span>{subjectLabel(request.subject, titles, deleted)}</span>}</>}
210
- </p>
211
- {request.ask && !draftReview ? <p className="text-sm">{request.ask}</p> : null}
212
- {!draftReview && request.links.length ? (
213
- <ul className="text-xs space-y-0.5" aria-label="Items in this request">
214
- {request.links.slice(0, 10).map((link, index) => {
215
- const target = link.url ?? (link.type ? subjectHref({ type: link.type, id: link.id ?? "" }, demo) : null);
216
- return <li key={`${link.id ?? link.url}-${index}`}>{target ? <Link href={target}>{link.label ?? link.id ?? link.url}</Link> : (link.label ?? link.id)}</li>;
217
- })}
218
- {request.links.length > 10 ? <li>and {request.links.length - 10} more</li> : null}
219
- </ul>
220
- ) : null}
221
- {draftReview && open ? (
222
- <div className="space-y-2">
223
- <p className="text-sm">Accept or Reject each draft. An accepted draft is approved in Proposals; a rejected one is declined there. Nothing is published by this page.</p>
187
+ {showsSection(filter, "drafts") && (items.drafts.length || draftsState === "failed" || missingCount > 0) ? (
188
+ <Section count={counts.drafts} id="drafts" subtitle="AI story drafts. Accept sends one on to an editor in Proposals; Reject declines it." title="Drafts">
224
189
  {draftsState === "failed" ? (
225
- <div className="space-y-2" role="alert">
226
- <p className="text-sm">The drafts could not be loaded right now.</p>
227
- <Button onClick={onRetryDrafts} size="sm" type="button" variant="outline">Try again</Button>
228
- </div>
229
- ) : null}
230
- <ul aria-label="Drafts in this request" className="space-y-3" data-review-drafts>
231
- {draftsState === "failed" ? null : available.map((id) => (
232
- <DraftCard
233
- busy={draftBusyId === `${request.id}#${id}`} error={draftErrors[`${request.id}#${id}`] ?? null} id={id} key={id} request={request}
234
- onDecide={(draftId, choice, extras) => onDecideDraft?.(request, draftId, choice, extras)} view={drafts[id]}
235
- />
236
- ))}
237
- </ul>
238
- {draftIds.length > available.length ? <p className="text-sm text-muted-foreground">{draftIds.length - available.length} of these drafts are not in Proposals on this site, so they cannot be opened here.</p> : null}
239
- </div>
190
+ <Note role="alert">
191
+ <p>The drafts could not be loaded right now.</p>
192
+ <button className={cn(PILL, "mt-3 bg-[var(--feed-control)] text-foreground")} onClick={onRetryDrafts} type="button">Try again</button>
193
+ </Note>
194
+ ) : sectionGrid(draftCards)}
195
+ {missingCount ? <p className="mt-4 text-sm text-muted-foreground">{missingCount} of these drafts {missingCount === 1 ? "is" : "are"} not in Proposals on this site, so they cannot be opened here.</p> : null}
196
+ </Section>
240
197
  ) : null}
241
- {!open && recorded.length ? (
242
- <ul aria-label="What you decided" className="space-y-1" data-review-decisions>
243
- {recorded.map((entry) => (
244
- <li key={entry.id}>
245
- <DecidedLine choice={entry.choice} notThisKind={entry.notThisKind} why={entry.why} />
246
- <Link className="ml-5 text-xs text-primary underline underline-offset-4" href={proposalHref(entry.id, demo)}>{entry.title ?? entry.id}</Link>
247
- </li>
248
- ))}
249
- </ul>
198
+
199
+ {showsSection(filter, "references") && (referenceItems.length || referencesLoading) ? (
200
+ <Section count={referencesLoading ? undefined : waitingReferences} id="references" subtitle="Sources the Cyclotron wants a person to rate. The same cards as the References feed, best bets first." title="References">
201
+ {referencesLoading ? <ReferenceFeedSkeleton count={3} /> : (
202
+ <>
203
+ <div className={GRID} data-notification-grid data-notification-references>
204
+ {visibleReferences.map((item) => (
205
+ <FeedCard
206
+ decision={item.decision} disabledReason={referenceDisabledReason} item={item} key={item.lineageId} lead={false} now={now}
207
+ onOpen={() => onOpenReference?.(item.lineageId)}
208
+ onRate={async (reference, choice, reasonCode) => { onVisitRated?.(item.lineageId); await onRateReference?.(reference, choice, reasonCode); }}
209
+ predictionLoading={predictionLoading} preview={previews.get(item.lineageId)} selected={false} visitRated={visitRated.has(item.lineageId)}
210
+ />
211
+ ))}
212
+ </div>
213
+ <div className="mt-5 flex flex-wrap items-center gap-2">
214
+ {moreReferences > 0 ? (
215
+ <button className={cn(PILL, "bg-[var(--feed-control)] text-foreground")} data-notification-more-references onClick={() => setShown((current) => current + REFERENCE_PAGE)} type="button">
216
+ Show {Math.min(moreReferences, REFERENCE_PAGE)} more
217
+ </button>
218
+ ) : null}
219
+ <Link className={cn(PILL, "text-muted-foreground hover:text-foreground")} href={getNewsroomNavHref("/newsroom/references", demo)}>
220
+ {partition.earlier.length ? `Open the References feed (${partition.earlier.length} earlier)` : "Open the References feed"}
221
+ </Link>
222
+ </div>
223
+ </>
224
+ )}
225
+ </Section>
250
226
  ) : null}
251
- <p className="text-sm" data-review-next>{nextText(request)}</p>
252
- {steering ? <p className="text-sm" data-review-steering>{steering}</p> : null}
253
- {requestLevel ? (
254
- <RequestControls busy={busy} onAnswer={onAnswer} onMarkRead={onMarkRead} request={request} unread={unread} />
255
- ) : open ? (
256
- unread ? <Button aria-disabled={busy || undefined} onClick={() => { if (!busy) onMarkRead?.(request.id); }} size="sm" variant="ghost">Mark read</Button> : null
257
- ) : unread ? (
258
- <Button aria-disabled={busy || undefined} onClick={() => { if (!busy) onMarkRead?.(request.id); }} size="sm" variant="ghost">Mark read</Button>
227
+
228
+ {showsSection(filter, "other") && items.reviews.length ? (
229
+ <Section count={items.reviews.filter((item) => item.request.state === "open").length} id="reviews" subtitle="Reviews that are about more than one draft or one source." title="Reviews">
230
+ {sectionGrid(items.reviews.map((item) => (
231
+ <RequestCard
232
+ busy={busyId === item.request.id} deleted={deletedStories} demo={demo} error={null} item={item} key={item.key}
233
+ onAnswer={requestAnswer} onOpen={onMarkRead} session={session} titles={storyTitles}
234
+ />
235
+ )))}
236
+ </Section>
259
237
  ) : null}
260
- </li>
261
- );
262
- }
263
238
 
264
- function CyclotronLine({ entry, held }: { entry: CyclotronEntry; held: boolean }) {
265
- const confidence = entry.confidence === null ? "confidence not recorded" : `confidence ${Math.round(entry.confidence * 100)}%`;
266
- return (
267
- <li className="rounded-lg border p-3 space-y-1" data-cyclotron-entry={entry.lineageId} data-cyclotron-held={held || undefined}>
268
- <div className="flex flex-wrap items-center gap-2">
269
- <Badge variant="outline">{held ? <HourglassIcon aria-hidden="true" className="mr-1 size-3" /> : <CircleCheckIcon aria-hidden="true" className="mr-1 size-3" />}{held ? "Held for review" : "Decided by the system"}</Badge>
270
- <strong className="text-sm">{entry.title}</strong>
271
- </div>
272
- <p className="text-xs text-muted-foreground">
273
- Classified as {entry.label} · {confidence}{entry.decidedAt ? ` · ${formatWhen(entry.decidedAt)}` : ""}
274
- {entry.reviewedByPerson ? " · reviewed by a person" : ""}
275
- </p>
276
- {held ? <p className="text-sm">{entry.reviewDetail ? `${entry.reviewDetail}. ` : ""}Held for review: it stays pending until someone decides.</p> : null}
277
- <Link className="text-sm" href={entry.href}>{held ? "Review it in References" : "Open it in References to correct it"}</Link>
278
- </li>
279
- );
280
- }
239
+ {showsSection(filter, "other") && items.flags.length ? (
240
+ <Section count={items.flags.filter((item) => item.request.state === "open").length} id="flags" subtitle="Nothing is waiting on these. Acknowledge them to clear them." title="For your information">
241
+ {sectionGrid(items.flags.map((item) => (
242
+ <FlagCard
243
+ busy={busyId === item.request.id} deleted={deletedStories} demo={demo} error={null} item={item} key={item.key} onOpen={onMarkRead} session={session} titles={storyTitles}
244
+ onAcknowledge={(request, note) => onAnswer?.(request, { decision: "acknowledge", feedback: note || null })}
245
+ />
246
+ )))}
247
+ </Section>
248
+ ) : null}
281
249
 
282
- function Section({ id, title, count, children, empty }: { id: string; title: string; count: number; children: React.ReactNode; empty: string }) {
283
- return (
284
- <section aria-labelledby={`notification-${id}`} className="space-y-2" data-notification-section={id}>
285
- <h2 className="text-sm font-medium" id={`notification-${id}`}>{title} ({count})</h2>
286
- {count ? children : <p className="text-sm text-muted-foreground">{empty}</p>}
287
- </section>
288
- );
289
- }
250
+ {!nothing && filter !== "all" && filterCount(filter, counts) === 0 && !referencesLoading ? (
251
+ <Note data-filter-empty>Nothing in {FILTER_LABELS[filter]} needs you right now.</Note>
252
+ ) : null}
290
253
 
291
- /** The CMS console home: what needs a person, what will close on its own, and what the system decided. Ignore-safe by design. */
292
- export function NotificationCenterView({
293
- loadState, requests, readIds, held, autoDecided, demo = false, storyTitles = {}, deletedStories = new Set<string>(), busyId = null, error = null,
294
- drafts = {}, missingDrafts = new Set<string>(), draftsState = "ready", draftErrors = {}, draftBusyId = null,
295
- onAnswer, onDecideDraft, onRetryDrafts, onMarkRead, onMarkAllRead,
296
- }: ViewProps) {
297
- const sections = useMemo(() => sectionReviews(requests), [requests]);
298
- const unreadCount = requests.filter((request) => isUnread(request, readIds)).length;
299
- const summary = summarize(sections, held, autoDecided, unreadCount, draftsToDecide(sections.draftBatches, drafts, missingDrafts));
300
- const entry = (request: ReviewRequest) => (
301
- <Entry
302
- busy={busyId === request.id} demo={demo} deleted={deletedStories} draftBusyId={draftBusyId} draftErrors={draftErrors} drafts={drafts} draftsState={draftsState}
303
- key={request.id} missingDrafts={missingDrafts} onAnswer={onAnswer} onDecideDraft={onDecideDraft} onMarkRead={onMarkRead} onRetryDrafts={onRetryDrafts}
304
- request={request} titles={storyTitles} unread={isUnread(request, readIds)}
305
- />
306
- );
307
- if (loadState === "loading") return (
308
- <section aria-label="Loading Notification Center" className="space-y-4" data-notification-state="loading" role="status">
309
- <div className="h-6 w-48 rounded bg-muted" />
310
- <div className="h-4 w-3/4 max-w-xl rounded bg-muted" />
311
- <div className="h-4 w-2/3 max-w-lg rounded bg-muted" />
312
- <div className="grid gap-3 sm:grid-cols-2">
313
- {Array.from({ length: 4 }, (_, index) => <div className="h-28 rounded-xl border bg-card p-4" key={index}>
314
- <div className="h-4 w-2/3 rounded bg-muted" /><div className="mt-4 h-3 w-1/2 rounded bg-muted" />
315
- </div>)}
316
- </div>
317
- </section>
318
- );
319
- if (loadState === "forbidden") return <div role="alert" data-notification-state="forbidden"><strong>The Notification Center is for editors and admins</strong></div>;
320
- if (loadState === "unavailable") return <div role="alert" data-notification-state="unavailable"><strong>Review requests could not be read right now</strong><p>Try again in a minute. The current request state is unknown.</p></div>;
321
- return (
322
- <div className="space-y-6" data-notification-center>
323
- <header className="space-y-1">
324
- <h1 className="text-lg font-semibold">Notification Center</h1>
325
- <p className="text-sm" data-notification-summary>{summaryText(summary)}</p>
326
- <p className="text-xs text-muted-foreground">
327
- Accept or Reject what you want to decide, and ignore the rest. An AI draft you leave alone stays in Proposals and nothing is published without an editor. A review with a deadline closes when the deadline passes, and flags never wait. Only a hard gate waits for you.
328
- </p>
329
- <p className="text-xs text-muted-foreground" data-notification-unread>
330
- {unreadCount ? `${unreadCount} unread` : "Nothing unread"}
331
- {unreadCount ? <Button className="ml-2" onClick={onMarkAllRead} size="sm" variant="ghost">Mark all read</Button> : null}
332
- </p>
333
- {error ? <p role="alert" className="text-sm" data-notification-error>{error}</p> : null}
334
- </header>
254
+ {autoDecided.length ? (
255
+ <details className="group rounded-[20px] bg-[var(--feed-card)] p-4" data-notification-section="auto-decided">
256
+ <summary className="flex min-h-9 cursor-pointer list-none items-center gap-2 text-sm font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring">
257
+ <ChevronDownIcon aria-hidden="true" className="size-4 transition-transform group-open:rotate-180" />
258
+ Decided automatically ({autoDecided.length})
259
+ </summary>
260
+ <p className="mt-2 text-xs text-muted-foreground">Read only. The Cyclotron was confident enough to decide these without you. To correct one, open it in References.</p>
261
+ <ul className="mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
262
+ {autoDecided.map((entry) => (
263
+ <li data-cyclotron-entry={entry.lineageId} key={entry.lineageId}>
264
+ <Link className="block rounded-[16px] bg-[var(--feed-control)] p-3 text-sm outline-none hover:bg-[var(--feed-control)]/70 focus-visible:ring-2 focus-visible:ring-ring" href={entry.href}>
265
+ <span className="block break-words font-medium leading-snug">{entry.title}</span>
266
+ <span className="block text-xs text-muted-foreground">
267
+ Classified as {entry.label} · {entry.confidence === null ? "confidence not recorded" : `confidence ${Math.round(entry.confidence * 100)}%`}{entry.reviewedByPerson ? " · reviewed by a person" : ""}
268
+ </span>
269
+ </Link>
270
+ </li>
271
+ ))}
272
+ </ul>
273
+ </details>
274
+ ) : null}
335
275
 
336
- <Section count={sections.hardGates.length} empty="No hard gates. Nothing is blocked on you." id="hard-gates" title="Waiting for you: hard gates">
337
- <ul className="space-y-2">{sections.hardGates.map(entry)}</ul>
338
- </Section>
339
- <Section count={sections.draftBatches.length} empty="No AI drafts are waiting for review." id="drafts" title="AI drafts to review">
340
- <ul className="space-y-3">{sections.draftBatches.map(entry)}</ul>
341
- </Section>
342
- <Section count={sections.softReviews.length} empty="No other reviews are open." id="soft-reviews" title="Other reviews, soonest deadline first">
343
- <ul className="space-y-2">{sections.softReviews.map(entry)}</ul>
344
- </Section>
345
- <Section count={held.length} empty="No Cyclotron items are held for review." id="cyclotron" title="Cyclotron items routed to a person (low confidence)">
346
- <ul className="space-y-2">{held.slice(0, 10).map((item) => <CyclotronLine entry={item} held key={item.lineageId} />)}</ul>
347
- {held.length > 10 ? <p className="text-sm">and {held.length - 10} more. <Link href={getNewsroomNavHref("/newsroom/references", demo)}>See all in References</Link></p> : null}
348
- </Section>
349
- <Section count={sections.flags.length} empty="No flags." id="flags" title="Flags: for your information">
350
- <ul className="space-y-2">{sections.flags.map(entry)}</ul>
351
- </Section>
352
- <Section count={sections.decided.length} empty="Nothing answered or proceeded yet." id="decided" title="Answered, and what became of it">
353
- <ul className="space-y-2">{sections.decided.slice(0, 20).map(entry)}</ul>
354
- </Section>
355
- <details data-notification-section="auto-decided">
356
- <summary className="cursor-pointer text-sm font-medium">Recently decided by the system ({autoDecided.length})</summary>
357
- <div className="mt-2 space-y-2">
358
- <p className="text-xs text-muted-foreground">Read only. The Cyclotron was confident enough to decide these without you. To correct one, open it in References.</p>
359
- {autoDecided.length ? <ul className="space-y-2">{autoDecided.map((item) => <CyclotronLine entry={item} held={false} key={item.lineageId} />)}</ul> : <p className="text-sm text-muted-foreground">No automatic decisions yet.</p>}
360
- </div>
361
- </details>
276
+ {recent.length ? (
277
+ <Section id="decided" subtitle="The last few things you decided." title="Recently decided">
278
+ <RecentDecisions entries={recent} />
279
+ </Section>
280
+ ) : null}
362
281
  </div>
363
282
  );
364
283
  }
@@ -384,21 +303,56 @@ const liveApi: NotificationApi = {
384
303
  transitionProposalPath: async (proposalId, path) => (await import("../lib/proposals-client")).transitionProposalPath(proposalId, path),
385
304
  };
386
305
 
306
+ /** The demo and the real-data preview answer in memory with the same rules as the live data API; nothing leaves the page. */
307
+ const demoApi: NotificationApi = {
308
+ loadReviews: async () => ({ state: "unavailable" }),
309
+ loadDrafts: async () => ({ drafts: {}, missing: [], failed: [] }),
310
+ loadStoryTitles: async () => ({ titles: {}, deleted: [] }),
311
+ answerReviewRequest: async (request, input, by) => {
312
+ const update = answerUpdate(request, input, by, new Date());
313
+ return { ...request, state: "answered", answer: JSON.parse(String(update.answer)), links: update.links ? JSON.parse(String(update.links)) : request.links, history: JSON.parse(String(update.history)), updatedAt: String(update.updatedAt) };
314
+ },
315
+ saveDraftDecision: async (request, draftId, decision) => {
316
+ const update = draftDecisionUpdate(request, draftId, decision);
317
+ return { ...request, links: JSON.parse(String(update.links)), history: JSON.parse(String(update.history)), updatedAt: String(update.updatedAt) };
318
+ },
319
+ markReviewRead: async () => undefined,
320
+ transitionProposalPath: async () => ({ status: "ok" }),
321
+ };
322
+
387
323
  type ContainerProps = {
388
324
  references: ReferenceRecord[];
389
325
  decisions: ReadonlyMap<string, RelevanceDecisionView>;
390
- humanCuratedReferenceLineageIds: ReadonlySet<string>;
326
+ humanCuratedReferenceLineageIds?: ReadonlySet<string>;
327
+ previews?: ReadonlyMap<string, ReferencePreview>;
328
+ referencesLoading?: boolean;
329
+ predictionLoading?: boolean;
330
+ referenceDisabledReason?: string | null;
331
+ onOpenReference?: (lineageId: string) => void;
332
+ onRateReference?: (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) => Promise<void> | void;
391
333
  actorLabel: string;
392
334
  demo: boolean;
335
+ /** A real-data preview (see scripts/export-notification-center-fixture.py); the invented sample otherwise. */
336
+ fixture?: NotificationFixture | null;
393
337
  api?: NotificationApi;
394
338
  };
395
339
 
396
340
  const REFRESH_MILLISECONDS = 60_000;
397
341
 
398
- export function NotificationCenter({ references, decisions, humanCuratedReferenceLineageIds, actorLabel, demo, api = liveApi }: ContainerProps) {
342
+ /** Preview only: ?ncState=loading holds the skeleton, caught-up shows the empty state, sample swaps in the invented hard gate, flags and batches. */
343
+ function previewState(): "loading" | "caught-up" | "sample" | null {
344
+ if (typeof window === "undefined" || !isLocalPreviewEnabled()) return null;
345
+ const requested = new URLSearchParams(window.location.search).get("ncState");
346
+ return requested === "loading" || requested === "caught-up" || requested === "sample" ? requested : null;
347
+ }
348
+
349
+ export function NotificationCenter({
350
+ references, decisions, humanCuratedReferenceLineageIds = NO_VISIT, previews, referencesLoading, predictionLoading, referenceDisabledReason, onOpenReference, onRateReference, actorLabel, demo, fixture = null, api: liveOverride = liveApi,
351
+ }: ContainerProps) {
352
+ const api = demo ? demoApi : liveOverride;
399
353
  const [loadState, setLoadState] = useState<NotificationLoadState>("loading");
354
+ const [stale, setStale] = useState(false);
400
355
  const [requests, setRequests] = useState<ReviewRequest[]>([]);
401
- const [readIds, setReadIds] = useState<Set<string>>(new Set());
402
356
  const [userKey, setUserKey] = useState<string | null>(null);
403
357
  const [busyId, setBusyId] = useState<string | null>(null);
404
358
  const [error, setError] = useState<string | null>(null);
@@ -409,7 +363,18 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
409
363
  const [draftsState, setDraftsState] = useState<DraftsLoadState>("loading");
410
364
  const [draftErrors, setDraftErrors] = useState<Record<string, string>>({});
411
365
  const [draftBusyId, setDraftBusyId] = useState<string | null>(null);
366
+ const [stay, setStay] = useState<ReadonlySet<string>>(new Set());
367
+ const [visitRated, setVisitRated] = useState<ReadonlySet<string>>(new Set());
368
+ const [now] = useState(() => Date.now());
412
369
  const resolving = useRef(new Set<string>());
370
+ const demoStarted = useRef(false);
371
+ const preview = useMemo(() => previewState(), []);
372
+ const sample = useMemo(() => {
373
+ if (preview === "sample" || !fixture) return { requests: FIXTURE_REVIEW_REQUESTS, drafts: FIXTURE_DRAFTS };
374
+ return { requests: preview === "caught-up" ? [] : fixture.requests, drafts: fixture.drafts };
375
+ }, [fixture, preview]);
376
+
377
+ const keep = useCallback((key: string) => setStay((current) => (current.has(key) ? current : new Set(current).add(key))), []);
413
378
 
414
379
  const loadDraftsFor = useCallback(async (list: ReviewRequest[]) => {
415
380
  const ids = list.filter((request) => request.state === "open" && isDraftReview(request)).flatMap(draftIdsOf);
@@ -418,47 +383,65 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
418
383
  return;
419
384
  }
420
385
  if (demo) {
421
- setDrafts(Object.fromEntries(FIXTURE_DRAFTS.map((row) => [row.id, draftView(row, true)])));
422
- setMissingDrafts(new Set(ids.filter((id) => !FIXTURE_DRAFTS.some((row) => row.id === id))));
386
+ const known = sample.drafts.filter((row) => ids.includes(row.id));
387
+ setDrafts((current) => ({ ...current, ...Object.fromEntries(known.map((row) => [row.id, draftView(row, true)])) }));
388
+ setMissingDrafts(new Set(ids.filter((id) => !known.some((row) => row.id === id))));
423
389
  setDraftsState("ready");
424
390
  return;
425
391
  }
426
392
  try {
427
393
  const loaded = await api.loadDrafts(ids);
428
- setDrafts(loaded.drafts);
394
+ setDrafts((current) => ({ ...current, ...loaded.drafts }));
429
395
  setMissingDrafts(new Set(loaded.missing));
430
396
  setDraftsState(loaded.failed.length ? "failed" : "ready");
431
397
  } catch {
432
398
  setDraftsState("failed");
433
399
  }
434
- }, [api, demo]);
400
+ }, [api, demo, sample]);
401
+
402
+ // A return visit paints the last view at once, marked as such, while the real reads run.
403
+ useEffect(() => {
404
+ if (demo) return;
405
+ const snapshot = loadNotificationSnapshot(SITE_BRAND.id);
406
+ if (!snapshot) return;
407
+ setRequests(snapshot.requests);
408
+ setDrafts(snapshot.drafts);
409
+ setMissingDrafts(new Set(snapshot.missing));
410
+ setDraftsState("ready");
411
+ setStale(true);
412
+ setLoadState((current) => (current === "loading" ? "ready" : current));
413
+ }, [demo]);
435
414
 
436
415
  const refresh = useCallback(async () => {
437
416
  if (demo) {
438
- setRequests(FIXTURE_REVIEW_REQUESTS);
439
- setReadIds(new Set(["rr-flag-1"]));
417
+ // The sample is read once: answering in the preview must not be undone by the next refresh.
418
+ if (demoStarted.current) return;
419
+ demoStarted.current = true;
420
+ setRequests(sample.requests);
440
421
  setLoadState("ready");
441
- void loadDraftsFor(FIXTURE_REVIEW_REQUESTS);
422
+ void loadDraftsFor(sample.requests);
442
423
  return;
443
424
  }
444
425
  try {
445
426
  const loaded = await api.loadReviews();
446
427
  if (loaded.state !== "ready") {
428
+ setStale(false);
447
429
  setLoadState(loaded.state);
448
430
  return;
449
431
  }
450
432
  setRequests(loaded.requests);
451
- setReadIds(new Set(loaded.readIds));
452
433
  setUserKey(loaded.userKey);
434
+ setStale(false);
453
435
  setLoadState("ready");
454
436
  void loadDraftsFor(loaded.requests);
455
437
  const stories = await api.loadStoryTitles(loaded.requests.filter((request) => request.subject.type === "story").map((request) => request.subject.id)).catch(() => ({ titles: {}, deleted: [] as string[] }));
456
438
  setStoryTitles(stories.titles);
457
439
  setDeletedStories(new Set(stories.deleted));
458
440
  } catch {
441
+ setStale(false);
459
442
  setLoadState("unavailable");
460
443
  }
461
- }, [api, demo, loadDraftsFor]);
444
+ }, [api, demo, loadDraftsFor, sample]);
462
445
 
463
446
  useEffect(() => {
464
447
  void refresh();
@@ -466,11 +449,14 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
466
449
  return () => window.clearInterval(timer);
467
450
  }, [refresh]);
468
451
 
469
- const held = useMemo(() => cyclotronHeldForReview(references, decisions, demo), [references, decisions, demo]);
470
- const autoDecided = useMemo(() => cyclotronAutoDecided(references, decisions, humanCuratedReferenceLineageIds, demo), [references, decisions, humanCuratedReferenceLineageIds, demo]);
452
+ // Keep a display copy for the next visit once the live read and its drafts have arrived.
453
+ useEffect(() => {
454
+ if (demo || stale || loadState !== "ready" || draftsState !== "ready") return;
455
+ const timer = window.setTimeout(() => saveNotificationSnapshot(SITE_BRAND.id, { requests, drafts, missing: missingDrafts }), 800);
456
+ return () => window.clearTimeout(timer);
457
+ }, [demo, draftsState, drafts, loadState, missingDrafts, requests, stale]);
471
458
 
472
459
  const markRead = useCallback(async (requestId: string) => {
473
- setReadIds((current) => new Set(current).add(requestId));
474
460
  if (demo || !userKey) return;
475
461
  await api.markReviewRead(userKey, requestId).catch(() => undefined);
476
462
  }, [api, demo, userKey]);
@@ -479,8 +465,8 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
479
465
  setError(null);
480
466
  setBusyId(request.id);
481
467
  try {
482
- if (demo) throw new Error("This is a demo: answers are not saved.");
483
468
  const saved = await api.answerReviewRequest(request, input, actorLabel);
469
+ keep(request.id);
484
470
  setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
485
471
  await markRead(request.id);
486
472
  } catch (failure) {
@@ -488,7 +474,7 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
488
474
  } finally {
489
475
  setBusyId(null);
490
476
  }
491
- }, [actorLabel, api, demo, markRead]);
477
+ }, [actorLabel, api, keep, markRead]);
492
478
 
493
479
  /** Accept or Reject one draft: the Proposal moves to approved or declined, the decision is recorded on the request, and the request closes once every draft is decided. */
494
480
  const decideDraft = useCallback(async (request: ReviewRequest, draftId: string, choice: DraftChoice, extras: DraftExtras) => {
@@ -497,7 +483,6 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
497
483
  setDraftBusyId(key);
498
484
  resolving.current.add(request.id);
499
485
  try {
500
- if (demo) throw new Error("This is a demo: decisions are not saved.");
501
486
  const view = drafts[draftId];
502
487
  if (!view) throw new Error("This draft has not loaded yet.");
503
488
  const path = choice === "accept" ? acceptPath(view.state) : rejectPath(view.state);
@@ -507,6 +492,7 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
507
492
  const decided: DraftView = { ...view, state: choice === "accept" ? "approved" : "declined", decided: choice };
508
493
  const nextDrafts = { ...drafts, [draftId]: decided };
509
494
  setDrafts(nextDrafts);
495
+ keep(key);
510
496
  const decision: DraftDecision = { choice, title: view.headline, why: extras.why || null, notThisKind: choice === "reject" && extras.notThisKind, by: actorLabel, at: new Date().toISOString() };
511
497
  const withDecisionRequest = { ...request, links: withDecision(request, draftId, decision) };
512
498
  const closing = batchAnswer(withDecisionRequest, nextDrafts, { by: actorLabel, at: decision.at });
@@ -524,11 +510,11 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
524
510
  resolving.current.delete(request.id);
525
511
  setDraftBusyId(null);
526
512
  }
527
- }, [actorLabel, api, demo, drafts, loadDraftsFor, markRead]);
513
+ }, [actorLabel, api, demo, drafts, keep, loadDraftsFor, markRead]);
528
514
 
529
515
  // Drafts decided in Proposals (not here) still close their request.
530
516
  useEffect(() => {
531
- if (demo || draftsState !== "ready") return;
517
+ if (demo || draftsState !== "ready" || stale) return;
532
518
  for (const request of requests) {
533
519
  if (request.state !== "open" || !isDraftReview(request) || resolving.current.has(request.id)) continue;
534
520
  if (undecidedDraftIds(request, drafts).length) continue;
@@ -546,18 +532,20 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
546
532
  }
547
533
  })();
548
534
  }
549
- }, [api, demo, drafts, draftsState, requests]);
550
-
551
- const markAllRead = useCallback(() => {
552
- for (const request of requests) if (isUnread(request, readIds)) void markRead(request.id);
553
- }, [markRead, readIds, requests]);
535
+ }, [api, demo, drafts, draftsState, requests, stale]);
554
536
 
537
+ // Preview only: caught-up hides what is still pending; loading holds the skeleton.
538
+ const autoDecided = useMemo(() => cyclotronAutoDecided(references, decisions, humanCuratedReferenceLineageIds, demo), [references, decisions, humanCuratedReferenceLineageIds, demo]);
539
+ const shownReferences = preview === "caught-up" ? references.filter((reference) => normalizeReferenceCurationStatus(reference.curationStatus) !== "pending") : references;
555
540
  return (
556
541
  <NotificationCenterView
557
- autoDecided={autoDecided} busyId={busyId} storyTitles={storyTitles} deletedStories={deletedStories} demo={demo} draftBusyId={draftBusyId} draftErrors={draftErrors}
558
- drafts={drafts} draftsState={draftsState} error={error} held={held} loadState={loadState} missingDrafts={missingDrafts}
559
- onAnswer={answer} onDecideDraft={(request, draftId, choice, extras) => void decideDraft(request, draftId, choice, extras)}
560
- onMarkAllRead={markAllRead} onMarkRead={markRead} onRetryDrafts={() => void loadDraftsFor(requests)} readIds={readIds} requests={requests}
542
+ autoDecided={autoDecided} busyId={busyId} decisions={decisions} deletedStories={deletedStories} demo={demo} draftBusyId={draftBusyId} draftErrors={draftErrors} drafts={drafts} draftsState={draftsState}
543
+ error={error} loadState={preview === "loading" ? "loading" : loadState} missingDrafts={missingDrafts} now={now} onAnswer={(request, input) => void answer(request, input)}
544
+ onDecideDraft={(request, draftId, choice, extras) => void decideDraft(request, draftId, choice, extras)} onMarkRead={(requestId) => void markRead(requestId)}
545
+ onOpenReference={onOpenReference} onRateReference={onRateReference} onRetryDrafts={() => void loadDraftsFor(requests)}
546
+ onVisitRated={(lineageId) => setVisitRated((current) => (current.has(lineageId) ? current : new Set(current).add(lineageId)))}
547
+ predictionLoading={predictionLoading} previews={previews} referenceDisabledReason={referenceDisabledReason} references={shownReferences} referencesLoading={referencesLoading}
548
+ requests={requests} stale={stale} stay={stay} storyTitles={storyTitles} visitRated={visitRated}
561
549
  />
562
550
  );
563
551
  }