@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.
- package/amplify/data/resource.js +6 -0
- package/amplify/data/resource.ts +7 -0
- package/amplify/data/schema.js +6 -0
- package/amplify/data/schema.ts +7 -0
- package/amplify/functions/content-actions/handler.py +8 -0
- package/bin/papyrus-app.mjs +12 -1
- package/components/news-desk-client-provider.tsx +2 -2
- package/components/newsroom-ops-overview.tsx +63 -60
- package/components/newsroom-ops-shell.tsx +3 -1
- package/components/newsroom-organizer.css +5 -2
- package/components/newsroom-references-view.tsx +120 -8
- package/components/notification-cards.tsx +364 -0
- package/components/notification-center.tsx +319 -331
- package/components/reference-capture-client.ts +110 -0
- package/components/reference-capture-panel.tsx +180 -0
- package/components/reference-feed.tsx +94 -60
- package/components/reference-review-card.tsx +21 -66
- package/components/references-prefetch.ts +5 -0
- package/components/review-rating-grid.tsx +31 -15
- package/components/topic-steering-workspace.tsx +12 -2
- package/components/use-reference-ratings.ts +132 -0
- package/lib/category-repository.ts +2 -0
- package/lib/content-actions-client.ts +18 -0
- package/lib/local-preview-feed-fixture.ts +20 -0
- package/lib/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +1 -1
- package/lib/notification-feed.ts +250 -0
- package/lib/reference-capture.ts +293 -0
- package/lib/reference-feed-cache.ts +37 -0
- package/lib/reference-feed-ranking.ts +44 -0
- package/lib/review-drafts.ts +8 -3
- package/lib/site-brand.ts +2 -0
- package/package.json +7 -1
- package/routes.manifest.json +1 -1
|
@@ -1,37 +1,64 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { CheckIcon,
|
|
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 {
|
|
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,
|
|
10
|
+
import { acceptPath, batchAnswer, draftView, rejectPath, undecidedDraftIds, withDecision, draftDecisionUpdate, type DraftChoice, type DraftView } from "../lib/review-drafts";
|
|
12
11
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 {
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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
|
-
/**
|
|
83
|
-
function
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
<
|
|
136
|
-
<header className="space-y-
|
|
137
|
-
<
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
143
|
-
{
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
<
|
|
226
|
-
<p
|
|
227
|
-
<
|
|
228
|
-
</
|
|
229
|
-
) :
|
|
230
|
-
<
|
|
231
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
<
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
252
|
-
{
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
337
|
-
<
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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(
|
|
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
|
-
|
|
470
|
-
|
|
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,
|
|
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}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
}
|