@anthusai/papyrus 5.42.1 → 5.43.0-next.10

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 (83) hide show
  1. package/amplify/data/schema.js +2 -0
  2. package/amplify/data/schema.ts +2 -0
  3. package/amplify/functions/category-action/handler.ts +2 -1
  4. package/amplify/functions/monitoring-view/schema.js +22 -0
  5. package/amplify/functions/monitoring-view/schema.ts +22 -0
  6. package/amplify/functions/monitoring-view/wire.js +1 -0
  7. package/amplify/functions/monitoring-view/wire.ts +1 -0
  8. package/amplify/functions/procedure-action/handler.ts +2 -10
  9. package/amplify/functions/proposal-actions/handler.py +137 -0
  10. package/amplify/{plugins/reader-steering/function → functions/proposal-actions}/proposals.py +20 -3
  11. package/amplify/functions/proposal-actions/resource.js +15 -0
  12. package/amplify/functions/proposal-actions/resource.ts +22 -0
  13. package/amplify/functions/proposal-actions/schema.js +106 -0
  14. package/amplify/functions/proposal-actions/schema.ts +108 -0
  15. package/amplify/functions/proposal-actions/wire.js +10 -0
  16. package/amplify/functions/proposal-actions/wire.ts +13 -0
  17. package/amplify/plugins/reader-steering/function/handler.py +13 -72
  18. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  19. package/amplify/plugins/reader-steering/papyrus.plugin.js +5 -5
  20. package/amplify/plugins/reader-steering/papyrus.plugin.ts +5 -5
  21. package/amplify/plugins/reader-steering/schema.js +0 -95
  22. package/amplify/plugins/reader-steering/schema.ts +0 -95
  23. package/amplify/site-backend.js +4 -0
  24. package/amplify/site-backend.ts +4 -0
  25. package/app/.well-known/papyrus-cli.json/route.ts +9 -0
  26. package/app/globals.css +31 -35
  27. package/app/tailwind.css +2 -0
  28. package/bin/papyrus-infra.mjs +21 -1
  29. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  30. package/components/monitoring-overview-view.tsx +140 -23
  31. package/components/monitoring-panel.tsx +96 -6
  32. package/components/news-desk-client-provider.tsx +8 -2
  33. package/components/news-desk-taxonomy-client.ts +2 -1
  34. package/components/newsroom-mobile-navigation.tsx +23 -0
  35. package/components/newsroom-ops-overview.tsx +6 -1
  36. package/components/newsroom-ops-shell.tsx +8 -10
  37. package/components/newsroom-organizer.css +1 -0
  38. package/components/newsroom-references-view.tsx +139 -257
  39. package/components/notification-center.tsx +345 -63
  40. package/components/papyrus-console-frame.tsx +66 -0
  41. package/components/papyrus-console-shell.tsx +14 -42
  42. package/components/papyrus-private-chat-panel.tsx +50 -16
  43. package/components/proposals-panel.tsx +107 -0
  44. package/components/proposals-view.tsx +95 -0
  45. package/components/reference-review-card.tsx +400 -0
  46. package/components/topic-steering-workspace.tsx +13 -2
  47. package/components/use-console-visibility.ts +47 -0
  48. package/infra/amplify-app-shell.js +18 -2
  49. package/infra/amplify-app-shell.ts +18 -2
  50. package/infra/chat-stack.js +222 -0
  51. package/infra/chat-stack.ts +250 -0
  52. package/infra/chat.test.js +142 -0
  53. package/infra/chat.test.ts +151 -0
  54. package/infra/index.js +1 -0
  55. package/infra/site-config.js +58 -1
  56. package/infra/site-config.ts +78 -1
  57. package/lib/cli-login-discovery.ts +78 -0
  58. package/lib/monitoring-client.ts +39 -3
  59. package/lib/monitoring-overview-fixture.ts +2 -2
  60. package/lib/monitoring-overview.ts +47 -3
  61. package/lib/monitoring-paging.ts +58 -0
  62. package/lib/newsroom-chat-client.ts +30 -0
  63. package/lib/newsroom-chat-identity.ts +40 -0
  64. package/lib/newsroom-organizer-nav.ts +1 -0
  65. package/lib/papyrus-hosted-chat.ts +62 -80
  66. package/lib/papyrus-private-chat-client.ts +7 -4
  67. package/lib/proposals-admin.ts +115 -0
  68. package/lib/proposals-client.ts +29 -0
  69. package/lib/prospectus-session.ts +2 -0
  70. package/lib/prospectus-sign-in-routes.ts +19 -2
  71. package/lib/prospectus-site-identity.ts +7 -0
  72. package/lib/reader-subject.ts +3 -2
  73. package/lib/relevance-decisions.ts +9 -0
  74. package/lib/review-client.ts +38 -1
  75. package/lib/review-drafts.ts +231 -0
  76. package/lib/review-fixture.ts +36 -2
  77. package/lib/review-requests.ts +66 -24
  78. package/lib/staff-groups.ts +23 -0
  79. package/lib/staging-gate.ts +2 -2
  80. package/package.json +16 -1
  81. package/routes.manifest.json +12 -1
  82. package/src/sync.mjs +11 -2
  83. package/src/with-papyrus.mjs +1 -1
@@ -33,6 +33,7 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
33
33
  .organizer-account-popup{min-width:250px;max-width:calc(100vw - 32px);border:1px solid var(--org-line);padding:6px;border-radius:4px}
34
34
  .organizer-account-popup [role=menuitem]{min-height:40px;font-size:12px}.organizer-account-popup .reader-auth{display:contents}.organizer-account-popup .reader-auth__error{display:block;white-space:normal;font-size:11px;padding:8px}
35
35
  .organizer-bottom-nav{display:none}
36
+ .organizer-bottom-nav button{appearance:none;border:0;background:transparent;color:inherit}
36
37
  .organizer-main:has(.organizer-native-board){display:flex;overflow:hidden}
37
38
  .organizer-native-board{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0;gap:8px}
38
39
  .organizer-board-view-toggle{display:flex;gap:4px;flex-shrink:0}
@@ -4,14 +4,7 @@ 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 {
8
- ArchiveIcon,
9
- ChevronLeftIcon,
10
- ChevronRightIcon,
11
- ExternalLinkIcon,
12
- ThumbsDownIcon,
13
- ThumbsUpIcon,
14
- } from "lucide-react";
7
+ import { ExternalLinkIcon } from "lucide-react";
15
8
  import { usePathname } from "next/navigation";
16
9
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
17
10
  import { Badge } from "./ui/badge";
@@ -46,14 +39,21 @@ import {
46
39
  type ReferenceCurationAction,
47
40
  type ReferenceStatusFilter,
48
41
  } from "../lib/newsroom-references";
49
- import { normalizeReferenceCurationStatus, referenceReasonCode, REFERENCE_REJECTION_REASON_CODES, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
42
+ import { normalizeReferenceCurationStatus, referenceReasonCode, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
50
43
  import { newsroomListRowClassName } from "../lib/newsroom-list-selection";
51
44
  import { ReferenceSourcePreview } from "./reference-source-preview";
52
- import { ReferenceRelevanceReview } from "./reference-relevance-review";
45
+ import {
46
+ ReviewCard,
47
+ predictionFromDecision,
48
+ ReviewQueueBar,
49
+ useQueueGestures,
50
+ type CardPrediction,
51
+ type ReviewCardDecision,
52
+ type ReviewChoice,
53
+ } from "./reference-review-card";
53
54
  import {
54
55
  CountsAsTrainingLabelNote,
55
56
  DelegatedCurationNote,
56
- ReferenceSuggestionBlock,
57
57
  ReferenceSuggestionProvenance,
58
58
  ReferenceSuggestionRowLine,
59
59
  } from "./reference-cyclotron-suggestion";
@@ -75,7 +75,7 @@ import type { PrimingFormInput } from "./reference-cyclotron-priming";
75
75
  import { ReferenceCyclotronStatus, type ReviewRateChoice } from "./reference-cyclotron-status";
76
76
  import type { PrimingRequestView } from "../lib/relevance-status";
77
77
  import type { CyclotronStatus } from "cyclotron/cyclotron-status";
78
- import type { RelevanceCuration, RelevanceDecisionView } from "../lib/relevance-decisions";
78
+ import { awaitsEditorReview, type RelevanceCuration, type RelevanceDecisionView } from "../lib/relevance-decisions";
79
79
  import {
80
80
  loadReferenceAttachmentsForLineageId,
81
81
  loadStoragePathUrl,
@@ -252,17 +252,35 @@ function useReferencePreviewAttachments(
252
252
  }
253
253
 
254
254
  function needsReview(reference: ReferenceRecord, decision: RelevanceDecisionView | undefined): boolean {
255
- return Boolean(decision?.reviewRecommended)
256
- && normalizeReferenceCurationStatus(reference.curationStatus) === "pending";
255
+ return awaitsEditorReview(reference.curationStatus, decision);
256
+ }
257
+
258
+ function summaryOf(reference: ReferenceRecord): string | null {
259
+ const raw = reference.metadata;
260
+ let metadata: Record<string, unknown> = {};
261
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) metadata = raw as Record<string, unknown>;
262
+ else if (typeof raw === "string") {
263
+ try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) metadata = parsed; } catch { /* no summary */ }
264
+ }
265
+ for (const key of ["summary", "abstract", "description"]) {
266
+ const value = metadata[key];
267
+ if (typeof value === "string" && value.trim()) return value.trim().length > 280 ? `${value.trim().slice(0, 277)}…` : value.trim();
268
+ }
269
+ return null;
270
+ }
271
+
272
+ function cardPrediction(decision: RelevanceDecisionView | undefined, predictionState: PredictionState): CardPrediction {
273
+ if (decision) return predictionFromDecision(decision);
274
+ if (predictionState === "loading") return { state: "loading" };
275
+ if (predictionState === "error") return { state: "error" };
276
+ return { state: "none" };
257
277
  }
258
278
 
259
279
  function ReferenceDetailPanel({
260
280
  countsAsTrainingLabel,
261
281
  delegated,
262
282
  decision,
263
- demo,
264
283
  disabled,
265
- onRelevanceReview,
266
284
  onReview,
267
285
  predictionState = "ready",
268
286
  reviewReason,
@@ -292,76 +310,25 @@ function ReferenceDetailPanel({
292
310
  onClose?: () => void;
293
311
  }) {
294
312
  const lineageId = referenceLineageId(reference);
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]);
313
+ const status = normalizeReferenceCurationStatus(reference.curationStatus);
314
+ const sourceNote = status === "pending" ? "" : reference.curationStatusReason ?? "";
315
+ const sourceReason = reviewReason ?? referenceReasonCode(reference) ?? null;
315
316
  const [chosenSectionId, setChosenSectionId] = useState<string | null>(reviewSectionId ?? null);
316
- const [savedSectionId, setSavedSectionId] = useState<string | null>(reviewSectionId ?? null);
317
317
  const sectionEdited = useRef(false);
318
318
  useEffect(() => {
319
- setSavedSectionId(reviewSectionId ?? null);
320
319
  if (!sectionEdited.current) setChosenSectionId(reviewSectionId ?? null);
321
320
  }, [reviewSectionId]);
322
- const status = normalizeReferenceCurationStatus(reference.curationStatus);
323
321
  const url = referenceUrl(reference);
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
- }
322
+
323
+ async function decide(choice: ReviewChoice, review: ReviewCardDecision) {
324
+ const action: ReferenceCurationAction = choice;
325
+ await onReview(action, {
326
+ note: review.note,
327
+ reasonCode: review.reasonCode,
328
+ ...(choice === "accept" && chosenSectionId ? { sectionId: chosenSectionId } : {}),
329
+ ...(decision ? { decisionRelationId: decision.decisionRelationId, shareable: false } : {}),
330
+ });
331
+ sectionEdited.current = false;
365
332
  }
366
333
 
367
334
  return (
@@ -369,135 +336,45 @@ function ReferenceDetailPanel({
369
336
  className="flex h-full flex-col font-sans"
370
337
  data-news-desk-reference-detail={lineageId}
371
338
  >
372
- <div className="space-y-4 p-4">
373
- <div className="space-y-2">
374
- <div className="flex flex-wrap items-center gap-2">
375
- <Badge variant={referenceStatusBadgeVariant(status)}>{referenceStatusLabel(status)}</Badge>
376
- {reference.corpusId ? (
377
- <Badge variant="outline">{reference.corpusId}</Badge>
378
- ) : null}
379
- </div>
380
- <h2 className="text-lg font-semibold leading-snug text-foreground">{referenceDisplayTitle(reference)}</h2>
381
- {reference.externalItemId ? (
382
- <p className="text-sm text-muted-foreground">{reference.externalItemId}</p>
383
- ) : null}
339
+ <div className="space-y-4 p-4" data-reference-curation-status={status}>
340
+ <div className="flex flex-wrap items-center gap-2">
341
+ <Badge variant={referenceStatusBadgeVariant(status)}>{referenceStatusLabel(status)}</Badge>
342
+ {reference.corpusId ? <Badge variant="outline">{reference.corpusId}</Badge> : null}
384
343
  </div>
385
344
 
386
- {decision ? (
387
- <div className="space-y-3" data-reference-relevance-decision={lineageId}>
388
- <ReferenceSuggestionBlock
389
- decision={decision}
390
- disabled={locked}
391
- onCurate={onRelevanceReview ? (review) => { void save(review.action, { ...review, note: note.trim() || null, reasonCode: review.action === "reject" ? reasonCode || null : null }); } : undefined}
392
- referenceLineageId={lineageId}
393
- settled={status !== "pending"}
394
- statusLabel={referenceStatusLabel(status)}
395
- />
396
- <ReferenceSuggestionProvenance decision={decision} />
397
- {status === "pending" && onRelevanceReview ? (
398
- <details className="text-sm text-foreground">
399
- <summary className="cursor-pointer font-semibold underline-offset-4 hover:underline">Review with a reason</summary>
400
- <div className="mt-2">
401
- <ReferenceRelevanceReview
402
- decision={decision}
403
- disabled={locked}
404
- onCurate={(review) => save(review.action, review)}
405
- referenceLineageId={lineageId}
406
- />
407
- </div>
408
- </details>
409
- ) : null}
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>
416
- ) : null}
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
423
- agreement={sectionAgreement}
424
- chosenSectionId={chosenSectionId}
425
- disabled={locked}
426
- onChoose={(sectionId) => { setChosenSectionId(sectionId); sectionEdited.current = true; setSaved(false); }}
427
- referenceLineageId={lineageId}
428
- sections={sections}
429
- suggestedSectionId={sectionSuggestion?.sectionId ?? null}
430
- /> : null}
431
- </div>
432
- </details>
433
- ) : null}
434
- {delegated ? <DelegatedCurationNote delegated={delegated} referenceLineageId={lineageId} /> : countsAsTrainingLabel ? <CountsAsTrainingLabelNote referenceLineageId={lineageId} /> : null}
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>
451
- <div
452
- className="flex flex-wrap items-center gap-2"
453
- data-news-desk-reference-curation-cluster
454
- data-reference-curation-status={status}
345
+ <ReviewCard
346
+ decided={status === "accepted" ? "accept" : status === "rejected" ? "reject" : null}
347
+ disabledReason={disabled ? "Editor access is required to change this reference." : null}
348
+ initialNote={sourceNote}
349
+ initialReason={sourceReason}
350
+ itemId={lineageId}
351
+ onArchive={status === "archived" ? undefined : async () => { await onReview("archive"); }}
352
+ onDecide={decide}
353
+ prediction={cardPrediction(decision, predictionState)}
354
+ summary={summaryOf(reference)}
355
+ title={referenceDisplayTitle(reference)}
356
+ titleHref={url}
455
357
  >
456
- <Button
457
- aria-label="Accept reference"
458
- aria-pressed={status === "accepted"}
459
- data-news-desk-reference-accept
460
- disabled={acceptDisabled}
461
- onClick={() => { void save("accept"); }}
462
- size="sm"
463
- type="button"
464
- variant={status === "accepted" ? "default" : "outline"}
465
- >
466
- <ThumbsUpIcon className="size-4" />
467
- Accept
468
- </Button>
469
- <Button
470
- aria-label="Reject reference"
471
- aria-pressed={status === "rejected"}
472
- data-news-desk-reference-reject
473
- disabled={rejectDisabled}
474
- onClick={() => { void save("reject"); }}
475
- size="sm"
476
- type="button"
477
- variant={status === "rejected" ? "destructive" : "outline"}
478
- >
479
- <ThumbsDownIcon className="size-4" />
480
- Reject
481
- </Button>
482
- <Button
483
- aria-label="Archive reference"
484
- disabled={archiveDisabled}
485
- onClick={() => { void save("archive"); }}
486
- size="sm"
487
- type="button"
488
- variant="outline"
489
- >
490
- <ArchiveIcon className="size-4" />
491
- Archive
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>
358
+ {decision ? <div data-reference-relevance-decision={lineageId}><ReferenceSuggestionProvenance decision={decision} /></div> : null}
359
+ {sectionSuggestion || ((status === "pending" || status === "accepted") && sections.length) ? (
360
+ <details className="rounded-md border p-3 text-sm">
361
+ <summary className="cursor-pointer font-semibold">Section placement (optional)</summary>
362
+ <div className="mt-3 space-y-3">
363
+ {sectionSuggestion ? <SectionSuggestionBlock agreement={sectionAgreement} referenceLineageId={lineageId} sections={sections} suggestion={sectionSuggestion} /> : null}
364
+ {(status === "pending" || status === "accepted") && sections.length ? <SectionPicker
365
+ agreement={sectionAgreement}
366
+ chosenSectionId={chosenSectionId}
367
+ disabled={Boolean(disabled)}
368
+ onChoose={(sectionId) => { setChosenSectionId(sectionId); sectionEdited.current = true; }}
369
+ referenceLineageId={lineageId}
370
+ sections={sections}
371
+ suggestedSectionId={sectionSuggestion?.sectionId ?? null}
372
+ /> : null}
373
+ </div>
374
+ </details>
495
375
  ) : null}
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>
376
+ {delegated ? <DelegatedCurationNote delegated={delegated} referenceLineageId={lineageId} /> : countsAsTrainingLabel ? <CountsAsTrainingLabelNote referenceLineageId={lineageId} /> : null}
377
+ </ReviewCard>
501
378
 
502
379
  <Separator />
503
380
 
@@ -514,22 +391,6 @@ function ReferenceDetailPanel({
514
391
  || "No explanation recorded."}
515
392
  </dd>
516
393
  </div>
517
- {url ? (
518
- <div>
519
- <dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Source</dt>
520
- <dd className="mt-1">
521
- <a
522
- className="inline-flex items-center gap-1 text-primary underline-offset-4 hover:underline"
523
- href={url}
524
- rel="noreferrer"
525
- target="_blank"
526
- >
527
- {url}
528
- <ExternalLinkIcon className="size-3.5" aria-hidden="true" />
529
- </a>
530
- </dd>
531
- </div>
532
- ) : null}
533
394
  <div className="grid grid-cols-2 gap-3">
534
395
  <div>
535
396
  <dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Imported</dt>
@@ -615,7 +476,7 @@ export function NewsroomReferencesView({
615
476
  const canonicalReferences = useMemo(() => selectCanonicalReferenceRecords(references), [references]);
616
477
  const statusCounts = useMemo(() => countReferencesByStatus(canonicalReferences), [canonicalReferences]);
617
478
 
618
- const [statusFilter, setStatusFilter] = useState<ReferenceListFilter>("all");
479
+ const [statusFilter, setStatusFilter] = useState<ReferenceListFilter>(routeLineageId ? "all" : "pending");
619
480
  const needsReviewCount = useMemo(
620
481
  () => canonicalReferences.filter((reference) => needsReview(reference, decisions?.get(referenceLineageId(reference)))).length,
621
482
  [canonicalReferences, decisions],
@@ -639,13 +500,25 @@ export function NewsroomReferencesView({
639
500
 
640
501
  const previewAttachments = useReferencePreviewAttachments(selectedReference, referenceAttachments);
641
502
 
642
- const selectedIndex = selectedReference
643
- ? filteredReferences.findIndex((entry) => referenceLineageId(entry) === referenceLineageId(selectedReference))
644
- : -1;
645
- const previousReference = selectedIndex > 0 ? filteredReferences[selectedIndex - 1] : null;
646
- const nextReference = selectedIndex >= 0 && selectedIndex < filteredReferences.length - 1
647
- ? filteredReferences[selectedIndex + 1]
648
- : null;
503
+ // The queue is the current list filter. A reference the editor has just decided leaves the filtered
504
+ // list, but keeps its place in the queue until they move on, so "3 of 29" does not jump under them.
505
+ const queueRef = useRef<string[]>([]);
506
+ const queue = useMemo(() => {
507
+ const ids = filteredReferences.map(referenceLineageId);
508
+ const open = selectedReference ? referenceLineageId(selectedReference) : "";
509
+ if (open && !ids.includes(open)) {
510
+ const before = queueRef.current.indexOf(open);
511
+ if (before >= 0) ids.splice(Math.min(before, ids.length), 0, open);
512
+ }
513
+ return ids;
514
+ }, [filteredReferences, selectedReference]);
515
+ useEffect(() => { queueRef.current = queue; }, [queue]);
516
+ const [caughtUp, setCaughtUp] = useState(false);
517
+ const selectedIndex = selectedReference ? queue.indexOf(referenceLineageId(selectedReference)) : -1;
518
+ const queuePosition = selectedIndex >= 0 ? selectedIndex + 1 : null;
519
+ const previousLineageId = caughtUp ? queue[queue.length - 1] ?? null : selectedIndex > 0 ? queue[selectedIndex - 1] : null;
520
+ const nextLineageId = selectedIndex >= 0 && selectedIndex < queue.length - 1 ? queue[selectedIndex + 1] : null;
521
+ const nextReference = nextLineageId ? selectedReferenceRecordByLineage(canonicalReferences, nextLineageId) : null;
649
522
 
650
523
  const syncStatusUrl = useCallback((nextStatus: ReferenceListFilter) => {
651
524
  if (demo) return;
@@ -672,6 +545,7 @@ export function NewsroomReferencesView({
672
545
  }, [isMobileDetail]);
673
546
 
674
547
  const selectReference = (lineageId: string) => {
548
+ setCaughtUp(false);
675
549
  setSelectedLineageId(lineageId);
676
550
  if (isMobileDetail) setMobileDetailOpen(true);
677
551
  if (typeof window !== "undefined") {
@@ -682,7 +556,22 @@ export function NewsroomReferencesView({
682
556
  }
683
557
  };
684
558
 
559
+ const goNext = () => {
560
+ if (nextLineageId) selectReference(nextLineageId);
561
+ else if (queue.length && !caughtUp) {
562
+ // Past the last item: say so, rather than showing an empty page.
563
+ setCaughtUp(true);
564
+ setSelectedLineageId("");
565
+ if (typeof window !== "undefined") window.history.pushState(null, "", getNewsroomNavHref("/newsroom/references", demo));
566
+ } else if (!queue.length) setCaughtUp(true);
567
+ };
568
+ const goPrevious = () => {
569
+ if (previousLineageId) selectReference(previousLineageId);
570
+ };
571
+ const gestures = useQueueGestures({ onNext: goNext, onPrevious: goPrevious });
572
+
685
573
  const closeDetail = () => {
574
+ setCaughtUp(false);
686
575
  setMobileDetailOpen(false);
687
576
  setSelectedLineageId("");
688
577
  if (typeof window !== "undefined") {
@@ -696,6 +585,16 @@ export function NewsroomReferencesView({
696
585
  return onReview(selectedReference, action, options);
697
586
  };
698
587
 
588
+ const caughtUpPanel = (
589
+ <div className="space-y-2 p-6 text-center" data-review-caught-up>
590
+ <h3 className="text-lg font-semibold">All caught up</h3>
591
+ <p className="text-sm text-muted-foreground">There is nothing more in this list. Use Previous to go back.</p>
592
+ </div>
593
+ );
594
+ const queueBar = queue.length || caughtUp ? (
595
+ <ReviewQueueBar caughtUp={caughtUp} onNext={goNext} onPrevious={goPrevious} position={queuePosition} total={queue.length} />
596
+ ) : null;
597
+
699
598
  const emptyLabel = canonicalReferences.length
700
599
  ? "No references match this filter."
701
600
  : "No private references imported yet.";
@@ -744,10 +643,11 @@ export function NewsroomReferencesView({
744
643
 
745
644
  <Tabs
746
645
  className="shrink-0"
747
- defaultValue="all"
646
+ defaultValue="pending"
748
647
  onValueChange={(value) => {
749
648
  const next = value as ReferenceListFilter;
750
649
  setStatusFilter(next);
650
+ setCaughtUp(false);
751
651
  syncStatusUrl(next);
752
652
  }}
753
653
  value={statusFilter}
@@ -826,29 +726,8 @@ export function NewsroomReferencesView({
826
726
  >
827
727
  <div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2">
828
728
  <span className="text-sm font-medium text-foreground">Detail</span>
829
- <div className="newsroom-list-detail-shell__detail-toolbar-trailing flex items-center gap-1">
830
- <Button
831
- aria-label="Previous"
832
- disabled={!previousReference}
833
- onClick={() => previousReference && selectReference(referenceLineageId(previousReference))}
834
- size="icon-sm"
835
- type="button"
836
- variant="ghost"
837
- >
838
- <ChevronLeftIcon className="size-4" />
839
- </Button>
840
- <Button
841
- aria-label="Next"
842
- disabled={!nextReference}
843
- onClick={() => nextReference && selectReference(referenceLineageId(nextReference))}
844
- size="icon-sm"
845
- type="button"
846
- variant="ghost"
847
- >
848
- <ChevronRightIcon className="size-4" />
849
- </Button>
850
- </div>
851
729
  </div>
730
+ <div className="flex min-h-0 flex-1 flex-col" data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}>
852
731
  <ScrollArea className="min-h-0 flex-1">
853
732
  {selectedReference ? (
854
733
  <ReferenceDetailPanel
@@ -869,12 +748,14 @@ export function NewsroomReferencesView({
869
748
  previewAttachments={previewAttachments}
870
749
  reference={selectedReference}
871
750
  />
872
- ) : (
751
+ ) : caughtUp ? caughtUpPanel : (
873
752
  <CardContent className="p-4 text-sm text-muted-foreground">
874
753
  Select a reference to inspect curation details.
875
754
  </CardContent>
876
755
  )}
877
756
  </ScrollArea>
757
+ </div>
758
+ {queueBar}
878
759
  </div>
879
760
 
880
761
  {isMobileDetail ? (
@@ -883,14 +764,14 @@ export function NewsroomReferencesView({
883
764
  if (!open) closeDetail();
884
765
  else setMobileDetailOpen(true);
885
766
  }}
886
- open={mobileDetailOpen && Boolean(selectedReference)}
767
+ open={(mobileDetailOpen && Boolean(selectedReference)) || caughtUp}
887
768
  >
888
769
  <SheetContent className="gap-0 p-0" side="bottom">
889
770
  <SheetHeader className="border-b border-border px-4 py-3">
890
771
  <SheetTitle className="text-left text-base">Reference</SheetTitle>
891
772
  </SheetHeader>
892
773
  {selectedReference ? (
893
- <ScrollArea className="max-h-[75dvh]">
774
+ <div data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}><ScrollArea className="max-h-[70dvh]">
894
775
  <ReferenceDetailPanel
895
776
  key={referenceLineageId(selectedReference)}
896
777
  predictionState={predictionState}
@@ -910,8 +791,9 @@ export function NewsroomReferencesView({
910
791
  previewAttachments={previewAttachments}
911
792
  reference={selectedReference}
912
793
  />
913
- </ScrollArea>
914
- ) : null}
794
+ </ScrollArea></div>
795
+ ) : caughtUp ? caughtUpPanel : null}
796
+ {queueBar}
915
797
  </SheetContent>
916
798
  </Sheet>
917
799
  ) : null}