@anthusai/papyrus 5.44.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 (48) 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/amplify/functions/model-attachment-upload/handler.ts +1 -0
  7. package/amplify/functions/monitoring-view/schema.js +8 -0
  8. package/amplify/functions/monitoring-view/schema.ts +8 -0
  9. package/bin/papyrus-app.mjs +12 -1
  10. package/components/monitoring-overview-view.tsx +52 -6
  11. package/components/monitoring-panel.tsx +61 -3
  12. package/components/news-desk-client-provider.tsx +16 -1
  13. package/components/news-desk-page.tsx +2 -1
  14. package/components/newsroom-access-gate.tsx +7 -5
  15. package/components/newsroom-client-shell.tsx +2 -1
  16. package/components/newsroom-ops-overview.tsx +63 -60
  17. package/components/newsroom-ops-shell.tsx +4 -2
  18. package/components/newsroom-organizer.css +49 -2
  19. package/components/newsroom-references-view.tsx +296 -23
  20. package/components/notification-cards.tsx +364 -0
  21. package/components/notification-center.tsx +319 -331
  22. package/components/reference-capture-client.ts +110 -0
  23. package/components/reference-capture-panel.tsx +180 -0
  24. package/components/reference-feed.tsx +502 -0
  25. package/components/reference-review-card.tsx +32 -80
  26. package/components/references-page-loading.tsx +61 -0
  27. package/components/references-prefetch.ts +52 -0
  28. package/components/review-rating-grid.tsx +177 -0
  29. package/components/topic-steering-workspace.tsx +43 -9
  30. package/components/use-reference-ratings.ts +132 -0
  31. package/lib/category-repository.ts +6 -1
  32. package/lib/content-actions-client.ts +18 -0
  33. package/lib/local-preview-feed-fixture.ts +63 -0
  34. package/lib/local-preview-synthetic-rejects.ts +41 -0
  35. package/lib/monitoring-client.ts +16 -2
  36. package/lib/monitoring-overview.ts +20 -1
  37. package/lib/monitoring-paging.ts +58 -0
  38. package/lib/newsroom-nav.ts +1 -1
  39. package/lib/newsroom-organizer-nav.ts +1 -1
  40. package/lib/notification-feed.ts +250 -0
  41. package/lib/reference-capture.ts +293 -0
  42. package/lib/reference-feed-cache.ts +170 -0
  43. package/lib/reference-feed-ranking.ts +235 -0
  44. package/lib/reference-preview.ts +112 -0
  45. package/lib/review-drafts.ts +8 -3
  46. package/lib/site-brand.ts +2 -0
  47. package/package.json +17 -1
  48. package/routes.manifest.json +1 -1
@@ -0,0 +1,61 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import { LoaderIcon } from "lucide-react";
5
+ import { SITE_BRAND } from "../lib/site-brand";
6
+ import { loadFeedSnapshot, type FeedSnapshot } from "../lib/reference-feed-cache";
7
+ import { partitionReferenceFeed, type ReferenceFeedTabKey } from "../lib/reference-feed-ranking";
8
+ import { selectCanonicalReferenceRecords } from "../lib/newsroom-references";
9
+ import { ReferenceFeed, ReferenceFeedSkeleton } from "./reference-feed";
10
+
11
+ /**
12
+ * What the References page shows while the session and the first reads are still resolving: the real page header at once,
13
+ * then this browser's last saved view (read only, answering switches on when the session is confirmed), or card skeletons
14
+ * when there is none. Never a full-screen "checking" screen.
15
+ */
16
+ export function ReferencesPageLoading() {
17
+ const [snapshot, setSnapshot] = useState<FeedSnapshot | null>(null);
18
+ const [checked, setChecked] = useState(false);
19
+ const [tab, setTab] = useState<ReferenceFeedTabKey>("feed");
20
+ const [note, setNote] = useState<string | null>(null);
21
+ const [now] = useState(() => Date.now());
22
+ useEffect(() => {
23
+ setSnapshot(loadFeedSnapshot(SITE_BRAND.id));
24
+ setChecked(true);
25
+ }, []);
26
+ const references = useMemo(() => selectCanonicalReferenceRecords(snapshot?.references ?? []), [snapshot]);
27
+ const decisions = useMemo(() => new Map(snapshot?.decisions ?? []), [snapshot]);
28
+ const previews = useMemo(() => new Map(snapshot?.previews ?? []), [snapshot]);
29
+ const partition = useMemo(() => partitionReferenceFeed(references, decisions, { now }), [references, decisions, now]);
30
+ const noVisitRated = useMemo(() => new Set<string>(), []);
31
+
32
+ return (
33
+ <div className="space-y-4" data-references-page-loading>
34
+ <div className="space-y-1">
35
+ <h2 className="text-2xl font-bold tracking-tight">References</h2>
36
+ <p className="text-sm text-muted-foreground">A feed ranked by what you are most likely to accept.</p>
37
+ </div>
38
+ {note ? <p className="text-sm text-muted-foreground" role="status">{note}</p> : null}
39
+ {checked && references.length ? (
40
+ <ReferenceFeed
41
+ disabledReason="Confirming your sign-in. You can answer in a moment."
42
+ now={now}
43
+ onOpen={() => setNote("Opening a reference is available in a moment, once your sign-in is confirmed.")}
44
+ onRate={() => undefined}
45
+ onTabChange={setTab}
46
+ onVisitRated={() => undefined}
47
+ partition={partition}
48
+ previews={previews}
49
+ stale
50
+ tab={tab}
51
+ visitRated={noVisitRated}
52
+ />
53
+ ) : (
54
+ <>
55
+ <p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground" role="status"><LoaderIcon aria-hidden="true" className="size-3.5 animate-spin" />Loading your references</p>
56
+ <ReferenceFeedSkeleton />
57
+ </>
58
+ )}
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Starts the References reads as soon as the editor session is confirmed, in parallel with the summary dashboard, instead of after it.
3
+ * The page that needs them takes the in-flight request once; anything later, or a retry, makes a fresh read. This is a head start
4
+ * on the same reads, not a cache: the backend authorizes each call exactly as before.
5
+ */
6
+ import {
7
+ loadEditorMessagesData,
8
+ loadEditorReferencesData,
9
+ loadEditorSemanticRelationsData,
10
+ } from "./news-desk-taxonomy-client";
11
+
12
+ const HEAD_START_MS = 60_000;
13
+ type Started = { startedAt: number; promise: Promise<unknown> };
14
+ const started = new Map<string, Started>();
15
+
16
+ const LOADERS = {
17
+ references: loadEditorReferencesData,
18
+ relations: loadEditorSemanticRelationsData,
19
+ messages: loadEditorMessagesData,
20
+ } as const;
21
+ type Key = keyof typeof LOADERS;
22
+
23
+ /** Begin all three reads. Safe to call twice; a failure is reported to whoever takes the read, never as an unhandled rejection. */
24
+ export function prefetchReferencesFeed(now = Date.now()) {
25
+ for (const key of Object.keys(LOADERS) as Key[]) {
26
+ if (started.has(key)) continue;
27
+ const promise = LOADERS[key]();
28
+ promise.catch(() => undefined);
29
+ started.set(key, { startedAt: now, promise });
30
+ }
31
+ }
32
+
33
+ function take<Key2 extends Key>(key: Key2, now = Date.now()): ReturnType<(typeof LOADERS)[Key2]> {
34
+ const head = started.get(key);
35
+ started.delete(key);
36
+ if (head && now - head.startedAt <= HEAD_START_MS) return head.promise as ReturnType<(typeof LOADERS)[Key2]>;
37
+ return LOADERS[key]() as ReturnType<(typeof LOADERS)[Key2]>;
38
+ }
39
+
40
+ export const loadReferencesWithHeadStart = () => take("references");
41
+ export const loadRelationsWithHeadStart = () => take("relations");
42
+ export const loadMessagesWithHeadStart = () => take("messages");
43
+
44
+ /** Forget any unclaimed head start (sign-out). */
45
+ export function discardReferencesHeadStart() {
46
+ started.clear();
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
+ }
@@ -0,0 +1,177 @@
1
+ "use client";
2
+
3
+ import { CheckIcon, CircleCheckIcon, CircleXIcon, Loader2Icon, XIcon } from "lucide-react";
4
+ import { UNRECORDED_CONFIDENCE } from "../lib/reference-feed-ranking";
5
+ import { cn } from "../lib/utils";
6
+ import type { CardPrediction, ReviewChoice } from "./reference-review-card";
7
+
8
+ /**
9
+ * The rating area of a card: one grid, three columns (label | Accept | Reject), and the two rows share its columns exactly.
10
+ * Cyclotron [ Accept 96% ] [ Reject ] read only; the predicted side is highlighted with its confidence
11
+ * [====== confidence bar, centred at 50% ======]
12
+ * You [ Accept ] [ Reject ] the real buttons, each directly under its matching cell
13
+ * Feedback is never colour alone: before you answer the predicted cell is neutral-accent with a check or cross; after, it turns
14
+ * green with a CircleCheck (Match) or red with a CircleX (Miss), and the confidence bar tints the same way. Your chosen button is
15
+ * filled. Flat tones and rounded shapes only, no borders.
16
+ */
17
+
18
+ export type RatingFeedback = { phase: "idle" | "saving" | "saved" | "error"; message?: string };
19
+
20
+ /** P(accept) from a prediction: its confidence is in the side it chose; an unrecorded confidence is a mild lean. */
21
+ export function predictionAcceptProbability(prediction: Extract<CardPrediction, { state: "ready" }>): number {
22
+ const confidence = prediction.confidence ?? UNRECORDED_CONFIDENCE;
23
+ return prediction.choice === "accept" ? confidence : 1 - confidence;
24
+ }
25
+
26
+ /** How far, in percent of the whole bar, the fill reaches from the centre: |P(accept) - 0.5| * 2, over the two halves. */
27
+ export function confidenceFillPercent(probability: number): number {
28
+ return Math.min(100, Math.abs(probability - 0.5) * 2 * 100);
29
+ }
30
+
31
+ const WORD: Record<ReviewChoice, string> = { accept: "Accept", reject: "Reject" };
32
+
33
+ const TONE = {
34
+ match: "bg-emerald-600/20 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-100",
35
+ miss: "bg-red-600/20 text-red-900 dark:bg-red-400/20 dark:text-red-100",
36
+ } as const;
37
+ const FILL = { match: "bg-emerald-600 dark:bg-emerald-400", miss: "bg-red-600 dark:bg-red-400", neutral: "bg-[var(--primary)]" } as const;
38
+
39
+ export function RatingGrid({
40
+ answer,
41
+ decided = answer,
42
+ disabled = false,
43
+ disabledReason = null,
44
+ feedback = { phase: "idle" },
45
+ note = null,
46
+ onChoose,
47
+ prediction,
48
+ showPrediction = true,
49
+ size = "compact",
50
+ surface = "footer",
51
+ }: {
52
+ /** The side the buttons show as chosen (it may be a pending choice that is still saving). */
53
+ answer: ReviewChoice | null;
54
+ /** The saved human answer, which the Match / Miss verdict compares against. */
55
+ decided?: ReviewChoice | null;
56
+ disabled?: boolean;
57
+ disabledReason?: string | null;
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;
61
+ onChoose: (choice: ReviewChoice) => void;
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;
65
+ size?: "compact" | "large";
66
+ /** The tone the grid sits on, so the bar's centre notch matches it. */
67
+ surface?: "footer" | "card";
68
+ }) {
69
+ const ready = prediction.state === "ready" ? prediction : null;
70
+ const verdict = ready && decided ? (ready.choice === decided ? "match" : "miss") : null;
71
+ const probability = ready ? predictionAcceptProbability(ready) : 0.5;
72
+ const half = confidenceFillPercent(probability) / 2; // percent of the full bar width, on one side of the centre
73
+ const leansAccept = probability >= 0.5;
74
+ const confidenceText = ready && ready.confidence !== null ? `${Math.round(ready.confidence * 100)}%` : null;
75
+ const cell = size === "large" ? "min-h-14 text-base" : "min-h-12 text-sm";
76
+ const label = size === "large" ? "text-sm" : "text-xs";
77
+
78
+ return (
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">
88
+ <span className={cn("self-center font-semibold", label)}>Cyclotron</span>
89
+ {(["accept", "reject"] as const).map((choice) => {
90
+ const predicted = ready?.choice === choice;
91
+ const Icon = predicted ? (verdict === "match" ? CircleCheckIcon : verdict === "miss" ? CircleXIcon : choice === "accept" ? CheckIcon : XIcon) : null;
92
+ return (
93
+ <span
94
+ className={cn(
95
+ "flex flex-col items-center justify-center rounded-[16px] px-2 py-1.5 text-center",
96
+ cell,
97
+ !predicted && "bg-[var(--feed-control)]/45 text-muted-foreground",
98
+ predicted && !verdict && "bg-[var(--feed-control)] font-semibold text-[var(--primary)]",
99
+ predicted && verdict && cn("font-semibold", TONE[verdict]),
100
+ )}
101
+ data-cyclotron-choice={choice}
102
+ data-predicted={predicted || undefined}
103
+ data-verdict={predicted && verdict ? verdict : undefined}
104
+ key={choice}
105
+ >
106
+ <span className="inline-flex items-center gap-1.5">
107
+ {Icon ? <Icon aria-hidden="true" className="size-4 shrink-0" /> : null}
108
+ {WORD[choice]}
109
+ {predicted && confidenceText ? <small className="text-xs font-normal opacity-80">{confidenceText}</small> : null}
110
+ </span>
111
+ {predicted && verdict ? <small className="text-xs font-medium" data-verdict-word>{verdict === "match" ? "Match" : "Miss"}</small> : null}
112
+ {predicted && verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
113
+ </span>
114
+ );
115
+ })}
116
+ <div className="col-span-2 col-start-2 flex flex-col gap-1" data-confidence>
117
+ <div
118
+ aria-label={ready ? `Cyclotron confidence: leans ${leansAccept ? "Accept" : "Reject"}, ${Math.round(confidenceFillPercent(probability))} percent of the way` : "No confidence"}
119
+ className="relative h-2.5 w-full overflow-hidden rounded-full bg-[var(--feed-control)]"
120
+ data-confidence-bar
121
+ data-confidence-direction={ready ? (leansAccept ? "accept" : "reject") : "none"}
122
+ role="img"
123
+ >
124
+ {ready ? (
125
+ <span
126
+ className={cn("absolute inset-y-0 rounded-full", FILL[verdict ?? "neutral"])}
127
+ data-confidence-fill
128
+ data-fill-percent={Math.round(half * 2)}
129
+ style={{ left: leansAccept ? `${50 - half}%` : "50%", width: `${half}%` }}
130
+ />
131
+ ) : null}
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)]")} />
133
+ </div>
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>
138
+ </div>
139
+ </div> : null}
140
+
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}
143
+ {(["accept", "reject"] as const).map((choice) => {
144
+ const on = answer === choice;
145
+ const Icon = choice === "accept" ? CheckIcon : XIcon;
146
+ return (
147
+ <button
148
+ aria-disabled={disabled ? true : undefined}
149
+ aria-pressed={on}
150
+ className={cn(
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",
152
+ cell,
153
+ on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)] text-foreground hover:bg-[var(--feed-control)]/70",
154
+ disabled && !on && "opacity-60",
155
+ )}
156
+ data-review-choice={choice}
157
+ key={choice}
158
+ onClick={() => onChoose(choice)}
159
+ type="button"
160
+ >
161
+ <Icon aria-hidden="true" className={cn("size-4", !on && "opacity-60")} />
162
+ {WORD[choice]}
163
+ </button>
164
+ );
165
+ })}
166
+ </div>
167
+
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}
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}
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}
172
+ </div>
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}
175
+ </div>
176
+ );
177
+ }
@@ -94,6 +94,10 @@ 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";
98
+ import { loadMessagesWithHeadStart, loadReferencesWithHeadStart, loadRelationsWithHeadStart } from "./references-prefetch";
99
+ import { loadFeedSnapshot, saveFeedSnapshot, type FeedSnapshot } from "../lib/reference-feed-cache";
100
+ import { referencePreviewsByLineage } from "../lib/reference-preview";
97
101
  import { NewsroomOpsOverview } from "./newsroom-ops-overview";
98
102
  import { NewsroomCurationBootstrap } from "./newsroom-curation-bootstrap";
99
103
  import { NewsroomReferencesView } from "./newsroom-references-view";
@@ -696,7 +700,7 @@ export function NewsDeskWorkspace({
696
700
  }
697
701
 
698
702
  if (dashboard?.isPublicSkeleton && session?.shell.phase !== "ready") {
699
- return <NewsDeskAccessGate shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
703
+ return <NewsDeskAccessGate initialTab={initialTab} shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
700
704
  }
701
705
 
702
706
  if (dashboard) {
@@ -716,7 +720,7 @@ export function NewsDeskWorkspace({
716
720
  );
717
721
  }
718
722
 
719
- return <NewsDeskAccessGate shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
723
+ return <NewsDeskAccessGate initialTab={initialTab} shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
720
724
  }
721
725
 
722
726
  function NewsDeskDashboard({
@@ -777,9 +781,16 @@ function NewsDeskDashboard({
777
781
  const [referenceAttachments, setReferenceAttachments] = useState(dashboard.referenceAttachments);
778
782
  const [messages, setMessages] = useState(dashboard.messages);
779
783
  const [semanticRelations, setSemanticRelations] = useState(dashboard.semanticRelations);
784
+ const [savedFeedCopy, setSavedFeedCopy] = useState<Pick<FeedSnapshot, "references" | "previews" | "decisions"> | null>(null);
785
+ useEffect(() => {
786
+ // Display only: the last view this browser saved, shown until the live read arrives.
787
+ if (dashboard.isDemo) return;
788
+ const snapshot = loadFeedSnapshot(SITE_BRAND.id);
789
+ if (snapshot) setSavedFeedCopy(snapshot);
790
+ }, [dashboard.isDemo]);
780
791
  const cyclotronPreviewFixtures = useMemo(
781
- () => (isLocalPreviewEnabled() ? createLocalPreviewCyclotronFixtures(references) : null),
782
- [references],
792
+ () => (isLocalPreviewEnabled() && !semanticRelations.some((relation) => relation.predicate === "relevance_decision_is") ? createLocalPreviewCyclotronFixtures(references) : null),
793
+ [references, semanticRelations],
783
794
  );
784
795
  const relevanceDecisions = useMemo(
785
796
  () => currentRelevanceDecisions(cyclotronPreviewFixtures ? [...semanticRelations, ...cyclotronPreviewFixtures.relations] : semanticRelations),
@@ -1215,7 +1226,7 @@ function NewsDeskDashboard({
1215
1226
  useEffect(() => {
1216
1227
  if (!canLoadPrivateCorpus || hasHydratedReferences) return;
1217
1228
  let active = true;
1218
- void loadEditorReferencesData()
1229
+ void loadReferencesWithHeadStart()
1219
1230
  .then(({ references: nextReferences, referenceAttachments }) => {
1220
1231
  if (!active) return;
1221
1232
  setReferences(nextReferences);
@@ -1322,12 +1333,12 @@ function NewsDeskDashboard({
1322
1333
  refreshNewsroomSections,
1323
1334
  ]);
1324
1335
 
1325
- const needsReferenceReviewData = activeTab === "references" || activeTab === "assignments" || activeTab === "messages";
1336
+ const needsReferenceReviewData = activeTab === "overview" || activeTab === "references" || activeTab === "assignments" || activeTab === "messages";
1326
1337
  const selectedReviewLineageId = pathname ? pathnameReferenceLineageId ?? "" : initialSelection.reference ?? "";
1327
1338
  const referenceReviewData = useReferenceReviewData({
1328
1339
  enabled: canLoadPrivateCorpus && needsReferenceReviewData && !(activeTab === "references" && selectedReviewLineageId),
1329
- loadRelations: loadEditorSemanticRelationsData,
1330
- loadMessages: loadEditorMessagesData,
1340
+ loadRelations: loadRelationsWithHeadStart,
1341
+ loadMessages: loadMessagesWithHeadStart,
1331
1342
  onRelations: setSemanticRelations,
1332
1343
  onMessages: setMessages,
1333
1344
  });
@@ -1351,6 +1362,18 @@ function NewsDeskDashboard({
1351
1362
  ? selectedReviewRead.lineageId === selectedReviewLineageId ? selectedReviewRead.state : "loading"
1352
1363
  : referenceReviewData.state;
1353
1364
  const predictionError = selectedReviewIsCurrent ? selectedReviewRead.error : referenceReviewData.error;
1365
+ useEffect(() => {
1366
+ // Keep a display copy for the next visit once everything the feed shows has been read.
1367
+ if (dashboard.isDemo || (activeTab !== "references" && activeTab !== "overview") || !hasHydratedReferences || predictionState !== "ready" || !references.length) return;
1368
+ const timer = window.setTimeout(() => {
1369
+ saveFeedSnapshot(SITE_BRAND.id, {
1370
+ references: selectCanonicalReferenceRecords(references),
1371
+ previews: referencePreviewsByLineage(referenceAttachments),
1372
+ decisions: relevanceDecisions,
1373
+ });
1374
+ }, 800);
1375
+ return () => window.clearTimeout(timer);
1376
+ }, [activeTab, dashboard.isDemo, hasHydratedReferences, predictionState, referenceAttachments, references, relevanceDecisions]);
1354
1377
  const retryReferenceReviewData = () => { referenceReviewData.retry(); setSelectedReviewAttempt((current) => current + 1); };
1355
1378
 
1356
1379
  const applyReferenceRecord = useCallback((nextReference: ReferenceRecord) => {
@@ -3035,7 +3058,7 @@ function NewsDeskDashboard({
3035
3058
  headerActions={(
3036
3059
  <>
3037
3060
  {dashboard.isDemo ? (
3038
- <Badge variant="outline">Demo</Badge>
3061
+ dashboard.realDataPreview ? null : <Badge variant="outline">Demo</Badge>
3039
3062
  ) : (
3040
3063
  <Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }))} href="/settings">Settings</Link>
3041
3064
  )}
@@ -3081,9 +3104,17 @@ function NewsDeskDashboard({
3081
3104
  assignments={assignments}
3082
3105
  decisions={relevanceDecisions}
3083
3106
  demo={Boolean(dashboard.isDemo)}
3107
+ disabledReason={controlsDisabled ? "Editor access is required to change this reference." : null}
3084
3108
  humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
3109
+ loading={!dashboard.isDemo && !hasHydratedReferences}
3085
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}
3086
3116
  references={references}
3117
+ savedCopy={savedFeedCopy}
3087
3118
  />
3088
3119
  ) : null}
3089
3120
  {activeTab === "search" ? (
@@ -3182,6 +3213,9 @@ function NewsDeskDashboard({
3182
3213
  options?.sectionId)}
3183
3214
  referenceAttachments={referenceAttachments}
3184
3215
  references={references}
3216
+ loading={!dashboard.isDemo && !hasHydratedReferences}
3217
+ onCorrectCapture={dashboard.isDemo ? undefined : (input) => correctReferenceCapture(input)}
3218
+ savedCopy={savedFeedCopy}
3185
3219
  />
3186
3220
  </NewsroomCurationBootstrap>
3187
3221
  ) : null}
@@ -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
+ }
@@ -656,6 +656,10 @@ export type NewsroomSummaryFacets = {
656
656
 
657
657
  export type CategorySteeringDashboard = {
658
658
  isDemo?: boolean;
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
+ 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;
659
663
  isPublicSkeleton?: boolean;
660
664
  summaryStatus?: "loading" | "missing" | "ready";
661
665
  summary?: NewsroomSummaryRecord | null;
@@ -820,7 +824,8 @@ function sortCategorys(categorys: CategorySteeringCategory[]): CategorySteeringC
820
824
  }
821
825
 
822
826
  function defaultNewsroomSections(importedAt: string, sectionsConfigPath?: string): NewsroomSectionRecord[] {
823
- if (!sectionsConfigPath) return [];
827
+ // A data-driven site names a seed file it does not ship: its Sections live in the backend, so there is nothing to seed.
828
+ if (!sectionsConfigPath || !fs.existsSync(sectionsConfigPath)) return [];
824
829
  return loadNewsroomSectionSeedRows(sectionsConfigPath).map((row, index) => ({
825
830
  ...row,
826
831
  enabledStatus: "enabled",
@@ -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);