@anthusai/papyrus 2.0.0-next.77 → 2.0.0-next.79

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 (40) hide show
  1. package/amplify/site-backend-config.ts +1 -1
  2. package/amplify/site-backend-names.js +4 -2
  3. package/amplify/site-backend-names.ts +4 -2
  4. package/amplify/site-backend.js +1 -1
  5. package/amplify/site-backend.ts +1 -1
  6. package/components/news-desk-client-provider.tsx +18 -18
  7. package/components/news-desk-taxonomy-client.ts +28 -8
  8. package/components/newsroom-access-gate.tsx +15 -1
  9. package/components/newsroom-article-editor.tsx +4 -2
  10. package/components/newsroom-articles-view.tsx +6 -6
  11. package/components/newsroom-board-view.tsx +8 -3
  12. package/components/newsroom-ops-overview.tsx +12 -100
  13. package/components/newsroom-ops-shell.tsx +2 -2
  14. package/components/newsroom-organizer.css +2 -0
  15. package/components/newsroom-overview-skeleton.tsx +25 -0
  16. package/components/newsroom-references-view.tsx +179 -36
  17. package/components/notification-center.tsx +13 -2
  18. package/components/reader-auth-state.ts +1 -1
  19. package/components/reference-cyclotron-suggestion.tsx +9 -5
  20. package/components/reference-relevance-review.tsx +3 -3
  21. package/components/topic-steering-workspace.tsx +144 -123
  22. package/infra/amplify-app-shell.js +4 -0
  23. package/infra/amplify-app-shell.ts +4 -0
  24. package/infra/cloud-email-config.js +32 -0
  25. package/infra/cloud-email-config.ts +39 -0
  26. package/infra/cloud-email.js +57 -0
  27. package/infra/cloud-email.test.js +69 -0
  28. package/infra/cloud-email.test.ts +66 -0
  29. package/infra/cloud-email.ts +60 -0
  30. package/infra/site-config.js +4 -2
  31. package/infra/site-config.ts +7 -2
  32. package/lib/cyclotron-suggestions.ts +4 -4
  33. package/lib/news-desk-session.ts +12 -21
  34. package/lib/newsroom-organizer-nav.ts +2 -1
  35. package/lib/reference-review-details.ts +71 -0
  36. package/lib/reference-review-request.ts +14 -0
  37. package/lib/relevance-decisions.ts +42 -10
  38. package/lib/use-reference-review-data.ts +44 -0
  39. package/package.json +5 -1
  40. package/routes.manifest.json +1 -1
@@ -13,7 +13,7 @@ import {
13
13
  ThumbsUpIcon,
14
14
  } from "lucide-react";
15
15
  import { usePathname } from "next/navigation";
16
- import { useCallback, useEffect, useMemo, useState } from "react";
16
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
17
17
  import { Badge } from "./ui/badge";
18
18
  import { Button } from "./ui/button";
19
19
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
@@ -28,6 +28,7 @@ import {
28
28
  import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
29
29
  import type { ReferenceAttachmentRecord, ReferenceRecord } from "../lib/category-repository";
30
30
  import {
31
+ buildReferencesIndexQuery,
31
32
  parseReferenceLineageIdFromNewsroomPathname,
32
33
  syncBrowserNewsroomIndexUrl,
33
34
  } from "../lib/newsroom-index-filters";
@@ -45,7 +46,7 @@ import {
45
46
  type ReferenceCurationAction,
46
47
  type ReferenceStatusFilter,
47
48
  } from "../lib/newsroom-references";
48
- import { normalizeReferenceCurationStatus } from "../lib/reference-policy";
49
+ import { normalizeReferenceCurationStatus, referenceReasonCode, REFERENCE_REJECTION_REASON_CODES, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
49
50
  import { newsroomListRowClassName } from "../lib/newsroom-list-selection";
50
51
  import { ReferenceSourcePreview } from "./reference-source-preview";
51
52
  import { ReferenceRelevanceReview } from "./reference-relevance-review";
@@ -61,7 +62,14 @@ import { SectionPicker, SectionSuggestionBlock, type SectionChoice } from "./ref
61
62
  import type { SectionSuggestion, SiteSectionAgreement } from "../lib/site-section-suggestions";
62
63
 
63
64
  /** What an editor adds to an accept: the Section the reference belongs in, which becomes a training label. */
64
- export type ReferenceReviewOptions = { sectionId?: string | null };
65
+ export type ReferenceReviewOptions = {
66
+ sectionId?: string | null;
67
+ note?: string;
68
+ reasonCode?: ReferenceRejectionReasonCode | null;
69
+ decisionRelationId?: string;
70
+ shareable?: boolean;
71
+ };
72
+ type PredictionState = "loading" | "ready" | "error";
65
73
  import type { DelegatedCuration, ShadowAgreement } from "../lib/cyclotron-suggestions";
66
74
  import type { PrimingFormInput } from "./reference-cyclotron-priming";
67
75
  import { ReferenceCyclotronStatus, type ReviewRateChoice } from "./reference-cyclotron-status";
@@ -102,9 +110,14 @@ type NewsroomReferencesViewProps = {
102
110
  sectionSuggestions?: Map<string, SectionSuggestion>;
103
111
  /** How often the Section suggestions matched later editor decisions. */
104
112
  sectionAgreement?: SiteSectionAgreement | null;
105
- onRelevanceReview?: (reference: ReferenceRecord, curation: RelevanceCuration) => void;
113
+ reviewReasons?: Map<string, ReferenceRejectionReasonCode>;
114
+ reviewSectionIds?: Map<string, string>;
115
+ predictionState?: PredictionState;
116
+ predictionError?: string | null;
117
+ onRetryPredictions?: () => void;
118
+ onRelevanceReview?: (reference: ReferenceRecord, curation: RelevanceCuration) => Promise<void> | void;
106
119
  initialReferenceLineageId?: string | null;
107
- onReview: (reference: ReferenceRecord, action: ReferenceCurationAction, options?: ReferenceReviewOptions) => void;
120
+ onReview: (reference: ReferenceRecord, action: ReferenceCurationAction, options?: ReferenceReviewOptions) => Promise<void> | void;
108
121
  referenceAttachments?: ReferenceAttachmentRecord[];
109
122
  references: ReferenceRecord[];
110
123
  };
@@ -251,6 +264,9 @@ function ReferenceDetailPanel({
251
264
  disabled,
252
265
  onRelevanceReview,
253
266
  onReview,
267
+ predictionState = "ready",
268
+ reviewReason,
269
+ reviewSectionId,
254
270
  previewAttachments,
255
271
  reference,
256
272
  onClose,
@@ -263,8 +279,11 @@ function ReferenceDetailPanel({
263
279
  decision?: RelevanceDecisionView;
264
280
  demo?: boolean;
265
281
  disabled?: boolean;
266
- onRelevanceReview?: (curation: RelevanceCuration) => void;
267
- onReview: (action: ReferenceCurationAction, options?: ReferenceReviewOptions) => void;
282
+ onRelevanceReview?: (curation: RelevanceCuration) => Promise<void> | void;
283
+ onReview: (action: ReferenceCurationAction, options?: ReferenceReviewOptions) => Promise<void> | void;
284
+ predictionState?: PredictionState;
285
+ reviewReason?: ReferenceRejectionReasonCode;
286
+ reviewSectionId?: string;
268
287
  sectionAgreement?: SiteSectionAgreement | null;
269
288
  sectionSuggestion?: SectionSuggestion;
270
289
  sections?: SectionChoice[];
@@ -273,13 +292,77 @@ function ReferenceDetailPanel({
273
292
  onClose?: () => void;
274
293
  }) {
275
294
  const lineageId = referenceLineageId(reference);
276
- const [chosenSectionId, setChosenSectionId] = useState<string | null>(null);
277
- useEffect(() => { setChosenSectionId(null); }, [lineageId]);
295
+ const sourceNote = normalizeReferenceCurationStatus(reference.curationStatus) === "pending" ? "" : reference.curationStatusReason ?? "";
296
+ const sourceReason = reviewReason ?? referenceReasonCode(reference) ?? "";
297
+ const [note, setNote] = useState(sourceNote);
298
+ const [reasonCode, setReasonCode] = useState<ReferenceRejectionReasonCode | "">(sourceReason);
299
+ const [savedNote, setSavedNote] = useState(sourceNote);
300
+ const [savedReason, setSavedReason] = useState<ReferenceRejectionReasonCode | "">(sourceReason);
301
+ const [busy, setBusy] = useState(false);
302
+ const [reviewError, setReviewError] = useState<string | null>(null);
303
+ const [saved, setSaved] = useState(false);
304
+ const saving = useRef(false);
305
+ const noteEdited = useRef(false);
306
+ const reasonEdited = useRef(false);
307
+ useEffect(() => {
308
+ setSavedNote(sourceNote);
309
+ if (!noteEdited.current) setNote(sourceNote);
310
+ }, [sourceNote]);
311
+ useEffect(() => {
312
+ setSavedReason(sourceReason);
313
+ if (!reasonEdited.current) setReasonCode(sourceReason);
314
+ }, [sourceReason]);
315
+ const [chosenSectionId, setChosenSectionId] = useState<string | null>(reviewSectionId ?? null);
316
+ const [savedSectionId, setSavedSectionId] = useState<string | null>(reviewSectionId ?? null);
317
+ const sectionEdited = useRef(false);
318
+ useEffect(() => {
319
+ setSavedSectionId(reviewSectionId ?? null);
320
+ if (!sectionEdited.current) setChosenSectionId(reviewSectionId ?? null);
321
+ }, [reviewSectionId]);
278
322
  const status = normalizeReferenceCurationStatus(reference.curationStatus);
279
323
  const url = referenceUrl(reference);
280
- const acceptDisabled = disabled || status === "accepted";
281
- const rejectDisabled = disabled || status === "rejected";
282
- const archiveDisabled = disabled || status === "archived";
324
+ const locked = disabled || busy;
325
+ const acceptDisabled = locked || status === "accepted";
326
+ const rejectDisabled = locked || status === "rejected";
327
+ const archiveDisabled = locked || status === "archived";
328
+
329
+ async function save(action: ReferenceCurationAction, review?: RelevanceCuration) {
330
+ if (disabled || saving.current) return;
331
+ const rejectionReason = review?.reasonCode ?? reasonCode;
332
+ if (action === "reject" && !rejectionReason) {
333
+ setReviewError("Choose a rejection reason before saving. An explanation is optional.");
334
+ return;
335
+ }
336
+ saving.current = true;
337
+ setBusy(true);
338
+ setSaved(false);
339
+ setReviewError(null);
340
+ try {
341
+ if (review && onRelevanceReview) await onRelevanceReview({ ...review, ...(action === "accept" && chosenSectionId ? { sectionId: chosenSectionId } : {}) });
342
+ else await onReview(action, {
343
+ note: note.trim(),
344
+ reasonCode: action === "reject" ? reasonCode || null : null,
345
+ ...(action === "accept" && chosenSectionId ? { sectionId: chosenSectionId } : {}),
346
+ ...(decision ? { decisionRelationId: decision.decisionRelationId, shareable: false } : {}),
347
+ });
348
+ if (review) {
349
+ setNote(review.note ?? "");
350
+ setReasonCode((review.reasonCode as ReferenceRejectionReasonCode) ?? "");
351
+ }
352
+ setSavedNote(review ? review.note ?? "" : note.trim());
353
+ setSavedReason(action === "reject" ? rejectionReason as ReferenceRejectionReasonCode : "");
354
+ setSavedSectionId(action === "accept" ? chosenSectionId : null);
355
+ noteEdited.current = false;
356
+ reasonEdited.current = false;
357
+ sectionEdited.current = false;
358
+ setSaved(true);
359
+ } catch (error) {
360
+ setReviewError(error instanceof Error ? error.message : "Review could not be saved. Try again.");
361
+ } finally {
362
+ saving.current = false;
363
+ setBusy(false);
364
+ }
365
+ }
283
366
 
284
367
  return (
285
368
  <div
@@ -304,8 +387,8 @@ function ReferenceDetailPanel({
304
387
  <div className="space-y-3" data-reference-relevance-decision={lineageId}>
305
388
  <ReferenceSuggestionBlock
306
389
  decision={decision}
307
- disabled={disabled}
308
- onCurate={onRelevanceReview}
390
+ disabled={locked}
391
+ onCurate={onRelevanceReview ? (review) => { void save(review.action, { ...review, note: note.trim() || null, reasonCode: review.action === "reject" ? reasonCode || null : null }); } : undefined}
309
392
  referenceLineageId={lineageId}
310
393
  settled={status !== "pending"}
311
394
  statusLabel={referenceStatusLabel(status)}
@@ -317,31 +400,54 @@ function ReferenceDetailPanel({
317
400
  <div className="mt-2">
318
401
  <ReferenceRelevanceReview
319
402
  decision={decision}
320
- disabled={disabled}
321
- onCurate={onRelevanceReview}
403
+ disabled={locked}
404
+ onCurate={(review) => save(review.action, review)}
322
405
  referenceLineageId={lineageId}
323
406
  />
324
407
  </div>
325
408
  </details>
326
409
  ) : null}
327
410
  </div>
411
+ ) : predictionState === "ready" ? (
412
+ <section aria-label="Publication prediction" className="space-y-1 rounded-md border p-3">
413
+ <h3 className="text-sm font-semibold">No prediction recorded</h3>
414
+ <p className="text-sm text-muted-foreground">No publication relevance prediction was returned for this reference. You can still review it. Pending is its editorial status, not a prediction status.</p>
415
+ </section>
328
416
  ) : null}
329
- {status === "pending" && sectionSuggestion ? (
330
- <SectionSuggestionBlock agreement={sectionAgreement} referenceLineageId={lineageId} sections={sections} suggestion={sectionSuggestion} />
331
- ) : null}
332
- {status === "pending" && sections.length ? (
333
- <SectionPicker
417
+ {sectionSuggestion || ((status === "pending" || status === "accepted") && sections.length) ? (
418
+ <details className="rounded-md border p-3 text-sm">
419
+ <summary className="cursor-pointer font-semibold">Section placement (optional)</summary>
420
+ <div className="mt-3 space-y-3">
421
+ {sectionSuggestion ? <SectionSuggestionBlock agreement={sectionAgreement} referenceLineageId={lineageId} sections={sections} suggestion={sectionSuggestion} /> : null}
422
+ {(status === "pending" || status === "accepted") && sections.length ? <SectionPicker
334
423
  agreement={sectionAgreement}
335
424
  chosenSectionId={chosenSectionId}
336
- disabled={disabled}
337
- onChoose={setChosenSectionId}
425
+ disabled={locked}
426
+ onChoose={(sectionId) => { setChosenSectionId(sectionId); sectionEdited.current = true; setSaved(false); }}
338
427
  referenceLineageId={lineageId}
339
428
  sections={sections}
340
429
  suggestedSectionId={sectionSuggestion?.sectionId ?? null}
341
- />
430
+ /> : null}
431
+ </div>
432
+ </details>
342
433
  ) : null}
343
434
  {delegated ? <DelegatedCurationNote delegated={delegated} referenceLineageId={lineageId} /> : countsAsTrainingLabel ? <CountsAsTrainingLabelNote referenceLineageId={lineageId} /> : null}
344
435
 
436
+ <section aria-label="Editor feedback" className="space-y-3">
437
+ <h3 className="text-sm font-semibold">Editor feedback</h3>
438
+ {disabled ? <p className="text-sm text-muted-foreground">Editor access is required to change this reference.</p> : null}
439
+ <label className="block text-sm">
440
+ Review explanation (optional)
441
+ <textarea aria-label="Review explanation (optional)" className="mt-1 block min-h-20 w-full rounded-md border bg-background p-2" disabled={locked} value={note} onChange={(event) => { setNote(event.target.value); noteEdited.current = true; setSaved(false); }} />
442
+ </label>
443
+ <label className="block text-sm">
444
+ Rejection reason
445
+ <select aria-label="Rejection reason" className="mt-1 block w-full rounded-md border bg-background p-2" disabled={locked} value={reasonCode} onChange={(event) => { setReasonCode(event.target.value as ReferenceRejectionReasonCode | ""); reasonEdited.current = true; setSaved(false); }}>
446
+ <option value="">Choose a reason when rejecting</option>
447
+ {REFERENCE_REJECTION_REASON_CODES.map((code) => <option key={code} value={code}>{code.charAt(0).toUpperCase() + code.slice(1).replaceAll("_", " ")}</option>)}
448
+ </select>
449
+ </label>
450
+ <p className="text-xs text-muted-foreground">Only out of scope and policy exclusion are negative relevance labels. Other rejection reasons do not teach relevance.</p>
345
451
  <div
346
452
  className="flex flex-wrap items-center gap-2"
347
453
  data-news-desk-reference-curation-cluster
@@ -352,7 +458,7 @@ function ReferenceDetailPanel({
352
458
  aria-pressed={status === "accepted"}
353
459
  data-news-desk-reference-accept
354
460
  disabled={acceptDisabled}
355
- onClick={() => onReview("accept", chosenSectionId ? { sectionId: chosenSectionId } : undefined)}
461
+ onClick={() => { void save("accept"); }}
356
462
  size="sm"
357
463
  type="button"
358
464
  variant={status === "accepted" ? "default" : "outline"}
@@ -365,7 +471,7 @@ function ReferenceDetailPanel({
365
471
  aria-pressed={status === "rejected"}
366
472
  data-news-desk-reference-reject
367
473
  disabled={rejectDisabled}
368
- onClick={() => onReview("reject")}
474
+ onClick={() => { void save("reject"); }}
369
475
  size="sm"
370
476
  type="button"
371
477
  variant={status === "rejected" ? "destructive" : "outline"}
@@ -376,7 +482,7 @@ function ReferenceDetailPanel({
376
482
  <Button
377
483
  aria-label="Archive reference"
378
484
  disabled={archiveDisabled}
379
- onClick={() => onReview("archive")}
485
+ onClick={() => { void save("archive"); }}
380
486
  size="sm"
381
487
  type="button"
382
488
  variant="outline"
@@ -384,7 +490,14 @@ function ReferenceDetailPanel({
384
490
  <ArchiveIcon className="size-4" />
385
491
  Archive
386
492
  </Button>
493
+ {status === "accepted" || status === "rejected" ? (
494
+ <Button disabled={locked || predictionState !== "ready" || (note.trim() === savedNote.trim() && (status !== "rejected" || reasonCode === savedReason) && (status !== "accepted" || chosenSectionId === savedSectionId))} onClick={() => { void save(status === "accepted" ? "accept" : "reject"); }} size="sm" type="button" variant="outline">Save feedback</Button>
495
+ ) : null}
387
496
  </div>
497
+ {status !== "pending" && predictionState !== "ready" ? <p className="text-sm text-muted-foreground">Load the review details before revising saved feedback.</p> : null}
498
+ {busy ? <p role="status" className="text-sm">Saving review…</p> : saved ? <p role="status" className="text-sm">Review saved.</p> : null}
499
+ {reviewError ? <p role="alert" className="text-sm text-destructive">{reviewError}</p> : null}
500
+ </section>
388
501
 
389
502
  <Separator />
390
503
 
@@ -398,7 +511,7 @@ function ReferenceDetailPanel({
398
511
  <dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Why</dt>
399
512
  <dd className="mt-1 text-foreground">
400
513
  {reference.curationStatusReason?.trim()
401
- || "Private corpus item awaiting editorial review for evidence use."}
514
+ || "No explanation recorded."}
402
515
  </dd>
403
516
  </div>
404
517
  {url ? (
@@ -476,6 +589,11 @@ export function NewsroomReferencesView({
476
589
  demo = false,
477
590
  disabled = false,
478
591
  onRelevanceReview,
592
+ predictionState = "ready",
593
+ predictionError,
594
+ reviewReasons,
595
+ reviewSectionIds,
596
+ onRetryPredictions,
479
597
  sections = [],
480
598
  sectionSuggestions,
481
599
  sectionAgreement = null,
@@ -492,7 +610,7 @@ export function NewsroomReferencesView({
492
610
  () => parseReferenceLineageIdFromNewsroomPathname(pathname),
493
611
  [pathname],
494
612
  );
495
- const routeLineageId = initialReferenceLineageId ?? pathnameLineageId ?? "";
613
+ const routeLineageId = pathname ? pathnameLineageId ?? "" : initialReferenceLineageId ?? "";
496
614
 
497
615
  const canonicalReferences = useMemo(() => selectCanonicalReferenceRecords(references), [references]);
498
616
  const statusCounts = useMemo(() => countReferencesByStatus(canonicalReferences), [canonicalReferences]);
@@ -531,17 +649,21 @@ export function NewsroomReferencesView({
531
649
 
532
650
  const syncStatusUrl = useCallback((nextStatus: ReferenceListFilter) => {
533
651
  if (demo) return;
534
- syncBrowserNewsroomIndexUrl("references", {
652
+ const filters = {
535
653
  status: nextStatus === "all" ? "" : nextStatus,
536
654
  processing: "",
537
- order: "published",
538
- }, { replace: true });
539
- }, [demo]);
655
+ order: "published" as const,
656
+ };
657
+ if (selectedLineageId && typeof window !== "undefined") {
658
+ window.history.replaceState(null, "", `${buildReferenceDetailPath(selectedLineageId)}${buildReferencesIndexQuery(filters)}`);
659
+ } else {
660
+ syncBrowserNewsroomIndexUrl("references", filters, { replace: true });
661
+ }
662
+ }, [demo, selectedLineageId]);
540
663
 
541
664
  useEffect(() => {
542
- if (!routeLineageId) return;
543
665
  setSelectedLineageId(routeLineageId);
544
- if (isMobileDetail) setMobileDetailOpen(true);
666
+ if (isMobileDetail) setMobileDetailOpen(Boolean(routeLineageId));
545
667
  }, [isMobileDetail, routeLineageId]);
546
668
 
547
669
  useEffect(() => {
@@ -571,7 +693,7 @@ export function NewsroomReferencesView({
571
693
 
572
694
  const runReview = (action: ReferenceCurationAction, options?: ReferenceReviewOptions) => {
573
695
  if (!selectedReference) return;
574
- onReview(selectedReference, action, options);
696
+ return onReview(selectedReference, action, options);
575
697
  };
576
698
 
577
699
  const emptyLabel = canonicalReferences.length
@@ -591,6 +713,17 @@ export function NewsroomReferencesView({
591
713
  Review source intake before it becomes accepted evidence.
592
714
  </p>
593
715
  </div>
716
+ {predictionState === "loading" ? <p role="status">Loading predictions…</p> : null}
717
+ {predictionState === "error" ? (
718
+ <div role="alert" className="space-y-2 rounded-md border p-3">
719
+ <p>Predictions unavailable. {predictionError}</p>
720
+ <p className="text-sm">You can still review references. Existing suggestions may be out of date.</p>
721
+ {onRetryPredictions ? <Button onClick={onRetryPredictions} size="sm" type="button" variant="outline">Retry predictions</Button> : null}
722
+ </div>
723
+ ) : null}
724
+ <details className="shrink-0 overflow-y-auto rounded-md border p-3 text-sm" style={{ maxHeight: "35dvh" }}>
725
+ <summary className="cursor-pointer font-semibold">Add a reference and prediction activity</summary>
726
+ <div className="mt-3 space-y-4">
594
727
  <ReferenceLiveAgreement agreement={shadowAgreement} />
595
728
  <ReferenceAddEntry
596
729
  importOptions={disabled ? null : priming ?? sessionImport?.panel}
@@ -606,6 +739,8 @@ export function NewsroomReferencesView({
606
739
  <ReferenceCyclotronStatus onRequestRate={disabled ? undefined : onRequestReviewRate} status={cyclotronStatus} />
607
740
  </div>
608
741
  ) : null}
742
+ </div>
743
+ </details>
609
744
 
610
745
  <Tabs
611
746
  className="shrink-0"
@@ -717,6 +852,10 @@ export function NewsroomReferencesView({
717
852
  <ScrollArea className="min-h-0 flex-1">
718
853
  {selectedReference ? (
719
854
  <ReferenceDetailPanel
855
+ key={referenceLineageId(selectedReference)}
856
+ predictionState={predictionState}
857
+ reviewReason={reviewReasons?.get(referenceLineageId(selectedReference))}
858
+ reviewSectionId={reviewSectionIds?.get(referenceLineageId(selectedReference))}
720
859
  countsAsTrainingLabel={humanCuratedReferenceLineageIds?.has(referenceLineageId(selectedReference))}
721
860
  delegated={delegatedCurations?.get(referenceLineageId(selectedReference))}
722
861
  decision={decisions?.get(referenceLineageId(selectedReference))}
@@ -753,6 +892,10 @@ export function NewsroomReferencesView({
753
892
  {selectedReference ? (
754
893
  <ScrollArea className="max-h-[75dvh]">
755
894
  <ReferenceDetailPanel
895
+ key={referenceLineageId(selectedReference)}
896
+ predictionState={predictionState}
897
+ reviewReason={reviewReasons?.get(referenceLineageId(selectedReference))}
898
+ reviewSectionId={reviewSectionIds?.get(referenceLineageId(selectedReference))}
756
899
  countsAsTrainingLabel={humanCuratedReferenceLineageIds?.has(referenceLineageId(selectedReference))}
757
900
  delegated={delegatedCurations?.get(referenceLineageId(selectedReference))}
758
901
  decision={decisions?.get(referenceLineageId(selectedReference))}
@@ -131,9 +131,20 @@ export function NotificationCenterView({ loadState, requests, readIds, held, aut
131
131
  const entry = (request: ReviewRequest) => (
132
132
  <Entry busy={busyId === request.id} demo={demo} key={request.id} onAnswer={onAnswer} onMarkRead={onMarkRead} request={request} unread={isUnread(request, readIds)} />
133
133
  );
134
- if (loadState === "loading") return <div role="status" data-notification-state="loading"><strong>Loading the Notification Center</strong></div>;
134
+ if (loadState === "loading") return (
135
+ <section aria-label="Loading Notification Center" className="space-y-4" data-notification-state="loading" role="status">
136
+ <div className="h-6 w-48 rounded bg-muted" />
137
+ <div className="h-4 w-3/4 max-w-xl rounded bg-muted" />
138
+ <div className="h-4 w-2/3 max-w-lg rounded bg-muted" />
139
+ <div className="grid gap-3 sm:grid-cols-2">
140
+ {Array.from({ length: 4 }, (_, index) => <div className="h-28 rounded-xl border bg-card p-4" key={index}>
141
+ <div className="h-4 w-2/3 rounded bg-muted" /><div className="mt-4 h-3 w-1/2 rounded bg-muted" />
142
+ </div>)}
143
+ </div>
144
+ </section>
145
+ );
135
146
  if (loadState === "forbidden") return <div role="alert" data-notification-state="forbidden"><strong>The Notification Center is for editors and admins</strong></div>;
136
- if (loadState === "unavailable") return <div role="alert" data-notification-state="unavailable"><strong>Review requests could not be read right now</strong><p>Try again in a minute. Nothing waiting on a person has changed.</p></div>;
147
+ if (loadState === "unavailable") return <div role="alert" data-notification-state="unavailable"><strong>Review requests could not be read right now</strong><p>Try again in a minute. The current request state is unknown.</p></div>;
137
148
  return (
138
149
  <div className="space-y-6" data-notification-center>
139
150
  <header className="space-y-1">
@@ -61,7 +61,7 @@ export async function loadReaderSessionSnapshot(options?: { forceRefresh?: boole
61
61
 
62
62
  export function isUnauthenticatedError(error: unknown): boolean {
63
63
  const message = error instanceof Error ? error.message : String(error);
64
- return /unauth|not authenticated|no current user|not signed in/i.test(message);
64
+ return /unauth|not.?authorized|not authenticated|no current user|not signed in|session expired|token expired|invalid.*token|\b401\b/i.test(message);
65
65
  }
66
66
 
67
67
  function readIdentityLabel(claims: {
@@ -57,11 +57,12 @@ export function ReferenceSuggestionBlock({
57
57
  const choose = (choice: SuggestionChoice) => onCurate?.(curationForSuggestionChoice(decision, choice));
58
58
  const suggestsAccept = decision.positiveLabel !== null && decision.label === decision.positiveLabel;
59
59
  return (
60
- <section aria-label="Suggestion" className="space-y-2 border-y border-foreground py-3" data-reference-suggestion={referenceLineageId}>
61
- <h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">Suggestion</h3>
60
+ <section aria-label="Publication prediction" className="space-y-2 border-y border-foreground py-3" data-reference-suggestion={referenceLineageId}>
61
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">Publication prediction</h3>
62
62
  <p className="text-sm text-foreground">
63
63
  Papyrus suggests <span className="font-semibold">{decision.label}</span> ({suggestionConfidenceWords(decision)}).
64
64
  </p>
65
+ {decision.reviewDetail ? <p className="text-xs text-foreground">Why review was requested: {decision.reviewDetail}</p> : null}
65
66
  <p className="text-xs text-foreground" data-reference-suggestion-notice>
66
67
  A suggestion never changes the status until an editor acts. This reference is {statusLabel.toLowerCase()}.
67
68
  </p>
@@ -99,14 +100,17 @@ export function ReferenceSuggestionProvenance({ decision }: { decision: Relevanc
99
100
  <summary className="cursor-pointer font-semibold underline-offset-4 hover:underline">How this suggestion was made</summary>
100
101
  {summary ? (
101
102
  <ul className="mt-2 space-y-1">
102
- <li>{summary.teachingLabelsSentence}.</li>
103
- <li>{summary.primingExamplesSentence}.</li>
103
+ {summary.teachingLabelsSentence ? <li>{summary.teachingLabelsSentence}.</li> : null}
104
+ {summary.primingExamplesSentence ? <li>{summary.primingExamplesSentence}.</li> : null}
105
+ {decision.provenance?.system ? <li>Source: {decision.provenance.system === "cyclotron-lab" ? "Shared publication classifier" : decision.provenance.system}.</li> : null}
106
+ {decision.provenance?.output ? <li>Publication output: {decision.provenance.output}.</li> : null}
107
+ {decision.classifierId ? <li>Classifier: {decision.classifierId}.</li> : null}
104
108
  <li>Guidelines code {summary.doctrineShortHash ?? "not recorded"}.</li>
105
109
  <li>Suggestion {summary.modelVersion ?? "revision not recorded"}.</li>
106
110
  <li>Decided {formatSuggestionTime(summary.decidedAt)}.</li>
107
111
  </ul>
108
112
  ) : (
109
- <p className="mt-2">This suggestion was made before the details were recorded. Only its revision and confidence are known.</p>
113
+ <p className="mt-2">Additional provenance was not recorded for this prediction.</p>
110
114
  )}
111
115
  </details>
112
116
  );
@@ -28,7 +28,7 @@ export function ReferenceRelevanceReview({
28
28
  }: {
29
29
  disabled?: boolean;
30
30
  decision: RelevanceDecisionView;
31
- onCurate: (curation: RelevanceCuration) => void;
31
+ onCurate: (curation: RelevanceCuration) => Promise<void> | void;
32
32
  referenceLineageId: string;
33
33
  }) {
34
34
  const [shareable, setShareable] = useState(false);
@@ -47,10 +47,10 @@ export function ReferenceRelevanceReview({
47
47
  disabled={disabled}
48
48
  itemId={referenceLineageId}
49
49
  mode={decision.reviewControl === "thumbs" && classes.length === 2 ? "thumbs" : "labels"}
50
- onReview={(review) => {
50
+ onReview={async (review) => {
51
51
  try {
52
52
  setError(null);
53
- onCurate(curationForRelevanceReview(decision, { ...review, shareable }));
53
+ await onCurate(curationForRelevanceReview(decision, { ...review, shareable }));
54
54
  } catch (failure) {
55
55
  setError(failure instanceof Error ? failure.message : String(failure));
56
56
  }