@anthusai/papyrus 5.43.0 → 5.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/amplify/functions/category-action/handler.ts +2 -1
  2. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  3. package/amplify/functions/monitoring-view/schema.js +16 -0
  4. package/amplify/functions/monitoring-view/schema.ts +16 -0
  5. package/amplify/functions/procedure-action/handler.ts +2 -10
  6. package/amplify/functions/proposal-actions/proposals.py +20 -3
  7. package/amplify/plugins/reader-steering/function/handler.py +1 -3
  8. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  9. package/bin/papyrus-infra.mjs +21 -1
  10. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  11. package/components/monitoring-overview-view.tsx +143 -21
  12. package/components/monitoring-panel.tsx +64 -4
  13. package/components/news-desk-client-provider.tsx +24 -3
  14. package/components/news-desk-page.tsx +2 -1
  15. package/components/news-desk-taxonomy-client.ts +2 -1
  16. package/components/newsroom-access-gate.tsx +7 -5
  17. package/components/newsroom-client-shell.tsx +2 -1
  18. package/components/newsroom-ops-overview.tsx +6 -1
  19. package/components/newsroom-ops-shell.tsx +1 -1
  20. package/components/newsroom-organizer.css +48 -0
  21. package/components/newsroom-references-view.tsx +183 -24
  22. package/components/notification-center.tsx +345 -63
  23. package/components/papyrus-console-shell.tsx +2 -1
  24. package/components/proposals-panel.tsx +21 -24
  25. package/components/proposals-view.tsx +4 -2
  26. package/components/reference-feed.tsx +469 -0
  27. package/components/reference-review-card.tsx +90 -76
  28. package/components/references-page-loading.tsx +61 -0
  29. package/components/references-prefetch.ts +47 -0
  30. package/components/review-rating-grid.tsx +164 -0
  31. package/components/topic-steering-workspace.tsx +32 -8
  32. package/infra/amplify-app-shell.js +18 -2
  33. package/infra/amplify-app-shell.ts +18 -2
  34. package/infra/chat-stack.js +222 -0
  35. package/infra/chat-stack.ts +250 -0
  36. package/infra/chat.test.js +142 -0
  37. package/infra/chat.test.ts +151 -0
  38. package/infra/index.js +1 -0
  39. package/infra/site-config.js +58 -1
  40. package/infra/site-config.ts +78 -1
  41. package/lib/category-repository.ts +4 -1
  42. package/lib/local-preview-feed-fixture.ts +43 -0
  43. package/lib/local-preview-synthetic-rejects.ts +41 -0
  44. package/lib/monitoring-client.ts +29 -2
  45. package/lib/monitoring-overview.ts +52 -1
  46. package/lib/monitoring-paging.ts +58 -0
  47. package/lib/newsroom-chat-identity.ts +2 -1
  48. package/lib/newsroom-organizer-nav.ts +1 -0
  49. package/lib/proposals-client.ts +29 -0
  50. package/lib/reader-subject.ts +3 -2
  51. package/lib/reference-feed-cache.ts +133 -0
  52. package/lib/reference-feed-ranking.ts +191 -0
  53. package/lib/reference-preview.ts +112 -0
  54. package/lib/review-client.ts +38 -1
  55. package/lib/review-drafts.ts +231 -0
  56. package/lib/review-fixture.ts +36 -2
  57. package/lib/review-requests.ts +64 -24
  58. package/lib/staff-groups.ts +23 -0
  59. package/lib/staging-gate.ts +2 -2
  60. package/package.json +14 -1
  61. package/routes.manifest.json +1 -1
@@ -1,20 +1,27 @@
1
1
  "use client";
2
2
 
3
+ import { CheckIcon, CircleCheckIcon, CircleDotIcon, CircleXIcon, ClockIcon, ExternalLinkIcon, FlagIcon, HourglassIcon, LockIcon } from "lucide-react";
3
4
  import Link from "next/link";
4
- import { useCallback, useEffect, useMemo, useState } from "react";
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
6
  import { Badge } from "./ui/badge";
6
7
  import { Button } from "./ui/button";
7
8
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
9
+ import { proposalReceiptMessage, stateLabel, type ProposalReceipt } from "../lib/proposals-admin";
10
+ import type { DraftLoad, ReviewLoad } from "../lib/review-client";
11
+ import { acceptPath, batchAnswer, cannotMoveReason, draftDecisionOf, draftsToDecide, draftView, rejectPath, undecidedDraftIds, withDecision, type DraftChoice, type DraftView } from "../lib/review-drafts";
8
12
  import {
9
- cyclotronAutoDecided, cyclotronHeldForReview, formatWhen, isUnread, modeLabel, modeMark, nextText, sectionReviews,
13
+ cyclotronAutoDecided, cyclotronHeldForReview, draftIdsOf, formatWhen, isDraftReview, isUnread, modeLabel, nextText, proposalHref, sectionReviews,
10
14
  steeringText, subjectHref, subjectLabel, summarize, summaryText,
11
- type AnswerInput, type CyclotronEntry, type ReviewRequest,
15
+ type AnswerInput, type CyclotronEntry, type DraftDecision, type ReviewRequest,
12
16
  } from "../lib/review-requests";
13
17
  import type { ReferenceRecord } from "../lib/category-repository";
14
18
  import type { RelevanceDecisionView } from "../lib/relevance-decisions";
15
- import { FIXTURE_REVIEW_REQUESTS } from "../lib/review-fixture";
19
+ import { FIXTURE_DRAFTS, FIXTURE_REVIEW_REQUESTS } from "../lib/review-fixture";
20
+ import { ChoiceButtons } from "./reference-review-card";
16
21
 
17
22
  export type NotificationLoadState = "loading" | "ready" | "forbidden" | "unavailable";
23
+ export type DraftsLoadState = "loading" | "ready" | "failed";
24
+ export type DraftExtras = { why: string; notThisKind: boolean };
18
25
 
19
26
  type ViewProps = {
20
27
  loadState: NotificationLoadState;
@@ -27,33 +34,182 @@ type ViewProps = {
27
34
  demo?: boolean;
28
35
  busyId?: string | null;
29
36
  error?: string | null;
37
+ /** The AI drafts that draft reviews are about, by proposal id, and which ids have no proposal on this site. */
38
+ drafts?: Readonly<Record<string, DraftView>>;
39
+ missingDrafts?: ReadonlySet<string>;
40
+ draftsState?: DraftsLoadState;
41
+ /** Keyed `<request id>#<draft id>`. */
42
+ draftErrors?: Readonly<Record<string, string>>;
43
+ draftBusyId?: string | null;
30
44
  onAnswer?: (request: ReviewRequest, input: AnswerInput) => void;
45
+ onDecideDraft?: (request: ReviewRequest, draftId: string, choice: DraftChoice, extras: DraftExtras) => void;
46
+ onRetryDrafts?: () => void;
31
47
  onMarkRead?: (requestId: string) => void;
32
48
  onMarkAllRead?: () => void;
33
49
  };
34
50
 
35
- function Entry({ request, unread, demo, busy, titles, deleted, onAnswer, onMarkRead }: {
51
+ const CONTROL_ICON = { hard_gate: LockIcon, soft_review: ClockIcon, flag: FlagIcon } as const;
52
+ const NOT_THIS_KIND = { request: "Not this kind of thing", draft: "Not this kind of story" } as const;
53
+
54
+ function WhyBox({ busy, onChange, placeholder, value }: { busy: boolean; onChange: (value: string) => void; placeholder: string; value: string }) {
55
+ return (
56
+ <label className="block text-sm font-medium">
57
+ Why? (optional)
58
+ <textarea
59
+ aria-label="Why? (optional)"
60
+ className="mt-1 block min-h-20 w-full rounded-md border bg-background p-3 text-base font-normal"
61
+ onChange={(event) => onChange(event.target.value)}
62
+ placeholder={placeholder}
63
+ readOnly={busy}
64
+ value={value}
65
+ />
66
+ </label>
67
+ );
68
+ }
69
+
70
+ function NotThisKind({ busy, label, onToggle, pressed }: { busy: boolean; label: string; onToggle: () => void; pressed: boolean }) {
71
+ return (
72
+ <div className="space-y-1" data-review-reasons>
73
+ <p className="text-sm font-medium">If you reject it</p>
74
+ <Button aria-disabled={busy || undefined} aria-pressed={pressed} className="min-h-10" data-review-reason="not_this_kind" onClick={() => { if (!busy) onToggle(); }} type="button" variant={pressed ? "default" : "outline"}>
75
+ {pressed ? <CheckIcon aria-hidden="true" className="size-4" /> : null}
76
+ {label}
77
+ </Button>
78
+ </div>
79
+ );
80
+ }
81
+
82
+ /** Accept or Reject on the request itself: a hard gate, a soft review without drafts, or a draft review whose drafts could not be loaded. */
83
+ function RequestControls({ request, busy, unread, onAnswer, onMarkRead }: { request: ReviewRequest; busy: boolean; unread: boolean; onAnswer?: ViewProps["onAnswer"]; onMarkRead?: ViewProps["onMarkRead"] }) {
84
+ const [note, setNote] = useState("");
85
+ const [notThisKind, setNotThisKind] = useState(false);
86
+ const flag = request.mode === "flag";
87
+ const act = (input: AnswerInput) => { if (!busy) onAnswer?.(request, { ...input, feedback: note.trim() || null }); };
88
+ return (
89
+ <div className="space-y-3" data-review-controls>
90
+ {flag ? (
91
+ <Button aria-disabled={busy || undefined} className="min-h-12 w-full text-base" onClick={() => act({ decision: "acknowledge" })} type="button" variant="outline">Acknowledge</Button>
92
+ ) : (
93
+ <ChoiceButtons chosen={null} disabled={busy} onChoose={(choice) => act(choice === "accept" ? { decision: "approve" } : { decision: "reject", steer: notThisKind ? "not_this_kind" : null })} />
94
+ )}
95
+ <div aria-live="polite" className="min-h-5 text-sm" role="status">{busy ? "Saving…" : ""}</div>
96
+ <WhyBox busy={busy} onChange={setNote} placeholder={flag ? "Optional. Tell the step what to do differently." : "Optional. Write it before you tap Accept or Reject; it is saved with your answer."} value={note} />
97
+ {flag ? null : <NotThisKind busy={busy} label={NOT_THIS_KIND.request} onToggle={() => setNotThisKind((current) => !current)} pressed={notThisKind} />}
98
+ {unread ? <Button aria-disabled={busy || undefined} onClick={() => { if (!busy) onMarkRead?.(request.id); }} size="sm" variant="ghost">Mark read</Button> : null}
99
+ </div>
100
+ );
101
+ }
102
+
103
+ function DecidedLine({ choice, notThisKind, why }: { choice: "accept" | "reject"; notThisKind?: boolean; why?: string | null }) {
104
+ const Icon = choice === "accept" ? CircleCheckIcon : CircleXIcon;
105
+ return (
106
+ <p className="flex items-start gap-1.5 text-sm font-medium" data-draft-decided={choice}>
107
+ <Icon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
108
+ <span className="break-words">
109
+ {choice === "accept" ? "Accepted" : "Rejected"}{choice === "reject" && notThisKind ? " as not this kind of story" : ""}
110
+ {why ? <span className="font-normal text-muted-foreground">: {why}</span> : null}
111
+ </span>
112
+ </p>
113
+ );
114
+ }
115
+
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
+ return (
135
+ <li className="space-y-3 rounded-lg border p-3" data-review-draft={id} data-draft-state={decision ? "decided" : "open"}>
136
+ <header className="space-y-1">
137
+ <h3 className="break-words text-base font-semibold leading-snug">{view.headline}</h3>
138
+ <p className="text-xs text-muted-foreground">
139
+ {view.kindLabel}{view.section ? ` · Section: ${view.section}` : ""} · Proposed by AI · {stateLabel(view.state, false)}
140
+ </p>
141
+ </header>
142
+ {view.dek ? <p className="break-words text-sm" data-draft-dek>{view.dek}</p> : null}
143
+ {view.evidence.length ? (
144
+ <ul aria-label="Evidence" className="space-y-0.5 text-sm" data-draft-evidence>
145
+ {view.evidence.map((line) => <li className="break-words" key={line}><span className="font-medium">Evidence:</span> {line}</li>)}
146
+ </ul>
147
+ ) : null}
148
+ {view.sources.length ? (
149
+ <div className="space-y-1">
150
+ <p className="text-sm font-medium">Sources</p>
151
+ <ul aria-label="Sources" className="space-y-0.5 text-sm" data-draft-sources>
152
+ {view.sources.map((source, index) => (
153
+ <li className="break-words" key={`${source.title}-${index}`}>
154
+ {source.url ? (
155
+ <a className="inline-flex max-w-full items-center gap-1 text-primary underline underline-offset-4" href={source.url} rel="noreferrer" target="_blank">
156
+ <span className="break-words">{source.title}</span><ExternalLinkIcon aria-hidden="true" className="size-3.5 shrink-0" />
157
+ </a>
158
+ ) : source.title}
159
+ {source.date ? <span className="text-muted-foreground"> ({source.date})</span> : null}
160
+ </li>
161
+ ))}
162
+ {view.moreSources ? <li className="text-muted-foreground">and {view.moreSources} more in the full draft</li> : null}
163
+ </ul>
164
+ </div>
165
+ ) : 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
+
185
+ function Entry({ request, unread, demo, busy, titles, deleted, drafts, missingDrafts, draftsState, draftErrors, draftBusyId, onAnswer, onMarkRead, onDecideDraft, onRetryDrafts }: {
36
186
  request: ReviewRequest; unread: boolean; demo: boolean; busy: boolean; titles: Readonly<Record<string, string>>; deleted: ReadonlySet<string>;
37
- onAnswer?: ViewProps["onAnswer"]; onMarkRead?: ViewProps["onMarkRead"];
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;
38
189
  }) {
39
- const [note, setNote] = useState("");
40
190
  const href = subjectHref(request.subject, demo);
41
191
  const open = request.state === "open";
42
192
  const steering = steeringText(request);
43
- const act = (input: AnswerInput) => onAnswer?.(request, { ...input, feedback: input.feedback ?? (note.trim() || null) });
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 ?? [];
44
200
  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}>
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}>
46
202
  <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>}
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>}
49
205
  <strong className="text-sm">{request.reason}</strong>
50
206
  </div>
51
207
  <p className="text-xs text-muted-foreground">
52
208
  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>}
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>}</>}
54
210
  </p>
55
- {request.ask ? <p className="text-sm">{request.ask}</p> : null}
56
- {request.links.length ? (
211
+ {request.ask && !draftReview ? <p className="text-sm">{request.ask}</p> : null}
212
+ {!draftReview && request.links.length ? (
57
213
  <ul className="text-xs space-y-0.5" aria-label="Items in this request">
58
214
  {request.links.slice(0, 10).map((link, index) => {
59
215
  const target = link.url ?? (link.type ? subjectHref({ type: link.type, id: link.id ?? "" }, demo) : null);
@@ -62,37 +218,44 @@ function Entry({ request, unread, demo, busy, titles, deleted, onAnswer, onMarkR
62
218
  {request.links.length > 10 ? <li>and {request.links.length - 10} more</li> : null}
63
219
  </ul>
64
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>
224
+ {draftsState === "failed" ? (
225
+ <div className="space-y-2" role="alert">
226
+ <p className="text-sm">The drafts could not be loaded right now.</p>
227
+ <Button onClick={onRetryDrafts} size="sm" type="button" variant="outline">Try again</Button>
228
+ </div>
229
+ ) : null}
230
+ <ul aria-label="Drafts in this request" className="space-y-3" data-review-drafts>
231
+ {draftsState === "failed" ? null : available.map((id) => (
232
+ <DraftCard
233
+ busy={draftBusyId === `${request.id}#${id}`} error={draftErrors[`${request.id}#${id}`] ?? null} id={id} key={id} request={request}
234
+ onDecide={(draftId, choice, extras) => onDecideDraft?.(request, draftId, choice, extras)} view={drafts[id]}
235
+ />
236
+ ))}
237
+ </ul>
238
+ {draftIds.length > available.length ? <p className="text-sm text-muted-foreground">{draftIds.length - available.length} of these drafts are not in Proposals on this site, so they cannot be opened here.</p> : null}
239
+ </div>
240
+ ) : null}
241
+ {!open && recorded.length ? (
242
+ <ul aria-label="What you decided" className="space-y-1" data-review-decisions>
243
+ {recorded.map((entry) => (
244
+ <li key={entry.id}>
245
+ <DecidedLine choice={entry.choice} notThisKind={entry.notThisKind} why={entry.why} />
246
+ <Link className="ml-5 text-xs text-primary underline underline-offset-4" href={proposalHref(entry.id, demo)}>{entry.title ?? entry.id}</Link>
247
+ </li>
248
+ ))}
249
+ </ul>
250
+ ) : null}
65
251
  <p className="text-sm" data-review-next>{nextText(request)}</p>
66
252
  {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>
253
+ {requestLevel ? (
254
+ <RequestControls busy={busy} onAnswer={onAnswer} onMarkRead={onMarkRead} request={request} unread={unread} />
255
+ ) : open ? (
256
+ unread ? <Button aria-disabled={busy || undefined} onClick={() => { if (!busy) onMarkRead?.(request.id); }} size="sm" variant="ghost">Mark read</Button> : null
94
257
  ) : unread ? (
95
- <Button disabled={busy} onClick={() => onMarkRead?.(request.id)} size="sm" variant="ghost">Mark read</Button>
258
+ <Button aria-disabled={busy || undefined} onClick={() => { if (!busy) onMarkRead?.(request.id); }} size="sm" variant="ghost">Mark read</Button>
96
259
  ) : null}
97
260
  </li>
98
261
  );
@@ -103,7 +266,7 @@ function CyclotronLine({ entry, held }: { entry: CyclotronEntry; held: boolean }
103
266
  return (
104
267
  <li className="rounded-lg border p-3 space-y-1" data-cyclotron-entry={entry.lineageId} data-cyclotron-held={held || undefined}>
105
268
  <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>
269
+ <Badge variant="outline">{held ? <HourglassIcon aria-hidden="true" className="mr-1 size-3" /> : <CircleCheckIcon aria-hidden="true" className="mr-1 size-3" />}{held ? "Held for review" : "Decided by the system"}</Badge>
107
270
  <strong className="text-sm">{entry.title}</strong>
108
271
  </div>
109
272
  <p className="text-xs text-muted-foreground">
@@ -125,13 +288,21 @@ function Section({ id, title, count, children, empty }: { id: string; title: str
125
288
  );
126
289
  }
127
290
 
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) {
291
+ /** The CMS console home: what needs a person, what will close on its own, and what the system decided. Ignore-safe by design. */
292
+ export function NotificationCenterView({
293
+ loadState, requests, readIds, held, autoDecided, demo = false, storyTitles = {}, deletedStories = new Set<string>(), busyId = null, error = null,
294
+ drafts = {}, missingDrafts = new Set<string>(), draftsState = "ready", draftErrors = {}, draftBusyId = null,
295
+ onAnswer, onDecideDraft, onRetryDrafts, onMarkRead, onMarkAllRead,
296
+ }: ViewProps) {
130
297
  const sections = useMemo(() => sectionReviews(requests), [requests]);
131
298
  const unreadCount = requests.filter((request) => isUnread(request, readIds)).length;
132
- const summary = summarize(sections, held, autoDecided, unreadCount);
299
+ const summary = summarize(sections, held, autoDecided, unreadCount, draftsToDecide(sections.draftBatches, drafts, missingDrafts));
133
300
  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)} />
301
+ <Entry
302
+ busy={busyId === request.id} demo={demo} deleted={deletedStories} draftBusyId={draftBusyId} draftErrors={draftErrors} drafts={drafts} draftsState={draftsState}
303
+ key={request.id} missingDrafts={missingDrafts} onAnswer={onAnswer} onDecideDraft={onDecideDraft} onMarkRead={onMarkRead} onRetryDrafts={onRetryDrafts}
304
+ request={request} titles={storyTitles} unread={isUnread(request, readIds)}
305
+ />
135
306
  );
136
307
  if (loadState === "loading") return (
137
308
  <section aria-label="Loading Notification Center" className="space-y-4" data-notification-state="loading" role="status">
@@ -153,7 +324,7 @@ export function NotificationCenterView({ loadState, requests, readIds, held, aut
153
324
  <h1 className="text-lg font-semibold">Notification Center</h1>
154
325
  <p className="text-sm" data-notification-summary>{summaryText(summary)}</p>
155
326
  <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.
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.
157
328
  </p>
158
329
  <p className="text-xs text-muted-foreground" data-notification-unread>
159
330
  {unreadCount ? `${unreadCount} unread` : "Nothing unread"}
@@ -165,7 +336,10 @@ export function NotificationCenterView({ loadState, requests, readIds, held, aut
165
336
  <Section count={sections.hardGates.length} empty="No hard gates. Nothing is blocked on you." id="hard-gates" title="Waiting for you: hard gates">
166
337
  <ul className="space-y-2">{sections.hardGates.map(entry)}</ul>
167
338
  </Section>
168
- <Section count={sections.softReviews.length} empty="No soft reviews are running." id="soft-reviews" title="Proceeding soon: soft reviews, soonest deadline first">
339
+ <Section count={sections.draftBatches.length} empty="No AI drafts are waiting for review." id="drafts" title="AI drafts to review">
340
+ <ul className="space-y-3">{sections.draftBatches.map(entry)}</ul>
341
+ </Section>
342
+ <Section count={sections.softReviews.length} empty="No other reviews are open." id="soft-reviews" title="Other reviews, soonest deadline first">
169
343
  <ul className="space-y-2">{sections.softReviews.map(entry)}</ul>
170
344
  </Section>
171
345
  <Section count={held.length} empty="No Cyclotron items are held for review." id="cyclotron" title="Cyclotron items routed to a person (low confidence)">
@@ -189,17 +363,39 @@ export function NotificationCenterView({ loadState, requests, readIds, held, aut
189
363
  );
190
364
  }
191
365
 
366
+ /** Everything the container reads and writes. The live one talks to the site's data API; tests pass a fake. */
367
+ export type NotificationApi = {
368
+ loadReviews: () => Promise<ReviewLoad>;
369
+ loadDrafts: (ids: string[]) => Promise<DraftLoad>;
370
+ loadStoryTitles: (ids: string[]) => Promise<{ titles: Record<string, string>; deleted: string[] }>;
371
+ answerReviewRequest: (request: ReviewRequest, input: AnswerInput, by: string) => Promise<ReviewRequest>;
372
+ saveDraftDecision: (request: ReviewRequest, draftId: string, decision: DraftDecision) => Promise<ReviewRequest>;
373
+ markReviewRead: (userKey: string, requestId: string) => Promise<void>;
374
+ transitionProposalPath: (proposalId: string, path: readonly string[]) => Promise<ProposalReceipt>;
375
+ };
376
+
377
+ const liveApi: NotificationApi = {
378
+ loadReviews: async () => (await import("../lib/review-client")).loadReviews(),
379
+ loadDrafts: async (ids) => (await import("../lib/review-client")).loadDrafts(ids),
380
+ loadStoryTitles: async (ids) => (await import("../lib/review-client")).loadStoryTitles(ids),
381
+ answerReviewRequest: async (request, input, by) => (await import("../lib/review-client")).answerReviewRequest(request, input, by),
382
+ saveDraftDecision: async (request, draftId, decision) => (await import("../lib/review-client")).saveDraftDecision(request, draftId, decision),
383
+ markReviewRead: async (userKey, requestId) => (await import("../lib/review-client")).markReviewRead(userKey, requestId),
384
+ transitionProposalPath: async (proposalId, path) => (await import("../lib/proposals-client")).transitionProposalPath(proposalId, path),
385
+ };
386
+
192
387
  type ContainerProps = {
193
388
  references: ReferenceRecord[];
194
389
  decisions: ReadonlyMap<string, RelevanceDecisionView>;
195
390
  humanCuratedReferenceLineageIds: ReadonlySet<string>;
196
391
  actorLabel: string;
197
392
  demo: boolean;
393
+ api?: NotificationApi;
198
394
  };
199
395
 
200
396
  const REFRESH_MILLISECONDS = 60_000;
201
397
 
202
- export function NotificationCenter({ references, decisions, humanCuratedReferenceLineageIds, actorLabel, demo }: ContainerProps) {
398
+ export function NotificationCenter({ references, decisions, humanCuratedReferenceLineageIds, actorLabel, demo, api = liveApi }: ContainerProps) {
203
399
  const [loadState, setLoadState] = useState<NotificationLoadState>("loading");
204
400
  const [requests, setRequests] = useState<ReviewRequest[]>([]);
205
401
  const [readIds, setReadIds] = useState<Set<string>>(new Set());
@@ -208,17 +404,45 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
208
404
  const [error, setError] = useState<string | null>(null);
209
405
  const [storyTitles, setStoryTitles] = useState<Record<string, string>>({});
210
406
  const [deletedStories, setDeletedStories] = useState<ReadonlySet<string>>(new Set());
407
+ const [drafts, setDrafts] = useState<Record<string, DraftView>>({});
408
+ const [missingDrafts, setMissingDrafts] = useState<ReadonlySet<string>>(new Set());
409
+ const [draftsState, setDraftsState] = useState<DraftsLoadState>("loading");
410
+ const [draftErrors, setDraftErrors] = useState<Record<string, string>>({});
411
+ const [draftBusyId, setDraftBusyId] = useState<string | null>(null);
412
+ const resolving = useRef(new Set<string>());
413
+
414
+ const loadDraftsFor = useCallback(async (list: ReviewRequest[]) => {
415
+ const ids = list.filter((request) => request.state === "open" && isDraftReview(request)).flatMap(draftIdsOf);
416
+ if (!ids.length) {
417
+ setDraftsState("ready");
418
+ return;
419
+ }
420
+ if (demo) {
421
+ setDrafts(Object.fromEntries(FIXTURE_DRAFTS.map((row) => [row.id, draftView(row, true)])));
422
+ setMissingDrafts(new Set(ids.filter((id) => !FIXTURE_DRAFTS.some((row) => row.id === id))));
423
+ setDraftsState("ready");
424
+ return;
425
+ }
426
+ try {
427
+ const loaded = await api.loadDrafts(ids);
428
+ setDrafts(loaded.drafts);
429
+ setMissingDrafts(new Set(loaded.missing));
430
+ setDraftsState(loaded.failed.length ? "failed" : "ready");
431
+ } catch {
432
+ setDraftsState("failed");
433
+ }
434
+ }, [api, demo]);
211
435
 
212
436
  const refresh = useCallback(async () => {
213
437
  if (demo) {
214
438
  setRequests(FIXTURE_REVIEW_REQUESTS);
215
439
  setReadIds(new Set(["rr-flag-1"]));
216
440
  setLoadState("ready");
441
+ void loadDraftsFor(FIXTURE_REVIEW_REQUESTS);
217
442
  return;
218
443
  }
219
444
  try {
220
- const { loadReviews } = await import("../lib/review-client");
221
- const loaded = await loadReviews();
445
+ const loaded = await api.loadReviews();
222
446
  if (loaded.state !== "ready") {
223
447
  setLoadState(loaded.state);
224
448
  return;
@@ -227,14 +451,14 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
227
451
  setReadIds(new Set(loaded.readIds));
228
452
  setUserKey(loaded.userKey);
229
453
  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[] }));
454
+ void loadDraftsFor(loaded.requests);
455
+ const stories = await api.loadStoryTitles(loaded.requests.filter((request) => request.subject.type === "story").map((request) => request.subject.id)).catch(() => ({ titles: {}, deleted: [] as string[] }));
232
456
  setStoryTitles(stories.titles);
233
457
  setDeletedStories(new Set(stories.deleted));
234
458
  } catch {
235
459
  setLoadState("unavailable");
236
460
  }
237
- }, [demo]);
461
+ }, [api, demo, loadDraftsFor]);
238
462
 
239
463
  useEffect(() => {
240
464
  void refresh();
@@ -248,17 +472,15 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
248
472
  const markRead = useCallback(async (requestId: string) => {
249
473
  setReadIds((current) => new Set(current).add(requestId));
250
474
  if (demo || !userKey) return;
251
- const { markReviewRead } = await import("../lib/review-client");
252
- await markReviewRead(userKey, requestId).catch(() => undefined);
253
- }, [demo, userKey]);
475
+ await api.markReviewRead(userKey, requestId).catch(() => undefined);
476
+ }, [api, demo, userKey]);
254
477
 
255
478
  const answer = useCallback(async (request: ReviewRequest, input: AnswerInput) => {
256
479
  setError(null);
257
480
  setBusyId(request.id);
258
481
  try {
259
482
  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);
483
+ const saved = await api.answerReviewRequest(request, input, actorLabel);
262
484
  setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
263
485
  await markRead(request.id);
264
486
  } catch (failure) {
@@ -266,7 +488,65 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
266
488
  } finally {
267
489
  setBusyId(null);
268
490
  }
269
- }, [actorLabel, demo, markRead]);
491
+ }, [actorLabel, api, demo, markRead]);
492
+
493
+ /** 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
+ const decideDraft = useCallback(async (request: ReviewRequest, draftId: string, choice: DraftChoice, extras: DraftExtras) => {
495
+ const key = `${request.id}#${draftId}`;
496
+ setDraftErrors(({ [key]: _gone, ...rest }) => rest);
497
+ setDraftBusyId(key);
498
+ resolving.current.add(request.id);
499
+ try {
500
+ if (demo) throw new Error("This is a demo: decisions are not saved.");
501
+ const view = drafts[draftId];
502
+ if (!view) throw new Error("This draft has not loaded yet.");
503
+ const path = choice === "accept" ? acceptPath(view.state) : rejectPath(view.state);
504
+ 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.`);
505
+ const receipt = await api.transitionProposalPath(draftId, path);
506
+ if (receipt.status !== "ok") throw new Error(proposalReceiptMessage(receipt));
507
+ const decided: DraftView = { ...view, state: choice === "accept" ? "approved" : "declined", decided: choice };
508
+ const nextDrafts = { ...drafts, [draftId]: decided };
509
+ setDrafts(nextDrafts);
510
+ const decision: DraftDecision = { choice, title: view.headline, why: extras.why || null, notThisKind: choice === "reject" && extras.notThisKind, by: actorLabel, at: new Date().toISOString() };
511
+ const withDecisionRequest = { ...request, links: withDecision(request, draftId, decision) };
512
+ const closing = batchAnswer(withDecisionRequest, nextDrafts, { by: actorLabel, at: decision.at });
513
+ try {
514
+ const saved = closing ? await api.answerReviewRequest(request, closing, actorLabel) : await api.saveDraftDecision(request, draftId, decision);
515
+ setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
516
+ if (closing) await markRead(request.id);
517
+ } catch (failure) {
518
+ 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());
519
+ }
520
+ } catch (failure) {
521
+ setDraftErrors((current) => ({ ...current, [key]: failure instanceof Error ? failure.message : "Could not save the decision." }));
522
+ if (!demo) void loadDraftsFor([request]);
523
+ } finally {
524
+ resolving.current.delete(request.id);
525
+ setDraftBusyId(null);
526
+ }
527
+ }, [actorLabel, api, demo, drafts, loadDraftsFor, markRead]);
528
+
529
+ // Drafts decided in Proposals (not here) still close their request.
530
+ useEffect(() => {
531
+ if (demo || draftsState !== "ready") return;
532
+ for (const request of requests) {
533
+ if (request.state !== "open" || !isDraftReview(request) || resolving.current.has(request.id)) continue;
534
+ if (undecidedDraftIds(request, drafts).length) continue;
535
+ const closing = batchAnswer(request, drafts, { by: "an editor in Proposals", at: new Date().toISOString() });
536
+ if (!closing) continue;
537
+ resolving.current.add(request.id);
538
+ void (async () => {
539
+ try {
540
+ const saved = await api.answerReviewRequest(request, closing, "an editor in Proposals");
541
+ setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
542
+ } catch {
543
+ // The next refresh shows what became of it.
544
+ } finally {
545
+ resolving.current.delete(request.id);
546
+ }
547
+ })();
548
+ }
549
+ }, [api, demo, drafts, draftsState, requests]);
270
550
 
271
551
  const markAllRead = useCallback(() => {
272
552
  for (const request of requests) if (isUnread(request, readIds)) void markRead(request.id);
@@ -274,8 +554,10 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
274
554
 
275
555
  return (
276
556
  <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}
557
+ autoDecided={autoDecided} busyId={busyId} storyTitles={storyTitles} deletedStories={deletedStories} demo={demo} draftBusyId={draftBusyId} draftErrors={draftErrors}
558
+ drafts={drafts} draftsState={draftsState} error={error} held={held} loadState={loadState} missingDrafts={missingDrafts}
559
+ onAnswer={answer} onDecideDraft={(request, draftId, choice, extras) => void decideDraft(request, draftId, choice, extras)}
560
+ onMarkAllRead={markAllRead} onMarkRead={markRead} onRetryDrafts={() => void loadDraftsFor(requests)} readIds={readIds} requests={requests}
279
561
  />
280
562
  );
281
563
  }
@@ -6,6 +6,7 @@ import dynamic from "next/dynamic";
6
6
  import { usePathname, useRouter, useSearchParams } from "next/navigation";
7
7
  import { CheckIcon, UserIcon } from "lucide-react";
8
8
  import { Md5 } from "@smithy/md5-js";
9
+ import { hasEditorRights } from "../lib/staff-groups";
9
10
  import {
10
11
  createConsoleMessage,
11
12
  createConsoleThread,
@@ -154,7 +155,7 @@ export function PapyrusConsoleShell({ children, privateChatEnabled = false }: Pa
154
155
  const [openingReferenceChat, setOpeningReferenceChat] = useState(false);
155
156
  const internalPathname = browserPathToInternalNewsroomPath(pathname);
156
157
  const isNewsroomPath = internalPathname === "/newsroom" || internalPathname.startsWith("/newsroom/");
157
- const canUseConsole = Boolean(session?.hasSession && session.groups.some((group) => group === "editor" || group === "admin"));
158
+ const canUseConsole = Boolean(session?.hasSession && hasEditorRights(session.groups));
158
159
  const shouldOfferConsole = isNewsroomPath && canUseConsole;
159
160
  const { open, openConsole, closeConsole, toggleOpen } = useConsoleVisibility({ available: shouldOfferConsole });
160
161