@anthusai/papyrus 5.42.1 → 5.43.0-next.1

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 (57) hide show
  1. package/amplify/data/schema.js +2 -0
  2. package/amplify/data/schema.ts +2 -0
  3. package/amplify/functions/monitoring-view/schema.js +6 -0
  4. package/amplify/functions/monitoring-view/schema.ts +6 -0
  5. package/amplify/functions/monitoring-view/wire.js +1 -0
  6. package/amplify/functions/monitoring-view/wire.ts +1 -0
  7. package/amplify/functions/proposal-actions/handler.py +137 -0
  8. package/amplify/functions/proposal-actions/resource.js +15 -0
  9. package/amplify/functions/proposal-actions/resource.ts +22 -0
  10. package/amplify/functions/proposal-actions/schema.js +106 -0
  11. package/amplify/functions/proposal-actions/schema.ts +108 -0
  12. package/amplify/functions/proposal-actions/wire.js +10 -0
  13. package/amplify/functions/proposal-actions/wire.ts +13 -0
  14. package/amplify/plugins/reader-steering/function/handler.py +12 -69
  15. package/amplify/plugins/reader-steering/papyrus.plugin.js +5 -5
  16. package/amplify/plugins/reader-steering/papyrus.plugin.ts +5 -5
  17. package/amplify/plugins/reader-steering/schema.js +0 -95
  18. package/amplify/plugins/reader-steering/schema.ts +0 -95
  19. package/amplify/site-backend.js +4 -0
  20. package/amplify/site-backend.ts +4 -0
  21. package/app/.well-known/papyrus-cli.json/route.ts +9 -0
  22. package/app/globals.css +31 -35
  23. package/app/tailwind.css +2 -0
  24. package/components/monitoring-overview-view.tsx +12 -7
  25. package/components/monitoring-panel.tsx +33 -3
  26. package/components/newsroom-mobile-navigation.tsx +23 -0
  27. package/components/newsroom-ops-shell.tsx +8 -10
  28. package/components/newsroom-organizer.css +1 -0
  29. package/components/newsroom-references-view.tsx +141 -257
  30. package/components/notification-center.tsx +1 -1
  31. package/components/papyrus-console-frame.tsx +66 -0
  32. package/components/papyrus-console-shell.tsx +12 -41
  33. package/components/papyrus-private-chat-panel.tsx +50 -16
  34. package/components/proposals-panel.tsx +110 -0
  35. package/components/proposals-view.tsx +93 -0
  36. package/components/reference-review-card.tsx +383 -0
  37. package/components/topic-steering-workspace.tsx +13 -2
  38. package/components/use-console-visibility.ts +47 -0
  39. package/lib/cli-login-discovery.ts +78 -0
  40. package/lib/monitoring-client.ts +11 -2
  41. package/lib/monitoring-overview-fixture.ts +2 -2
  42. package/lib/monitoring-overview.ts +1 -2
  43. package/lib/newsroom-chat-client.ts +30 -0
  44. package/lib/newsroom-chat-identity.ts +39 -0
  45. package/lib/papyrus-hosted-chat.ts +62 -80
  46. package/lib/papyrus-private-chat-client.ts +7 -4
  47. package/lib/proposals-admin.ts +115 -0
  48. package/lib/prospectus-session.ts +2 -0
  49. package/lib/prospectus-sign-in-routes.ts +19 -2
  50. package/lib/prospectus-site-identity.ts +7 -0
  51. package/lib/relevance-decisions.ts +9 -0
  52. package/lib/review-requests.ts +4 -2
  53. package/package.json +12 -1
  54. package/routes.manifest.json +12 -1
  55. package/src/sync.mjs +11 -2
  56. package/src/with-papyrus.mjs +1 -1
  57. /package/amplify/{plugins/reader-steering/function → functions/proposal-actions}/proposals.py +0 -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,20 @@ 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
+ ReviewQueueBar,
48
+ useQueueGestures,
49
+ type CardPrediction,
50
+ type ReviewCardDecision,
51
+ type ReviewChoice,
52
+ } from "./reference-review-card";
53
53
  import {
54
54
  CountsAsTrainingLabelNote,
55
55
  DelegatedCurationNote,
56
- ReferenceSuggestionBlock,
57
56
  ReferenceSuggestionProvenance,
58
57
  ReferenceSuggestionRowLine,
59
58
  } from "./reference-cyclotron-suggestion";
@@ -75,7 +74,7 @@ import type { PrimingFormInput } from "./reference-cyclotron-priming";
75
74
  import { ReferenceCyclotronStatus, type ReviewRateChoice } from "./reference-cyclotron-status";
76
75
  import type { PrimingRequestView } from "../lib/relevance-status";
77
76
  import type { CyclotronStatus } from "cyclotron/cyclotron-status";
78
- import type { RelevanceCuration, RelevanceDecisionView } from "../lib/relevance-decisions";
77
+ import { awaitsEditorReview, type RelevanceCuration, type RelevanceDecisionView } from "../lib/relevance-decisions";
79
78
  import {
80
79
  loadReferenceAttachmentsForLineageId,
81
80
  loadStoragePathUrl,
@@ -252,17 +251,38 @@ function useReferencePreviewAttachments(
252
251
  }
253
252
 
254
253
  function needsReview(reference: ReferenceRecord, decision: RelevanceDecisionView | undefined): boolean {
255
- return Boolean(decision?.reviewRecommended)
256
- && normalizeReferenceCurationStatus(reference.curationStatus) === "pending";
254
+ return awaitsEditorReview(reference.curationStatus, decision);
255
+ }
256
+
257
+ function summaryOf(reference: ReferenceRecord): string | null {
258
+ const raw = reference.metadata;
259
+ let metadata: Record<string, unknown> = {};
260
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) metadata = raw as Record<string, unknown>;
261
+ else if (typeof raw === "string") {
262
+ try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) metadata = parsed; } catch { /* no summary */ }
263
+ }
264
+ for (const key of ["summary", "abstract", "description"]) {
265
+ const value = metadata[key];
266
+ if (typeof value === "string" && value.trim()) return value.trim().length > 280 ? `${value.trim().slice(0, 277)}…` : value.trim();
267
+ }
268
+ return null;
269
+ }
270
+
271
+ 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
+ }
276
+ if (predictionState === "loading") return { state: "loading" };
277
+ if (predictionState === "error") return { state: "error" };
278
+ return { state: "none" };
257
279
  }
258
280
 
259
281
  function ReferenceDetailPanel({
260
282
  countsAsTrainingLabel,
261
283
  delegated,
262
284
  decision,
263
- demo,
264
285
  disabled,
265
- onRelevanceReview,
266
286
  onReview,
267
287
  predictionState = "ready",
268
288
  reviewReason,
@@ -292,76 +312,25 @@ function ReferenceDetailPanel({
292
312
  onClose?: () => void;
293
313
  }) {
294
314
  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]);
315
+ const status = normalizeReferenceCurationStatus(reference.curationStatus);
316
+ const sourceNote = status === "pending" ? "" : reference.curationStatusReason ?? "";
317
+ const sourceReason = reviewReason ?? referenceReasonCode(reference) ?? null;
315
318
  const [chosenSectionId, setChosenSectionId] = useState<string | null>(reviewSectionId ?? null);
316
- const [savedSectionId, setSavedSectionId] = useState<string | null>(reviewSectionId ?? null);
317
319
  const sectionEdited = useRef(false);
318
320
  useEffect(() => {
319
- setSavedSectionId(reviewSectionId ?? null);
320
321
  if (!sectionEdited.current) setChosenSectionId(reviewSectionId ?? null);
321
322
  }, [reviewSectionId]);
322
- const status = normalizeReferenceCurationStatus(reference.curationStatus);
323
323
  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
- }
324
+
325
+ async function decide(choice: ReviewChoice, review: ReviewCardDecision) {
326
+ const action: ReferenceCurationAction = choice;
327
+ await onReview(action, {
328
+ note: review.note,
329
+ reasonCode: review.reasonCode,
330
+ ...(choice === "accept" && chosenSectionId ? { sectionId: chosenSectionId } : {}),
331
+ ...(decision ? { decisionRelationId: decision.decisionRelationId, shareable: false } : {}),
332
+ });
333
+ sectionEdited.current = false;
365
334
  }
366
335
 
367
336
  return (
@@ -369,135 +338,45 @@ function ReferenceDetailPanel({
369
338
  className="flex h-full flex-col font-sans"
370
339
  data-news-desk-reference-detail={lineageId}
371
340
  >
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}
341
+ <div className="space-y-4 p-4" data-reference-curation-status={status}>
342
+ <div className="flex flex-wrap items-center gap-2">
343
+ <Badge variant={referenceStatusBadgeVariant(status)}>{referenceStatusLabel(status)}</Badge>
344
+ {reference.corpusId ? <Badge variant="outline">{reference.corpusId}</Badge> : null}
384
345
  </div>
385
346
 
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}
347
+ <ReviewCard
348
+ decided={status === "accepted" ? "accept" : status === "rejected" ? "reject" : null}
349
+ disabledReason={disabled ? "Editor access is required to change this reference." : null}
350
+ initialNote={sourceNote}
351
+ initialReason={sourceReason}
352
+ itemId={lineageId}
353
+ onArchive={status === "archived" ? undefined : async () => { await onReview("archive"); }}
354
+ onDecide={decide}
355
+ prediction={cardPrediction(decision, predictionState)}
356
+ summary={summaryOf(reference)}
357
+ title={referenceDisplayTitle(reference)}
358
+ titleHref={url}
455
359
  >
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>
360
+ {decision ? <div data-reference-relevance-decision={lineageId}><ReferenceSuggestionProvenance decision={decision} /></div> : null}
361
+ {sectionSuggestion || ((status === "pending" || status === "accepted") && sections.length) ? (
362
+ <details className="rounded-md border p-3 text-sm">
363
+ <summary className="cursor-pointer font-semibold">Section placement (optional)</summary>
364
+ <div className="mt-3 space-y-3">
365
+ {sectionSuggestion ? <SectionSuggestionBlock agreement={sectionAgreement} referenceLineageId={lineageId} sections={sections} suggestion={sectionSuggestion} /> : null}
366
+ {(status === "pending" || status === "accepted") && sections.length ? <SectionPicker
367
+ agreement={sectionAgreement}
368
+ chosenSectionId={chosenSectionId}
369
+ disabled={Boolean(disabled)}
370
+ onChoose={(sectionId) => { setChosenSectionId(sectionId); sectionEdited.current = true; }}
371
+ referenceLineageId={lineageId}
372
+ sections={sections}
373
+ suggestedSectionId={sectionSuggestion?.sectionId ?? null}
374
+ /> : null}
375
+ </div>
376
+ </details>
495
377
  ) : 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>
378
+ {delegated ? <DelegatedCurationNote delegated={delegated} referenceLineageId={lineageId} /> : countsAsTrainingLabel ? <CountsAsTrainingLabelNote referenceLineageId={lineageId} /> : null}
379
+ </ReviewCard>
501
380
 
502
381
  <Separator />
503
382
 
@@ -514,22 +393,6 @@ function ReferenceDetailPanel({
514
393
  || "No explanation recorded."}
515
394
  </dd>
516
395
  </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
396
  <div className="grid grid-cols-2 gap-3">
534
397
  <div>
535
398
  <dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Imported</dt>
@@ -615,7 +478,7 @@ export function NewsroomReferencesView({
615
478
  const canonicalReferences = useMemo(() => selectCanonicalReferenceRecords(references), [references]);
616
479
  const statusCounts = useMemo(() => countReferencesByStatus(canonicalReferences), [canonicalReferences]);
617
480
 
618
- const [statusFilter, setStatusFilter] = useState<ReferenceListFilter>("all");
481
+ const [statusFilter, setStatusFilter] = useState<ReferenceListFilter>(routeLineageId ? "all" : "pending");
619
482
  const needsReviewCount = useMemo(
620
483
  () => canonicalReferences.filter((reference) => needsReview(reference, decisions?.get(referenceLineageId(reference)))).length,
621
484
  [canonicalReferences, decisions],
@@ -639,13 +502,25 @@ export function NewsroomReferencesView({
639
502
 
640
503
  const previewAttachments = useReferencePreviewAttachments(selectedReference, referenceAttachments);
641
504
 
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;
505
+ // The queue is the current list filter. A reference the editor has just decided leaves the filtered
506
+ // list, but keeps its place in the queue until they move on, so "3 of 29" does not jump under them.
507
+ const queueRef = useRef<string[]>([]);
508
+ const queue = useMemo(() => {
509
+ const ids = filteredReferences.map(referenceLineageId);
510
+ const open = selectedReference ? referenceLineageId(selectedReference) : "";
511
+ if (open && !ids.includes(open)) {
512
+ const before = queueRef.current.indexOf(open);
513
+ if (before >= 0) ids.splice(Math.min(before, ids.length), 0, open);
514
+ }
515
+ return ids;
516
+ }, [filteredReferences, selectedReference]);
517
+ useEffect(() => { queueRef.current = queue; }, [queue]);
518
+ const [caughtUp, setCaughtUp] = useState(false);
519
+ const selectedIndex = selectedReference ? queue.indexOf(referenceLineageId(selectedReference)) : -1;
520
+ const queuePosition = selectedIndex >= 0 ? selectedIndex + 1 : null;
521
+ const previousLineageId = caughtUp ? queue[queue.length - 1] ?? null : selectedIndex > 0 ? queue[selectedIndex - 1] : null;
522
+ const nextLineageId = selectedIndex >= 0 && selectedIndex < queue.length - 1 ? queue[selectedIndex + 1] : null;
523
+ const nextReference = nextLineageId ? selectedReferenceRecordByLineage(canonicalReferences, nextLineageId) : null;
649
524
 
650
525
  const syncStatusUrl = useCallback((nextStatus: ReferenceListFilter) => {
651
526
  if (demo) return;
@@ -672,6 +547,7 @@ export function NewsroomReferencesView({
672
547
  }, [isMobileDetail]);
673
548
 
674
549
  const selectReference = (lineageId: string) => {
550
+ setCaughtUp(false);
675
551
  setSelectedLineageId(lineageId);
676
552
  if (isMobileDetail) setMobileDetailOpen(true);
677
553
  if (typeof window !== "undefined") {
@@ -682,7 +558,22 @@ export function NewsroomReferencesView({
682
558
  }
683
559
  };
684
560
 
561
+ const goNext = () => {
562
+ if (nextLineageId) selectReference(nextLineageId);
563
+ else if (queue.length && !caughtUp) {
564
+ // Past the last item: say so, rather than showing an empty page.
565
+ setCaughtUp(true);
566
+ setSelectedLineageId("");
567
+ if (typeof window !== "undefined") window.history.pushState(null, "", getNewsroomNavHref("/newsroom/references", demo));
568
+ } else if (!queue.length) setCaughtUp(true);
569
+ };
570
+ const goPrevious = () => {
571
+ if (previousLineageId) selectReference(previousLineageId);
572
+ };
573
+ const gestures = useQueueGestures({ onNext: goNext, onPrevious: goPrevious });
574
+
685
575
  const closeDetail = () => {
576
+ setCaughtUp(false);
686
577
  setMobileDetailOpen(false);
687
578
  setSelectedLineageId("");
688
579
  if (typeof window !== "undefined") {
@@ -696,6 +587,16 @@ export function NewsroomReferencesView({
696
587
  return onReview(selectedReference, action, options);
697
588
  };
698
589
 
590
+ const caughtUpPanel = (
591
+ <div className="space-y-2 p-6 text-center" data-review-caught-up>
592
+ <h3 className="text-lg font-semibold">All caught up</h3>
593
+ <p className="text-sm text-muted-foreground">There is nothing more in this list. Use Previous to go back.</p>
594
+ </div>
595
+ );
596
+ const queueBar = queue.length || caughtUp ? (
597
+ <ReviewQueueBar caughtUp={caughtUp} onNext={goNext} onPrevious={goPrevious} position={queuePosition} total={queue.length} />
598
+ ) : null;
599
+
699
600
  const emptyLabel = canonicalReferences.length
700
601
  ? "No references match this filter."
701
602
  : "No private references imported yet.";
@@ -744,10 +645,11 @@ export function NewsroomReferencesView({
744
645
 
745
646
  <Tabs
746
647
  className="shrink-0"
747
- defaultValue="all"
648
+ defaultValue="pending"
748
649
  onValueChange={(value) => {
749
650
  const next = value as ReferenceListFilter;
750
651
  setStatusFilter(next);
652
+ setCaughtUp(false);
751
653
  syncStatusUrl(next);
752
654
  }}
753
655
  value={statusFilter}
@@ -826,29 +728,8 @@ export function NewsroomReferencesView({
826
728
  >
827
729
  <div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2">
828
730
  <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
731
  </div>
732
+ <div className="flex min-h-0 flex-1 flex-col" data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}>
852
733
  <ScrollArea className="min-h-0 flex-1">
853
734
  {selectedReference ? (
854
735
  <ReferenceDetailPanel
@@ -869,12 +750,14 @@ export function NewsroomReferencesView({
869
750
  previewAttachments={previewAttachments}
870
751
  reference={selectedReference}
871
752
  />
872
- ) : (
753
+ ) : caughtUp ? caughtUpPanel : (
873
754
  <CardContent className="p-4 text-sm text-muted-foreground">
874
755
  Select a reference to inspect curation details.
875
756
  </CardContent>
876
757
  )}
877
758
  </ScrollArea>
759
+ </div>
760
+ {queueBar}
878
761
  </div>
879
762
 
880
763
  {isMobileDetail ? (
@@ -883,14 +766,14 @@ export function NewsroomReferencesView({
883
766
  if (!open) closeDetail();
884
767
  else setMobileDetailOpen(true);
885
768
  }}
886
- open={mobileDetailOpen && Boolean(selectedReference)}
769
+ open={(mobileDetailOpen && Boolean(selectedReference)) || caughtUp}
887
770
  >
888
771
  <SheetContent className="gap-0 p-0" side="bottom">
889
772
  <SheetHeader className="border-b border-border px-4 py-3">
890
773
  <SheetTitle className="text-left text-base">Reference</SheetTitle>
891
774
  </SheetHeader>
892
775
  {selectedReference ? (
893
- <ScrollArea className="max-h-[75dvh]">
776
+ <div data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}><ScrollArea className="max-h-[70dvh]">
894
777
  <ReferenceDetailPanel
895
778
  key={referenceLineageId(selectedReference)}
896
779
  predictionState={predictionState}
@@ -910,8 +793,9 @@ export function NewsroomReferencesView({
910
793
  previewAttachments={previewAttachments}
911
794
  reference={selectedReference}
912
795
  />
913
- </ScrollArea>
914
- ) : null}
796
+ </ScrollArea></div>
797
+ ) : caughtUp ? caughtUpPanel : null}
798
+ {queueBar}
915
799
  </SheetContent>
916
800
  </Sheet>
917
801
  ) : null}
@@ -153,7 +153,7 @@ export function NotificationCenterView({ loadState, requests, readIds, held, aut
153
153
  <h1 className="text-lg font-semibold">Notification Center</h1>
154
154
  <p className="text-sm" data-notification-summary>{summaryText(summary)}</p>
155
155
  <p className="text-xs text-muted-foreground">
156
- Look, or steer. Anything you ignore keeps going: soft reviews proceed at their deadline and flags never wait. Only a hard gate waits for you.
156
+ Look, or steer. Anything you ignore keeps going: a soft review with a deadline proceeds at it, one without stays open until handled, and flags never wait. Only a hard gate waits for you.
157
157
  </p>
158
158
  <p className="text-xs text-muted-foreground" data-notification-unread>
159
159
  {unreadCount ? `${unreadCount} unread` : "Nothing unread"}
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState, type ReactNode } from "react";
4
+
5
+ export function PapyrusConsoleFrame({ available, open, reserveSpace, onClose, page, children }: {
6
+ available: boolean;
7
+ open: boolean;
8
+ reserveSpace: boolean;
9
+ onClose: () => void;
10
+ page: ReactNode;
11
+ children: ReactNode;
12
+ }) {
13
+ const [mobile, setMobile] = useState(false);
14
+ const [viewport, setViewport] = useState<{ top: number; height: number } | null>(null);
15
+ const panel = useRef<HTMLElement>(null);
16
+ const modal = available && open && mobile;
17
+
18
+ useEffect(() => {
19
+ const media = window.matchMedia("(max-width: 900px)");
20
+ const update = () => setMobile(media.matches);
21
+ update();
22
+ media.addEventListener("change", update);
23
+ return () => media.removeEventListener("change", update);
24
+ }, []);
25
+
26
+ useEffect(() => {
27
+ if (!modal) return;
28
+ const visual = window.visualViewport;
29
+ const update = () => setViewport({ top: visual?.offsetTop ?? 0, height: visual?.height ?? window.innerHeight });
30
+ update();
31
+ visual?.addEventListener("resize", update);
32
+ visual?.addEventListener("scroll", update, { passive: true });
33
+ return () => {
34
+ visual?.removeEventListener("resize", update);
35
+ visual?.removeEventListener("scroll", update);
36
+ };
37
+ }, [modal]);
38
+
39
+ useEffect(() => {
40
+ if (!modal) return;
41
+ const previous = document.activeElement;
42
+ const frame = requestAnimationFrame(() => panel.current?.querySelector<HTMLElement>("button")?.focus());
43
+ return () => {
44
+ cancelAnimationFrame(frame);
45
+ requestAnimationFrame(() => {
46
+ if (previous instanceof HTMLElement && previous !== document.body && previous.isConnected && !previous.closest("[inert]")) previous.focus();
47
+ else Array.from(document.querySelectorAll<HTMLElement>(".organizer-chat, .edition-progress__button--console, .papyrus-console__header button")).find(element => element.getClientRects().length > 0)?.focus();
48
+ });
49
+ };
50
+ }, [modal]);
51
+
52
+ return <>
53
+ <div className={reserveSpace ? "papyrus-console-page papyrus-console-page--open" : "papyrus-console-page"} inert={modal || undefined}>{page}</div>
54
+ {available ? <aside ref={panel} id="papyrus-console-panel" className={open ? "papyrus-console papyrus-console--open" : "papyrus-console"} aria-label="Papyrus console" role={modal ? "dialog" : undefined} aria-modal={modal || undefined}
55
+ style={modal && viewport ? { top: viewport.top, height: viewport.height, bottom: "auto" } : undefined}
56
+ onKeyDown={event => {
57
+ if (!modal) return;
58
+ if (event.key === "Escape") { event.preventDefault(); onClose(); return; }
59
+ if (event.key !== "Tab") return;
60
+ const focusable = Array.from(panel.current?.querySelectorAll<HTMLElement>('button:not(:disabled), a[href], input:not(:disabled), textarea:not(:disabled), select:not(:disabled), [tabindex="0"]') ?? []).filter(element => element.getClientRects().length > 0);
61
+ const first = focusable[0], last = focusable.at(-1);
62
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
63
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
64
+ }}>{children}</aside> : null}
65
+ </>;
66
+ }