@anthusai/papyrus 5.43.0-next.2 → 5.43.0-next.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/amplify/data/resource.js +6 -0
  2. package/amplify/data/resource.ts +7 -0
  3. package/amplify/data/schema.js +6 -0
  4. package/amplify/data/schema.ts +7 -0
  5. package/amplify/functions/category-action/handler.ts +2 -1
  6. package/amplify/functions/content-actions/handler.py +8 -0
  7. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  8. package/amplify/functions/monitoring-view/schema.js +16 -0
  9. package/amplify/functions/monitoring-view/schema.ts +16 -0
  10. package/amplify/functions/procedure-action/handler.ts +2 -10
  11. package/amplify/functions/proposal-actions/proposals.py +20 -3
  12. package/amplify/plugins/reader-steering/function/handler.py +1 -3
  13. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  14. package/bin/papyrus-app.mjs +12 -1
  15. package/bin/papyrus-infra.mjs +21 -1
  16. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  17. package/components/monitoring-overview-view.tsx +143 -21
  18. package/components/monitoring-panel.tsx +64 -4
  19. package/components/needs-me-bodies.tsx +290 -0
  20. package/components/needs-me-detail.tsx +23 -0
  21. package/components/needs-me-feed.tsx +561 -0
  22. package/components/needs-me-reference-detail.tsx +77 -0
  23. package/components/needs-me-shared.tsx +39 -0
  24. package/components/news-desk-client-provider.tsx +24 -3
  25. package/components/news-desk-page.tsx +2 -1
  26. package/components/news-desk-taxonomy-client.ts +2 -1
  27. package/components/newsroom-access-gate.tsx +7 -5
  28. package/components/newsroom-client-shell.tsx +2 -1
  29. package/components/newsroom-ops-overview.tsx +64 -55
  30. package/components/newsroom-ops-shell.tsx +4 -2
  31. package/components/newsroom-organizer.css +60 -2
  32. package/components/newsroom-references-view.tsx +301 -138
  33. package/components/notification-cards.tsx +369 -0
  34. package/components/notification-center.tsx +324 -213
  35. package/components/papyrus-console-shell.tsx +2 -1
  36. package/components/proposals-panel.tsx +21 -24
  37. package/components/proposals-view.tsx +4 -2
  38. package/components/reference-capture-client.ts +110 -0
  39. package/components/reference-capture-panel.tsx +180 -0
  40. package/components/reference-feed.tsx +503 -0
  41. package/components/reference-review-card.tsx +110 -141
  42. package/components/references-page-loading.tsx +61 -0
  43. package/components/references-prefetch.ts +52 -0
  44. package/components/review-rating-grid.tsx +180 -0
  45. package/components/topic-steering-workspace.tsx +44 -9
  46. package/components/use-media-query.ts +16 -0
  47. package/components/use-reference-preview-attachments.ts +101 -0
  48. package/components/use-reference-ratings.ts +134 -0
  49. package/infra/amplify-app-shell.js +18 -2
  50. package/infra/amplify-app-shell.ts +18 -2
  51. package/infra/chat-stack.js +222 -0
  52. package/infra/chat-stack.ts +250 -0
  53. package/infra/chat.test.js +142 -0
  54. package/infra/chat.test.ts +151 -0
  55. package/infra/index.js +1 -0
  56. package/infra/site-config.js +58 -1
  57. package/infra/site-config.ts +78 -1
  58. package/lib/category-repository.ts +6 -1
  59. package/lib/content-actions-client.ts +18 -0
  60. package/lib/local-preview-feed-fixture.ts +63 -0
  61. package/lib/local-preview-synthetic-rejects.ts +41 -0
  62. package/lib/monitoring-client.ts +29 -2
  63. package/lib/monitoring-overview.ts +52 -1
  64. package/lib/monitoring-paging.ts +58 -0
  65. package/lib/needs-me-items.ts +289 -0
  66. package/lib/needs-me-ranking.ts +315 -0
  67. package/lib/newsroom-chat-identity.ts +2 -1
  68. package/lib/newsroom-nav.ts +1 -1
  69. package/lib/newsroom-organizer-nav.ts +2 -1
  70. package/lib/notification-feed.ts +212 -0
  71. package/lib/proposals-client.ts +29 -0
  72. package/lib/reader-subject.ts +3 -2
  73. package/lib/reference-capture.ts +293 -0
  74. package/lib/reference-feed-cache.ts +170 -0
  75. package/lib/reference-feed-ranking.ts +235 -0
  76. package/lib/reference-preview.ts +112 -0
  77. package/lib/review-client.ts +55 -5
  78. package/lib/review-drafts.ts +252 -0
  79. package/lib/review-fixture.ts +56 -2
  80. package/lib/review-requests.ts +64 -24
  81. package/lib/site-brand.ts +2 -0
  82. package/lib/staff-groups.ts +23 -0
  83. package/lib/staging-gate.ts +2 -2
  84. package/package.json +29 -1
  85. package/routes.manifest.json +1 -1
@@ -1,240 +1,254 @@
1
1
  "use client";
2
2
 
3
- import Link from "next/link";
4
- import { useCallback, useEffect, useMemo, useState } from "react";
5
- import { Badge } from "./ui/badge";
6
- import { Button } from "./ui/button";
7
- import { getNewsroomNavHref } from "../lib/newsroom-nav";
8
- import {
9
- cyclotronAutoDecided, cyclotronHeldForReview, formatWhen, isUnread, modeLabel, modeMark, nextText, sectionReviews,
10
- steeringText, subjectHref, subjectLabel, summarize, summaryText,
11
- type AnswerInput, type CyclotronEntry, type ReviewRequest,
12
- } from "../lib/review-requests";
13
- import type { ReferenceRecord } from "../lib/category-repository";
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";
6
+ import { proposalReceiptMessage, stateLabel, type ProposalReceipt } from "../lib/proposals-admin";
7
+ import type { DraftLoad, ReviewLoad } from "../lib/review-client";
8
+ import { acceptPath, batchAnswer, draftView, rejectPath, undecidedDraftIds, withDecision, draftDecisionUpdate, type DraftChoice, type DraftView } from "../lib/review-drafts";
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";
14
11
  import type { RelevanceDecisionView } from "../lib/relevance-decisions";
15
- import { FIXTURE_REVIEW_REQUESTS } from "../lib/review-fixture";
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";
16
+ import { normalizeReferenceCurationStatus, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
17
+ import type { ReferencePreview } from "../lib/reference-preview";
18
+ import { referenceLineageId } from "../lib/newsroom-references";
19
+ import { loadNotificationSnapshot, saveNotificationSnapshot } from "../lib/reference-feed-cache";
20
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
21
+ import { SITE_BRAND } from "../lib/site-brand";
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";
26
+ import type { ReviewChoice } from "./reference-review-card";
16
27
 
17
28
  export type NotificationLoadState = "loading" | "ready" | "forbidden" | "unavailable";
29
+ export type DraftsLoadState = "loading" | "ready" | "failed";
18
30
 
19
- type ViewProps = {
20
- loadState: NotificationLoadState;
21
- requests: ReviewRequest[];
22
- readIds: ReadonlySet<string>;
23
- held: CyclotronEntry[];
24
- autoDecided: CyclotronEntry[];
25
- storyTitles?: Readonly<Record<string, string>>;
26
- deletedStories?: ReadonlySet<string>;
27
- demo?: boolean;
28
- busyId?: string | null;
29
- error?: string | null;
30
- onAnswer?: (request: ReviewRequest, input: AnswerInput) => void;
31
- onMarkRead?: (requestId: string) => void;
32
- onMarkAllRead?: () => void;
33
- };
31
+ /** What a real-data preview shows instead of the invented sample (scripts/export-notification-center-fixture.py). */
32
+ export type NotificationFixture = { requests: ReviewRequest[]; drafts: Parameters<typeof draftView>[0][]; emails?: MonitoringEmailSummary[] };
34
33
 
35
- function Entry({ request, unread, demo, busy, titles, deleted, onAnswer, onMarkRead }: {
36
- request: ReviewRequest; unread: boolean; demo: boolean; busy: boolean; titles: Readonly<Record<string, string>>; deleted: ReadonlySet<string>;
37
- onAnswer?: ViewProps["onAnswer"]; onMarkRead?: ViewProps["onMarkRead"];
38
- }) {
39
- const [note, setNote] = useState("");
40
- const href = subjectHref(request.subject, demo);
41
- const open = request.state === "open";
42
- const steering = steeringText(request);
43
- const act = (input: AnswerInput) => onAnswer?.(request, { ...input, feedback: input.feedback ?? (note.trim() || null) });
44
- return (
45
- <li className="rounded-lg border p-3 space-y-2" data-review-request={request.id} data-review-mode={request.mode} data-review-state={request.state} data-review-unread={unread || undefined}>
46
- <div className="flex flex-wrap items-center gap-2">
47
- <Badge variant="outline"><span aria-hidden="true">{modeMark(request.mode)} </span>{modeLabel(request.mode)}</Badge>
48
- {unread ? <Badge variant="default"><span aria-hidden="true">● </span>New</Badge> : <span className="text-xs text-muted-foreground">Read</span>}
49
- <strong className="text-sm">{request.reason}</strong>
50
- </div>
51
- <p className="text-xs text-muted-foreground">
52
- Raised by {request.raisedBy} on {formatWhen(request.raisedAt)}
53
- {" · "}About: {href ? <Link href={href} onClick={() => onMarkRead?.(request.id)}>{subjectLabel(request.subject, titles, deleted)}</Link> : <span>{subjectLabel(request.subject, titles, deleted)}</span>}
54
- </p>
55
- {request.ask ? <p className="text-sm">{request.ask}</p> : null}
56
- {request.links.length ? (
57
- <ul className="text-xs space-y-0.5" aria-label="Items in this request">
58
- {request.links.slice(0, 10).map((link, index) => {
59
- const target = link.url ?? (link.type ? subjectHref({ type: link.type, id: link.id ?? "" }, demo) : null);
60
- return <li key={`${link.id ?? link.url}-${index}`}>{target ? <Link href={target}>{link.label ?? link.id ?? link.url}</Link> : (link.label ?? link.id)}</li>;
61
- })}
62
- {request.links.length > 10 ? <li>and {request.links.length - 10} more</li> : null}
63
- </ul>
64
- ) : null}
65
- <p className="text-sm" data-review-next>{nextText(request)}</p>
66
- {steering ? <p className="text-sm" data-review-steering>{steering}</p> : null}
67
- {open ? (
68
- <div className="space-y-2" data-review-controls>
69
- <label className="block text-xs text-muted-foreground">
70
- Steering note (optional)
71
- <textarea
72
- className="mt-1 block w-full rounded-md border bg-background p-2 text-sm"
73
- disabled={busy}
74
- onChange={(event) => setNote(event.target.value)}
75
- placeholder={request.mode === "flag" ? "Tell the step what to do differently" : "What should change, or what did you want instead?"}
76
- rows={2}
77
- value={note}
78
- />
79
- </label>
80
- <div className="flex flex-wrap gap-2">
81
- {request.mode === "flag" ? (
82
- <Button disabled={busy} onClick={() => act({ decision: "acknowledge" })} size="sm" variant="outline">Acknowledge</Button>
83
- ) : (
84
- <>
85
- <Button disabled={busy} onClick={() => act({ decision: "approve" })} size="sm" variant="outline">Approve</Button>
86
- <Button disabled={busy} onClick={() => act({ decision: "reject" })} size="sm" variant="outline">Reject</Button>
87
- </>
88
- )}
89
- <Button disabled={busy || !note.trim()} onClick={() => act({ decision: "feedback", steer: "adjust" })} size="sm" variant="outline">Steer with this note</Button>
90
- <Button disabled={busy} onClick={() => act({ decision: "feedback", steer: "not_this_kind" })} size="sm" variant="outline">Not this kind of thing</Button>
91
- {unread ? <Button disabled={busy} onClick={() => onMarkRead?.(request.id)} size="sm" variant="ghost">Mark read</Button> : null}
92
- </div>
93
- </div>
94
- ) : unread ? (
95
- <Button disabled={busy} onClick={() => onMarkRead?.(request.id)} size="sm" variant="ghost">Mark read</Button>
96
- ) : null}
97
- </li>
98
- );
99
- }
100
-
101
- function CyclotronLine({ entry, held }: { entry: CyclotronEntry; held: boolean }) {
102
- const confidence = entry.confidence === null ? "confidence not recorded" : `confidence ${Math.round(entry.confidence * 100)}%`;
103
- return (
104
- <li className="rounded-lg border p-3 space-y-1" data-cyclotron-entry={entry.lineageId} data-cyclotron-held={held || undefined}>
105
- <div className="flex flex-wrap items-center gap-2">
106
- <Badge variant="outline"><span aria-hidden="true">{held ? "◔" : "✓"} </span>{held ? "Held for review" : "Decided by the system"}</Badge>
107
- <strong className="text-sm">{entry.title}</strong>
108
- </div>
109
- <p className="text-xs text-muted-foreground">
110
- Classified as {entry.label} · {confidence}{entry.decidedAt ? ` · ${formatWhen(entry.decidedAt)}` : ""}
111
- {entry.reviewedByPerson ? " · reviewed by a person" : ""}
112
- </p>
113
- {held ? <p className="text-sm">{entry.reviewDetail ? `${entry.reviewDetail}. ` : ""}Held for review: it stays pending until someone decides.</p> : null}
114
- <Link className="text-sm" href={entry.href}>{held ? "Review it in References" : "Open it in References to correct it"}</Link>
115
- </li>
116
- );
117
- }
34
+ const NO_VISIT: ReadonlySet<string> = new Set();
35
+ const NO_PREVIEWS: ReadonlyMap<string, ReferencePreview> = new Map();
36
+ const NO_ATTACHMENTS: ReferenceAttachmentRecord[] = [];
118
37
 
119
- function Section({ id, title, count, children, empty }: { id: string; title: string; count: number; children: React.ReactNode; empty: string }) {
120
- return (
121
- <section aria-labelledby={`notification-${id}`} className="space-y-2" data-notification-section={id}>
122
- <h2 className="text-sm font-medium" id={`notification-${id}`}>{title} ({count})</h2>
123
- {count ? children : <p className="text-sm text-muted-foreground">{empty}</p>}
124
- </section>
125
- );
126
- }
38
+ /** Everything the container reads and writes. The live one talks to the site's data API; tests pass a fake. */
39
+ export type NotificationApi = {
40
+ loadReviews: () => Promise<ReviewLoad>;
41
+ loadDrafts: (ids: string[]) => Promise<DraftLoad>;
42
+ loadStoryTitles: (ids: string[]) => Promise<{ titles: Record<string, string>; deleted: string[]; excerpts?: Record<string, string> }>;
43
+ answerReviewRequest: (request: ReviewRequest, input: AnswerInput, by: string) => Promise<ReviewRequest>;
44
+ saveDraftDecision: (request: ReviewRequest, draftId: string, decision: DraftDecision) => Promise<ReviewRequest>;
45
+ markReviewRead: (userKey: string, requestId: string) => Promise<void>;
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>;
51
+ };
127
52
 
128
- /** The CMS console home: what needs a person, what will proceed on its own, and what the system decided. Ignore-safe by design. */
129
- export function NotificationCenterView({ loadState, requests, readIds, held, autoDecided, demo = false, storyTitles = {}, deletedStories = new Set<string>(), busyId = null, error = null, onAnswer, onMarkRead, onMarkAllRead }: ViewProps) {
130
- const sections = useMemo(() => sectionReviews(requests), [requests]);
131
- const unreadCount = requests.filter((request) => isUnread(request, readIds)).length;
132
- const summary = summarize(sections, held, autoDecided, unreadCount);
133
- const entry = (request: ReviewRequest) => (
134
- <Entry busy={busyId === request.id} demo={demo} key={request.id} titles={storyTitles} deleted={deletedStories} onAnswer={onAnswer} onMarkRead={onMarkRead} request={request} unread={isUnread(request, readIds)} />
135
- );
136
- if (loadState === "loading") return (
137
- <section aria-label="Loading Notification Center" className="space-y-4" data-notification-state="loading" role="status">
138
- <div className="h-6 w-48 rounded bg-muted" />
139
- <div className="h-4 w-3/4 max-w-xl rounded bg-muted" />
140
- <div className="h-4 w-2/3 max-w-lg rounded bg-muted" />
141
- <div className="grid gap-3 sm:grid-cols-2">
142
- {Array.from({ length: 4 }, (_, index) => <div className="h-28 rounded-xl border bg-card p-4" key={index}>
143
- <div className="h-4 w-2/3 rounded bg-muted" /><div className="mt-4 h-3 w-1/2 rounded bg-muted" />
144
- </div>)}
145
- </div>
146
- </section>
147
- );
148
- if (loadState === "forbidden") return <div role="alert" data-notification-state="forbidden"><strong>The Notification Center is for editors and admins</strong></div>;
149
- 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>;
150
- return (
151
- <div className="space-y-6" data-notification-center>
152
- <header className="space-y-1">
153
- <h1 className="text-lg font-semibold">Notification Center</h1>
154
- <p className="text-sm" data-notification-summary>{summaryText(summary)}</p>
155
- <p className="text-xs text-muted-foreground">
156
- Look, or steer. Anything you ignore keeps going: a soft review with a deadline proceeds at it, one without stays open until handled, and flags never wait. Only a hard gate waits for you.
157
- </p>
158
- <p className="text-xs text-muted-foreground" data-notification-unread>
159
- {unreadCount ? `${unreadCount} unread` : "Nothing unread"}
160
- {unreadCount ? <Button className="ml-2" onClick={onMarkAllRead} size="sm" variant="ghost">Mark all read</Button> : null}
161
- </p>
162
- {error ? <p role="alert" className="text-sm" data-notification-error>{error}</p> : null}
163
- </header>
53
+ const liveApi: NotificationApi = {
54
+ loadReviews: async () => (await import("../lib/review-client")).loadReviews(),
55
+ loadDrafts: async (ids) => (await import("../lib/review-client")).loadDrafts(ids),
56
+ loadStoryTitles: async (ids) => (await import("../lib/review-client")).loadStoryTitles(ids),
57
+ answerReviewRequest: async (request, input, by) => (await import("../lib/review-client")).answerReviewRequest(request, input, by),
58
+ saveDraftDecision: async (request, draftId, decision) => (await import("../lib/review-client")).saveDraftDecision(request, draftId, decision),
59
+ markReviewRead: async (userKey, requestId) => (await import("../lib/review-client")).markReviewRead(userKey, requestId),
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),
75
+ };
164
76
 
165
- <Section count={sections.hardGates.length} empty="No hard gates. Nothing is blocked on you." id="hard-gates" title="Waiting for you: hard gates">
166
- <ul className="space-y-2">{sections.hardGates.map(entry)}</ul>
167
- </Section>
168
- <Section count={sections.softReviews.length} empty="No soft reviews are running." id="soft-reviews" title="Proceeding soon: soft reviews, soonest deadline first">
169
- <ul className="space-y-2">{sections.softReviews.map(entry)}</ul>
170
- </Section>
171
- <Section count={held.length} empty="No Cyclotron items are held for review." id="cyclotron" title="Cyclotron items routed to a person (low confidence)">
172
- <ul className="space-y-2">{held.slice(0, 10).map((item) => <CyclotronLine entry={item} held key={item.lineageId} />)}</ul>
173
- {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}
174
- </Section>
175
- <Section count={sections.flags.length} empty="No flags." id="flags" title="Flags: for your information">
176
- <ul className="space-y-2">{sections.flags.map(entry)}</ul>
177
- </Section>
178
- <Section count={sections.decided.length} empty="Nothing answered or proceeded yet." id="decided" title="Answered, and what became of it">
179
- <ul className="space-y-2">{sections.decided.slice(0, 20).map(entry)}</ul>
180
- </Section>
181
- <details data-notification-section="auto-decided">
182
- <summary className="cursor-pointer text-sm font-medium">Recently decided by the system ({autoDecided.length})</summary>
183
- <div className="mt-2 space-y-2">
184
- <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>
185
- {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>}
186
- </div>
187
- </details>
188
- </div>
189
- );
190
- }
77
+ /** The demo and the real-data preview answer in memory with the same rules as the live data API; nothing leaves the page. */
78
+ const demoApi: NotificationApi = {
79
+ loadReviews: async () => ({ state: "unavailable" }),
80
+ loadDrafts: async () => ({ drafts: {}, missing: [], failed: [] }),
81
+ loadStoryTitles: async () => ({ titles: {}, deleted: [] }),
82
+ answerReviewRequest: async (request, input, by) => {
83
+ const update = answerUpdate(request, input, by, new Date());
84
+ 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) };
85
+ },
86
+ saveDraftDecision: async (request, draftId, decision) => {
87
+ const update = draftDecisionUpdate(request, draftId, decision);
88
+ return { ...request, links: JSON.parse(String(update.links)), history: JSON.parse(String(update.history)), updatedAt: String(update.updatedAt) };
89
+ },
90
+ markReviewRead: async () => undefined,
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 } }),
95
+ };
191
96
 
192
97
  type ContainerProps = {
193
98
  references: ReferenceRecord[];
194
99
  decisions: ReadonlyMap<string, RelevanceDecisionView>;
195
- humanCuratedReferenceLineageIds: ReadonlySet<string>;
100
+ humanCuratedReferenceLineageIds?: ReadonlySet<string>;
101
+ previews?: ReadonlyMap<string, ReferencePreview>;
102
+ referencesLoading?: boolean;
103
+ predictionLoading?: boolean;
104
+ referenceDisabledReason?: string | null;
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;
196
110
  actorLabel: string;
197
111
  demo: boolean;
112
+ /** A real-data preview (see scripts/export-notification-center-fixture.py); the invented sample otherwise. */
113
+ fixture?: NotificationFixture | null;
114
+ api?: NotificationApi;
198
115
  };
199
116
 
200
117
  const REFRESH_MILLISECONDS = 60_000;
201
118
 
202
- export function NotificationCenter({ references, decisions, humanCuratedReferenceLineageIds, actorLabel, demo }: ContainerProps) {
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 {
121
+ if (typeof window === "undefined" || !isLocalPreviewEnabled()) return null;
122
+ const requested = new URLSearchParams(window.location.search).get("ncState");
123
+ return requested === "loading" || requested === "caught-up" || requested === "sample" || requested === "mixed" ? requested : null;
124
+ }
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
+
134
+ export function NotificationCenter({
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,
137
+ }: ContainerProps) {
138
+ const api = demo ? demoApi : liveOverride;
203
139
  const [loadState, setLoadState] = useState<NotificationLoadState>("loading");
140
+ const [stale, setStale] = useState(false);
204
141
  const [requests, setRequests] = useState<ReviewRequest[]>([]);
205
- const [readIds, setReadIds] = useState<Set<string>>(new Set());
206
142
  const [userKey, setUserKey] = useState<string | null>(null);
207
- const [busyId, setBusyId] = useState<string | null>(null);
208
143
  const [error, setError] = useState<string | null>(null);
209
144
  const [storyTitles, setStoryTitles] = useState<Record<string, string>>({});
145
+ const [storyExcerpts, setStoryExcerpts] = useState<Record<string, string>>({});
210
146
  const [deletedStories, setDeletedStories] = useState<ReadonlySet<string>>(new Set());
147
+ const [drafts, setDrafts] = useState<Record<string, DraftView>>({});
148
+ const [missingDrafts, setMissingDrafts] = useState<ReadonlySet<string>>(new Set());
149
+ const [draftsState, setDraftsState] = useState<DraftsLoadState>("loading");
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());
153
+ const [stay, setStay] = useState<ReadonlySet<string>>(new Set());
154
+ const [visitRated, setVisitRated] = useState<ReadonlySet<string>>(new Set());
155
+ const [now] = useState(() => Date.now());
156
+ const resolving = useRef(new Set<string>());
157
+ const demoStarted = useRef(false);
158
+ const preview = useMemo(() => previewState(), []);
159
+ const sample = useMemo(() => {
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
+ }
165
+ return { requests: preview === "caught-up" ? [] : fixture.requests, drafts: fixture.drafts };
166
+ }, [fixture, preview]);
167
+
168
+ const keep = useCallback((key: string) => setStay((current) => (current.has(key) ? current : new Set(current).add(key))), []);
169
+
170
+ const loadDraftsFor = useCallback(async (list: ReviewRequest[]) => {
171
+ const ids = list.filter((request) => request.state === "open" && isDraftReview(request)).flatMap(draftIdsOf);
172
+ if (!ids.length) {
173
+ setDraftsState("ready");
174
+ return;
175
+ }
176
+ if (demo) {
177
+ const known = sample.drafts.filter((row) => ids.includes(row.id));
178
+ setDrafts((current) => ({ ...current, ...Object.fromEntries(known.map((row) => [row.id, draftView(row, true)])) }));
179
+ setMissingDrafts(new Set(ids.filter((id) => !known.some((row) => row.id === id))));
180
+ setDraftsState("ready");
181
+ return;
182
+ }
183
+ try {
184
+ const loaded = await api.loadDrafts(ids);
185
+ setDrafts((current) => ({ ...current, ...loaded.drafts }));
186
+ setMissingDrafts(new Set(loaded.missing));
187
+ setDraftsState(loaded.failed.length ? "failed" : "ready");
188
+ } catch {
189
+ setDraftsState("failed");
190
+ }
191
+ }, [api, demo, sample]);
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
+
206
+ // A return visit paints the last view at once, marked as such, while the real reads run.
207
+ useEffect(() => {
208
+ if (demo) return;
209
+ const snapshot = loadNotificationSnapshot(SITE_BRAND.id);
210
+ if (!snapshot) return;
211
+ setRequests(snapshot.requests);
212
+ setDrafts(snapshot.drafts);
213
+ setMissingDrafts(new Set(snapshot.missing));
214
+ setDraftsState("ready");
215
+ setStale(true);
216
+ setLoadState((current) => (current === "loading" ? "ready" : current));
217
+ }, [demo]);
211
218
 
212
219
  const refresh = useCallback(async () => {
213
220
  if (demo) {
214
- setRequests(FIXTURE_REVIEW_REQUESTS);
215
- setReadIds(new Set(["rr-flag-1"]));
221
+ // The sample is read once: answering in the preview must not be undone by the next refresh.
222
+ if (demoStarted.current) return;
223
+ demoStarted.current = true;
224
+ setRequests(sample.requests);
216
225
  setLoadState("ready");
226
+ void loadDraftsFor(sample.requests);
227
+ void loadEmailsNow();
217
228
  return;
218
229
  }
219
230
  try {
220
- const { loadReviews } = await import("../lib/review-client");
221
- const loaded = await loadReviews();
231
+ const loaded = await api.loadReviews();
222
232
  if (loaded.state !== "ready") {
233
+ setStale(false);
223
234
  setLoadState(loaded.state);
224
235
  return;
225
236
  }
226
237
  setRequests(loaded.requests);
227
- setReadIds(new Set(loaded.readIds));
228
238
  setUserKey(loaded.userKey);
239
+ setStale(false);
229
240
  setLoadState("ready");
230
- const { loadStoryTitles } = await import("../lib/review-client");
231
- const stories = await loadStoryTitles(loaded.requests.filter((request) => request.subject.type === "story").map((request) => request.subject.id)).catch(() => ({ titles: {}, deleted: [] as string[] }));
241
+ void loadDraftsFor(loaded.requests);
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: {} }));
232
244
  setStoryTitles(stories.titles);
245
+ setStoryExcerpts(stories.excerpts ?? {});
233
246
  setDeletedStories(new Set(stories.deleted));
234
247
  } catch {
248
+ setStale(false);
235
249
  setLoadState("unavailable");
236
250
  }
237
- }, [demo]);
251
+ }, [api, demo, loadDraftsFor, loadEmailsNow, sample]);
238
252
 
239
253
  useEffect(() => {
240
254
  void refresh();
@@ -242,40 +256,137 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
242
256
  return () => window.clearInterval(timer);
243
257
  }, [refresh]);
244
258
 
245
- const held = useMemo(() => cyclotronHeldForReview(references, decisions, demo), [references, decisions, demo]);
246
- const autoDecided = useMemo(() => cyclotronAutoDecided(references, decisions, humanCuratedReferenceLineageIds, demo), [references, decisions, humanCuratedReferenceLineageIds, demo]);
259
+ // Keep a display copy for the next visit once the live read and its drafts have arrived.
260
+ useEffect(() => {
261
+ if (demo || stale || loadState !== "ready" || draftsState !== "ready") return;
262
+ const timer = window.setTimeout(() => saveNotificationSnapshot(SITE_BRAND.id, { requests, drafts, missing: missingDrafts }), 800);
263
+ return () => window.clearTimeout(timer);
264
+ }, [demo, draftsState, drafts, loadState, missingDrafts, requests, stale]);
247
265
 
248
266
  const markRead = useCallback(async (requestId: string) => {
249
- setReadIds((current) => new Set(current).add(requestId));
250
267
  if (demo || !userKey) return;
251
- const { markReviewRead } = await import("../lib/review-client");
252
- await markReviewRead(userKey, requestId).catch(() => undefined);
253
- }, [demo, userKey]);
268
+ await api.markReviewRead(userKey, requestId).catch(() => undefined);
269
+ }, [api, demo, userKey]);
254
270
 
271
+ /** Answers a whole request. Throws a sentence for the card when it cannot be saved. */
255
272
  const answer = useCallback(async (request: ReviewRequest, input: AnswerInput) => {
256
273
  setError(null);
257
- setBusyId(request.id);
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);
278
+ }, [actorLabel, api, keep, markRead]);
279
+
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. */
281
+ const decideDraft = useCallback(async (request: ReviewRequest, draftId: string, choice: DraftChoice, extras: { why: string; notThisKind: boolean }) => {
282
+ setError(null);
283
+ resolving.current.add(request.id);
258
284
  try {
259
- if (demo) throw new Error("This is a demo: answers are not saved.");
260
- const { answerReviewRequest } = await import("../lib/review-client");
261
- const saved = await answerReviewRequest(request, input, actorLabel);
262
- setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
263
- await markRead(request.id);
285
+ const view = drafts[draftId];
286
+ if (!view) throw new Error("This draft has not loaded yet.");
287
+ const path = choice === "accept" ? acceptPath(view.state) : rejectPath(view.state);
288
+ if (!path) throw new Error(`This draft is ${stateLabel(view.state, false).toLowerCase()} and cannot be ${choice === "accept" ? "accepted" : "rejected"} from here. Open it in Proposals.`);
289
+ const receipt = await api.transitionProposalPath(draftId, path);
290
+ if (receipt.status !== "ok") throw new Error(proposalReceiptMessage(receipt));
291
+ const decided: DraftView = { ...view, state: choice === "accept" ? "approved" : "declined", decided: choice };
292
+ const nextDrafts = { ...drafts, [draftId]: decided };
293
+ setDrafts(nextDrafts);
294
+ keep(`${request.id}#${draftId}`);
295
+ const decision: DraftDecision = { choice, title: view.headline, why: extras.why || null, notThisKind: choice === "reject" && extras.notThisKind, by: actorLabel, at: new Date().toISOString() };
296
+ const withDecisionRequest = { ...request, links: withDecision(request, draftId, decision) };
297
+ const closing = batchAnswer(withDecisionRequest, nextDrafts, { by: actorLabel, at: decision.at });
298
+ try {
299
+ const saved = closing ? await api.answerReviewRequest(request, closing, actorLabel) : await api.saveDraftDecision(request, draftId, decision);
300
+ setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
301
+ if (closing) await markRead(request.id);
302
+ } catch (failure) {
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());
304
+ }
264
305
  } catch (failure) {
265
- setError(failure instanceof Error ? failure.message : "Could not save the answer.");
306
+ if (!demo) void loadDraftsFor([request]);
307
+ throw failure;
266
308
  } finally {
267
- setBusyId(null);
309
+ resolving.current.delete(request.id);
310
+ }
311
+ }, [actorLabel, api, demo, drafts, keep, loadDraftsFor, markRead]);
312
+
313
+ // Drafts decided in Proposals (not here) still close their request.
314
+ useEffect(() => {
315
+ if (demo || draftsState !== "ready" || stale) return;
316
+ for (const request of requests) {
317
+ if (request.state !== "open" || !isDraftReview(request) || resolving.current.has(request.id)) continue;
318
+ if (undecidedDraftIds(request, drafts).length) continue;
319
+ const closing = batchAnswer(request, drafts, { by: "an editor in Proposals", at: new Date().toISOString() });
320
+ if (!closing) continue;
321
+ resolving.current.add(request.id);
322
+ void (async () => {
323
+ try {
324
+ const saved = await api.answerReviewRequest(request, closing, "an editor in Proposals");
325
+ setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
326
+ } catch {
327
+ // The next refresh shows what became of it.
328
+ } finally {
329
+ resolving.current.delete(request.id);
330
+ }
331
+ })();
268
332
  }
269
- }, [actorLabel, demo, markRead]);
333
+ }, [api, demo, drafts, draftsState, requests, stale]);
270
334
 
271
- const markAllRead = useCallback(() => {
272
- for (const request of requests) if (isUnread(request, readIds)) void markRead(request.id);
273
- }, [markRead, readIds, requests]);
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
+
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;
274
383
 
275
384
  return (
276
- <NotificationCenterView
277
- autoDecided={autoDecided} busyId={busyId} storyTitles={storyTitles} deletedStories={deletedStories} demo={demo} error={error} held={held} loadState={loadState}
278
- onAnswer={answer} onMarkAllRead={markAllRead} onMarkRead={markRead} readIds={readIds} requests={requests}
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}
279
390
  />
280
391
  );
281
392
  }