@anthusai/papyrus 5.46.0 → 5.48.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.
@@ -1,296 +1,53 @@
1
1
  "use client";
2
2
 
3
- import { CheckIcon, ChevronDownIcon, CircleCheckBigIcon } from "lucide-react";
4
- import Link from "next/link";
5
- import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
6
- import { cn } from "../lib/utils";
7
- import { getNewsroomNavHref } from "../lib/newsroom-nav";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import type { ReferenceAttachmentRecord, ReferenceRecord } from "../lib/category-repository";
5
+ import type { EmailDecisionResult, MonitoringEmailDetails, MonitoringEmailSummary } from "../lib/monitoring-overview";
8
6
  import { proposalReceiptMessage, stateLabel, type ProposalReceipt } from "../lib/proposals-admin";
9
7
  import type { DraftLoad, ReviewLoad } from "../lib/review-client";
10
8
  import { acceptPath, batchAnswer, draftView, rejectPath, undecidedDraftIds, withDecision, draftDecisionUpdate, type DraftChoice, type DraftView } from "../lib/review-drafts";
11
- import {
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";
16
- import type { ReferenceRecord } from "../lib/category-repository";
9
+ import { previewsBySourceAddress, recentDecisions } from "../lib/notification-feed";
10
+ import { answerUpdate, cyclotronAutoDecided, draftIdsOf, isDraftReview, sectionReviews, type AnswerInput, type DraftDecision, type ReviewRequest } from "../lib/review-requests";
17
11
  import type { RelevanceDecisionView } from "../lib/relevance-decisions";
18
- import { partitionReferenceFeed } from "../lib/reference-feed-ranking";
12
+ import { learnFreshness } from "../lib/reference-feed-ranking";
13
+ import { learnConfidenceThreshold, shapingThisWeek } from "../lib/needs-me-ranking";
14
+ import { buildNeedsMeItems } from "../lib/needs-me-items";
15
+ import { captureStatusesByLineage } from "../lib/reference-capture";
19
16
  import { normalizeReferenceCurationStatus, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
20
17
  import type { ReferencePreview } from "../lib/reference-preview";
18
+ import { referenceLineageId } from "../lib/newsroom-references";
21
19
  import { loadNotificationSnapshot, saveNotificationSnapshot } from "../lib/reference-feed-cache";
22
20
  import { isLocalPreviewEnabled } from "../lib/local-preview";
23
21
  import { SITE_BRAND } from "../lib/site-brand";
24
- import { FIXTURE_DRAFTS, FIXTURE_REVIEW_REQUESTS } from "../lib/review-fixture";
25
- import { DraftCard, FlagCard, NotificationSkeleton, RecentDecisions, RequestCard, type DecisionExtras } from "./notification-cards";
26
- import { FEED_SURFACE_PROPS, FeedCard, PILL, ReferenceFeedSkeleton } from "./reference-feed";
22
+ import { FIXTURE_DRAFTS, FIXTURE_EMAIL_DETAILS, FIXTURE_HELD_EMAILS, FIXTURE_REVIEW_REQUESTS } from "../lib/review-fixture";
23
+ import { NeedsMeView, type NeedsMeDecision } from "./needs-me-feed";
24
+ import type { NeedsMeEntry } from "../lib/needs-me-items";
25
+ import type { CorrectCaptureAction } from "./reference-capture-client";
27
26
  import type { ReviewChoice } from "./reference-review-card";
28
27
 
29
28
  export type NotificationLoadState = "loading" | "ready" | "forbidden" | "unavailable";
30
29
  export type DraftsLoadState = "loading" | "ready" | "failed";
31
- export type DraftExtras = { why: string; notThisKind: boolean };
32
30
 
33
31
  /** 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][] };
32
+ export type NotificationFixture = { requests: ReviewRequest[]; drafts: Parameters<typeof draftView>[0][]; emails?: MonitoringEmailSummary[] };
35
33
 
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
34
  const NO_VISIT: ReadonlySet<string> = new Set();
42
-
43
- type ViewProps = {
44
- loadState: NotificationLoadState;
45
- requests: ReviewRequest[];
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>;
57
- storyTitles?: Readonly<Record<string, string>>;
58
- deletedStories?: ReadonlySet<string>;
59
- demo?: boolean;
60
- /** True while the cards are a saved copy that is being refreshed. */
61
- stale?: boolean;
62
- busyId?: string | null;
63
- error?: string | null;
64
- /** The AI drafts that draft reviews are about, by proposal id, and which ids have no proposal on this site. */
65
- drafts?: Readonly<Record<string, DraftView>>;
66
- missingDrafts?: ReadonlySet<string>;
67
- draftsState?: DraftsLoadState;
68
- /** Keyed `<request id>#<draft id>`. */
69
- draftErrors?: Readonly<Record<string, string>>;
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;
74
- onAnswer?: (request: ReviewRequest, input: AnswerInput) => void;
75
- onDecideDraft?: (request: ReviewRequest, draftId: string, choice: DraftChoice, extras: DraftExtras) => void;
76
- onRetryDrafts?: () => void;
77
- onMarkRead?: (requestId: string) => void;
78
- onOpenReference?: (lineageId: string) => void;
79
- onRateReference?: (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) => Promise<void> | void;
80
- onVisitRated?: (lineageId: string) => void;
81
- };
82
-
83
- function Section({ children, count, id, subtitle, title }: { children: ReactNode; count?: number; id: string; subtitle?: ReactNode; title: string }) {
84
- return (
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>
92
- );
93
- }
94
-
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>;
97
- }
98
-
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;
109
-
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],
116
- );
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 });
142
-
143
- return (
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.
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}
177
- </header>
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>
184
- </div>
185
- ) : null}
186
-
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">
189
- {draftsState === "failed" ? (
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>
197
- ) : null}
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>
226
- ) : null}
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>
237
- ) : null}
238
-
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}
249
-
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}
253
-
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}
275
-
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}
281
- </div>
282
- );
283
- }
35
+ const NO_PREVIEWS: ReadonlyMap<string, ReferencePreview> = new Map();
36
+ const NO_ATTACHMENTS: ReferenceAttachmentRecord[] = [];
284
37
 
285
38
  /** Everything the container reads and writes. The live one talks to the site's data API; tests pass a fake. */
286
39
  export type NotificationApi = {
287
40
  loadReviews: () => Promise<ReviewLoad>;
288
41
  loadDrafts: (ids: string[]) => Promise<DraftLoad>;
289
- loadStoryTitles: (ids: string[]) => Promise<{ titles: Record<string, string>; deleted: string[] }>;
42
+ loadStoryTitles: (ids: string[]) => Promise<{ titles: Record<string, string>; deleted: string[]; excerpts?: Record<string, string> }>;
290
43
  answerReviewRequest: (request: ReviewRequest, input: AnswerInput, by: string) => Promise<ReviewRequest>;
291
44
  saveDraftDecision: (request: ReviewRequest, draftId: string, decision: DraftDecision) => Promise<ReviewRequest>;
292
45
  markReviewRead: (userKey: string, requestId: string) => Promise<void>;
293
46
  transitionProposalPath: (proposalId: string, path: readonly string[]) => Promise<ProposalReceipt>;
47
+ /** Email submissions held for review (Monitoring's own read). Optional: a site without monitoring has none. */
48
+ loadHeldEmails?: () => Promise<MonitoringEmailSummary[]>;
49
+ loadEmail?: (receiptId: string) => Promise<MonitoringEmailDetails>;
50
+ decideEmail?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
294
51
  };
295
52
 
296
53
  const liveApi: NotificationApi = {
@@ -301,6 +58,20 @@ const liveApi: NotificationApi = {
301
58
  saveDraftDecision: async (request, draftId, decision) => (await import("../lib/review-client")).saveDraftDecision(request, draftId, decision),
302
59
  markReviewRead: async (userKey, requestId) => (await import("../lib/review-client")).markReviewRead(userKey, requestId),
303
60
  transitionProposalPath: async (proposalId, path) => (await import("../lib/proposals-client")).transitionProposalPath(proposalId, path),
61
+ loadHeldEmails: async () => {
62
+ const { loadMonitoringPage } = await import("../lib/monitoring-client");
63
+ const rows: MonitoringEmailSummary[] = [];
64
+ let cursor: string | null = null;
65
+ do {
66
+ const page = await loadMonitoringPage("emailSubmissions", cursor, "publication", true);
67
+ if (!("rows" in page)) return rows;
68
+ rows.push(...(page.rows as MonitoringEmailSummary[]));
69
+ cursor = page.cursor;
70
+ } while (cursor);
71
+ return rows;
72
+ },
73
+ loadEmail: async (receiptId) => (await import("../lib/monitoring-client")).loadMonitoringEmailSubmission(receiptId),
74
+ decideEmail: async (receiptId, decision, reason) => (await import("../lib/monitoring-client")).decideMonitoringEmailSubmission(receiptId, decision, reason),
304
75
  };
305
76
 
306
77
  /** The demo and the real-data preview answer in memory with the same rules as the live data API; nothing leaves the page. */
@@ -318,6 +89,9 @@ const demoApi: NotificationApi = {
318
89
  },
319
90
  markReviewRead: async () => undefined,
320
91
  transitionProposalPath: async () => ({ status: "ok" }),
92
+ loadHeldEmails: async () => FIXTURE_HELD_EMAILS,
93
+ loadEmail: async (receiptId) => FIXTURE_EMAIL_DETAILS[receiptId] ?? { state: "unavailable" },
94
+ decideEmail: async (_receiptId, decision) => ({ ok: true, already: false, quarantine: { state: decision === "accept" ? "released" : "discarded", decision: null, failure: null } }),
321
95
  };
322
96
 
323
97
  type ContainerProps = {
@@ -328,8 +102,11 @@ type ContainerProps = {
328
102
  referencesLoading?: boolean;
329
103
  predictionLoading?: boolean;
330
104
  referenceDisabledReason?: string | null;
331
- onOpenReference?: (lineageId: string) => void;
332
- onRateReference?: (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) => Promise<void> | void;
105
+ onRateReference?: (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null, note?: string) => Promise<void> | void;
106
+ /** The Reference attachments (previews, captured text), for the cards' pictures and the capture checks. */
107
+ referenceAttachments?: ReferenceAttachmentRecord[];
108
+ /** Saves a pasted or corrected capture. Without it the capture panel has no edit action. */
109
+ onCorrectCapture?: CorrectCaptureAction;
333
110
  actorLabel: string;
334
111
  demo: boolean;
335
112
  /** A real-data preview (see scripts/export-notification-center-fixture.py); the invented sample otherwise. */
@@ -339,30 +116,40 @@ type ContainerProps = {
339
116
 
340
117
  const REFRESH_MILLISECONDS = 60_000;
341
118
 
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 {
119
+ /** Preview only: ?ncState=loading holds the skeleton, caught-up shows the empty state, sample swaps in the invented hard gate, flags, batches and held email, mixed adds the invented hard gate, flag and held email to the real data. */
120
+ function previewState(): "loading" | "caught-up" | "sample" | "mixed" | null {
344
121
  if (typeof window === "undefined" || !isLocalPreviewEnabled()) return null;
345
122
  const requested = new URLSearchParams(window.location.search).get("ncState");
346
- return requested === "loading" || requested === "caught-up" || requested === "sample" ? requested : null;
123
+ return requested === "loading" || requested === "caught-up" || requested === "sample" || requested === "mixed" ? requested : null;
347
124
  }
348
125
 
126
+ const EMAIL_REFUSALS: Record<string, string> = {
127
+ forbidden: "Your session cannot decide held email.",
128
+ bad_request: "That email could not be decided from here.",
129
+ already_decided: "Someone already decided this email. Refresh to see what they chose.",
130
+ not_quarantined: "This email is no longer held.",
131
+ expired: "This email expired and can no longer be accepted.",
132
+ };
133
+
349
134
  export function NotificationCenter({
350
- references, decisions, humanCuratedReferenceLineageIds = NO_VISIT, previews, referencesLoading, predictionLoading, referenceDisabledReason, onOpenReference, onRateReference, actorLabel, demo, fixture = null, api: liveOverride = liveApi,
135
+ references, decisions, humanCuratedReferenceLineageIds = NO_VISIT, previews = NO_PREVIEWS, referencesLoading = false, predictionLoading = false, referenceDisabledReason = null,
136
+ onRateReference, referenceAttachments = NO_ATTACHMENTS, onCorrectCapture, actorLabel, demo, fixture = null, api: liveOverride = liveApi,
351
137
  }: ContainerProps) {
352
138
  const api = demo ? demoApi : liveOverride;
353
139
  const [loadState, setLoadState] = useState<NotificationLoadState>("loading");
354
140
  const [stale, setStale] = useState(false);
355
141
  const [requests, setRequests] = useState<ReviewRequest[]>([]);
356
142
  const [userKey, setUserKey] = useState<string | null>(null);
357
- const [busyId, setBusyId] = useState<string | null>(null);
358
143
  const [error, setError] = useState<string | null>(null);
359
144
  const [storyTitles, setStoryTitles] = useState<Record<string, string>>({});
145
+ const [storyExcerpts, setStoryExcerpts] = useState<Record<string, string>>({});
360
146
  const [deletedStories, setDeletedStories] = useState<ReadonlySet<string>>(new Set());
361
147
  const [drafts, setDrafts] = useState<Record<string, DraftView>>({});
362
148
  const [missingDrafts, setMissingDrafts] = useState<ReadonlySet<string>>(new Set());
363
149
  const [draftsState, setDraftsState] = useState<DraftsLoadState>("loading");
364
- const [draftErrors, setDraftErrors] = useState<Record<string, string>>({});
365
- const [draftBusyId, setDraftBusyId] = useState<string | null>(null);
150
+ const [emails, setEmails] = useState<MonitoringEmailSummary[]>([]);
151
+ const [emailsState, setEmailsState] = useState<"loading" | "ready">("loading");
152
+ const [emailsDecided, setEmailsDecided] = useState<ReadonlyMap<string, "accept" | "reject">>(new Map());
366
153
  const [stay, setStay] = useState<ReadonlySet<string>>(new Set());
367
154
  const [visitRated, setVisitRated] = useState<ReadonlySet<string>>(new Set());
368
155
  const [now] = useState(() => Date.now());
@@ -371,6 +158,10 @@ export function NotificationCenter({
371
158
  const preview = useMemo(() => previewState(), []);
372
159
  const sample = useMemo(() => {
373
160
  if (preview === "sample" || !fixture) return { requests: FIXTURE_REVIEW_REQUESTS, drafts: FIXTURE_DRAFTS };
161
+ if (preview === "mixed") {
162
+ const invented = FIXTURE_REVIEW_REQUESTS.filter((request) => request.id === "rr-hard-1" || request.id === "rr-flag-1");
163
+ return { requests: [...invented, ...fixture.requests], drafts: fixture.drafts };
164
+ }
374
165
  return { requests: preview === "caught-up" ? [] : fixture.requests, drafts: fixture.drafts };
375
166
  }, [fixture, preview]);
376
167
 
@@ -399,6 +190,19 @@ export function NotificationCenter({
399
190
  }
400
191
  }, [api, demo, sample]);
401
192
 
193
+ // Email held for review is read on its own: a site without monitoring, or an editor who may not read it, simply has none.
194
+ const loadEmailsNow = useCallback(async () => {
195
+ if (preview === "caught-up") { setEmails([]); setEmailsState("ready"); return; }
196
+ // The real-data preview has no held email of its own; only the sample and mixed previews show the invented one.
197
+ if (demo && fixture && preview !== "sample" && preview !== "mixed") { setEmails(fixture.emails ?? []); setEmailsState("ready"); return; }
198
+ try {
199
+ setEmails((await api.loadHeldEmails?.()) ?? []);
200
+ } catch {
201
+ // Held email is one source among several; the others still show.
202
+ }
203
+ setEmailsState("ready");
204
+ }, [api, demo, fixture, preview]);
205
+
402
206
  // A return visit paints the last view at once, marked as such, while the real reads run.
403
207
  useEffect(() => {
404
208
  if (demo) return;
@@ -420,6 +224,7 @@ export function NotificationCenter({
420
224
  setRequests(sample.requests);
421
225
  setLoadState("ready");
422
226
  void loadDraftsFor(sample.requests);
227
+ void loadEmailsNow();
423
228
  return;
424
229
  }
425
230
  try {
@@ -434,14 +239,16 @@ export function NotificationCenter({
434
239
  setStale(false);
435
240
  setLoadState("ready");
436
241
  void loadDraftsFor(loaded.requests);
437
- const stories = await api.loadStoryTitles(loaded.requests.filter((request) => request.subject.type === "story").map((request) => request.subject.id)).catch(() => ({ titles: {}, deleted: [] as string[] }));
242
+ void loadEmailsNow();
243
+ const stories = await api.loadStoryTitles(loaded.requests.filter((request) => request.subject.type === "story").map((request) => request.subject.id)).catch(() => ({ titles: {}, deleted: [] as string[], excerpts: {} }));
438
244
  setStoryTitles(stories.titles);
245
+ setStoryExcerpts(stories.excerpts ?? {});
439
246
  setDeletedStories(new Set(stories.deleted));
440
247
  } catch {
441
248
  setStale(false);
442
249
  setLoadState("unavailable");
443
250
  }
444
- }, [api, demo, loadDraftsFor, sample]);
251
+ }, [api, demo, loadDraftsFor, loadEmailsNow, sample]);
445
252
 
446
253
  useEffect(() => {
447
254
  void refresh();
@@ -461,26 +268,18 @@ export function NotificationCenter({
461
268
  await api.markReviewRead(userKey, requestId).catch(() => undefined);
462
269
  }, [api, demo, userKey]);
463
270
 
271
+ /** Answers a whole request. Throws a sentence for the card when it cannot be saved. */
464
272
  const answer = useCallback(async (request: ReviewRequest, input: AnswerInput) => {
465
273
  setError(null);
466
- setBusyId(request.id);
467
- try {
468
- const saved = await api.answerReviewRequest(request, input, actorLabel);
469
- keep(request.id);
470
- setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
471
- await markRead(request.id);
472
- } catch (failure) {
473
- setError(failure instanceof Error ? failure.message : "Could not save the answer.");
474
- } finally {
475
- setBusyId(null);
476
- }
274
+ const saved = await api.answerReviewRequest(request, input, actorLabel);
275
+ keep(request.id);
276
+ setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
277
+ await markRead(request.id);
477
278
  }, [actorLabel, api, keep, markRead]);
478
279
 
479
280
  /** 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. */
480
- const decideDraft = useCallback(async (request: ReviewRequest, draftId: string, choice: DraftChoice, extras: DraftExtras) => {
481
- const key = `${request.id}#${draftId}`;
482
- setDraftErrors(({ [key]: _gone, ...rest }) => rest);
483
- setDraftBusyId(key);
281
+ const decideDraft = useCallback(async (request: ReviewRequest, draftId: string, choice: DraftChoice, extras: { why: string; notThisKind: boolean }) => {
282
+ setError(null);
484
283
  resolving.current.add(request.id);
485
284
  try {
486
285
  const view = drafts[draftId];
@@ -492,7 +291,7 @@ export function NotificationCenter({
492
291
  const decided: DraftView = { ...view, state: choice === "accept" ? "approved" : "declined", decided: choice };
493
292
  const nextDrafts = { ...drafts, [draftId]: decided };
494
293
  setDrafts(nextDrafts);
495
- keep(key);
294
+ keep(`${request.id}#${draftId}`);
496
295
  const decision: DraftDecision = { choice, title: view.headline, why: extras.why || null, notThisKind: choice === "reject" && extras.notThisKind, by: actorLabel, at: new Date().toISOString() };
497
296
  const withDecisionRequest = { ...request, links: withDecision(request, draftId, decision) };
498
297
  const closing = batchAnswer(withDecisionRequest, nextDrafts, { by: actorLabel, at: decision.at });
@@ -504,11 +303,10 @@ export function NotificationCenter({
504
303
  throw new Error(`The draft was ${choice === "accept" ? "accepted" : "rejected"} in Proposals, but this request could not record it. ${failure instanceof Error ? failure.message : ""}`.trim());
505
304
  }
506
305
  } catch (failure) {
507
- setDraftErrors((current) => ({ ...current, [key]: failure instanceof Error ? failure.message : "Could not save the decision." }));
508
306
  if (!demo) void loadDraftsFor([request]);
307
+ throw failure;
509
308
  } finally {
510
309
  resolving.current.delete(request.id);
511
- setDraftBusyId(null);
512
310
  }
513
311
  }, [actorLabel, api, demo, drafts, keep, loadDraftsFor, markRead]);
514
312
 
@@ -534,18 +332,61 @@ export function NotificationCenter({
534
332
  }
535
333
  }, [api, demo, drafts, draftsState, requests, stale]);
536
334
 
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]);
335
+ /** One decision, whatever the type: it goes to the action that already owns that kind of thing. */
336
+ const decide = useCallback(async (item: NeedsMeEntry, decision: NeedsMeDecision) => {
337
+ const { payload } = item;
338
+ if (payload.kind === "reference") {
339
+ setVisitRated((current) => (current.has(payload.lineageId) ? current : new Set(current).add(payload.lineageId)));
340
+ await onRateReference?.(payload.reference, decision.choice, decision.choice === "reject" ? decision.reasonCode : null, decision.why || undefined);
341
+ } else if (payload.kind === "draft") {
342
+ await decideDraft(payload.item.request, payload.item.draftId, decision.choice, { why: decision.why, notThisKind: decision.notThisKind });
343
+ } else if (payload.kind === "request") {
344
+ const { request } = payload;
345
+ if (request.mode === "flag") await answer(request, { decision: "acknowledge", feedback: decision.why || null });
346
+ else if (decision.choice === "accept") await answer(request, { decision: "approve", feedback: decision.why || null });
347
+ else await answer(request, { decision: "reject", feedback: decision.why || null, steer: decision.notThisKind ? "not_this_kind" : null });
348
+ } else if (payload.kind === "email") {
349
+ const result = await api.decideEmail?.(payload.summary.receiptId, decision.choice, decision.why);
350
+ if (!result) throw new Error("Held email cannot be decided from here.");
351
+ if (!("ok" in result)) throw new Error("error" in result ? EMAIL_REFUSALS[result.error] ?? "The decision could not be saved." : "The decision could not be saved right now. Try again in a minute.");
352
+ setEmailsDecided((current) => new Map(current).set(payload.summary.receiptId, decision.choice));
353
+ }
354
+ }, [answer, api, decideDraft, onRateReference]);
355
+
539
356
  const shownReferences = preview === "caught-up" ? references.filter((reference) => normalizeReferenceCurationStatus(reference.curationStatus) !== "pending") : references;
357
+ const bySource = useMemo(() => previewsBySourceAddress(shownReferences, previews), [shownReferences, previews]);
358
+ const lineageIds = useMemo(() => shownReferences.map(referenceLineageId), [shownReferences]);
359
+ const { statuses: captures, teaserLine } = useMemo(() => captureStatusesByLineage(referenceAttachments, lineageIds), [lineageIds, referenceAttachments]);
360
+
361
+ const entries = useMemo(
362
+ () => buildNeedsMeItems({
363
+ references: shownReferences, decisions, previews, captures, visitRated, requests, drafts, missingDrafts, draftsState, stay, bySource,
364
+ storyTitles, storyExcerpts, deletedStories, emails, emailsDecided,
365
+ }),
366
+ [shownReferences, decisions, previews, captures, visitRated, requests, drafts, missingDrafts, draftsState, stay, bySource, storyTitles, storyExcerpts, deletedStories, emails, emailsDecided],
367
+ );
368
+ const freshness = useMemo(() => learnFreshness(shownReferences, visitRated), [shownReferences, visitRated]);
369
+ const threshold = useMemo(() => learnConfidenceThreshold(shownReferences, decisions, visitRated), [shownReferences, decisions, visitRated]);
370
+ const recent = useMemo(() => recentDecisions(requests, references, demo), [requests, references, demo]);
371
+ const shaping = useMemo(
372
+ () => shapingThisWeek({ decidedAt: recentDecisions(requests, references, demo, Number.MAX_SAFE_INTEGER).map((entry) => entry.at), references, decisions, now }),
373
+ [requests, references, demo, decisions, now],
374
+ );
375
+ const shownIds = useMemo(() => new Set(entries.map((item) => item.id)), [entries]);
376
+ const legacyAuto = useMemo(
377
+ () => cyclotronAutoDecided(shownReferences, decisions, humanCuratedReferenceLineageIds, demo).filter((entry) => !shownIds.has(entry.lineageId)),
378
+ [shownReferences, decisions, humanCuratedReferenceLineageIds, demo, shownIds],
379
+ );
380
+ const openDraftRequests = useMemo(() => sectionReviews(requests).draftBatches, [requests]);
381
+ const missingCount = openDraftRequests.reduce((total, request) => total + draftIdsOf(request).filter((id) => missingDrafts.has(id)).length, 0);
382
+ const settled = !stale && loadState === "ready" && draftsState !== "loading" && emailsState === "ready" && !referencesLoading && !predictionLoading;
383
+
540
384
  return (
541
- <NotificationCenterView
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}
385
+ <NeedsMeView
386
+ captureTeaserLine={teaserLine} demo={demo} draftsFailed={draftsState === "failed"} entries={entries} error={error} freshness={freshness} legacyAuto={legacyAuto}
387
+ loadEmail={api.loadEmail} loadState={preview === "loading" ? "loading" : loadState} missingDraftCount={missingCount} now={now} onCorrectCapture={onCorrectCapture} onDecide={decide}
388
+ onRetryDrafts={() => void loadDraftsFor(requests)} predictionLoading={predictionLoading} recent={recent} referenceAttachments={referenceAttachments}
389
+ referenceDisabledReason={referenceDisabledReason} referencesLoading={referencesLoading} settled={settled} shaping={shaping} stale={stale} threshold={threshold}
549
390
  />
550
391
  );
551
392
  }
@@ -113,9 +113,9 @@ function CorrectionForm({ capture, onDone }: { capture: CaptureView; onDone: ()
113
113
  }
114
114
 
115
115
  /** Our captured record of a Reference: what we stored of its content, said plainly, above the live source preview. */
116
- export function ReferenceCapturePanel({ capture }: { capture: CaptureView }) {
116
+ export function ReferenceCapturePanel({ capture, startEditing = false }: { capture: CaptureView; startEditing?: boolean }) {
117
117
  const [expanded, setExpanded] = useState(false);
118
- const [editing, setEditing] = useState(false);
118
+ const [editing, setEditing] = useState(startEditing && Boolean(capture.correct));
119
119
  const [savedNote, setSavedNote] = useState(false);
120
120
  const line = captureLine(capture.status);
121
121
  const Icon = ICON[line.state];
@@ -35,7 +35,7 @@ export const FEED_SURFACE_STYLE = {} as CSSProperties;
35
35
  /** Spread on every feed surface: the tone variables, and the hook the newsroom stylesheet uses to allow round corners here. */
36
36
  export const FEED_SURFACE_PROPS = { "data-feed-surface": "", style: FEED_SURFACE_STYLE } as const;
37
37
 
38
- const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
38
+ export const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
39
39
  out_of_scope: "Out of scope",
40
40
  policy_exclusion: "Policy exclusion",
41
41
  duplicate: "Duplicate",
@@ -293,7 +293,7 @@ export function FeedCard({
293
293
  </span>
294
294
  </button>
295
295
 
296
- <div className="relative bg-[var(--feed-footer)] p-3" data-feed-actions>
296
+ <div className="relative bg-[var(--feed-footer)] px-4 pb-4 pt-3" data-feed-actions>
297
297
  <RatingGrid
298
298
  answer={chosen}
299
299
  decided={answer}
@@ -303,6 +303,7 @@ export function FeedCard({
303
303
  note={reasonLabel ? `Reason: ${reasonLabel}` : null}
304
304
  onChoose={(choice) => { if (!disabledReason) store.choose(item.lineageId, choice); }}
305
305
  prediction={decision ? predictionFromDecision(decision) : predictionLoading ? { state: "loading" } : { state: "none" }}
306
+ statusOverlay
306
307
  />
307
308
  {rating.askReason ? (
308
309
  // Covers the footer rather than adding to it, so asking for a reason never pushes the cards below it down.