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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/amplify/data/resource.js +6 -0
  2. package/amplify/data/resource.ts +7 -0
  3. package/amplify/data/schema.js +6 -0
  4. package/amplify/data/schema.ts +7 -0
  5. package/amplify/functions/category-action/handler.ts +2 -1
  6. package/amplify/functions/content-actions/handler.py +8 -0
  7. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  8. package/amplify/functions/monitoring-view/schema.js +16 -0
  9. package/amplify/functions/monitoring-view/schema.ts +16 -0
  10. package/amplify/functions/procedure-action/handler.ts +2 -10
  11. package/amplify/functions/proposal-actions/proposals.py +20 -3
  12. package/amplify/plugins/reader-steering/function/handler.py +1 -3
  13. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  14. package/bin/papyrus-app.mjs +12 -1
  15. package/bin/papyrus-infra.mjs +21 -1
  16. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  17. package/components/monitoring-overview-view.tsx +143 -21
  18. package/components/monitoring-panel.tsx +64 -4
  19. package/components/needs-me-bodies.tsx +290 -0
  20. package/components/needs-me-detail.tsx +23 -0
  21. package/components/needs-me-feed.tsx +561 -0
  22. package/components/needs-me-reference-detail.tsx +77 -0
  23. package/components/needs-me-shared.tsx +39 -0
  24. package/components/news-desk-client-provider.tsx +24 -3
  25. package/components/news-desk-page.tsx +2 -1
  26. package/components/news-desk-taxonomy-client.ts +2 -1
  27. package/components/newsroom-access-gate.tsx +7 -5
  28. package/components/newsroom-client-shell.tsx +2 -1
  29. package/components/newsroom-ops-overview.tsx +64 -55
  30. package/components/newsroom-ops-shell.tsx +4 -2
  31. package/components/newsroom-organizer.css +60 -2
  32. package/components/newsroom-references-view.tsx +301 -138
  33. package/components/notification-cards.tsx +369 -0
  34. package/components/notification-center.tsx +324 -213
  35. package/components/papyrus-console-shell.tsx +2 -1
  36. package/components/proposals-panel.tsx +21 -24
  37. package/components/proposals-view.tsx +4 -2
  38. package/components/reference-capture-client.ts +110 -0
  39. package/components/reference-capture-panel.tsx +180 -0
  40. package/components/reference-feed.tsx +503 -0
  41. package/components/reference-review-card.tsx +110 -141
  42. package/components/references-page-loading.tsx +61 -0
  43. package/components/references-prefetch.ts +52 -0
  44. package/components/review-rating-grid.tsx +180 -0
  45. package/components/topic-steering-workspace.tsx +44 -9
  46. package/components/use-media-query.ts +16 -0
  47. package/components/use-reference-preview-attachments.ts +101 -0
  48. package/components/use-reference-ratings.ts +134 -0
  49. package/infra/amplify-app-shell.js +18 -2
  50. package/infra/amplify-app-shell.ts +18 -2
  51. package/infra/chat-stack.js +222 -0
  52. package/infra/chat-stack.ts +250 -0
  53. package/infra/chat.test.js +142 -0
  54. package/infra/chat.test.ts +151 -0
  55. package/infra/index.js +1 -0
  56. package/infra/site-config.js +58 -1
  57. package/infra/site-config.ts +78 -1
  58. package/lib/category-repository.ts +6 -1
  59. package/lib/content-actions-client.ts +18 -0
  60. package/lib/local-preview-feed-fixture.ts +63 -0
  61. package/lib/local-preview-synthetic-rejects.ts +41 -0
  62. package/lib/monitoring-client.ts +29 -2
  63. package/lib/monitoring-overview.ts +52 -1
  64. package/lib/monitoring-paging.ts +58 -0
  65. package/lib/needs-me-items.ts +289 -0
  66. package/lib/needs-me-ranking.ts +315 -0
  67. package/lib/newsroom-chat-identity.ts +2 -1
  68. package/lib/newsroom-nav.ts +1 -1
  69. package/lib/newsroom-organizer-nav.ts +2 -1
  70. package/lib/notification-feed.ts +212 -0
  71. package/lib/proposals-client.ts +29 -0
  72. package/lib/reader-subject.ts +3 -2
  73. package/lib/reference-capture.ts +293 -0
  74. package/lib/reference-feed-cache.ts +170 -0
  75. package/lib/reference-feed-ranking.ts +235 -0
  76. package/lib/reference-preview.ts +112 -0
  77. package/lib/review-client.ts +55 -5
  78. package/lib/review-drafts.ts +252 -0
  79. package/lib/review-fixture.ts +56 -2
  80. package/lib/review-requests.ts +64 -24
  81. package/lib/site-brand.ts +2 -0
  82. package/lib/staff-groups.ts +23 -0
  83. package/lib/staging-gate.ts +2 -2
  84. package/package.json +29 -1
  85. package/routes.manifest.json +1 -1
@@ -12,6 +12,8 @@ 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";
16
+ import { useRatingStore, type RatingController } from "./use-reference-ratings";
15
17
  import { REFERENCE_REJECTION_REASON_CODES, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
16
18
 
17
19
  export type ReviewChoice = "accept" | "reject";
@@ -44,6 +46,84 @@ function percent(confidence: number | null): string | null {
44
46
  return confidence === null ? null : `${Math.round(confidence * 100)}%`;
45
47
  }
46
48
 
49
+ /**
50
+ * The Cyclotron's read-only row: its Accept or Reject highlighted with its confidence, and a check or cross once a person has answered.
51
+ * Shared by the review card and by every list that shows a suggestion before a decision.
52
+ */
53
+ export function CyclotronRow({ decided = null, prediction }: { decided?: ReviewChoice | null; prediction: CardPrediction }) {
54
+ const matched = decided && prediction.state === "ready" ? prediction.choice === decided : null;
55
+ return (
56
+ <div className="space-y-2" data-review-row="cyclotron">
57
+ <div className="flex items-center gap-3">
58
+ <span className="w-20 shrink-0 text-sm font-semibold">Cyclotron</span>
59
+ {prediction.state === "ready" ? (
60
+ <div className="flex flex-1 gap-2">
61
+ {(["accept", "reject"] as const).map((choice) => {
62
+ const predicted = prediction.state === "ready" && prediction.choice === choice;
63
+ const verdict = predicted && matched !== null ? (matched ? "match" : "miss") : null;
64
+ return (
65
+ <span
66
+ className={cn(
67
+ "inline-flex min-h-10 flex-1 items-center justify-center gap-1.5 rounded-md border px-3 text-sm",
68
+ predicted ? "border-foreground bg-muted font-semibold" : "border-dashed text-muted-foreground",
69
+ verdict === "match" && "border-green-700 bg-green-100 text-green-950",
70
+ verdict === "miss" && "border-red-700 bg-red-100 text-red-950",
71
+ )}
72
+ data-cyclotron-choice={choice}
73
+ data-predicted={predicted || undefined}
74
+ data-verdict={verdict ?? undefined}
75
+ key={choice}
76
+ >
77
+ {verdict === "match" ? <CircleCheckIcon aria-hidden="true" className="size-4" /> : null}
78
+ {verdict === "miss" ? <CircleXIcon aria-hidden="true" className="size-4" /> : null}
79
+ {CHOICE_LABEL[choice]}
80
+ {predicted && percent(prediction.confidence) ? <small className="text-xs font-normal">{percent(prediction.confidence)}</small> : null}
81
+ {verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
82
+ </span>
83
+ );
84
+ })}
85
+ </div>
86
+ ) : (
87
+ <p className="text-sm text-muted-foreground" data-cyclotron-state={prediction.state}>
88
+ {prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
89
+ </p>
90
+ )}
91
+ </div>
92
+ </div>
93
+ );
94
+ }
95
+
96
+ /** The two answers, large enough to tap: Accept and Reject, the chosen one filled and checked (never colour alone). */
97
+ export function ChoiceButtons({ chosen, disabled = false, onChoose }: { chosen: ReviewChoice | null; disabled?: boolean; onChoose: (choice: ReviewChoice) => void }) {
98
+ return (
99
+ <div className="flex flex-1 gap-2">
100
+ {(["accept", "reject"] as const).map((choice) => {
101
+ const on = chosen === choice;
102
+ return (
103
+ <Button
104
+ aria-disabled={disabled ? true : undefined}
105
+ aria-pressed={on}
106
+ className="min-h-14 flex-1 text-base"
107
+ data-review-choice={choice}
108
+ key={choice}
109
+ onClick={() => onChoose(choice)}
110
+ type="button"
111
+ variant={on ? (choice === "accept" ? "default" : "destructive") : "outline"}
112
+ >
113
+ {on ? <CheckIcon aria-hidden="true" className="size-5" /> : null}
114
+ {CHOICE_LABEL[choice]}
115
+ </Button>
116
+ );
117
+ })}
118
+ </div>
119
+ );
120
+ }
121
+
122
+ /** A reference's relevance decision in the card's own vocabulary. */
123
+ export function predictionFromDecision(decision: { label: string; positiveLabel: string | null; confidence: number | null }): CardPrediction {
124
+ return { state: "ready", choice: decision.label === (decision.positiveLabel ?? "include") ? "accept" : "reject", confidence: decision.confidence };
125
+ }
126
+
47
127
  /**
48
128
  * One reusable card for answering a single question: Accept or Reject. The Cyclotron's row is
49
129
  * read-only; the human row is the real control and its answer is final. Saves on tap.
@@ -62,8 +142,11 @@ export function ReviewCard({
62
142
  summary,
63
143
  title,
64
144
  titleHref,
145
+ ratings,
65
146
  }: {
66
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;
67
150
  /** The saved human answer, or null while the item is undecided. */
68
151
  decided: ReviewChoice | null;
69
152
  /** When set, the controls explain in words why they cannot change anything. */
@@ -78,70 +161,21 @@ export function ReviewCard({
78
161
  title: string;
79
162
  titleHref?: string | null;
80
163
  }) {
81
- const [draft, setDraft] = useState<ReviewChoice | null>(null);
82
- const [note, setNote] = useState(initialNote);
83
- const [reason, setReason] = useState<ReferenceRejectionReasonCode | null>(initialReason);
84
- const [saving, setSaving] = useState(false);
85
- const [saved, setSaved] = useState(false);
86
- const [error, setError] = useState<string | null>(null);
87
- const inFlight = useRef(false);
88
- const noteTouched = useRef(false);
89
- const reasonTouched = useRef(false);
90
- const savedNote = useRef(initialNote);
91
-
92
- // Saved values from outside (another editor, a reload) refresh whatever the human has not touched.
93
- useEffect(() => {
94
- savedNote.current = initialNote;
95
- if (!noteTouched.current) setNote(initialNote);
96
- }, [initialNote]);
97
- useEffect(() => {
98
- if (!reasonTouched.current) setReason(initialReason);
99
- }, [initialReason]);
100
-
101
- const chosen = draft ?? decided;
102
-
103
- async function save(choice: ReviewChoice, nextReason: ReferenceRejectionReasonCode | null, nextNote: string) {
104
- if (disabledReason || inFlight.current) return;
105
- if (choice === "reject" && !nextReason) return;
106
- inFlight.current = true;
107
- setSaving(true);
108
- setSaved(false);
109
- setError(null);
110
- try {
111
- await onDecide(choice, { note: nextNote.trim(), reasonCode: choice === "reject" ? nextReason : null });
112
- savedNote.current = nextNote.trim();
113
- noteTouched.current = false;
114
- reasonTouched.current = false;
115
- setDraft(null);
116
- setSaved(true);
117
- } catch (failure) {
118
- setDraft(null);
119
- setError(failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry.");
120
- } finally {
121
- inFlight.current = false;
122
- setSaving(false);
123
- }
124
- }
125
-
126
- function choose(choice: ReviewChoice) {
127
- if (disabledReason) return;
128
- if (chosen === choice && draft === null) return; // the selected answer never unselects itself
129
- setError(null);
130
- setDraft(choice);
131
- void save(choice, reason, note);
132
- }
133
-
134
- function chooseReason(code: ReferenceRejectionReasonCode) {
135
- if (disabledReason) return;
136
- setReason(code);
137
- reasonTouched.current = true;
138
- if (chosen === "reject") void save("reject", code, note);
139
- }
140
-
141
- function commitNote() {
142
- if (!decided || disabledReason || note.trim() === savedNote.current.trim()) return;
143
- void save(decided, reason, note);
144
- }
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); };
145
179
 
146
180
  // Desktop shortcuts: A accepts, R rejects. Never while typing.
147
181
  useEffect(() => {
@@ -156,12 +190,6 @@ export function ReviewCard({
156
190
  return () => window.removeEventListener("keydown", onKey);
157
191
  });
158
192
 
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
193
  return (
166
194
  <section aria-label="Review" className="space-y-4" data-review-card={itemId}>
167
195
  <header className="space-y-1">
@@ -175,76 +203,17 @@ export function ReviewCard({
175
203
  {summary ? <p className="text-sm text-muted-foreground">{summary}</p> : null}
176
204
  </header>
177
205
 
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>
206
+ <RatingGrid
207
+ answer={chosen}
208
+ decided={decided}
209
+ disabled={Boolean(disabledReason)}
210
+ disabledReason={disabledReason}
211
+ feedback={item.feedback}
212
+ onChoose={choose}
213
+ prediction={prediction}
214
+ size="large"
215
+ surface="card"
216
+ />
248
217
 
249
218
  <label className="block text-sm font-medium">
250
219
  Why? (optional)
@@ -252,7 +221,7 @@ export function ReviewCard({
252
221
  aria-label="Why? (optional)"
253
222
  className="mt-1 block min-h-24 w-full rounded-md border bg-background p-3 text-base font-normal"
254
223
  onBlur={commitNote}
255
- onChange={(event) => { setNote(event.target.value); noteTouched.current = true; setSaved(false); }}
224
+ onChange={(event) => store.setNote(itemId, event.target.value)}
256
225
  placeholder="Say what made this an easy yes or a clear no. Your note teaches the Cyclotron."
257
226
  value={note}
258
227
  />
@@ -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,180 @@
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
+ statusOverlay = false,
51
+ surface = "footer",
52
+ }: {
53
+ /** The side the buttons show as chosen (it may be a pending choice that is still saving). */
54
+ answer: ReviewChoice | null;
55
+ /** The saved human answer, which the Match / Miss verdict compares against. */
56
+ decided?: ReviewChoice | null;
57
+ disabled?: boolean;
58
+ disabledReason?: string | null;
59
+ feedback?: RatingFeedback;
60
+ /** A short line shown in the status row beside the Saved tick (for example the reason). It never adds a row. */
61
+ note?: string | null;
62
+ onChoose: (choice: ReviewChoice) => void;
63
+ prediction: CardPrediction;
64
+ /** 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. */
65
+ showPrediction?: boolean;
66
+ size?: "compact" | "large";
67
+ /** Lay the Saved / note line over the bottom padding of the nearest positioned ancestor, so it adds no height under the buttons. */
68
+ statusOverlay?: boolean;
69
+ /** The tone the grid sits on, so the bar's centre notch matches it. */
70
+ surface?: "footer" | "card";
71
+ }) {
72
+ const ready = prediction.state === "ready" ? prediction : null;
73
+ const verdict = ready && decided ? (ready.choice === decided ? "match" : "miss") : null;
74
+ const probability = ready ? predictionAcceptProbability(ready) : 0.5;
75
+ const half = confidenceFillPercent(probability) / 2; // percent of the full bar width, on one side of the centre
76
+ const leansAccept = probability >= 0.5;
77
+ const confidenceText = ready && ready.confidence !== null ? `${Math.round(ready.confidence * 100)}%` : null;
78
+ const cell = size === "large" ? "min-h-14 text-base" : "min-h-12 text-sm";
79
+ const label = size === "large" ? "text-sm" : "text-xs";
80
+
81
+ return (
82
+ <div
83
+ className={cn(
84
+ "grid gap-x-2 gap-y-2",
85
+ showPrediction ? "grid-cols-[4.5rem_minmax(0,1fr)_minmax(0,1fr)]" : "grid-cols-2",
86
+ showPrediction && size === "large" && "grid-cols-[5rem_minmax(0,1fr)_minmax(0,1fr)]",
87
+ )}
88
+ data-rating-grid
89
+ >
90
+ {showPrediction ? <div className="col-span-3 grid grid-cols-subgrid items-stretch gap-y-2" data-review-row="cyclotron">
91
+ <span className={cn("self-center font-semibold", label)}>Cyclotron</span>
92
+ {(["accept", "reject"] as const).map((choice) => {
93
+ const predicted = ready?.choice === choice;
94
+ const Icon = predicted ? (verdict === "match" ? CircleCheckIcon : verdict === "miss" ? CircleXIcon : choice === "accept" ? CheckIcon : XIcon) : null;
95
+ return (
96
+ <span
97
+ className={cn(
98
+ "flex flex-col items-center justify-center rounded-[16px] px-2 py-1.5 text-center",
99
+ cell,
100
+ !predicted && "bg-[var(--feed-control)]/45 text-muted-foreground",
101
+ predicted && !verdict && "bg-[var(--feed-control)] font-semibold text-[var(--primary)]",
102
+ predicted && verdict && cn("font-semibold", TONE[verdict]),
103
+ )}
104
+ data-cyclotron-choice={choice}
105
+ data-predicted={predicted || undefined}
106
+ data-verdict={predicted && verdict ? verdict : undefined}
107
+ key={choice}
108
+ >
109
+ <span className="inline-flex items-center gap-1.5">
110
+ {Icon ? <Icon aria-hidden="true" className="size-4 shrink-0" /> : null}
111
+ {WORD[choice]}
112
+ {predicted && confidenceText ? <small className="text-xs font-normal opacity-80">{confidenceText}</small> : null}
113
+ </span>
114
+ {predicted && verdict ? <small className="text-xs font-medium" data-verdict-word>{verdict === "match" ? "Match" : "Miss"}</small> : null}
115
+ {predicted && verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
116
+ </span>
117
+ );
118
+ })}
119
+ <div className="col-span-2 col-start-2 flex flex-col justify-center" data-confidence>
120
+ <div
121
+ aria-label={ready ? `Cyclotron confidence: leans ${leansAccept ? "Accept" : "Reject"}, ${Math.round(confidenceFillPercent(probability))} percent of the way` : "No confidence"}
122
+ className="relative h-2.5 w-full overflow-hidden rounded-full bg-[var(--feed-control)]"
123
+ data-confidence-bar
124
+ data-confidence-direction={ready ? (leansAccept ? "accept" : "reject") : "none"}
125
+ role="img"
126
+ >
127
+ {ready ? (
128
+ <span
129
+ className={cn("absolute inset-y-0 rounded-full", FILL[verdict ?? "neutral"])}
130
+ data-confidence-fill
131
+ data-fill-percent={Math.round(half * 2)}
132
+ style={{ left: leansAccept ? `${50 - half}%` : "50%", width: `${half}%` }}
133
+ />
134
+ ) : null}
135
+ <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)]")} />
136
+ </div>
137
+ {/* Screen-reader status only: a visible line under the bar would push it off the middle between the Cyclotron and You rows. */}
138
+ <small className="sr-only" data-cyclotron-state={prediction.state === "ready" ? undefined : prediction.state}>
139
+ {prediction.state === "ready" ? "" : prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
140
+ </small>
141
+ </div>
142
+ </div> : null}
143
+
144
+ <div className={cn("grid grid-cols-subgrid items-stretch", showPrediction ? "col-span-3" : "col-span-2")} data-review-row="you">
145
+ {showPrediction ? <span className={cn("self-center font-semibold", label)}>You</span> : null}
146
+ {(["accept", "reject"] as const).map((choice) => {
147
+ const on = answer === choice;
148
+ const Icon = choice === "accept" ? CheckIcon : XIcon;
149
+ return (
150
+ <button
151
+ aria-disabled={disabled ? true : undefined}
152
+ aria-pressed={on}
153
+ className={cn(
154
+ "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",
155
+ cell,
156
+ on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)] text-foreground hover:bg-[var(--feed-control)]/70",
157
+ disabled && !on && "opacity-60",
158
+ )}
159
+ data-review-choice={choice}
160
+ key={choice}
161
+ onClick={() => onChoose(choice)}
162
+ type="button"
163
+ >
164
+ <Icon aria-hidden="true" className={cn("size-4", !on && "opacity-60")} />
165
+ {WORD[choice]}
166
+ </button>
167
+ );
168
+ })}
169
+ </div>
170
+
171
+ <div className={cn("flex flex-wrap items-center gap-x-3 text-xs text-muted-foreground", statusOverlay ? "absolute inset-x-4 bottom-0 h-4 overflow-hidden leading-4" : cn("min-h-5", showPrediction ? "col-span-3" : "col-span-2"))} data-feed-saved role="status">
172
+ {note ? <span data-feed-reason>{note}</span> : null}
173
+ {feedback.phase === "saving" ? <span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Saving…</span> : null}
174
+ {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}
175
+ </div>
176
+ {disabledReason ? <p className={cn("text-xs text-muted-foreground", showPrediction ? "col-span-3" : "col-span-2")} data-review-disabled-reason>{disabledReason}</p> : null}
177
+ {feedback.phase === "error" ? <p className={cn("text-xs text-destructive", showPrediction ? "col-span-3" : "col-span-2")} role="alert">{feedback.message}</p> : null}
178
+ </div>
179
+ );
180
+ }