@anthusai/papyrus 5.43.0 → 5.45.0

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 (61) hide show
  1. package/amplify/functions/category-action/handler.ts +2 -1
  2. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  3. package/amplify/functions/monitoring-view/schema.js +16 -0
  4. package/amplify/functions/monitoring-view/schema.ts +16 -0
  5. package/amplify/functions/procedure-action/handler.ts +2 -10
  6. package/amplify/functions/proposal-actions/proposals.py +20 -3
  7. package/amplify/plugins/reader-steering/function/handler.py +1 -3
  8. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  9. package/bin/papyrus-infra.mjs +21 -1
  10. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  11. package/components/monitoring-overview-view.tsx +143 -21
  12. package/components/monitoring-panel.tsx +64 -4
  13. package/components/news-desk-client-provider.tsx +24 -3
  14. package/components/news-desk-page.tsx +2 -1
  15. package/components/news-desk-taxonomy-client.ts +2 -1
  16. package/components/newsroom-access-gate.tsx +7 -5
  17. package/components/newsroom-client-shell.tsx +2 -1
  18. package/components/newsroom-ops-overview.tsx +6 -1
  19. package/components/newsroom-ops-shell.tsx +1 -1
  20. package/components/newsroom-organizer.css +48 -0
  21. package/components/newsroom-references-view.tsx +183 -24
  22. package/components/notification-center.tsx +345 -63
  23. package/components/papyrus-console-shell.tsx +2 -1
  24. package/components/proposals-panel.tsx +21 -24
  25. package/components/proposals-view.tsx +4 -2
  26. package/components/reference-feed.tsx +469 -0
  27. package/components/reference-review-card.tsx +90 -76
  28. package/components/references-page-loading.tsx +61 -0
  29. package/components/references-prefetch.ts +47 -0
  30. package/components/review-rating-grid.tsx +164 -0
  31. package/components/topic-steering-workspace.tsx +32 -8
  32. package/infra/amplify-app-shell.js +18 -2
  33. package/infra/amplify-app-shell.ts +18 -2
  34. package/infra/chat-stack.js +222 -0
  35. package/infra/chat-stack.ts +250 -0
  36. package/infra/chat.test.js +142 -0
  37. package/infra/chat.test.ts +151 -0
  38. package/infra/index.js +1 -0
  39. package/infra/site-config.js +58 -1
  40. package/infra/site-config.ts +78 -1
  41. package/lib/category-repository.ts +4 -1
  42. package/lib/local-preview-feed-fixture.ts +43 -0
  43. package/lib/local-preview-synthetic-rejects.ts +41 -0
  44. package/lib/monitoring-client.ts +29 -2
  45. package/lib/monitoring-overview.ts +52 -1
  46. package/lib/monitoring-paging.ts +58 -0
  47. package/lib/newsroom-chat-identity.ts +2 -1
  48. package/lib/newsroom-organizer-nav.ts +1 -0
  49. package/lib/proposals-client.ts +29 -0
  50. package/lib/reader-subject.ts +3 -2
  51. package/lib/reference-feed-cache.ts +133 -0
  52. package/lib/reference-feed-ranking.ts +191 -0
  53. package/lib/reference-preview.ts +112 -0
  54. package/lib/review-client.ts +38 -1
  55. package/lib/review-drafts.ts +231 -0
  56. package/lib/review-fixture.ts +36 -2
  57. package/lib/review-requests.ts +64 -24
  58. package/lib/staff-groups.ts +23 -0
  59. package/lib/staging-gate.ts +2 -2
  60. package/package.json +14 -1
  61. package/routes.manifest.json +1 -1
@@ -4,7 +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 { ExternalLinkIcon } from "lucide-react";
7
+ import { ExternalLinkIcon, LayoutGridIcon, ListIcon, PlusIcon } from "lucide-react";
8
8
  import { usePathname } from "next/navigation";
9
9
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
10
10
  import { Badge } from "./ui/badge";
@@ -42,8 +42,17 @@ import {
42
42
  import { normalizeReferenceCurationStatus, referenceReasonCode, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
43
43
  import { newsroomListRowClassName } from "../lib/newsroom-list-selection";
44
44
  import { ReferenceSourcePreview } from "./reference-source-preview";
45
+ import { FEED_SURFACE_PROPS, FEED_SURFACE_STYLE, ReferenceFeed } from "./reference-feed";
46
+ import { partitionReferenceFeed, type ReferenceFeedTabKey } from "../lib/reference-feed-ranking";
47
+ import { referencePreviewsByLineage, type ReferencePreview } from "../lib/reference-preview";
48
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
49
+ import { chooseSyntheticRejects, withSyntheticRejects } from "../lib/local-preview-synthetic-rejects";
50
+ import { cn } from "../lib/utils";
51
+ import { getKanbusTheme, SITE_BRAND } from "../lib/site-brand";
52
+ import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
45
53
  import {
46
54
  ReviewCard,
55
+ predictionFromDecision,
47
56
  ReviewQueueBar,
48
57
  useQueueGestures,
49
58
  type CardPrediction,
@@ -119,8 +128,14 @@ type NewsroomReferencesViewProps = {
119
128
  onReview: (reference: ReferenceRecord, action: ReferenceCurationAction, options?: ReferenceReviewOptions) => Promise<void> | void;
120
129
  referenceAttachments?: ReferenceAttachmentRecord[];
121
130
  references: ReferenceRecord[];
131
+ /** True while the References are still being read: the feed shows card skeletons instead of an empty page. */
132
+ loading?: boolean;
133
+ /** The last view this browser saved. Shown, read only, until the live read arrives. */
134
+ savedCopy?: { references: ReferenceRecord[]; previews: Array<[string, ReferencePreview]>; decisions: Array<[string, RelevanceDecisionView]> } | null;
122
135
  };
123
136
 
137
+ type ReferenceView = "feed" | "list";
138
+
124
139
  type ReferenceListFilter = ReferenceStatusFilter | "needs_review";
125
140
 
126
141
  const NEEDS_REVIEW_FILTER: { key: ReferenceListFilter; label: string } = { key: "needs_review", label: "Needs review" };
@@ -269,10 +284,7 @@ function summaryOf(reference: ReferenceRecord): string | null {
269
284
  }
270
285
 
271
286
  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
- }
287
+ if (decision) return predictionFromDecision(decision);
276
288
  if (predictionState === "loading") return { state: "loading" };
277
289
  if (predictionState === "error") return { state: "error" };
278
290
  return { state: "none" };
@@ -444,7 +456,7 @@ export function NewsroomReferencesView({
444
456
  priming,
445
457
  cyclotronStatus,
446
458
  cyclotronPriming,
447
- decisions,
459
+ decisions: liveDecisions,
448
460
  humanCuratedReferenceLineageIds,
449
461
  delegatedCurations,
450
462
  shadowAgreement = null,
@@ -463,9 +475,14 @@ export function NewsroomReferencesView({
463
475
  initialReferenceLineageId = null,
464
476
  onReview,
465
477
  referenceAttachments = [],
466
- references,
478
+ references: liveReferences,
479
+ loading = false,
480
+ savedCopy = null,
467
481
  }: NewsroomReferencesViewProps) {
468
482
  const pathname = usePathname();
483
+ // The portaled mobile sheet sits outside the newsroom root, so it carries the theme attributes itself (same as useNewsroomThemeAttributes).
484
+ const themeMode = useResolvedPapyrusTheme();
485
+ const themeAttributes = { "data-kanbus-theme": getKanbusTheme(SITE_BRAND), "data-color-mode": themeMode };
469
486
  const sessionImport = useReferenceImport(disabled);
470
487
  const [importedCount, setImportedCount] = useState(0);
471
488
  const isMobileDetail = useMediaQuery("(max-width: 767px)");
@@ -475,7 +492,66 @@ export function NewsroomReferencesView({
475
492
  );
476
493
  const routeLineageId = pathname ? pathnameLineageId ?? "" : initialReferenceLineageId ?? "";
477
494
 
495
+ // Until the live read arrives, a saved copy of the last view fills the page. It is display only.
496
+ const showingSavedCopy = loading && !liveReferences.length && Boolean(savedCopy?.references.length);
497
+ const references = showingSavedCopy ? savedCopy!.references : liveReferences;
498
+ const baseDecisions = useMemo(
499
+ () => (showingSavedCopy && !liveDecisions?.size ? new Map(savedCopy!.decisions) : liveDecisions),
500
+ [liveDecisions, savedCopy, showingSavedCopy],
501
+ );
502
+ // Preview only: ?feedSynthRejects=N makes the N weakest Accept predictions into made-up Rejects (the page says so).
503
+ const synthRejectCount = useMemo(() => {
504
+ if (typeof window === "undefined" || !isLocalPreviewEnabled()) return 0;
505
+ const requested = Number(new URLSearchParams(window.location.search).get("feedSynthRejects"));
506
+ return Number.isInteger(requested) && requested > 0 ? requested : 0;
507
+ }, []);
508
+ const syntheticChoice = useRef<string[] | null>(null);
509
+ if (synthRejectCount && syntheticChoice.current === null && baseDecisions?.size && references.length) {
510
+ syntheticChoice.current = chooseSyntheticRejects(
511
+ baseDecisions,
512
+ new Set(references.filter((reference) => normalizeReferenceCurationStatus(reference.curationStatus) === "pending").map(referenceLineageId)),
513
+ synthRejectCount,
514
+ );
515
+ }
516
+ const synthetic = useMemo(
517
+ () => (synthRejectCount && syntheticChoice.current ? withSyntheticRejects(baseDecisions, syntheticChoice.current) : null),
518
+ [baseDecisions, synthRejectCount],
519
+ );
520
+ const decisions = synthetic?.decisions ?? baseDecisions;
521
+ const previews = useMemo(() => {
522
+ const live = referencePreviewsByLineage(referenceAttachments);
523
+ return showingSavedCopy && !live.size ? new Map(savedCopy!.previews) : live;
524
+ }, [referenceAttachments, savedCopy, showingSavedCopy]);
525
+ const [view, setView] = useState<ReferenceView>("feed");
526
+ const [addOpen, setAddOpen] = useState(false);
527
+ const [feedTab, setFeedTab] = useState<ReferenceFeedTabKey>("feed");
528
+ const [visitRated, setVisitRated] = useState<ReadonlySet<string>>(() => new Set());
529
+ const [mountedAt] = useState(() => Date.now());
530
+ const markVisitRated = useCallback((lineageId: string) => {
531
+ setVisitRated((current) => (current.has(lineageId) ? current : new Set(current).add(lineageId)));
532
+ }, []);
533
+ // Preview only: ?feedTau=<days> stands in for the learned freshness period, to review the Earlier section on a young corpus.
534
+ const tauDaysOverride = useMemo(() => {
535
+ if (typeof window === "undefined" || !isLocalPreviewEnabled()) return null;
536
+ const requested = Number(new URLSearchParams(window.location.search).get("feedTau"));
537
+ return Number.isFinite(requested) && requested > 0 ? requested : null;
538
+ }, []);
539
+
540
+ // Preview only: ?feedState=loading holds the page in its loading state, to review the card skeletons.
541
+ const previewSkeleton = useMemo(
542
+ () => typeof window !== "undefined" && isLocalPreviewEnabled() && new URLSearchParams(window.location.search).get("feedState") === "loading",
543
+ [],
544
+ );
545
+
478
546
  const canonicalReferences = useMemo(() => selectCanonicalReferenceRecords(references), [references]);
547
+ const partition = useMemo(
548
+ () => partitionReferenceFeed(canonicalReferences, decisions, { now: mountedAt, visitRated, tauDaysOverride }),
549
+ [canonicalReferences, decisions, mountedAt, tauDaysOverride, visitRated],
550
+ );
551
+ const feedOrderIds = useMemo(
552
+ () => (feedTab === "feed" ? [...partition.feed, ...partition.earlier] : feedTab === "rated" ? partition.rated : partition.earlier).map((item) => item.lineageId),
553
+ [feedTab, partition],
554
+ );
479
555
  const statusCounts = useMemo(() => countReferencesByStatus(canonicalReferences), [canonicalReferences]);
480
556
 
481
557
  const [statusFilter, setStatusFilter] = useState<ReferenceListFilter>(routeLineageId ? "all" : "pending");
@@ -506,14 +582,14 @@ export function NewsroomReferencesView({
506
582
  // list, but keeps its place in the queue until they move on, so "3 of 29" does not jump under them.
507
583
  const queueRef = useRef<string[]>([]);
508
584
  const queue = useMemo(() => {
509
- const ids = filteredReferences.map(referenceLineageId);
585
+ const ids = view === "feed" ? [...feedOrderIds] : filteredReferences.map(referenceLineageId);
510
586
  const open = selectedReference ? referenceLineageId(selectedReference) : "";
511
587
  if (open && !ids.includes(open)) {
512
588
  const before = queueRef.current.indexOf(open);
513
589
  if (before >= 0) ids.splice(Math.min(before, ids.length), 0, open);
514
590
  }
515
591
  return ids;
516
- }, [filteredReferences, selectedReference]);
592
+ }, [feedOrderIds, filteredReferences, selectedReference, view]);
517
593
  useEffect(() => { queueRef.current = queue; }, [queue]);
518
594
  const [caughtUp, setCaughtUp] = useState(false);
519
595
  const selectedIndex = selectedReference ? queue.indexOf(referenceLineageId(selectedReference)) : -1;
@@ -584,9 +660,21 @@ export function NewsroomReferencesView({
584
660
 
585
661
  const runReview = (action: ReferenceCurationAction, options?: ReferenceReviewOptions) => {
586
662
  if (!selectedReference) return;
663
+ if (action === "accept" || action === "reject") markVisitRated(referenceLineageId(selectedReference));
587
664
  return onReview(selectedReference, action, options);
588
665
  };
589
666
 
667
+ const rateFromFeed = async (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) => {
668
+ const decision = decisions?.get(referenceLineageId(reference));
669
+ await onReview(reference, choice, {
670
+ reasonCode: choice === "reject" ? reasonCode : null,
671
+ ...(decision ? { decisionRelationId: decision.decisionRelationId, shareable: false } : {}),
672
+ });
673
+ };
674
+ const feedDisabledReason = disabled
675
+ ? "Editor access is required to change this reference."
676
+ : showingSavedCopy ? "Confirming your sign-in. You can answer in a moment." : null;
677
+
590
678
  const caughtUpPanel = (
591
679
  <div className="space-y-2 p-6 text-center" data-review-caught-up>
592
680
  <h3 className="text-lg font-semibold">All caught up</h3>
@@ -608,23 +696,64 @@ export function NewsroomReferencesView({
608
696
  data-detail-open={selectedReference || routeLineageId ? "true" : "false"}
609
697
  >
610
698
  <div className="flex min-h-0 min-w-0 flex-1 flex-col gap-4 md:overflow-hidden">
611
- <div className="shrink-0 space-y-1">
612
- <h2 className="text-lg font-semibold tracking-tight">References</h2>
613
- <p className="text-sm text-muted-foreground">
614
- Review source intake before it becomes accepted evidence.
615
- </p>
699
+ <div className="flex shrink-0 items-center justify-between gap-3" {...FEED_SURFACE_PROPS}>
700
+ <div className="min-w-0 space-y-0.5">
701
+ <h2 className="text-2xl font-bold tracking-tight">References</h2>
702
+ <p className="hidden text-sm text-muted-foreground sm:block">
703
+ {view === "feed" ? "A feed ranked by what you are most likely to accept." : "Review source intake before it becomes accepted evidence."}
704
+ </p>
705
+ </div>
706
+ <div className="flex items-center gap-2">
707
+ <button
708
+ aria-controls="reference-add-panel"
709
+ aria-expanded={addOpen}
710
+ aria-label="Add a reference and prediction activity"
711
+ className={cn("inline-flex min-h-10 items-center justify-center gap-1.5 rounded-full px-3 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring", addOpen ? "bg-[var(--feed-control)] text-foreground" : "bg-[var(--feed-card)] text-muted-foreground hover:text-foreground")}
712
+ data-reference-add-toggle
713
+ onClick={() => setAddOpen((open) => !open)}
714
+ title="Add a reference and prediction activity"
715
+ type="button"
716
+ >
717
+ <PlusIcon aria-hidden="true" className="size-4" />
718
+ <span className="hidden sm:inline">Add</span>
719
+ </button>
720
+ <div aria-label="Reference view" className="inline-flex rounded-full bg-[var(--feed-card)] p-1" role="group">
721
+ {([["feed", "Feed", LayoutGridIcon], ["list", "List", ListIcon]] as const).map(([key, label, Icon]) => (
722
+ <button
723
+ aria-label={label}
724
+ aria-pressed={view === key}
725
+ className={cn(
726
+ "inline-flex min-h-8 items-center justify-center gap-1.5 rounded-full px-3 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
727
+ view === key ? "bg-[var(--feed-control)] text-foreground" : "text-muted-foreground hover:text-foreground",
728
+ )}
729
+ data-reference-view={key}
730
+ key={key}
731
+ onClick={() => { setView(key); setCaughtUp(false); }}
732
+ title={label}
733
+ type="button"
734
+ >
735
+ <Icon aria-hidden="true" className="size-4" />
736
+ <span className="hidden sm:inline">{label}</span>
737
+ </button>
738
+ ))}
739
+ </div>
740
+ </div>
616
741
  </div>
617
- {predictionState === "loading" ? <p role="status">Loading predictions…</p> : null}
742
+ {synthetic?.changed ? (
743
+ <p className="rounded-2xl bg-[var(--feed-card)] p-3 text-sm" data-preview-synthetic role="status" {...FEED_SURFACE_PROPS}>
744
+ Preview only: {synthetic.changed} of these predictions are made up to show the Reject end of the feed. Real data has none yet.
745
+ </p>
746
+ ) : null}
747
+ {predictionState === "loading" && view === "list" ? <p role="status">Loading predictions…</p> : null}
618
748
  {predictionState === "error" ? (
619
- <div role="alert" className="space-y-2 rounded-md border p-3">
749
+ <div role="alert" className="space-y-2 rounded-2xl bg-[var(--feed-card)] p-4" {...FEED_SURFACE_PROPS}>
620
750
  <p>Predictions unavailable. {predictionError}</p>
621
751
  <p className="text-sm">You can still review references. Existing suggestions may be out of date.</p>
622
752
  {onRetryPredictions ? <Button onClick={onRetryPredictions} size="sm" type="button" variant="outline">Retry predictions</Button> : null}
623
753
  </div>
624
754
  ) : null}
625
- <details className="shrink-0 overflow-y-auto rounded-md border p-3 text-sm" style={{ maxHeight: "35dvh" }}>
626
- <summary className="cursor-pointer font-semibold">Add a reference and prediction activity</summary>
627
- <div className="mt-3 space-y-4">
755
+ <div className="shrink-0 overflow-y-auto rounded-[20px] bg-[var(--feed-card)] p-4 text-sm" data-feed-flat="" data-feed-surface="" hidden={!addOpen} id="reference-add-panel" style={{ ...FEED_SURFACE_STYLE, maxHeight: "45dvh", overflowY: "auto" }}>
756
+ <div className="space-y-4">
628
757
  <ReferenceLiveAgreement agreement={shadowAgreement} />
629
758
  <ReferenceAddEntry
630
759
  importOptions={disabled ? null : priming ?? sessionImport?.panel}
@@ -641,8 +770,31 @@ export function NewsroomReferencesView({
641
770
  </div>
642
771
  ) : null}
643
772
  </div>
644
- </details>
773
+ </div>
645
774
 
775
+ {view === "feed" ? (
776
+ <ScrollArea className="min-h-0 flex-1" data-reference-feed-scroll>
777
+ <div className="pb-6 pr-1">
778
+ <ReferenceFeed
779
+ disabledReason={feedDisabledReason}
780
+ loading={previewSkeleton || (loading && !references.length)}
781
+ now={mountedAt}
782
+ onOpen={selectReference}
783
+ onRate={rateFromFeed}
784
+ onTabChange={(next) => { setFeedTab(next); setCaughtUp(false); }}
785
+ onVisitRated={markVisitRated}
786
+ partition={partition}
787
+ predictionLoading={predictionState === "loading"}
788
+ previews={previews}
789
+ selectedLineageId={selectedLineageId}
790
+ stale={showingSavedCopy || (loading && references.length > 0)}
791
+ tab={feedTab}
792
+ visitRated={visitRated}
793
+ />
794
+ </div>
795
+ </ScrollArea>
796
+ ) : (
797
+ <>
646
798
  <Tabs
647
799
  className="shrink-0"
648
800
  defaultValue="pending"
@@ -718,15 +870,22 @@ export function NewsroomReferencesView({
718
870
  )}
719
871
  </div>
720
872
  </ScrollArea>
873
+ </>
874
+ )}
721
875
  </div>
722
876
 
723
877
  <div
724
- className="hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-card md:flex md:w-[min(100%,24rem)] lg:w-[min(100%,28rem)]"
878
+ className={cn(
879
+ "hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-[20px] bg-[var(--feed-card)] md:flex md:w-[min(100%,24rem)] lg:w-[min(100%,28rem)]",
880
+ view === "feed" && !selectedReference && "md:hidden",
881
+ )}
725
882
  data-newsroom-list-detail-shell
726
883
  data-news-desk-section="references"
727
884
  data-detail-open={selectedReference ? "true" : "false"}
885
+ data-feed-flat=""
886
+ {...FEED_SURFACE_PROPS}
728
887
  >
729
- <div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2">
888
+ <div className="flex shrink-0 items-center justify-between gap-2 px-4 pt-3 pb-1">
730
889
  <span className="text-sm font-medium text-foreground">Detail</span>
731
890
  </div>
732
891
  <div className="flex min-h-0 flex-1 flex-col" data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}>
@@ -768,12 +927,12 @@ export function NewsroomReferencesView({
768
927
  }}
769
928
  open={(mobileDetailOpen && Boolean(selectedReference)) || caughtUp}
770
929
  >
771
- <SheetContent className="gap-0 p-0" side="bottom">
930
+ <SheetContent className="gap-0 rounded-t-[24px] bg-[var(--feed-card)] p-0" data-feed-flat="" data-feed-surface="" side="bottom" {...themeAttributes}>
772
931
  <SheetHeader className="border-b border-border px-4 py-3">
773
932
  <SheetTitle className="text-left text-base">Reference</SheetTitle>
774
933
  </SheetHeader>
775
934
  {selectedReference ? (
776
- <div data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}><ScrollArea className="max-h-[70dvh]">
935
+ <div className="min-w-0" data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}><ScrollArea className="max-h-[70dvh]">
777
936
  <ReferenceDetailPanel
778
937
  key={referenceLineageId(selectedReference)}
779
938
  predictionState={predictionState}