@anthusai/papyrus 5.44.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.
@@ -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,6 +42,14 @@ 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,
47
55
  predictionFromDecision,
@@ -120,8 +128,14 @@ type NewsroomReferencesViewProps = {
120
128
  onReview: (reference: ReferenceRecord, action: ReferenceCurationAction, options?: ReferenceReviewOptions) => Promise<void> | void;
121
129
  referenceAttachments?: ReferenceAttachmentRecord[];
122
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;
123
135
  };
124
136
 
137
+ type ReferenceView = "feed" | "list";
138
+
125
139
  type ReferenceListFilter = ReferenceStatusFilter | "needs_review";
126
140
 
127
141
  const NEEDS_REVIEW_FILTER: { key: ReferenceListFilter; label: string } = { key: "needs_review", label: "Needs review" };
@@ -442,7 +456,7 @@ export function NewsroomReferencesView({
442
456
  priming,
443
457
  cyclotronStatus,
444
458
  cyclotronPriming,
445
- decisions,
459
+ decisions: liveDecisions,
446
460
  humanCuratedReferenceLineageIds,
447
461
  delegatedCurations,
448
462
  shadowAgreement = null,
@@ -461,9 +475,14 @@ export function NewsroomReferencesView({
461
475
  initialReferenceLineageId = null,
462
476
  onReview,
463
477
  referenceAttachments = [],
464
- references,
478
+ references: liveReferences,
479
+ loading = false,
480
+ savedCopy = null,
465
481
  }: NewsroomReferencesViewProps) {
466
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 };
467
486
  const sessionImport = useReferenceImport(disabled);
468
487
  const [importedCount, setImportedCount] = useState(0);
469
488
  const isMobileDetail = useMediaQuery("(max-width: 767px)");
@@ -473,7 +492,66 @@ export function NewsroomReferencesView({
473
492
  );
474
493
  const routeLineageId = pathname ? pathnameLineageId ?? "" : initialReferenceLineageId ?? "";
475
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
+
476
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
+ );
477
555
  const statusCounts = useMemo(() => countReferencesByStatus(canonicalReferences), [canonicalReferences]);
478
556
 
479
557
  const [statusFilter, setStatusFilter] = useState<ReferenceListFilter>(routeLineageId ? "all" : "pending");
@@ -504,14 +582,14 @@ export function NewsroomReferencesView({
504
582
  // list, but keeps its place in the queue until they move on, so "3 of 29" does not jump under them.
505
583
  const queueRef = useRef<string[]>([]);
506
584
  const queue = useMemo(() => {
507
- const ids = filteredReferences.map(referenceLineageId);
585
+ const ids = view === "feed" ? [...feedOrderIds] : filteredReferences.map(referenceLineageId);
508
586
  const open = selectedReference ? referenceLineageId(selectedReference) : "";
509
587
  if (open && !ids.includes(open)) {
510
588
  const before = queueRef.current.indexOf(open);
511
589
  if (before >= 0) ids.splice(Math.min(before, ids.length), 0, open);
512
590
  }
513
591
  return ids;
514
- }, [filteredReferences, selectedReference]);
592
+ }, [feedOrderIds, filteredReferences, selectedReference, view]);
515
593
  useEffect(() => { queueRef.current = queue; }, [queue]);
516
594
  const [caughtUp, setCaughtUp] = useState(false);
517
595
  const selectedIndex = selectedReference ? queue.indexOf(referenceLineageId(selectedReference)) : -1;
@@ -582,9 +660,21 @@ export function NewsroomReferencesView({
582
660
 
583
661
  const runReview = (action: ReferenceCurationAction, options?: ReferenceReviewOptions) => {
584
662
  if (!selectedReference) return;
663
+ if (action === "accept" || action === "reject") markVisitRated(referenceLineageId(selectedReference));
585
664
  return onReview(selectedReference, action, options);
586
665
  };
587
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
+
588
678
  const caughtUpPanel = (
589
679
  <div className="space-y-2 p-6 text-center" data-review-caught-up>
590
680
  <h3 className="text-lg font-semibold">All caught up</h3>
@@ -606,23 +696,64 @@ export function NewsroomReferencesView({
606
696
  data-detail-open={selectedReference || routeLineageId ? "true" : "false"}
607
697
  >
608
698
  <div className="flex min-h-0 min-w-0 flex-1 flex-col gap-4 md:overflow-hidden">
609
- <div className="shrink-0 space-y-1">
610
- <h2 className="text-lg font-semibold tracking-tight">References</h2>
611
- <p className="text-sm text-muted-foreground">
612
- Review source intake before it becomes accepted evidence.
613
- </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>
614
741
  </div>
615
- {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}
616
748
  {predictionState === "error" ? (
617
- <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}>
618
750
  <p>Predictions unavailable. {predictionError}</p>
619
751
  <p className="text-sm">You can still review references. Existing suggestions may be out of date.</p>
620
752
  {onRetryPredictions ? <Button onClick={onRetryPredictions} size="sm" type="button" variant="outline">Retry predictions</Button> : null}
621
753
  </div>
622
754
  ) : null}
623
- <details className="shrink-0 overflow-y-auto rounded-md border p-3 text-sm" style={{ maxHeight: "35dvh" }}>
624
- <summary className="cursor-pointer font-semibold">Add a reference and prediction activity</summary>
625
- <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">
626
757
  <ReferenceLiveAgreement agreement={shadowAgreement} />
627
758
  <ReferenceAddEntry
628
759
  importOptions={disabled ? null : priming ?? sessionImport?.panel}
@@ -639,8 +770,31 @@ export function NewsroomReferencesView({
639
770
  </div>
640
771
  ) : null}
641
772
  </div>
642
- </details>
773
+ </div>
643
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
+ <>
644
798
  <Tabs
645
799
  className="shrink-0"
646
800
  defaultValue="pending"
@@ -716,15 +870,22 @@ export function NewsroomReferencesView({
716
870
  )}
717
871
  </div>
718
872
  </ScrollArea>
873
+ </>
874
+ )}
719
875
  </div>
720
876
 
721
877
  <div
722
- 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
+ )}
723
882
  data-newsroom-list-detail-shell
724
883
  data-news-desk-section="references"
725
884
  data-detail-open={selectedReference ? "true" : "false"}
885
+ data-feed-flat=""
886
+ {...FEED_SURFACE_PROPS}
726
887
  >
727
- <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">
728
889
  <span className="text-sm font-medium text-foreground">Detail</span>
729
890
  </div>
730
891
  <div className="flex min-h-0 flex-1 flex-col" data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}>
@@ -766,12 +927,12 @@ export function NewsroomReferencesView({
766
927
  }}
767
928
  open={(mobileDetailOpen && Boolean(selectedReference)) || caughtUp}
768
929
  >
769
- <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}>
770
931
  <SheetHeader className="border-b border-border px-4 py-3">
771
932
  <SheetTitle className="text-left text-base">Reference</SheetTitle>
772
933
  </SheetHeader>
773
934
  {selectedReference ? (
774
- <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]">
775
936
  <ReferenceDetailPanel
776
937
  key={referenceLineageId(selectedReference)}
777
938
  predictionState={predictionState}