@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
@@ -4,9 +4,9 @@ import type { PrimingPanelProps } from "cyclotron/components/priming-panel";
4
4
  import { ReferenceAddEntry } from "./reference-add-entry";
5
5
  import { ReferenceCurationControls, useReferenceImport } from "./reference-curation-controls";
6
6
 
7
- import { ExternalLinkIcon } from "lucide-react";
7
+ import { ExternalLinkIcon, LayoutGridIcon, ListIcon, PlusIcon } from "lucide-react";
8
8
  import { usePathname } from "next/navigation";
9
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
9
+ import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
10
10
  import { Badge } from "./ui/badge";
11
11
  import { Button } from "./ui/button";
12
12
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
@@ -42,8 +42,23 @@ import {
42
42
  import { normalizeReferenceCurationStatus, referenceReasonCode, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
43
43
  import { newsroomListRowClassName } from "../lib/newsroom-list-selection";
44
44
  import { ReferenceSourcePreview } from "./reference-source-preview";
45
+ import { useReferencePreviewAttachments } from "./use-reference-preview-attachments";
46
+ import { useMediaQuery } from "./use-media-query";
47
+ import { FEED_SURFACE_PROPS, FEED_SURFACE_STYLE, ReferenceFeed } from "./reference-feed";
48
+ import { ReferenceCapturePanel } from "./reference-capture-panel";
49
+ import { useReferenceCapture, type CorrectCaptureAction } from "./reference-capture-client";
50
+ import { useRatingStore, type RatingController } from "./use-reference-ratings";
51
+ import { captureStatusesByLineage } from "../lib/reference-capture";
52
+ import { applyFeedOrderSnapshot, partitionReferenceFeed, takeFeedOrderSnapshot, type FeedOrderSnapshot, type ReferenceFeedTabKey } from "../lib/reference-feed-ranking";
53
+ import { referencePreviewsByLineage, type ReferencePreview } from "../lib/reference-preview";
54
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
55
+ import { chooseSyntheticRejects, withSyntheticRejects } from "../lib/local-preview-synthetic-rejects";
56
+ import { cn } from "../lib/utils";
57
+ import { getKanbusTheme, SITE_BRAND } from "../lib/site-brand";
58
+ import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
45
59
  import {
46
60
  ReviewCard,
61
+ predictionFromDecision,
47
62
  ReviewQueueBar,
48
63
  useQueueGestures,
49
64
  type CardPrediction,
@@ -75,10 +90,6 @@ import { ReferenceCyclotronStatus, type ReviewRateChoice } from "./reference-cyc
75
90
  import type { PrimingRequestView } from "../lib/relevance-status";
76
91
  import type { CyclotronStatus } from "cyclotron/cyclotron-status";
77
92
  import { awaitsEditorReview, type RelevanceCuration, type RelevanceDecisionView } from "../lib/relevance-decisions";
78
- import {
79
- loadReferenceAttachmentsForLineageId,
80
- loadStoragePathUrl,
81
- } from "./news-desk-taxonomy-client";
82
93
 
83
94
  type NewsroomReferencesViewProps = {
84
95
  /** Present only when the host has configured optional example priming. */
@@ -119,8 +130,16 @@ type NewsroomReferencesViewProps = {
119
130
  onReview: (reference: ReferenceRecord, action: ReferenceCurationAction, options?: ReferenceReviewOptions) => Promise<void> | void;
120
131
  referenceAttachments?: ReferenceAttachmentRecord[];
121
132
  references: ReferenceRecord[];
133
+ /** Saves an editor's pasted or corrected capture text and their verdict on it. Optional: without it the capture panel only reads. */
134
+ onCorrectCapture?: CorrectCaptureAction;
135
+ /** True while the References are still being read: the feed shows card skeletons instead of an empty page. */
136
+ loading?: boolean;
137
+ /** The last view this browser saved. Shown, read only, until the live read arrives. */
138
+ savedCopy?: { references: ReferenceRecord[]; previews: Array<[string, ReferencePreview]>; decisions: Array<[string, RelevanceDecisionView]> } | null;
122
139
  };
123
140
 
141
+ type ReferenceView = "feed" | "list";
142
+
124
143
  type ReferenceListFilter = ReferenceStatusFilter | "needs_review";
125
144
 
126
145
  const NEEDS_REVIEW_FILTER: { key: ReferenceListFilter; label: string } = { key: "needs_review", label: "Needs review" };
@@ -133,18 +152,6 @@ const STATUS_FILTERS: Array<{ key: ReferenceListFilter; label: string }> = [
133
152
  { key: "archived", label: "Archived" },
134
153
  ];
135
154
 
136
- function useMediaQuery(query: string): boolean {
137
- const [matches, setMatches] = useState(false);
138
- useEffect(() => {
139
- const mediaQuery = window.matchMedia(query);
140
- const update = () => setMatches(mediaQuery.matches);
141
- update();
142
- mediaQuery.addEventListener("change", update);
143
- return () => mediaQuery.removeEventListener("change", update);
144
- }, [query]);
145
- return matches;
146
- }
147
-
148
155
  function buildReferenceDetailPath(lineageId: string, demo?: boolean): string {
149
156
  return getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo);
150
157
  }
@@ -156,100 +163,6 @@ function referenceUrl(reference: ReferenceRecord): string | null {
156
163
  return null;
157
164
  }
158
165
 
159
- function normalizeReferenceDetailHttpUri(value: string | null | undefined): string | null {
160
- const trimmed = value?.trim();
161
- if (!trimmed) return null;
162
- if (/^https?:\/\//i.test(trimmed)) return trimmed;
163
- return null;
164
- }
165
-
166
- function useReferencePreviewAttachments(
167
- reference: ReferenceRecord | null,
168
- referenceAttachments: ReferenceAttachmentRecord[],
169
- ) {
170
- const lineageId = reference ? referenceLineageId(reference) : "";
171
- const seededAttachments = useMemo(
172
- () => referenceAttachments.filter((attachment) => attachment.referenceLineageId === lineageId),
173
- [lineageId, referenceAttachments],
174
- );
175
- const [attachments, setAttachments] = useState<ReferenceAttachmentRecord[]>(seededAttachments);
176
- const [attachmentLinksById, setAttachmentLinksById] = useState<Record<string, string>>({});
177
-
178
- useEffect(() => {
179
- setAttachments(seededAttachments);
180
- }, [seededAttachments]);
181
-
182
- useEffect(() => {
183
- if (!lineageId) return;
184
- if (seededAttachments.length > 0) return;
185
- let active = true;
186
- void loadReferenceAttachmentsForLineageId(lineageId)
187
- .then((loaded) => {
188
- if (!active || !loaded.length) return;
189
- setAttachments(loaded);
190
- })
191
- .catch(() => undefined);
192
- return () => {
193
- active = false;
194
- };
195
- }, [lineageId, seededAttachments.length]);
196
-
197
- useEffect(() => {
198
- if (!attachments.length) {
199
- setAttachmentLinksById({});
200
- return;
201
- }
202
- let active = true;
203
- const staticLinks = new Map<string, string>();
204
- const storageLookups: Array<{ id: string; storagePath: string }> = [];
205
-
206
- for (const attachment of attachments) {
207
- if (attachment.storagePath) {
208
- storageLookups.push({ id: attachment.id, storagePath: attachment.storagePath });
209
- continue;
210
- }
211
- const sourceHref = normalizeReferenceDetailHttpUri(attachment.sourceUri);
212
- if (sourceHref) staticLinks.set(attachment.id, sourceHref);
213
- }
214
-
215
- if (!storageLookups.length) {
216
- setAttachmentLinksById(Object.fromEntries(staticLinks.entries()));
217
- return () => {
218
- active = false;
219
- };
220
- }
221
-
222
- setAttachmentLinksById(Object.fromEntries(staticLinks.entries()));
223
- void Promise.all(storageLookups.map(async (lookup) => {
224
- const result = await loadStoragePathUrl(lookup.storagePath);
225
- return { id: lookup.id, url: result.url };
226
- }))
227
- .then((resolved) => {
228
- if (!active) return;
229
- const nextLinks = Object.fromEntries(staticLinks.entries());
230
- for (const entry of resolved) {
231
- if (entry.url) nextLinks[entry.id] = entry.url;
232
- }
233
- setAttachmentLinksById(nextLinks);
234
- })
235
- .catch(() => undefined);
236
-
237
- return () => {
238
- active = false;
239
- };
240
- }, [attachments]);
241
-
242
- const previewAttachments = useMemo(
243
- () => attachments.map((attachment) => ({
244
- ...attachment,
245
- sourceUri: attachmentLinksById[attachment.id] ?? attachment.sourceUri,
246
- })),
247
- [attachmentLinksById, attachments],
248
- );
249
-
250
- return previewAttachments;
251
- }
252
-
253
166
  function needsReview(reference: ReferenceRecord, decision: RelevanceDecisionView | undefined): boolean {
254
167
  return awaitsEditorReview(reference.curationStatus, decision);
255
168
  }
@@ -269,16 +182,17 @@ function summaryOf(reference: ReferenceRecord): string | null {
269
182
  }
270
183
 
271
184
  function cardPrediction(decision: RelevanceDecisionView | undefined, predictionState: PredictionState): CardPrediction {
272
- if (decision) {
273
- const positive = decision.positiveLabel ?? "include";
274
- return { state: "ready", choice: decision.label === positive ? "accept" : "reject", confidence: decision.confidence };
275
- }
185
+ if (decision) return predictionFromDecision(decision);
276
186
  if (predictionState === "loading") return { state: "loading" };
277
187
  if (predictionState === "error") return { state: "error" };
278
188
  return { state: "none" };
279
189
  }
280
190
 
281
191
  function ReferenceDetailPanel({
192
+ captureTeaserLine,
193
+ correctCapture,
194
+ ratings,
195
+ sectionChoices,
282
196
  countsAsTrainingLabel,
283
197
  delegated,
284
198
  decision,
@@ -294,6 +208,13 @@ function ReferenceDetailPanel({
294
208
  sectionSuggestion,
295
209
  sections = [],
296
210
  }: {
211
+ captureTeaserLine: number;
212
+ /** Saves a pasted or corrected capture (the `correctReferenceCapture` action). Without it the panel has no edit action. */
213
+ correctCapture?: CorrectCaptureAction;
214
+ /** The rating store shared with the grid cards. */
215
+ ratings?: RatingController;
216
+ /** Where this panel records the Section chosen for an accept, so the shared store's save can include it. */
217
+ sectionChoices?: MutableRefObject<Map<string, string | null>>;
297
218
  countsAsTrainingLabel?: boolean;
298
219
  delegated?: DelegatedCuration;
299
220
  decision?: RelevanceDecisionView;
@@ -321,6 +242,8 @@ function ReferenceDetailPanel({
321
242
  if (!sectionEdited.current) setChosenSectionId(reviewSectionId ?? null);
322
243
  }, [reviewSectionId]);
323
244
  const url = referenceUrl(reference);
245
+ useEffect(() => { sectionChoices?.current.set(lineageId, chosenSectionId); }, [chosenSectionId, lineageId, sectionChoices]);
246
+ const capture = useReferenceCapture(reference, previewAttachments, captureTeaserLine, correctCapture);
324
247
 
325
248
  async function decide(choice: ReviewChoice, review: ReviewCardDecision) {
326
249
  const action: ReferenceCurationAction = choice;
@@ -350,6 +273,7 @@ function ReferenceDetailPanel({
350
273
  initialNote={sourceNote}
351
274
  initialReason={sourceReason}
352
275
  itemId={lineageId}
276
+ ratings={ratings}
353
277
  onArchive={status === "archived" ? undefined : async () => { await onReview("archive"); }}
354
278
  onDecide={decide}
355
279
  prediction={cardPrediction(decision, predictionState)}
@@ -380,6 +304,8 @@ function ReferenceDetailPanel({
380
304
 
381
305
  <Separator />
382
306
 
307
+ <ReferenceCapturePanel capture={capture} />
308
+
383
309
  <section aria-label="Source preview" className="space-y-2">
384
310
  <h3 className="text-sm font-medium text-foreground">Source preview</h3>
385
311
  <ReferenceSourcePreview attachments={previewAttachments} sourceUri={reference.sourceUri} />
@@ -444,7 +370,7 @@ export function NewsroomReferencesView({
444
370
  priming,
445
371
  cyclotronStatus,
446
372
  cyclotronPriming,
447
- decisions,
373
+ decisions: liveDecisions,
448
374
  humanCuratedReferenceLineageIds,
449
375
  delegatedCurations,
450
376
  shadowAgreement = null,
@@ -463,9 +389,15 @@ export function NewsroomReferencesView({
463
389
  initialReferenceLineageId = null,
464
390
  onReview,
465
391
  referenceAttachments = [],
466
- references,
392
+ references: liveReferences,
393
+ loading = false,
394
+ onCorrectCapture,
395
+ savedCopy = null,
467
396
  }: NewsroomReferencesViewProps) {
468
397
  const pathname = usePathname();
398
+ // The portaled mobile sheet sits outside the newsroom root, so it carries the theme attributes itself (same as useNewsroomThemeAttributes).
399
+ const themeMode = useResolvedPapyrusTheme();
400
+ const themeAttributes = { "data-kanbus-theme": getKanbusTheme(SITE_BRAND), "data-color-mode": themeMode };
469
401
  const sessionImport = useReferenceImport(disabled);
470
402
  const [importedCount, setImportedCount] = useState(0);
471
403
  const isMobileDetail = useMediaQuery("(max-width: 767px)");
@@ -475,7 +407,77 @@ export function NewsroomReferencesView({
475
407
  );
476
408
  const routeLineageId = pathname ? pathnameLineageId ?? "" : initialReferenceLineageId ?? "";
477
409
 
410
+ // Until the live read arrives, a saved copy of the last view fills the page. It is display only.
411
+ const showingSavedCopy = loading && !liveReferences.length && Boolean(savedCopy?.references.length);
412
+ const references = showingSavedCopy ? savedCopy!.references : liveReferences;
413
+ const baseDecisions = useMemo(
414
+ () => (showingSavedCopy && !liveDecisions?.size ? new Map(savedCopy!.decisions) : liveDecisions),
415
+ [liveDecisions, savedCopy, showingSavedCopy],
416
+ );
417
+ // Preview only: ?feedSynthRejects=N makes the N weakest Accept predictions into made-up Rejects (the page says so).
418
+ const synthRejectCount = useMemo(() => {
419
+ if (typeof window === "undefined" || !isLocalPreviewEnabled()) return 0;
420
+ const requested = Number(new URLSearchParams(window.location.search).get("feedSynthRejects"));
421
+ return Number.isInteger(requested) && requested > 0 ? requested : 0;
422
+ }, []);
423
+ const syntheticChoice = useRef<string[] | null>(null);
424
+ if (synthRejectCount && syntheticChoice.current === null && baseDecisions?.size && references.length) {
425
+ syntheticChoice.current = chooseSyntheticRejects(
426
+ baseDecisions,
427
+ new Set(references.filter((reference) => normalizeReferenceCurationStatus(reference.curationStatus) === "pending").map(referenceLineageId)),
428
+ synthRejectCount,
429
+ );
430
+ }
431
+ const synthetic = useMemo(
432
+ () => (synthRejectCount && syntheticChoice.current ? withSyntheticRejects(baseDecisions, syntheticChoice.current) : null),
433
+ [baseDecisions, synthRejectCount],
434
+ );
435
+ const decisions = synthetic?.decisions ?? baseDecisions;
436
+ const previews = useMemo(() => {
437
+ const live = referencePreviewsByLineage(referenceAttachments);
438
+ return showingSavedCopy && !live.size ? new Map(savedCopy!.previews) : live;
439
+ }, [referenceAttachments, savedCopy, showingSavedCopy]);
440
+ const [view, setView] = useState<ReferenceView>("feed");
441
+ const [addOpen, setAddOpen] = useState(false);
442
+ const [feedTab, setFeedTab] = useState<ReferenceFeedTabKey>("feed");
443
+ const [visitRated, setVisitRated] = useState<ReadonlySet<string>>(() => new Set());
444
+ const [mountedAt] = useState(() => Date.now());
445
+ const markVisitRated = useCallback((lineageId: string) => {
446
+ setVisitRated((current) => (current.has(lineageId) ? current : new Set(current).add(lineageId)));
447
+ }, []);
448
+ // Preview only: ?feedTau=<days> stands in for the learned freshness period, to review the Earlier section on a young corpus.
449
+ const tauDaysOverride = useMemo(() => {
450
+ if (typeof window === "undefined" || !isLocalPreviewEnabled()) return null;
451
+ const requested = Number(new URLSearchParams(window.location.search).get("feedTau"));
452
+ return Number.isFinite(requested) && requested > 0 ? requested : null;
453
+ }, []);
454
+
455
+ // Preview only: ?feedState=loading holds the page in its loading state, to review the card skeletons.
456
+ const previewSkeleton = useMemo(
457
+ () => typeof window !== "undefined" && isLocalPreviewEnabled() && new URLSearchParams(window.location.search).get("feedState") === "loading",
458
+ [],
459
+ );
460
+
478
461
  const canonicalReferences = useMemo(() => selectCanonicalReferenceRecords(references), [references]);
462
+ const livePartition = useMemo(
463
+ () => partitionReferenceFeed(canonicalReferences, decisions, { now: mountedAt, visitRated, tauDaysOverride }),
464
+ [canonicalReferences, decisions, mountedAt, tauDaysOverride, visitRated],
465
+ );
466
+ // The feed's order is taken ONCE per visit, when the feed has loaded (References and predictions), and then left alone. Selecting an
467
+ // item, rating one, a background refresh or a cache revalidation changes cards' content, never their places. Items that arrive later
468
+ // wait behind a "N new items: show" pill. (The Rated tab is a live list: it is the one place a new answer is meant to appear.)
469
+ const orderSnapshot = useRef<FeedOrderSnapshot | null>(null);
470
+ const [, bumpOrder] = useState(0);
471
+ const orderReady = !loading && !showingSavedCopy && !previewSkeleton && predictionState !== "loading" && canonicalReferences.length > 0;
472
+ if (!orderSnapshot.current && orderReady) orderSnapshot.current = takeFeedOrderSnapshot(livePartition);
473
+ const stable = orderSnapshot.current ? applyFeedOrderSnapshot(orderSnapshot.current, livePartition) : null;
474
+ const partition = stable?.partition ?? livePartition;
475
+ const showNewItems = () => { orderSnapshot.current = takeFeedOrderSnapshot(livePartition); bumpOrder((count) => count + 1); };
476
+ const [scrollTarget, setScrollTarget] = useState<{ id: string; nonce: number } | null>(null);
477
+ const feedOrderIds = useMemo(
478
+ () => (feedTab === "feed" ? [...partition.feed, ...partition.earlier] : feedTab === "rated" ? partition.rated : partition.earlier).map((item) => item.lineageId),
479
+ [feedTab, partition],
480
+ );
479
481
  const statusCounts = useMemo(() => countReferencesByStatus(canonicalReferences), [canonicalReferences]);
480
482
 
481
483
  const [statusFilter, setStatusFilter] = useState<ReferenceListFilter>(routeLineageId ? "all" : "pending");
@@ -506,14 +508,14 @@ export function NewsroomReferencesView({
506
508
  // list, but keeps its place in the queue until they move on, so "3 of 29" does not jump under them.
507
509
  const queueRef = useRef<string[]>([]);
508
510
  const queue = useMemo(() => {
509
- const ids = filteredReferences.map(referenceLineageId);
511
+ const ids = view === "feed" ? [...feedOrderIds] : filteredReferences.map(referenceLineageId);
510
512
  const open = selectedReference ? referenceLineageId(selectedReference) : "";
511
513
  if (open && !ids.includes(open)) {
512
514
  const before = queueRef.current.indexOf(open);
513
515
  if (before >= 0) ids.splice(Math.min(before, ids.length), 0, open);
514
516
  }
515
517
  return ids;
516
- }, [filteredReferences, selectedReference]);
518
+ }, [feedOrderIds, filteredReferences, selectedReference, view]);
517
519
  useEffect(() => { queueRef.current = queue; }, [queue]);
518
520
  const [caughtUp, setCaughtUp] = useState(false);
519
521
  const selectedIndex = selectedReference ? queue.indexOf(referenceLineageId(selectedReference)) : -1;
@@ -559,7 +561,7 @@ export function NewsroomReferencesView({
559
561
  };
560
562
 
561
563
  const goNext = () => {
562
- if (nextLineageId) selectReference(nextLineageId);
564
+ if (nextLineageId) { selectReference(nextLineageId); setScrollTarget({ id: nextLineageId, nonce: Date.now() }); }
563
565
  else if (queue.length && !caughtUp) {
564
566
  // Past the last item: say so, rather than showing an empty page.
565
567
  setCaughtUp(true);
@@ -568,7 +570,7 @@ export function NewsroomReferencesView({
568
570
  } else if (!queue.length) setCaughtUp(true);
569
571
  };
570
572
  const goPrevious = () => {
571
- if (previousLineageId) selectReference(previousLineageId);
573
+ if (previousLineageId) { selectReference(previousLineageId); setScrollTarget({ id: previousLineageId, nonce: Date.now() }); }
572
574
  };
573
575
  const gestures = useQueueGestures({ onNext: goNext, onPrevious: goPrevious });
574
576
 
@@ -584,9 +586,62 @@ export function NewsroomReferencesView({
584
586
 
585
587
  const runReview = (action: ReferenceCurationAction, options?: ReferenceReviewOptions) => {
586
588
  if (!selectedReference) return;
589
+ if (action === "accept" || action === "reject") markVisitRated(referenceLineageId(selectedReference));
587
590
  return onReview(selectedReference, action, options);
588
591
  };
589
592
 
593
+ const rateFromFeed = async (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) => {
594
+ const decision = decisions?.get(referenceLineageId(reference));
595
+ await onReview(reference, choice, {
596
+ reasonCode: choice === "reject" ? reasonCode : null,
597
+ ...(decision ? { decisionRelationId: decision.decisionRelationId, shareable: false } : {}),
598
+ });
599
+ };
600
+ // One rating store for the whole view: the grid cards and the detail pane read and write the same state, so a vote or a reason
601
+ // entered in either shows in both. Reason then vote, or vote then reason, both end up on one decision.
602
+ const sectionChoices = useRef<Map<string, string | null>>(new Map());
603
+ const referenceById = (lineageId: string) => selectedReferenceRecordByLineage(canonicalReferences, lineageId);
604
+ const ratings = useRatingStore({
605
+ save: async (lineageId, choice, detail) => {
606
+ const reference = referenceById(lineageId);
607
+ if (!reference) throw new Error("This reference is no longer in the list.");
608
+ markVisitRated(lineageId);
609
+ const decision = decisions?.get(lineageId);
610
+ const sectionId = choice === "accept" ? sectionChoices.current.get(lineageId) : null;
611
+ await onReview(reference, choice, {
612
+ note: detail.note || undefined,
613
+ reasonCode: choice === "reject" ? detail.reasonCode : null,
614
+ ...(sectionId ? { sectionId } : {}),
615
+ ...(decision ? { decisionRelationId: decision.decisionRelationId, shareable: false } : {}),
616
+ });
617
+ },
618
+ saved: (lineageId) => {
619
+ const status = normalizeReferenceCurationStatus(referenceById(lineageId)?.curationStatus);
620
+ return status === "accepted" ? "accept" : status === "rejected" ? "reject" : null;
621
+ },
622
+ seed: (lineageId) => {
623
+ const reference = referenceById(lineageId);
624
+ const status = normalizeReferenceCurationStatus(reference?.curationStatus);
625
+ return {
626
+ note: reference && status !== "pending" ? reference.curationStatusReason ?? "" : "",
627
+ reasonCode: reviewReasons?.get(lineageId) ?? (reference ? referenceReasonCode(reference) : null) ?? null,
628
+ };
629
+ },
630
+ });
631
+ const { statuses: captureStatuses, teaserLine } = useMemo(
632
+ () => captureStatusesByLineage(referenceAttachments, canonicalReferences.map(referenceLineageId)),
633
+ [canonicalReferences, referenceAttachments],
634
+ );
635
+ // Preview only: a fixture can carry captured text inline; say so, since production stores none for monitored References.
636
+ const previewCapturedText = useMemo(
637
+ () => isLocalPreviewEnabled() && referenceAttachments.some((attachment) => attachment.role === "extracted_text" && String(attachment.storagePath ?? "").startsWith("preview://")),
638
+ [referenceAttachments],
639
+ );
640
+ const showCaptures = !showingSavedCopy && !loading && !previewSkeleton;
641
+ const feedDisabledReason = disabled
642
+ ? "Editor access is required to change this reference."
643
+ : showingSavedCopy ? "Confirming your sign-in. You can answer in a moment." : null;
644
+
590
645
  const caughtUpPanel = (
591
646
  <div className="space-y-2 p-6 text-center" data-review-caught-up>
592
647
  <h3 className="text-lg font-semibold">All caught up</h3>
@@ -601,30 +656,81 @@ export function NewsroomReferencesView({
601
656
  ? "No references match this filter."
602
657
  : "No private references imported yet.";
603
658
 
659
+ // The grid must not move when a detail opens, so in the feed the detail pane's place is reserved all the time (from the md breakpoint
660
+ // up): opening it changes nothing, and the grid's column count follows the width it has beside that place, never the selection. Below
661
+ // md the detail is a sheet over the grid. There is no overlay mode: an overlay would hide the cards Previous / Next scrolls to.
662
+
604
663
  return (
605
664
  <div
606
- className="flex min-h-0 flex-1 flex-col gap-4 md:max-h-[calc(100dvh-8.5rem)] md:flex-row"
665
+ className="flex min-h-0 flex-1 flex-col gap-4 md:max-h-[100dvh] md:flex-row"
666
+ data-detail-reserved={view === "feed" ? "true" : undefined}
607
667
  data-news-desk-section="references"
608
668
  data-detail-open={selectedReference || routeLineageId ? "true" : "false"}
609
669
  >
610
670
  <div className="flex min-h-0 min-w-0 flex-1 flex-col gap-4 md:overflow-hidden">
611
- <div className="shrink-0 space-y-1">
612
- <h2 className="text-lg font-semibold tracking-tight">References</h2>
613
- <p className="text-sm text-muted-foreground">
614
- Review source intake before it becomes accepted evidence.
615
- </p>
671
+ <div className="flex shrink-0 items-center justify-between gap-3" {...FEED_SURFACE_PROPS}>
672
+ <div className="min-w-0 space-y-0.5">
673
+ <h2 className="text-2xl font-bold tracking-tight">References</h2>
674
+ <p className="hidden text-sm text-muted-foreground sm:block">
675
+ {view === "feed" ? "A feed ranked by what you are most likely to accept." : "Review source intake before it becomes accepted evidence."}
676
+ </p>
677
+ </div>
678
+ <div className="flex items-center gap-2">
679
+ <button
680
+ aria-controls="reference-add-panel"
681
+ aria-expanded={addOpen}
682
+ aria-label="Add a reference and prediction activity"
683
+ className={cn("inline-flex min-h-10 items-center justify-center gap-1.5 rounded-full px-3 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring", addOpen ? "bg-[var(--feed-control)] text-foreground" : "bg-[var(--feed-card)] text-muted-foreground hover:text-foreground")}
684
+ data-reference-add-toggle
685
+ onClick={() => setAddOpen((open) => !open)}
686
+ title="Add a reference and prediction activity"
687
+ type="button"
688
+ >
689
+ <PlusIcon aria-hidden="true" className="size-4" />
690
+ <span className="hidden sm:inline">Add</span>
691
+ </button>
692
+ <div aria-label="Reference view" className="inline-flex rounded-full bg-[var(--feed-card)] p-1" role="group">
693
+ {([["feed", "Feed", LayoutGridIcon], ["list", "List", ListIcon]] as const).map(([key, label, Icon]) => (
694
+ <button
695
+ aria-label={label}
696
+ aria-pressed={view === key}
697
+ className={cn(
698
+ "inline-flex min-h-8 items-center justify-center gap-1.5 rounded-full px-3 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
699
+ view === key ? "bg-[var(--feed-control)] text-foreground" : "text-muted-foreground hover:text-foreground",
700
+ )}
701
+ data-reference-view={key}
702
+ key={key}
703
+ onClick={() => { setView(key); setCaughtUp(false); }}
704
+ title={label}
705
+ type="button"
706
+ >
707
+ <Icon aria-hidden="true" className="size-4" />
708
+ <span className="hidden sm:inline">{label}</span>
709
+ </button>
710
+ ))}
711
+ </div>
712
+ </div>
616
713
  </div>
617
- {predictionState === "loading" ? <p role="status">Loading predictions…</p> : null}
714
+ {synthetic?.changed ? (
715
+ <p className="rounded-2xl bg-[var(--feed-card)] p-3 text-sm" data-preview-synthetic role="status" {...FEED_SURFACE_PROPS}>
716
+ Preview only: {synthetic.changed} of these predictions are made up to show the Reject end of the feed. Real data has none yet.
717
+ </p>
718
+ ) : null}
719
+ {previewCapturedText ? (
720
+ <p className="rounded-2xl bg-[var(--feed-card)] p-3 text-sm" data-preview-captured-text role="status" {...FEED_SURFACE_PROPS}>
721
+ Preview only: the captured text here was fetched live for this preview. In the newsroom record none of these References has stored text yet.
722
+ </p>
723
+ ) : null}
724
+ {predictionState === "loading" && view === "list" ? <p role="status">Loading predictions…</p> : null}
618
725
  {predictionState === "error" ? (
619
- <div role="alert" className="space-y-2 rounded-md border p-3">
726
+ <div role="alert" className="space-y-2 rounded-2xl bg-[var(--feed-card)] p-4" {...FEED_SURFACE_PROPS}>
620
727
  <p>Predictions unavailable. {predictionError}</p>
621
728
  <p className="text-sm">You can still review references. Existing suggestions may be out of date.</p>
622
729
  {onRetryPredictions ? <Button onClick={onRetryPredictions} size="sm" type="button" variant="outline">Retry predictions</Button> : null}
623
730
  </div>
624
731
  ) : null}
625
- <details className="shrink-0 overflow-y-auto rounded-md border p-3 text-sm" style={{ maxHeight: "35dvh" }}>
626
- <summary className="cursor-pointer font-semibold">Add a reference and prediction activity</summary>
627
- <div className="mt-3 space-y-4">
732
+ <div className="shrink-0 overflow-y-auto rounded-[20px] bg-[var(--feed-card)] p-4 text-sm" data-feed-flat="" data-feed-surface="" hidden={!addOpen} id="reference-add-panel" style={{ ...FEED_SURFACE_STYLE, maxHeight: "45dvh", overflowY: "auto" }}>
733
+ <div className="space-y-4">
628
734
  <ReferenceLiveAgreement agreement={shadowAgreement} />
629
735
  <ReferenceAddEntry
630
736
  importOptions={disabled ? null : priming ?? sessionImport?.panel}
@@ -641,8 +747,51 @@ export function NewsroomReferencesView({
641
747
  </div>
642
748
  ) : null}
643
749
  </div>
644
- </details>
750
+ </div>
645
751
 
752
+ {view === "feed" ? (
753
+ <div className="relative flex min-h-0 flex-1 flex-col" {...FEED_SURFACE_PROPS}>
754
+ {stable && stable.newCount > 0 && feedTab === "feed" ? (
755
+ // Floats over the top of the grid, outside the scrolling content: announcing new items never moves a card.
756
+ <div className="pointer-events-none absolute inset-x-0 top-2 z-20 flex justify-center">
757
+ <button
758
+ className="pointer-events-auto inline-flex min-h-9 items-center rounded-full bg-[var(--feed-control)] px-4 text-sm font-medium text-foreground shadow-lg outline-none focus-visible:ring-2 focus-visible:ring-ring"
759
+ data-feed-new-items
760
+ onClick={showNewItems}
761
+ type="button"
762
+ >
763
+ {stable.newCount} new {stable.newCount === 1 ? "item" : "items"}: show
764
+ </button>
765
+ </div>
766
+ ) : null}
767
+ <ScrollArea className="min-h-0 flex-1 [overflow-anchor:none]" data-reference-feed-scroll>
768
+ <div className="pb-6 pr-1">
769
+ <ReferenceFeed
770
+ disabledReason={feedDisabledReason}
771
+ loading={previewSkeleton || (loading && !references.length)}
772
+ now={mountedAt}
773
+ onOpen={selectReference}
774
+ onRate={rateFromFeed}
775
+ onTabChange={(next) => { setFeedTab(next); setCaughtUp(false); }}
776
+ onVisitRated={markVisitRated}
777
+ partition={partition}
778
+ predictionLoading={predictionState === "loading"}
779
+ previews={previews}
780
+ captures={showCaptures ? captureStatuses : undefined}
781
+ groups={stable?.groups}
782
+ leadId={stable?.leadId}
783
+ scrollTarget={scrollTarget}
784
+ ratings={ratings}
785
+ selectedLineageId={selectedLineageId}
786
+ stale={showingSavedCopy || (loading && references.length > 0)}
787
+ tab={feedTab}
788
+ visitRated={visitRated}
789
+ />
790
+ </div>
791
+ </ScrollArea>
792
+ </div>
793
+ ) : (
794
+ <>
646
795
  <Tabs
647
796
  className="shrink-0"
648
797
  defaultValue="pending"
@@ -718,15 +867,21 @@ export function NewsroomReferencesView({
718
867
  )}
719
868
  </div>
720
869
  </ScrollArea>
870
+ </>
871
+ )}
721
872
  </div>
722
873
 
723
874
  <div
724
- className="hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-card md:flex md:w-[min(100%,24rem)] lg:w-[min(100%,28rem)]"
875
+ className={cn(
876
+ "hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-[20px] bg-[var(--feed-card)] md:flex md:w-[min(100%,24rem)] lg:w-[min(100%,28rem)]",
877
+ )}
725
878
  data-newsroom-list-detail-shell
726
879
  data-news-desk-section="references"
727
880
  data-detail-open={selectedReference ? "true" : "false"}
881
+ data-feed-flat=""
882
+ {...FEED_SURFACE_PROPS}
728
883
  >
729
- <div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2">
884
+ <div className="flex shrink-0 items-center justify-between gap-2 px-4 pt-3 pb-1">
730
885
  <span className="text-sm font-medium text-foreground">Detail</span>
731
886
  </div>
732
887
  <div className="flex min-h-0 flex-1 flex-col" data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}>
@@ -734,6 +889,10 @@ export function NewsroomReferencesView({
734
889
  {selectedReference ? (
735
890
  <ReferenceDetailPanel
736
891
  key={referenceLineageId(selectedReference)}
892
+ captureTeaserLine={teaserLine}
893
+ correctCapture={onCorrectCapture}
894
+ ratings={ratings}
895
+ sectionChoices={sectionChoices}
737
896
  predictionState={predictionState}
738
897
  reviewReason={reviewReasons?.get(referenceLineageId(selectedReference))}
739
898
  reviewSectionId={reviewSectionIds?.get(referenceLineageId(selectedReference))}
@@ -768,14 +927,18 @@ export function NewsroomReferencesView({
768
927
  }}
769
928
  open={(mobileDetailOpen && Boolean(selectedReference)) || caughtUp}
770
929
  >
771
- <SheetContent className="gap-0 p-0" side="bottom">
930
+ <SheetContent className="gap-0 rounded-t-[24px] bg-[var(--feed-card)] p-0" data-feed-flat="" data-feed-surface="" side="bottom" {...themeAttributes}>
772
931
  <SheetHeader className="border-b border-border px-4 py-3">
773
932
  <SheetTitle className="text-left text-base">Reference</SheetTitle>
774
933
  </SheetHeader>
775
934
  {selectedReference ? (
776
- <div data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}><ScrollArea className="max-h-[70dvh]">
935
+ <div className="min-w-0" data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}><ScrollArea className="max-h-[70dvh]">
777
936
  <ReferenceDetailPanel
778
937
  key={referenceLineageId(selectedReference)}
938
+ captureTeaserLine={teaserLine}
939
+ correctCapture={onCorrectCapture}
940
+ ratings={ratings}
941
+ sectionChoices={sectionChoices}
779
942
  predictionState={predictionState}
780
943
  reviewReason={reviewReasons?.get(referenceLineageId(selectedReference))}
781
944
  reviewSectionId={reviewSectionIds?.get(referenceLineageId(selectedReference))}