@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
@@ -12,6 +12,7 @@ import {
12
12
  import { useEffect, useRef, useState, type ReactNode, type PointerEvent as ReactPointerEvent } from "react";
13
13
  import { Button } from "./ui/button";
14
14
  import { cn } from "../lib/utils";
15
+ import { RatingGrid } from "./review-rating-grid";
15
16
  import { REFERENCE_REJECTION_REASON_CODES, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
16
17
 
17
18
  export type ReviewChoice = "accept" | "reject";
@@ -44,6 +45,84 @@ function percent(confidence: number | null): string | null {
44
45
  return confidence === null ? null : `${Math.round(confidence * 100)}%`;
45
46
  }
46
47
 
48
+ /**
49
+ * The Cyclotron's read-only row: its Accept or Reject highlighted with its confidence, and a check or cross once a person has answered.
50
+ * Shared by the review card and by every list that shows a suggestion before a decision.
51
+ */
52
+ export function CyclotronRow({ decided = null, prediction }: { decided?: ReviewChoice | null; prediction: CardPrediction }) {
53
+ const matched = decided && prediction.state === "ready" ? prediction.choice === decided : null;
54
+ return (
55
+ <div className="space-y-2" data-review-row="cyclotron">
56
+ <div className="flex items-center gap-3">
57
+ <span className="w-20 shrink-0 text-sm font-semibold">Cyclotron</span>
58
+ {prediction.state === "ready" ? (
59
+ <div className="flex flex-1 gap-2">
60
+ {(["accept", "reject"] as const).map((choice) => {
61
+ const predicted = prediction.state === "ready" && prediction.choice === choice;
62
+ const verdict = predicted && matched !== null ? (matched ? "match" : "miss") : null;
63
+ return (
64
+ <span
65
+ className={cn(
66
+ "inline-flex min-h-10 flex-1 items-center justify-center gap-1.5 rounded-md border px-3 text-sm",
67
+ predicted ? "border-foreground bg-muted font-semibold" : "border-dashed text-muted-foreground",
68
+ verdict === "match" && "border-green-700 bg-green-100 text-green-950",
69
+ verdict === "miss" && "border-red-700 bg-red-100 text-red-950",
70
+ )}
71
+ data-cyclotron-choice={choice}
72
+ data-predicted={predicted || undefined}
73
+ data-verdict={verdict ?? undefined}
74
+ key={choice}
75
+ >
76
+ {verdict === "match" ? <CircleCheckIcon aria-hidden="true" className="size-4" /> : null}
77
+ {verdict === "miss" ? <CircleXIcon aria-hidden="true" className="size-4" /> : null}
78
+ {CHOICE_LABEL[choice]}
79
+ {predicted && percent(prediction.confidence) ? <small className="text-xs font-normal">{percent(prediction.confidence)}</small> : null}
80
+ {verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
81
+ </span>
82
+ );
83
+ })}
84
+ </div>
85
+ ) : (
86
+ <p className="text-sm text-muted-foreground" data-cyclotron-state={prediction.state}>
87
+ {prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
88
+ </p>
89
+ )}
90
+ </div>
91
+ </div>
92
+ );
93
+ }
94
+
95
+ /** The two answers, large enough to tap: Accept and Reject, the chosen one filled and checked (never colour alone). */
96
+ export function ChoiceButtons({ chosen, disabled = false, onChoose }: { chosen: ReviewChoice | null; disabled?: boolean; onChoose: (choice: ReviewChoice) => void }) {
97
+ return (
98
+ <div className="flex flex-1 gap-2">
99
+ {(["accept", "reject"] as const).map((choice) => {
100
+ const on = chosen === choice;
101
+ return (
102
+ <Button
103
+ aria-disabled={disabled ? true : undefined}
104
+ aria-pressed={on}
105
+ className="min-h-14 flex-1 text-base"
106
+ data-review-choice={choice}
107
+ key={choice}
108
+ onClick={() => onChoose(choice)}
109
+ type="button"
110
+ variant={on ? (choice === "accept" ? "default" : "destructive") : "outline"}
111
+ >
112
+ {on ? <CheckIcon aria-hidden="true" className="size-5" /> : null}
113
+ {CHOICE_LABEL[choice]}
114
+ </Button>
115
+ );
116
+ })}
117
+ </div>
118
+ );
119
+ }
120
+
121
+ /** A reference's relevance decision in the card's own vocabulary. */
122
+ export function predictionFromDecision(decision: { label: string; positiveLabel: string | null; confidence: number | null }): CardPrediction {
123
+ return { state: "ready", choice: decision.label === (decision.positiveLabel ?? "include") ? "accept" : "reject", confidence: decision.confidence };
124
+ }
125
+
47
126
  /**
48
127
  * One reusable card for answering a single question: Accept or Reject. The Cyclotron's row is
49
128
  * read-only; the human row is the real control and its answer is final. Saves on tap.
@@ -156,12 +235,6 @@ export function ReviewCard({
156
235
  return () => window.removeEventListener("keydown", onKey);
157
236
  });
158
237
 
159
- const predictedMarker = (choice: ReviewChoice) => {
160
- if (prediction.state !== "ready" || prediction.choice !== choice) return null;
161
- return prediction.choice;
162
- };
163
- const matched = decided && prediction.state === "ready" ? prediction.choice === decided : null;
164
-
165
238
  return (
166
239
  <section aria-label="Review" className="space-y-4" data-review-card={itemId}>
167
240
  <header className="space-y-1">
@@ -175,76 +248,17 @@ export function ReviewCard({
175
248
  {summary ? <p className="text-sm text-muted-foreground">{summary}</p> : null}
176
249
  </header>
177
250
 
178
- <div className="space-y-2" data-review-row="cyclotron">
179
- <div className="flex items-center gap-3">
180
- <span className="w-20 shrink-0 text-sm font-semibold">Cyclotron</span>
181
- {prediction.state === "ready" ? (
182
- <div className="flex flex-1 gap-2">
183
- {(["accept", "reject"] as const).map((choice) => {
184
- const predicted = predictedMarker(choice) !== null;
185
- const verdict = predicted && matched !== null ? (matched ? "match" : "miss") : null;
186
- return (
187
- <span
188
- className={cn(
189
- "inline-flex min-h-10 flex-1 items-center justify-center gap-1.5 rounded-md border px-3 text-sm",
190
- predicted ? "border-foreground bg-muted font-semibold" : "border-dashed text-muted-foreground",
191
- verdict === "match" && "border-green-700 bg-green-100 text-green-950",
192
- verdict === "miss" && "border-red-700 bg-red-100 text-red-950",
193
- )}
194
- data-cyclotron-choice={choice}
195
- data-predicted={predicted || undefined}
196
- data-verdict={verdict ?? undefined}
197
- key={choice}
198
- >
199
- {verdict === "match" ? <CircleCheckIcon aria-hidden="true" className="size-4" /> : null}
200
- {verdict === "miss" ? <CircleXIcon aria-hidden="true" className="size-4" /> : null}
201
- {CHOICE_LABEL[choice]}
202
- {predicted && percent(prediction.confidence) ? <small className="text-xs font-normal">{percent(prediction.confidence)}</small> : null}
203
- {verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
204
- </span>
205
- );
206
- })}
207
- </div>
208
- ) : (
209
- <p className="text-sm text-muted-foreground" data-cyclotron-state={prediction.state}>
210
- {prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
211
- </p>
212
- )}
213
- </div>
214
- </div>
215
-
216
- <div className="space-y-2" data-review-row="you">
217
- <div className="flex items-center gap-3">
218
- <span className="w-20 shrink-0 text-sm font-semibold">You</span>
219
- <div className="flex flex-1 gap-2">
220
- {(["accept", "reject"] as const).map((choice) => {
221
- const on = chosen === choice;
222
- return (
223
- <Button
224
- aria-disabled={disabledReason ? true : undefined}
225
- aria-pressed={on}
226
- className="min-h-14 flex-1 text-base"
227
- data-review-choice={choice}
228
- key={choice}
229
- onClick={() => choose(choice)}
230
- type="button"
231
- variant={on ? (choice === "accept" ? "default" : "destructive") : "outline"}
232
- >
233
- {on ? <CheckIcon aria-hidden="true" className="size-5" /> : null}
234
- {CHOICE_LABEL[choice]}
235
- </Button>
236
- );
237
- })}
238
- </div>
239
- </div>
240
- <div className="min-h-5 pl-[5.75rem] text-sm" role="status">
241
- {saving ? "Saving…" : saved ? (
242
- <span className="inline-flex items-center gap-1" data-review-saved><CheckIcon aria-hidden="true" className="size-4" />Saved</span>
243
- ) : null}
244
- </div>
245
- {disabledReason ? <p className="text-sm text-muted-foreground" data-review-disabled-reason>{disabledReason}</p> : null}
246
- {error ? <p className="text-sm text-destructive" role="alert">{error}</p> : null}
247
- </div>
251
+ <RatingGrid
252
+ answer={chosen}
253
+ decided={decided}
254
+ disabled={Boolean(disabledReason)}
255
+ disabledReason={disabledReason}
256
+ feedback={saving ? { phase: "saving" } : error ? { phase: "error", message: error } : saved ? { phase: "saved" } : { phase: "idle" }}
257
+ onChoose={choose}
258
+ prediction={prediction}
259
+ size="large"
260
+ surface="card"
261
+ />
248
262
 
249
263
  <label className="block text-sm font-medium">
250
264
  Why? (optional)
@@ -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,47 @@
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
+ }
@@ -0,0 +1,164 @@
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
+ onChoose,
46
+ prediction,
47
+ size = "compact",
48
+ surface = "footer",
49
+ }: {
50
+ /** The side the buttons show as chosen (it may be a pending choice that is still saving). */
51
+ answer: ReviewChoice | null;
52
+ /** The saved human answer, which the Match / Miss verdict compares against. */
53
+ decided?: ReviewChoice | null;
54
+ disabled?: boolean;
55
+ disabledReason?: string | null;
56
+ feedback?: RatingFeedback;
57
+ onChoose: (choice: ReviewChoice) => void;
58
+ prediction: CardPrediction;
59
+ size?: "compact" | "large";
60
+ /** The tone the grid sits on, so the bar's centre notch matches it. */
61
+ surface?: "footer" | "card";
62
+ }) {
63
+ const ready = prediction.state === "ready" ? prediction : null;
64
+ const verdict = ready && decided ? (ready.choice === decided ? "match" : "miss") : null;
65
+ const probability = ready ? predictionAcceptProbability(ready) : 0.5;
66
+ const half = confidenceFillPercent(probability) / 2; // percent of the full bar width, on one side of the centre
67
+ const leansAccept = probability >= 0.5;
68
+ const confidenceText = ready && ready.confidence !== null ? `${Math.round(ready.confidence * 100)}%` : null;
69
+ const cell = size === "large" ? "min-h-14 text-base" : "min-h-12 text-sm";
70
+ const label = size === "large" ? "text-sm" : "text-xs";
71
+
72
+ 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">
75
+ <span className={cn("self-center font-semibold", label)}>Cyclotron</span>
76
+ {(["accept", "reject"] as const).map((choice) => {
77
+ const predicted = ready?.choice === choice;
78
+ const Icon = predicted ? (verdict === "match" ? CircleCheckIcon : verdict === "miss" ? CircleXIcon : choice === "accept" ? CheckIcon : XIcon) : null;
79
+ return (
80
+ <span
81
+ className={cn(
82
+ "flex flex-col items-center justify-center rounded-[16px] px-2 py-1.5 text-center",
83
+ cell,
84
+ !predicted && "bg-[var(--feed-control)]/45 text-muted-foreground",
85
+ predicted && !verdict && "bg-[var(--feed-control)] font-semibold text-[var(--primary)]",
86
+ predicted && verdict && cn("font-semibold", TONE[verdict]),
87
+ )}
88
+ data-cyclotron-choice={choice}
89
+ data-predicted={predicted || undefined}
90
+ data-verdict={predicted && verdict ? verdict : undefined}
91
+ key={choice}
92
+ >
93
+ <span className="inline-flex items-center gap-1.5">
94
+ {Icon ? <Icon aria-hidden="true" className="size-4 shrink-0" /> : null}
95
+ {WORD[choice]}
96
+ {predicted && confidenceText ? <small className="text-xs font-normal opacity-80">{confidenceText}</small> : null}
97
+ </span>
98
+ {predicted && verdict ? <small className="text-xs font-medium" data-verdict-word>{verdict === "match" ? "Match" : "Miss"}</small> : null}
99
+ {predicted && verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
100
+ </span>
101
+ );
102
+ })}
103
+ <div className="col-span-2 col-start-2 flex flex-col gap-1" data-confidence>
104
+ <div
105
+ aria-label={ready ? `Cyclotron confidence: leans ${leansAccept ? "Accept" : "Reject"}, ${Math.round(confidenceFillPercent(probability))} percent of the way` : "No confidence"}
106
+ className="relative h-2.5 w-full overflow-hidden rounded-full bg-[var(--feed-control)]"
107
+ data-confidence-bar
108
+ data-confidence-direction={ready ? (leansAccept ? "accept" : "reject") : "none"}
109
+ role="img"
110
+ >
111
+ {ready ? (
112
+ <span
113
+ className={cn("absolute inset-y-0 rounded-full", FILL[verdict ?? "neutral"])}
114
+ data-confidence-fill
115
+ data-fill-percent={Math.round(half * 2)}
116
+ style={{ left: leansAccept ? `${50 - half}%` : "50%", width: `${half}%` }}
117
+ />
118
+ ) : null}
119
+ <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
+ </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
+ )}
126
+ </div>
127
+ </div>
128
+
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>
131
+ {(["accept", "reject"] as const).map((choice) => {
132
+ const on = answer === choice;
133
+ const Icon = choice === "accept" ? CheckIcon : XIcon;
134
+ return (
135
+ <button
136
+ aria-disabled={disabled ? true : undefined}
137
+ aria-pressed={on}
138
+ className={cn(
139
+ "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
+ cell,
141
+ 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",
143
+ )}
144
+ data-review-choice={choice}
145
+ key={choice}
146
+ onClick={() => onChoose(choice)}
147
+ type="button"
148
+ >
149
+ <Icon aria-hidden="true" className={cn("size-4", !on && "opacity-60")} />
150
+ {WORD[choice]}
151
+ </button>
152
+ );
153
+ })}
154
+ </div>
155
+
156
+ <div className="col-span-3 min-h-5 text-xs text-muted-foreground" data-feed-saved role="status">
157
+ {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
+ {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
+ </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}
162
+ </div>
163
+ );
164
+ }
@@ -94,6 +94,9 @@ 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 { loadMessagesWithHeadStart, loadReferencesWithHeadStart, loadRelationsWithHeadStart } from "./references-prefetch";
98
+ import { loadFeedSnapshot, saveFeedSnapshot, type FeedSnapshot } from "../lib/reference-feed-cache";
99
+ import { referencePreviewsByLineage } from "../lib/reference-preview";
97
100
  import { NewsroomOpsOverview } from "./newsroom-ops-overview";
98
101
  import { NewsroomCurationBootstrap } from "./newsroom-curation-bootstrap";
99
102
  import { NewsroomReferencesView } from "./newsroom-references-view";
@@ -696,7 +699,7 @@ export function NewsDeskWorkspace({
696
699
  }
697
700
 
698
701
  if (dashboard?.isPublicSkeleton && session?.shell.phase !== "ready") {
699
- return <NewsDeskAccessGate shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
702
+ return <NewsDeskAccessGate initialTab={initialTab} shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
700
703
  }
701
704
 
702
705
  if (dashboard) {
@@ -716,7 +719,7 @@ export function NewsDeskWorkspace({
716
719
  );
717
720
  }
718
721
 
719
- return <NewsDeskAccessGate shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
722
+ return <NewsDeskAccessGate initialTab={initialTab} shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
720
723
  }
721
724
 
722
725
  function NewsDeskDashboard({
@@ -777,9 +780,16 @@ function NewsDeskDashboard({
777
780
  const [referenceAttachments, setReferenceAttachments] = useState(dashboard.referenceAttachments);
778
781
  const [messages, setMessages] = useState(dashboard.messages);
779
782
  const [semanticRelations, setSemanticRelations] = useState(dashboard.semanticRelations);
783
+ const [savedFeedCopy, setSavedFeedCopy] = useState<Pick<FeedSnapshot, "references" | "previews" | "decisions"> | null>(null);
784
+ useEffect(() => {
785
+ // Display only: the last view this browser saved, shown until the live read arrives.
786
+ if (dashboard.isDemo) return;
787
+ const snapshot = loadFeedSnapshot(SITE_BRAND.id);
788
+ if (snapshot) setSavedFeedCopy(snapshot);
789
+ }, [dashboard.isDemo]);
780
790
  const cyclotronPreviewFixtures = useMemo(
781
- () => (isLocalPreviewEnabled() ? createLocalPreviewCyclotronFixtures(references) : null),
782
- [references],
791
+ () => (isLocalPreviewEnabled() && !semanticRelations.some((relation) => relation.predicate === "relevance_decision_is") ? createLocalPreviewCyclotronFixtures(references) : null),
792
+ [references, semanticRelations],
783
793
  );
784
794
  const relevanceDecisions = useMemo(
785
795
  () => currentRelevanceDecisions(cyclotronPreviewFixtures ? [...semanticRelations, ...cyclotronPreviewFixtures.relations] : semanticRelations),
@@ -1215,7 +1225,7 @@ function NewsDeskDashboard({
1215
1225
  useEffect(() => {
1216
1226
  if (!canLoadPrivateCorpus || hasHydratedReferences) return;
1217
1227
  let active = true;
1218
- void loadEditorReferencesData()
1228
+ void loadReferencesWithHeadStart()
1219
1229
  .then(({ references: nextReferences, referenceAttachments }) => {
1220
1230
  if (!active) return;
1221
1231
  setReferences(nextReferences);
@@ -1326,8 +1336,8 @@ function NewsDeskDashboard({
1326
1336
  const selectedReviewLineageId = pathname ? pathnameReferenceLineageId ?? "" : initialSelection.reference ?? "";
1327
1337
  const referenceReviewData = useReferenceReviewData({
1328
1338
  enabled: canLoadPrivateCorpus && needsReferenceReviewData && !(activeTab === "references" && selectedReviewLineageId),
1329
- loadRelations: loadEditorSemanticRelationsData,
1330
- loadMessages: loadEditorMessagesData,
1339
+ loadRelations: loadRelationsWithHeadStart,
1340
+ loadMessages: loadMessagesWithHeadStart,
1331
1341
  onRelations: setSemanticRelations,
1332
1342
  onMessages: setMessages,
1333
1343
  });
@@ -1351,6 +1361,18 @@ function NewsDeskDashboard({
1351
1361
  ? selectedReviewRead.lineageId === selectedReviewLineageId ? selectedReviewRead.state : "loading"
1352
1362
  : referenceReviewData.state;
1353
1363
  const predictionError = selectedReviewIsCurrent ? selectedReviewRead.error : referenceReviewData.error;
1364
+ useEffect(() => {
1365
+ // 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
+ const timer = window.setTimeout(() => {
1368
+ saveFeedSnapshot(SITE_BRAND.id, {
1369
+ references: selectCanonicalReferenceRecords(references),
1370
+ previews: referencePreviewsByLineage(referenceAttachments),
1371
+ decisions: relevanceDecisions,
1372
+ });
1373
+ }, 800);
1374
+ return () => window.clearTimeout(timer);
1375
+ }, [activeTab, dashboard.isDemo, hasHydratedReferences, predictionState, referenceAttachments, references, relevanceDecisions]);
1354
1376
  const retryReferenceReviewData = () => { referenceReviewData.retry(); setSelectedReviewAttempt((current) => current + 1); };
1355
1377
 
1356
1378
  const applyReferenceRecord = useCallback((nextReference: ReferenceRecord) => {
@@ -3035,7 +3057,7 @@ function NewsDeskDashboard({
3035
3057
  headerActions={(
3036
3058
  <>
3037
3059
  {dashboard.isDemo ? (
3038
- <Badge variant="outline">Demo</Badge>
3060
+ dashboard.realDataPreview ? null : <Badge variant="outline">Demo</Badge>
3039
3061
  ) : (
3040
3062
  <Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }))} href="/settings">Settings</Link>
3041
3063
  )}
@@ -3182,6 +3204,8 @@ function NewsDeskDashboard({
3182
3204
  options?.sectionId)}
3183
3205
  referenceAttachments={referenceAttachments}
3184
3206
  references={references}
3207
+ loading={!dashboard.isDemo && !hasHydratedReferences}
3208
+ savedCopy={savedFeedCopy}
3185
3209
  />
3186
3210
  </NewsroomCurationBootstrap>
3187
3211
  ) : null}
@@ -6,10 +6,26 @@ import { addCloudEmail } from "./cloud-email.js";
6
6
  import { parseCloudEmail } from "./cloud-email-config.js";
7
7
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
8
8
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
9
- import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
9
+ import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveChatStackName, chatExportName, CHAT_EXPORTED_RESOURCES, CHAT_ENVIRONMENT_NAMES, CHAT_PRIVATE_ASSISTANT_ID, CHAT_PRIVATE_ASSISTANT_NAME, resolveStoragePreviewPrefix } from "./site-config.js";
10
10
  function environmentVariables(variables) {
11
11
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
12
12
  }
13
+ /**
14
+ * The production branch's private chat environment. Only a site with an enabled `chat` block gets the flag and the
15
+ * resource names (imported from the chat data stack's exports, so that stack must be deployed first); every other
16
+ * site keeps the flag "false" and no chat variables, and its newsroom serves neither the chat route nor the UI.
17
+ */
18
+ function chatEnvironment(config) {
19
+ const chatStackName = resolveChatStackName(config);
20
+ if (!chatStackName)
21
+ return { PAPYRUS_PRIVATE_CHAT_ENABLED: "false" };
22
+ return {
23
+ PAPYRUS_PRIVATE_CHAT_ENABLED: "true",
24
+ ...Object.fromEntries(CHAT_EXPORTED_RESOURCES.map((resource) => [CHAT_ENVIRONMENT_NAMES[resource], Fn.importValue(chatExportName(chatStackName, resource))])),
25
+ PAPYRUS_CHAT_PRIVATE_BOT_ID: CHAT_PRIVATE_ASSISTANT_ID,
26
+ PAPYRUS_CHAT_PRIVATE_BOT_NAME: CHAT_PRIVATE_ASSISTANT_NAME,
27
+ };
28
+ }
13
29
  function readerCustomHeaders(rules) {
14
30
  if (!rules)
15
31
  return undefined;
@@ -199,7 +215,7 @@ export class AmplifyAppShellStack extends Stack {
199
215
  environmentVariables: environmentVariables({
200
216
  ...config.cms.environment,
201
217
  ...emailEnvironment,
202
- PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false",
218
+ ...(config.chat?.enabled ? chatEnvironment(config) : { PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false" }),
203
219
  PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
204
220
  ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
205
221
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
@@ -7,12 +7,28 @@ import { addCloudEmail } from "./cloud-email";
7
7
  import { parseCloudEmail } from "./cloud-email-config";
8
8
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
9
9
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
10
- import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
10
+ import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveChatStackName, chatExportName, CHAT_EXPORTED_RESOURCES, CHAT_ENVIRONMENT_NAMES, CHAT_PRIVATE_ASSISTANT_ID, CHAT_PRIVATE_ASSISTANT_NAME, resolveStoragePreviewPrefix } from "./site-config";
11
11
 
12
12
  function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
13
13
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
14
14
  }
15
15
 
16
+ /**
17
+ * The production branch's private chat environment. Only a site with an enabled `chat` block gets the flag and the
18
+ * resource names (imported from the chat data stack's exports, so that stack must be deployed first); every other
19
+ * site keeps the flag "false" and no chat variables, and its newsroom serves neither the chat route nor the UI.
20
+ */
21
+ function chatEnvironment(config: AmplifyAppShellSiteConfig): Record<string, string> {
22
+ const chatStackName = resolveChatStackName(config);
23
+ if (!chatStackName) return { PAPYRUS_PRIVATE_CHAT_ENABLED: "false" };
24
+ return {
25
+ PAPYRUS_PRIVATE_CHAT_ENABLED: "true",
26
+ ...Object.fromEntries(CHAT_EXPORTED_RESOURCES.map((resource) => [CHAT_ENVIRONMENT_NAMES[resource], Fn.importValue(chatExportName(chatStackName, resource))])),
27
+ PAPYRUS_CHAT_PRIVATE_BOT_ID: CHAT_PRIVATE_ASSISTANT_ID,
28
+ PAPYRUS_CHAT_PRIVATE_BOT_NAME: CHAT_PRIVATE_ASSISTANT_NAME,
29
+ };
30
+ }
31
+
16
32
  function readerCustomHeaders(rules: ReaderCustomHeaderRule[] | undefined): string | undefined {
17
33
  if (!rules) return undefined;
18
34
  const lines = ["customHeaders:"];
@@ -221,7 +237,7 @@ export class AmplifyAppShellStack extends Stack {
221
237
  environmentVariables: environmentVariables({
222
238
  ...config.cms.environment,
223
239
  ...emailEnvironment,
224
- PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false",
240
+ ...(config.chat?.enabled ? chatEnvironment(config) : { PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false" }),
225
241
  PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
226
242
  ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
227
243
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,