@anthusai/papyrus 5.45.0 → 5.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/amplify/data/resource.js +6 -0
  2. package/amplify/data/resource.ts +7 -0
  3. package/amplify/data/schema.js +6 -0
  4. package/amplify/data/schema.ts +7 -0
  5. package/amplify/functions/content-actions/handler.py +8 -0
  6. package/bin/papyrus-app.mjs +12 -1
  7. package/components/news-desk-client-provider.tsx +2 -2
  8. package/components/newsroom-ops-overview.tsx +63 -60
  9. package/components/newsroom-ops-shell.tsx +3 -1
  10. package/components/newsroom-organizer.css +1 -2
  11. package/components/newsroom-references-view.tsx +119 -7
  12. package/components/notification-cards.tsx +364 -0
  13. package/components/notification-center.tsx +319 -331
  14. package/components/reference-capture-client.ts +110 -0
  15. package/components/reference-capture-panel.tsx +180 -0
  16. package/components/reference-feed.tsx +93 -60
  17. package/components/reference-review-card.tsx +21 -66
  18. package/components/references-prefetch.ts +5 -0
  19. package/components/review-rating-grid.tsx +27 -14
  20. package/components/topic-steering-workspace.tsx +12 -2
  21. package/components/use-reference-ratings.ts +132 -0
  22. package/lib/category-repository.ts +2 -0
  23. package/lib/content-actions-client.ts +18 -0
  24. package/lib/local-preview-feed-fixture.ts +20 -0
  25. package/lib/newsroom-nav.ts +1 -1
  26. package/lib/newsroom-organizer-nav.ts +1 -1
  27. package/lib/notification-feed.ts +250 -0
  28. package/lib/reference-capture.ts +293 -0
  29. package/lib/reference-feed-cache.ts +37 -0
  30. package/lib/reference-feed-ranking.ts +44 -0
  31. package/lib/review-drafts.ts +8 -3
  32. package/lib/site-brand.ts +2 -0
  33. package/package.json +7 -1
  34. package/routes.manifest.json +1 -1
@@ -13,6 +13,7 @@ import { useEffect, useRef, useState, type ReactNode, type PointerEvent as React
13
13
  import { Button } from "./ui/button";
14
14
  import { cn } from "../lib/utils";
15
15
  import { RatingGrid } from "./review-rating-grid";
16
+ import { useRatingStore, type RatingController } from "./use-reference-ratings";
16
17
  import { REFERENCE_REJECTION_REASON_CODES, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
17
18
 
18
19
  export type ReviewChoice = "accept" | "reject";
@@ -141,8 +142,11 @@ export function ReviewCard({
141
142
  summary,
142
143
  title,
143
144
  titleHref,
145
+ ratings,
144
146
  }: {
145
147
  children?: ReactNode;
148
+ /** A shared rating store, so the grid card and this card stay in step. Optional: the card works alone without it. */
149
+ ratings?: RatingController;
146
150
  /** The saved human answer, or null while the item is undecided. */
147
151
  decided: ReviewChoice | null;
148
152
  /** When set, the controls explain in words why they cannot change anything. */
@@ -157,70 +161,21 @@ export function ReviewCard({
157
161
  title: string;
158
162
  titleHref?: string | null;
159
163
  }) {
160
- const [draft, setDraft] = useState<ReviewChoice | null>(null);
161
- const [note, setNote] = useState(initialNote);
162
- const [reason, setReason] = useState<ReferenceRejectionReasonCode | null>(initialReason);
163
- const [saving, setSaving] = useState(false);
164
- const [saved, setSaved] = useState(false);
165
- const [error, setError] = useState<string | null>(null);
166
- const inFlight = useRef(false);
167
- const noteTouched = useRef(false);
168
- const reasonTouched = useRef(false);
169
- const savedNote = useRef(initialNote);
170
-
171
- // Saved values from outside (another editor, a reload) refresh whatever the human has not touched.
172
- useEffect(() => {
173
- savedNote.current = initialNote;
174
- if (!noteTouched.current) setNote(initialNote);
175
- }, [initialNote]);
176
- useEffect(() => {
177
- if (!reasonTouched.current) setReason(initialReason);
178
- }, [initialReason]);
179
-
180
- const chosen = draft ?? decided;
181
-
182
- async function save(choice: ReviewChoice, nextReason: ReferenceRejectionReasonCode | null, nextNote: string) {
183
- if (disabledReason || inFlight.current) return;
184
- if (choice === "reject" && !nextReason) return;
185
- inFlight.current = true;
186
- setSaving(true);
187
- setSaved(false);
188
- setError(null);
189
- try {
190
- await onDecide(choice, { note: nextNote.trim(), reasonCode: choice === "reject" ? nextReason : null });
191
- savedNote.current = nextNote.trim();
192
- noteTouched.current = false;
193
- reasonTouched.current = false;
194
- setDraft(null);
195
- setSaved(true);
196
- } catch (failure) {
197
- setDraft(null);
198
- setError(failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry.");
199
- } finally {
200
- inFlight.current = false;
201
- setSaving(false);
202
- }
203
- }
204
-
205
- function choose(choice: ReviewChoice) {
206
- if (disabledReason) return;
207
- if (chosen === choice && draft === null) return; // the selected answer never unselects itself
208
- setError(null);
209
- setDraft(choice);
210
- void save(choice, reason, note);
211
- }
212
-
213
- function chooseReason(code: ReferenceRejectionReasonCode) {
214
- if (disabledReason) return;
215
- setReason(code);
216
- reasonTouched.current = true;
217
- if (chosen === "reject") void save("reject", code, note);
218
- }
219
-
220
- function commitNote() {
221
- if (!decided || disabledReason || note.trim() === savedNote.current.trim()) return;
222
- void save(decided, reason, note);
223
- }
164
+ // The rating state lives in a store. Given one (`ratings`), the grid card and this card share it, so a vote or a reason typed in
165
+ // either shows in both at once; without one the card keeps a private store, exactly as before.
166
+ const local = useRatingStore({
167
+ save: (_id, choice, detail) => onDecide(choice, detail),
168
+ saved: () => decided,
169
+ seed: () => ({ note: initialNote, reasonCode: initialReason }),
170
+ });
171
+ const store = ratings ?? local;
172
+ const item = store.get(itemId);
173
+ const note = item.note ?? initialNote;
174
+ const reason = item.reasonCode !== undefined ? item.reasonCode : initialReason;
175
+ const chosen = item.pending ?? decided;
176
+ const choose = (choice: ReviewChoice) => { if (!disabledReason) store.choose(itemId, choice); };
177
+ const chooseReason = (code: ReferenceRejectionReasonCode) => { if (!disabledReason) store.setReason(itemId, code); };
178
+ const commitNote = () => { if (!disabledReason) store.commitNote(itemId); };
224
179
 
225
180
  // Desktop shortcuts: A accepts, R rejects. Never while typing.
226
181
  useEffect(() => {
@@ -253,7 +208,7 @@ export function ReviewCard({
253
208
  decided={decided}
254
209
  disabled={Boolean(disabledReason)}
255
210
  disabledReason={disabledReason}
256
- feedback={saving ? { phase: "saving" } : error ? { phase: "error", message: error } : saved ? { phase: "saved" } : { phase: "idle" }}
211
+ feedback={item.feedback}
257
212
  onChoose={choose}
258
213
  prediction={prediction}
259
214
  size="large"
@@ -266,7 +221,7 @@ export function ReviewCard({
266
221
  aria-label="Why? (optional)"
267
222
  className="mt-1 block min-h-24 w-full rounded-md border bg-background p-3 text-base font-normal"
268
223
  onBlur={commitNote}
269
- onChange={(event) => { setNote(event.target.value); noteTouched.current = true; setSaved(false); }}
224
+ onChange={(event) => store.setNote(itemId, event.target.value)}
270
225
  placeholder="Say what made this an easy yes or a clear no. Your note teaches the Cyclotron."
271
226
  value={note}
272
227
  />
@@ -45,3 +45,8 @@ export const loadMessagesWithHeadStart = () => take("messages");
45
45
  export function discardReferencesHeadStart() {
46
46
  started.clear();
47
47
  }
48
+
49
+ /** The pages that need the References straight away: the feed itself and the home page, whose Notification Center shows References cards. */
50
+ export function wantsReferencesHeadStart(pathname: string): boolean {
51
+ return /\/newsroom\/references(\/|$)/.test(pathname) || /\/newsroom\/?$/.test(pathname);
52
+ }
@@ -42,8 +42,10 @@ export function RatingGrid({
42
42
  disabled = false,
43
43
  disabledReason = null,
44
44
  feedback = { phase: "idle" },
45
+ note = null,
45
46
  onChoose,
46
47
  prediction,
48
+ showPrediction = true,
47
49
  size = "compact",
48
50
  surface = "footer",
49
51
  }: {
@@ -54,8 +56,12 @@ export function RatingGrid({
54
56
  disabled?: boolean;
55
57
  disabledReason?: string | null;
56
58
  feedback?: RatingFeedback;
59
+ /** A short line shown in the status row beside the Saved tick (for example the reason). It never adds a row. */
60
+ note?: string | null;
57
61
  onChoose: (choice: ReviewChoice) => void;
58
62
  prediction: CardPrediction;
63
+ /** False for a card with nothing to predict (an AI draft): the Cyclotron row and the "You" label are left out and only the two buttons remain. */
64
+ showPrediction?: boolean;
59
65
  size?: "compact" | "large";
60
66
  /** The tone the grid sits on, so the bar's centre notch matches it. */
61
67
  surface?: "footer" | "card";
@@ -70,8 +76,15 @@ export function RatingGrid({
70
76
  const label = size === "large" ? "text-sm" : "text-xs";
71
77
 
72
78
  return (
73
- <div className={cn("grid grid-cols-[4.5rem_minmax(0,1fr)_minmax(0,1fr)] gap-x-2 gap-y-2.5", size === "large" && "grid-cols-[5rem_minmax(0,1fr)_minmax(0,1fr)]")} data-rating-grid>
74
- <div className="col-span-3 grid grid-cols-subgrid items-stretch gap-y-2" data-review-row="cyclotron">
79
+ <div
80
+ className={cn(
81
+ "grid gap-x-2 gap-y-2.5",
82
+ showPrediction ? "grid-cols-[4.5rem_minmax(0,1fr)_minmax(0,1fr)]" : "grid-cols-2",
83
+ showPrediction && size === "large" && "grid-cols-[5rem_minmax(0,1fr)_minmax(0,1fr)]",
84
+ )}
85
+ data-rating-grid
86
+ >
87
+ {showPrediction ? <div className="col-span-3 grid grid-cols-subgrid items-stretch gap-y-2" data-review-row="cyclotron">
75
88
  <span className={cn("self-center font-semibold", label)}>Cyclotron</span>
76
89
  {(["accept", "reject"] as const).map((choice) => {
77
90
  const predicted = ready?.choice === choice;
@@ -118,16 +131,15 @@ export function RatingGrid({
118
131
  ) : null}
119
132
  <span aria-hidden="true" className={cn("absolute inset-y-0 left-1/2 w-1 -translate-x-1/2", surface === "footer" ? "bg-[var(--feed-footer)]" : "bg-[var(--feed-card)]")} />
120
133
  </div>
121
- {prediction.state === "ready" ? null : (
122
- <small className="text-center text-xs text-muted-foreground" data-cyclotron-state={prediction.state}>
123
- {prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
124
- </small>
125
- )}
134
+ {/* The line is always there, so a prediction arriving later never changes the card's height. */}
135
+ <small className="min-h-4 text-center text-xs text-muted-foreground" data-cyclotron-state={prediction.state === "ready" ? undefined : prediction.state}>
136
+ {prediction.state === "ready" ? "\u00a0" : prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
137
+ </small>
126
138
  </div>
127
- </div>
139
+ </div> : null}
128
140
 
129
- <div className="col-span-3 grid grid-cols-subgrid items-stretch" data-review-row="you">
130
- <span className={cn("self-center font-semibold", label)}>You</span>
141
+ <div className={cn("grid grid-cols-subgrid items-stretch", showPrediction ? "col-span-3" : "col-span-2")} data-review-row="you">
142
+ {showPrediction ? <span className={cn("self-center font-semibold", label)}>You</span> : null}
131
143
  {(["accept", "reject"] as const).map((choice) => {
132
144
  const on = answer === choice;
133
145
  const Icon = choice === "accept" ? CheckIcon : XIcon;
@@ -139,7 +151,7 @@ export function RatingGrid({
139
151
  "inline-flex items-center justify-center gap-1.5 rounded-[16px] px-2 font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
140
152
  cell,
141
153
  on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)] text-foreground hover:bg-[var(--feed-control)]/70",
142
- disabled && "opacity-60",
154
+ disabled && !on && "opacity-60",
143
155
  )}
144
156
  data-review-choice={choice}
145
157
  key={choice}
@@ -153,12 +165,13 @@ export function RatingGrid({
153
165
  })}
154
166
  </div>
155
167
 
156
- <div className="col-span-3 min-h-5 text-xs text-muted-foreground" data-feed-saved role="status">
168
+ <div className={cn("flex min-h-5 flex-wrap items-center gap-x-3 text-xs text-muted-foreground", showPrediction ? "col-span-3" : "col-span-2")} data-feed-saved role="status">
169
+ {note ? <span data-feed-reason>{note}</span> : null}
157
170
  {feedback.phase === "saving" ? <span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Saving…</span> : null}
158
171
  {feedback.phase === "saved" ? <span className="inline-flex items-center gap-1" data-review-saved><CheckIcon aria-hidden="true" className="size-3.5" />Saved</span> : null}
159
172
  </div>
160
- {disabledReason ? <p className="col-span-3 text-xs text-muted-foreground" data-review-disabled-reason>{disabledReason}</p> : null}
161
- {feedback.phase === "error" ? <p className="col-span-3 text-xs text-destructive" role="alert">{feedback.message}</p> : null}
173
+ {disabledReason ? <p className={cn("text-xs text-muted-foreground", showPrediction ? "col-span-3" : "col-span-2")} data-review-disabled-reason>{disabledReason}</p> : null}
174
+ {feedback.phase === "error" ? <p className={cn("text-xs text-destructive", showPrediction ? "col-span-3" : "col-span-2")} role="alert">{feedback.message}</p> : null}
162
175
  </div>
163
176
  );
164
177
  }
@@ -94,6 +94,7 @@ import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
94
94
  import { ReferenceSourcePreview } from "./reference-source-preview";
95
95
  import type { ReaderAuthSnapshot } from "./reader-auth-state";
96
96
  import { NewsDeskAccessGate } from "./newsroom-access-gate";
97
+ import { correctReferenceCapture } from "../lib/content-actions-client";
97
98
  import { loadMessagesWithHeadStart, loadReferencesWithHeadStart, loadRelationsWithHeadStart } from "./references-prefetch";
98
99
  import { loadFeedSnapshot, saveFeedSnapshot, type FeedSnapshot } from "../lib/reference-feed-cache";
99
100
  import { referencePreviewsByLineage } from "../lib/reference-preview";
@@ -1332,7 +1333,7 @@ function NewsDeskDashboard({
1332
1333
  refreshNewsroomSections,
1333
1334
  ]);
1334
1335
 
1335
- const needsReferenceReviewData = activeTab === "references" || activeTab === "assignments" || activeTab === "messages";
1336
+ const needsReferenceReviewData = activeTab === "overview" || activeTab === "references" || activeTab === "assignments" || activeTab === "messages";
1336
1337
  const selectedReviewLineageId = pathname ? pathnameReferenceLineageId ?? "" : initialSelection.reference ?? "";
1337
1338
  const referenceReviewData = useReferenceReviewData({
1338
1339
  enabled: canLoadPrivateCorpus && needsReferenceReviewData && !(activeTab === "references" && selectedReviewLineageId),
@@ -1363,7 +1364,7 @@ function NewsDeskDashboard({
1363
1364
  const predictionError = selectedReviewIsCurrent ? selectedReviewRead.error : referenceReviewData.error;
1364
1365
  useEffect(() => {
1365
1366
  // Keep a display copy for the next visit once everything the feed shows has been read.
1366
- if (dashboard.isDemo || activeTab !== "references" || !hasHydratedReferences || predictionState !== "ready" || !references.length) return;
1367
+ if (dashboard.isDemo || (activeTab !== "references" && activeTab !== "overview") || !hasHydratedReferences || predictionState !== "ready" || !references.length) return;
1367
1368
  const timer = window.setTimeout(() => {
1368
1369
  saveFeedSnapshot(SITE_BRAND.id, {
1369
1370
  references: selectCanonicalReferenceRecords(references),
@@ -3103,9 +3104,17 @@ function NewsDeskDashboard({
3103
3104
  assignments={assignments}
3104
3105
  decisions={relevanceDecisions}
3105
3106
  demo={Boolean(dashboard.isDemo)}
3107
+ disabledReason={controlsDisabled ? "Editor access is required to change this reference." : null}
3106
3108
  humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
3109
+ loading={!dashboard.isDemo && !hasHydratedReferences}
3107
3110
  newsroomSections={newsroomSections}
3111
+ notificationFixture={dashboard.notificationFixture ?? null}
3112
+ onReview={(reference, action, options) => runReferenceCurationAction(reference, action, undefined, options?.reasonCode,
3113
+ options?.decisionRelationId ? { decisionRelationId: options.decisionRelationId, shareable: options.shareable === true } : undefined)}
3114
+ predictionState={referenceReviewData.state}
3115
+ referenceAttachments={referenceAttachments}
3108
3116
  references={references}
3117
+ savedCopy={savedFeedCopy}
3109
3118
  />
3110
3119
  ) : null}
3111
3120
  {activeTab === "search" ? (
@@ -3205,6 +3214,7 @@ function NewsDeskDashboard({
3205
3214
  referenceAttachments={referenceAttachments}
3206
3215
  references={references}
3207
3216
  loading={!dashboard.isDemo && !hasHydratedReferences}
3217
+ onCorrectCapture={dashboard.isDemo ? undefined : (input) => correctReferenceCapture(input)}
3208
3218
  savedCopy={savedFeedCopy}
3209
3219
  />
3210
3220
  </NewsroomCurationBootstrap>
@@ -0,0 +1,132 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import type { ReferenceRejectionReasonCode } from "../lib/reference-policy";
5
+ import type { ReviewChoice } from "./reference-review-card";
6
+ import type { RatingFeedback } from "./review-rating-grid";
7
+
8
+ /** How long after the last keystroke a typed reason is saved on its own. */
9
+ export const NOTE_AUTOSAVE_MS = 1200;
10
+
11
+ export type ItemRating = {
12
+ /** The reason as typed, else as saved. In the store itself it stays undefined until touched, so an untouched field follows the record. */
13
+ note?: string;
14
+ reasonCode?: ReferenceRejectionReasonCode | null;
15
+ /** A vote that is waiting for a reason (Reject needs one) or is still saving. */
16
+ pending: ReviewChoice | null;
17
+ /** True while a Reject is waiting for its reason. */
18
+ askReason: boolean;
19
+ feedback: RatingFeedback;
20
+ };
21
+
22
+ const EMPTY: ItemRating = { pending: null, askReason: false, feedback: { phase: "idle" } };
23
+
24
+ export type RatingSave = (id: string, choice: ReviewChoice, detail: { note: string; reasonCode: ReferenceRejectionReasonCode | null }) => Promise<void> | void;
25
+
26
+ export type RatingController = {
27
+ get: (id: string) => ItemRating;
28
+ choose: (id: string, choice: ReviewChoice) => void;
29
+ setReason: (id: string, code: ReferenceRejectionReasonCode) => void;
30
+ setNote: (id: string, text: string) => void;
31
+ /** Save a typed reason now (on blur). */
32
+ commitNote: (id: string) => void;
33
+ cancelReason: (id: string) => void;
34
+ };
35
+
36
+ /**
37
+ * One store for the rating state of every item, shared by the grid card and the detail pane so a vote or a reason typed in either
38
+ * shows in both at once. `saved(id)` is the saved human answer; `seed(id)` the saved note and reason code.
39
+ * vote, then reason -> the reason is saved onto the existing decision (on blur, or after a short pause)
40
+ * reason, then vote -> the vote carries the reason
41
+ * A Reject needs a reason code (the backend refuses one without), so it waits as `pending` until one is chosen; a typed reason is
42
+ * kept in the store until it is saved and is never dropped by a failed save.
43
+ */
44
+ export function useRatingStore({ save, saved, seed }: {
45
+ save: RatingSave;
46
+ saved: (id: string) => ReviewChoice | null;
47
+ seed: (id: string) => { note: string; reasonCode: ReferenceRejectionReasonCode | null };
48
+ }): RatingController {
49
+ const [items, setItems] = useState<Record<string, ItemRating>>({});
50
+ const itemsRef = useRef(items);
51
+ const latest = useRef({ save, saved, seed });
52
+ latest.current = { save, saved, seed };
53
+ const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
54
+ const inFlight = useRef<Set<string>>(new Set());
55
+ const dirty = useRef<Set<string>>(new Set());
56
+
57
+ const patch = useCallback((id: string, change: Partial<ItemRating>) => {
58
+ const next = { ...itemsRef.current, [id]: { ...(itemsRef.current[id] ?? EMPTY), ...change } };
59
+ itemsRef.current = next;
60
+ setItems(next);
61
+ }, []);
62
+
63
+ // What a reader of the store sees: the touched value, else what is saved. (A saved reason therefore shows on the card too.)
64
+ const get = useCallback((id: string): ItemRating => {
65
+ const item = items[id] ?? EMPTY;
66
+ const seeded = latest.current.seed(id);
67
+ return { ...item, note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
68
+ }, [items]);
69
+
70
+ const values = (id: string) => {
71
+ const item = itemsRef.current[id] ?? EMPTY;
72
+ const seeded = latest.current.seed(id);
73
+ return { note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
74
+ };
75
+
76
+ const commit = useCallback(async (id: string, choice: ReviewChoice) => {
77
+ const { note, reasonCode } = values(id);
78
+ if (choice === "reject" && !reasonCode) { patch(id, { pending: "reject", askReason: true }); return; }
79
+ if (inFlight.current.has(id)) { dirty.current.add(id); return; }
80
+ inFlight.current.add(id);
81
+ dirty.current.delete(id);
82
+ const started = itemsRef.current[id] ?? EMPTY;
83
+ patch(id, { pending: choice, askReason: false, feedback: { phase: "saving" } });
84
+ try {
85
+ await latest.current.save(id, choice, { note: note.trim(), reasonCode: choice === "reject" ? reasonCode : null });
86
+ // What was saved now follows the record again (another editor's update shows); anything typed meanwhile is kept.
87
+ const now = itemsRef.current[id] ?? EMPTY;
88
+ patch(id, { pending: null, feedback: { phase: "saved" }, ...(now.note === started.note ? { note: undefined } : {}), ...(now.reasonCode === started.reasonCode ? { reasonCode: undefined } : {}) });
89
+ } catch (failure) {
90
+ // The typed reason stays in the store; only the vote is undone.
91
+ patch(id, { pending: null, feedback: { phase: "error", message: failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry." } });
92
+ } finally {
93
+ inFlight.current.delete(id);
94
+ }
95
+ if (dirty.current.has(id)) { dirty.current.delete(id); const again = itemsRef.current[id]?.pending ?? latest.current.saved(id); if (again) void commit(id, again); }
96
+ }, [patch]);
97
+
98
+ const effective = (id: string): ReviewChoice | null => itemsRef.current[id]?.pending ?? latest.current.saved(id);
99
+
100
+ const choose = useCallback((id: string, choice: ReviewChoice) => {
101
+ if (effective(id) === choice && !itemsRef.current[id]?.askReason) return; // the chosen answer never unselects itself
102
+ void commit(id, choice);
103
+ }, [commit]);
104
+
105
+ const setReason = useCallback((id: string, code: ReferenceRejectionReasonCode) => {
106
+ patch(id, { reasonCode: code });
107
+ // Vote then reason: onto the existing (or waiting) decision. Reason then vote: stored, and the vote carries it.
108
+ if (effective(id) === "reject") void commit(id, "reject");
109
+ }, [commit, patch]);
110
+
111
+ const setNote = useCallback((id: string, text: string) => {
112
+ patch(id, { note: text, feedback: { phase: "idle" } });
113
+ dirty.current.add(`note:${id}`);
114
+ clearTimeout(timers.current[id]);
115
+ timers.current[id] = setTimeout(() => {
116
+ const choice = effective(id);
117
+ if (choice && dirty.current.delete(`note:${id}`)) void commit(id, choice);
118
+ }, NOTE_AUTOSAVE_MS);
119
+ }, [commit, patch]);
120
+
121
+ const commitNote = useCallback((id: string) => {
122
+ clearTimeout(timers.current[id]);
123
+ const choice = effective(id);
124
+ if (choice && dirty.current.delete(`note:${id}`)) void commit(id, choice);
125
+ }, [commit]);
126
+
127
+ const cancelReason = useCallback((id: string) => patch(id, { pending: null, askReason: false }), [patch]);
128
+
129
+ useEffect(() => () => { for (const timer of Object.values(timers.current)) clearTimeout(timer); }, []);
130
+
131
+ return { get, choose, setReason, setNote, commitNote, cancelReason };
132
+ }
@@ -658,6 +658,8 @@ export type CategorySteeringDashboard = {
658
658
  isDemo?: boolean;
659
659
  /** Local preview only: the demo newsroom is showing a real export (scripts/export-reference-feed-fixture.py), so it must not say "synthetic". */
660
660
  realDataPreview?: boolean;
661
+ /** Local preview only: review requests and their drafts from scripts/export-notification-center-fixture.py. */
662
+ notificationFixture?: { requests: import("./review-requests").ReviewRequest[]; drafts: import("./review-drafts").DraftRow[] } | null;
661
663
  isPublicSkeleton?: boolean;
662
664
  summaryStatus?: "loading" | "missing" | "ready";
663
665
  summary?: NewsroomSummaryRecord | null;
@@ -30,6 +30,18 @@ export type SaveItemDraftResult = { ok: true; item: SavedItemSummary };
30
30
  export type PublishItemResult = { ok: true; changed: boolean; publishedId: string; versionNumber: number | null };
31
31
  export type UnpublishItemResult = { ok: true; changed: boolean };
32
32
 
33
+ export type CaptureAssessment = "complete" | "incomplete" | "incorrect";
34
+ export type CorrectReferenceCaptureInput = {
35
+ referenceId: string;
36
+ /** The full corrected or pasted text. */
37
+ text?: string;
38
+ grobid?: Record<string, unknown>;
39
+ /** Required with text; alone it is just a label on the capture. */
40
+ assessment?: CaptureAssessment;
41
+ note?: string;
42
+ };
43
+ export type CorrectReferenceCaptureResult = { ok: true; referenceId: string; status: string; edits: unknown[]; label: unknown; summaryStale: boolean };
44
+
33
45
  export class ContentActionFailure extends Error {
34
46
  readonly errors: ContentActionError[];
35
47
 
@@ -48,6 +60,7 @@ export type ContentActionsTransport = {
48
60
  saveItemDraft: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
49
61
  publishItem: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
50
62
  unpublishItem: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
63
+ correctReferenceCapture: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
51
64
  };
52
65
  };
53
66
 
@@ -80,6 +93,9 @@ export function createContentActionsClient(transport: ContentActionsTransport) {
80
93
  async publishItem(id: string): Promise<PublishItemResult> {
81
94
  return parseActionResponse(await transport.mutations.publishItem({ input: JSON.stringify({ id }) }, USER_POOL));
82
95
  },
96
+ async correctReferenceCapture(input: CorrectReferenceCaptureInput): Promise<CorrectReferenceCaptureResult> {
97
+ return parseActionResponse(await transport.mutations.correctReferenceCapture({ input: JSON.stringify(input) }, USER_POOL));
98
+ },
83
99
  async unpublishItem(id: string): Promise<UnpublishItemResult> {
84
100
  return parseActionResponse(await transport.mutations.unpublishItem({ input: JSON.stringify({ id }) }, USER_POOL));
85
101
  },
@@ -99,3 +115,5 @@ export const deriveMarkus = (input: DeriveMarkusInput) => contentActionsClient()
99
115
  export const saveItemDraft = (input: SaveItemDraftInput) => contentActionsClient().saveItemDraft(input);
100
116
  export const publishItem = (id: string) => contentActionsClient().publishItem(id);
101
117
  export const unpublishItem = (id: string) => contentActionsClient().unpublishItem(id);
118
+
119
+ export const correctReferenceCapture = (input: CorrectReferenceCaptureInput) => contentActionsClient().correctReferenceCapture(input);
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { normalizeReviewRow } from "./review-requests";
3
4
  import type { CategorySteeringDashboard, ReferenceAttachmentRecord, ReferenceRecord, SemanticRelationRecord } from "./category-repository";
4
5
 
5
6
  /**
@@ -29,11 +30,30 @@ export function loadReferenceFeedFixture(env: NodeJS.ProcessEnv = process.env):
29
30
  }
30
31
  }
31
32
 
33
+ export type NotificationCenterFixture = NonNullable<CategorySteeringDashboard["notificationFixture"]>;
34
+
35
+ /**
36
+ * Local preview only: when PAPYRUS_NOTIFICATION_FIXTURE names a JSON file written by scripts/export-notification-center-fixture.py,
37
+ * the demo Notification Center shows those review requests and their drafts instead of the invented sample.
38
+ */
39
+ export function loadNotificationCenterFixture(env: NodeJS.ProcessEnv = process.env): NotificationCenterFixture | null {
40
+ const configured = env.PAPYRUS_NOTIFICATION_FIXTURE?.trim();
41
+ if (!configured || env.NEXT_PUBLIC_PAPYRUS_LOCAL_PREVIEW !== "1") return null;
42
+ try {
43
+ const parsed = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), configured), "utf8"));
44
+ if (parsed?.schema !== "papyrus-notification-center-fixture/1" || !Array.isArray(parsed.requests)) return null;
45
+ return { requests: parsed.requests.map(normalizeReviewRow), drafts: Array.isArray(parsed.drafts) ? parsed.drafts : [] };
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
32
51
  export function withReferenceFeedFixture(dashboard: CategorySteeringDashboard): CategorySteeringDashboard {
33
52
  const fixture = loadReferenceFeedFixture();
34
53
  if (!fixture) return dashboard;
35
54
  return {
36
55
  ...dashboard,
56
+ notificationFixture: loadNotificationCenterFixture(),
37
57
  references: fixture.references,
38
58
  referenceAttachments: fixture.referenceAttachments,
39
59
  semanticRelations: fixture.semanticRelations,
@@ -21,7 +21,7 @@ export type NewsroomNavItem = {
21
21
  };
22
22
 
23
23
  export const NEWSROOM_OPS_NAV: NewsroomNavItem[] = [
24
- { id: "overview", label: "Overview", detail: "Newsroom home", href: newsroomHref() },
24
+ { id: "overview", label: "Needs me", detail: "Newsroom home", href: newsroomHref() },
25
25
  { id: "articles", label: getContentItemName().plural, detail: "Drafts & published", href: newsroomHref("articles") },
26
26
  { id: "assignments", label: "Assignments", detail: "Work queue", href: newsroomHref("assignments") },
27
27
  { id: "references", label: "References", detail: "Knowledge base", href: newsroomHref("references") },
@@ -8,7 +8,7 @@ export type OrganizerDestination = {
8
8
  };
9
9
  export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
10
10
  { title: "Editorial work", items: [
11
- { key: "overview", label: "Overview", description: "See notifications and work needing attention.", path: "/newsroom", primary: true },
11
+ { key: "overview", label: "Needs me", description: "See what needs you: drafts, references and reviews.", path: "/newsroom", primary: true },
12
12
  { key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: `/newsroom/${contentItemBoardPathSegment()}`, alsoActiveOnPaths: ["/newsroom/articles"], primary: true },
13
13
  { key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
14
14
  { key: "drafts", label: "Drafts", description: "Read the story drafts that AI proposes, then accept or reject them.", path: "/newsroom/administration/proposals" },