@anthusai/papyrus 5.45.0 → 5.46.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 (34) hide show
  1. package/amplify/data/resource.js +6 -0
  2. package/amplify/data/resource.ts +7 -0
  3. package/amplify/data/schema.js +6 -0
  4. package/amplify/data/schema.ts +7 -0
  5. package/amplify/functions/content-actions/handler.py +8 -0
  6. package/bin/papyrus-app.mjs +12 -1
  7. package/components/news-desk-client-provider.tsx +2 -2
  8. package/components/newsroom-ops-overview.tsx +63 -60
  9. package/components/newsroom-ops-shell.tsx +3 -1
  10. package/components/newsroom-organizer.css +1 -2
  11. package/components/newsroom-references-view.tsx +119 -7
  12. package/components/notification-cards.tsx +364 -0
  13. package/components/notification-center.tsx +319 -331
  14. package/components/reference-capture-client.ts +110 -0
  15. package/components/reference-capture-panel.tsx +180 -0
  16. package/components/reference-feed.tsx +93 -60
  17. package/components/reference-review-card.tsx +21 -66
  18. package/components/references-prefetch.ts +5 -0
  19. package/components/review-rating-grid.tsx +27 -14
  20. package/components/topic-steering-workspace.tsx +12 -2
  21. package/components/use-reference-ratings.ts +132 -0
  22. package/lib/category-repository.ts +2 -0
  23. package/lib/content-actions-client.ts +18 -0
  24. package/lib/local-preview-feed-fixture.ts +20 -0
  25. package/lib/newsroom-nav.ts +1 -1
  26. package/lib/newsroom-organizer-nav.ts +1 -1
  27. package/lib/notification-feed.ts +250 -0
  28. package/lib/reference-capture.ts +293 -0
  29. package/lib/reference-feed-cache.ts +37 -0
  30. package/lib/reference-feed-ranking.ts +44 -0
  31. package/lib/review-drafts.ts +8 -3
  32. package/lib/site-brand.ts +2 -0
  33. package/package.json +7 -1
  34. package/routes.manifest.json +1 -1
@@ -603,6 +603,12 @@ const schema = a.schema({
603
603
  .returns(a.json())
604
604
  .authorization((allow) => [allow.groups(contentWriteGroups)])
605
605
  .handler(functionHandler("contentActions", contentActions)),
606
+ correctReferenceCapture: a
607
+ .mutation()
608
+ .arguments({ input: a.json().required() })
609
+ .returns(a.json())
610
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
611
+ .handler(functionHandler("contentActions", contentActions)),
606
612
  saveItemDraft: a
607
613
  .mutation()
608
614
  .arguments({ input: a.json().required() })
@@ -657,6 +657,13 @@ const schema = a.schema({
657
657
  .authorization((allow) => [allow.groups(contentWriteGroups)])
658
658
  .handler(functionHandler("contentActions", contentActions)),
659
659
 
660
+ correctReferenceCapture: a
661
+ .mutation()
662
+ .arguments({ input: a.json().required() })
663
+ .returns(a.json())
664
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
665
+ .handler(functionHandler("contentActions", contentActions)),
666
+
660
667
  saveItemDraft: a
661
668
  .mutation()
662
669
  .arguments({ input: a.json().required() })
@@ -530,6 +530,12 @@ function buildBaseDefinitions(includeEditions) {
530
530
  .returns(a.json())
531
531
  .authorization((allow) => [allow.groups(contentWriteGroups)])
532
532
  .handler(functionHandler("contentActions", contentActions)),
533
+ correctReferenceCapture: a
534
+ .mutation()
535
+ .arguments({ input: a.json().required() })
536
+ .returns(a.json())
537
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
538
+ .handler(functionHandler("contentActions", contentActions)),
533
539
  saveItemDraft: a
534
540
  .mutation()
535
541
  .arguments({ input: a.json().required() })
@@ -582,6 +582,13 @@ const definitions = {
582
582
  .authorization((allow) => [allow.groups(contentWriteGroups)])
583
583
  .handler(functionHandler("contentActions", contentActions)),
584
584
 
585
+ correctReferenceCapture: a
586
+ .mutation()
587
+ .arguments({ input: a.json().required() })
588
+ .returns(a.json())
589
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
590
+ .handler(functionHandler("contentActions", contentActions)),
591
+
585
592
  saveItemDraft: a
586
593
  .mutation()
587
594
  .arguments({ input: a.json().required() })
@@ -365,6 +365,14 @@ def handler(event, context):
365
365
  client, arguments, event.get("identity") or {}, receipt_store=receipt_store)
366
366
  except IntakeError as error:
367
367
  return {"ok": False, "errors": [{"code": "intake-rejected", "message": str(error)}]}
368
+ if field == "correctReferenceCapture":
369
+ from papyrus_content.reference_curation_store import CurationError, correct_reference_capture
370
+
371
+ client, _claims = create_authoring_client()
372
+ try:
373
+ return correct_reference_capture(client, arguments, event.get("identity") or {})
374
+ except CurationError as error:
375
+ return {"ok": False, "errors": [{"code": "correction-rejected", "message": str(error)}]}
368
376
  if field == "deriveMarkus":
369
377
  return derive_markus_action(arguments)
370
378
  if field == "saveItemDraft":
@@ -1,9 +1,20 @@
1
1
  #!/usr/bin/env node
2
+ import path from "node:path";
2
3
  import { sync } from "../src/sync.mjs";
3
4
 
4
5
  const [command, ...rest] = process.argv.slice(2);
5
6
  if (command === "sync") {
6
7
  sync();
8
+ } else if (command === "brand" && rest[0] === "diff") {
9
+ const { brandDiff } = await import("../src/brand.mjs");
10
+ const flag = rest.indexOf("--papyrus");
11
+ const checkout = flag === -1 ? undefined : rest[flag + 1];
12
+ try {
13
+ process.exit(brandDiff({ id: rest[1], checkout: checkout && path.resolve(checkout) }).drifted ? 1 : 0);
14
+ } catch (error) {
15
+ console.error(error.message);
16
+ process.exit(2);
17
+ }
7
18
  } else if (command === "videoml-bundle") {
8
19
  const preview = rest.includes("--preview");
9
20
  const { buildBrowserBundle } = await import("../scripts/videoml/build-browser-bundle.mjs");
@@ -15,6 +26,6 @@ if (command === "sync") {
15
26
  process.exit(1);
16
27
  }
17
28
  } else {
18
- console.error("Usage: papyrus-app sync | papyrus-app videoml-bundle [--preview]");
29
+ console.error("Usage: papyrus-app sync | papyrus-app brand diff <brand-id> [--papyrus <checkout>] | papyrus-app videoml-bundle [--preview]");
19
30
  process.exit(command ? 1 : 0);
20
31
  }
@@ -6,7 +6,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
6
6
  import type { DevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
7
7
  import type { CategorySteeringDashboard } from "../lib/category-repository";
8
8
  import { ensureDevSandboxEditorSession } from "./dev-sandbox-editor-sign-in";
9
- import { discardReferencesHeadStart, prefetchReferencesFeed } from "./references-prefetch";
9
+ import { discardReferencesHeadStart, prefetchReferencesFeed, wantsReferencesHeadStart } from "./references-prefetch";
10
10
  import { SITE_BRAND } from "../lib/site-brand";
11
11
  import { clearSavedNewsroomDisplay, loadSessionDisplay, saveSessionDisplay } from "../lib/reference-feed-cache";
12
12
  import {
@@ -113,7 +113,7 @@ export function NewsDeskClientProvider({
113
113
  saveSessionDisplay(SITE_BRAND.id, { label: access.auth.label, isAdmin: access.isAdmin });
114
114
  setShell((current) => beginDeskLoad(current, access.auth));
115
115
  // The References reads do not depend on the summary dashboard: start them now, side by side with it.
116
- if (typeof window !== "undefined" && /\/newsroom\/references(\/|$)/.test(window.location.pathname)) prefetchReferencesFeed();
116
+ if (typeof window !== "undefined" && wantsReferencesHeadStart(window.location.pathname)) prefetchReferencesFeed();
117
117
 
118
118
  try {
119
119
  const dashboard = await loadEditorNewsDeskDashboard({ isAdmin: access.isAdmin });
@@ -1,19 +1,13 @@
1
1
  "use client";
2
2
 
3
3
  import Link from "next/link";
4
- import { usePathname } from "next/navigation";
5
- import { useMemo } from "react";
4
+ import { usePathname, useRouter } from "next/navigation";
5
+ import { useCallback, useMemo } from "react";
6
6
  import { Badge } from "./ui/badge";
7
7
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
8
- import type { AssignmentRecord, NewsroomSectionRecord, ReferenceRecord } from "../lib/category-repository";
8
+ import type { AssignmentRecord, NewsroomSectionRecord, ReferenceAttachmentRecord, ReferenceRecord } from "../lib/category-repository";
9
9
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
10
- import {
11
- filterReferencesByStatus,
12
- formatReferenceListDate,
13
- referenceDisplayTitle,
14
- referenceLineageId,
15
- selectCanonicalReferenceRecords,
16
- } from "../lib/newsroom-references";
10
+ import { referenceLineageId, selectCanonicalReferenceRecords, type ReferenceCurationAction } from "../lib/newsroom-references";
17
11
  import {
18
12
  buildNewsroomSectionHref,
19
13
  displayNewsroomSectionShortTitle,
@@ -23,8 +17,11 @@ import {
23
17
  } from "../lib/newsroom-sections";
24
18
  import { newsroomInboxRowClassName } from "../lib/newsroom-list-selection";
25
19
  import type { RelevanceDecisionView } from "../lib/relevance-decisions";
20
+ import type { ReferenceRejectionReasonCode } from "../lib/reference-policy";
21
+ import { referencePreviewsByLineage, type ReferencePreview } from "../lib/reference-preview";
22
+ import type { NotificationFixture } from "./notification-center";
26
23
  import { NotificationCenter } from "./notification-center";
27
- import { CyclotronRow, predictionFromDecision } from "./reference-review-card";
24
+ import type { ReviewChoice } from "./reference-review-card";
28
25
 
29
26
  type NewsroomOpsOverviewProps = {
30
27
  assignments: AssignmentRecord[];
@@ -34,26 +31,58 @@ type NewsroomOpsOverviewProps = {
34
31
  humanCuratedReferenceLineageIds?: ReadonlySet<string>;
35
32
  newsroomSections: NewsroomSectionRecord[];
36
33
  references: ReferenceRecord[];
34
+ referenceAttachments?: ReferenceAttachmentRecord[];
35
+ /** True until the References have been read; a saved copy of the last view fills the page meanwhile. */
36
+ loading?: boolean;
37
+ savedCopy?: { references: ReferenceRecord[]; previews: Array<[string, ReferencePreview]>; decisions: Array<[string, RelevanceDecisionView]> } | null;
38
+ predictionState?: "loading" | "ready" | "error";
39
+ /** Words for why a rating cannot be saved right now, or null. */
40
+ disabledReason?: string | null;
41
+ onReview?: (reference: ReferenceRecord, action: ReferenceCurationAction, options?: { reasonCode?: ReferenceRejectionReasonCode | null; decisionRelationId?: string; shareable?: boolean }) => Promise<void> | void;
42
+ /** Local real-data preview of the Notification Center (scripts/export-notification-center-fixture.py). */
43
+ notificationFixture?: NotificationFixture | null;
37
44
  };
38
45
 
39
46
  const NO_DECISIONS: ReadonlyMap<string, RelevanceDecisionView> = new Map();
40
- const NO_IDS: ReadonlySet<string> = new Set();
41
47
 
42
48
  export function NewsroomOpsOverview({
43
49
  actorLabel = "",
44
50
  assignments,
45
51
  decisions = NO_DECISIONS,
46
52
  demo = false,
47
- humanCuratedReferenceLineageIds = NO_IDS,
53
+ humanCuratedReferenceLineageIds,
54
+ disabledReason = null,
55
+ loading = false,
48
56
  newsroomSections,
57
+ notificationFixture = null,
58
+ onReview,
59
+ predictionState = "ready",
60
+ referenceAttachments = [],
49
61
  references,
62
+ savedCopy = null,
50
63
  }: NewsroomOpsOverviewProps) {
51
64
  const pathname = usePathname();
52
- const canonicalReferences = useMemo(() => selectCanonicalReferenceRecords(references), [references]);
53
- const pendingReferences = useMemo(
54
- () => filterReferencesByStatus(canonicalReferences, "pending").slice(0, 5),
55
- [canonicalReferences],
65
+ const router = useRouter();
66
+ const liveReferences = useMemo(() => selectCanonicalReferenceRecords(references), [references]);
67
+ // Until the live read arrives, a saved copy of the last view fills the page. It is display only.
68
+ const showingSavedCopy = loading && !liveReferences.length && Boolean(savedCopy?.references.length);
69
+ const canonicalReferences = showingSavedCopy ? savedCopy!.references : liveReferences;
70
+ const shownDecisions = useMemo(() => (showingSavedCopy && !decisions.size ? new Map(savedCopy!.decisions) : decisions), [decisions, savedCopy, showingSavedCopy]);
71
+ const previews = useMemo(() => {
72
+ const live = referencePreviewsByLineage(referenceAttachments);
73
+ return showingSavedCopy && !live.size ? new Map(savedCopy!.previews) : live;
74
+ }, [referenceAttachments, savedCopy, showingSavedCopy]);
75
+ const openReference = useCallback(
76
+ (lineageId: string) => router.push(getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo)),
77
+ [demo, router],
56
78
  );
79
+ const rateReference = useCallback(async (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) => {
80
+ const decision = shownDecisions.get(referenceLineageId(reference));
81
+ await onReview?.(reference, choice, {
82
+ reasonCode: choice === "reject" ? reasonCode : null,
83
+ ...(decision ? { decisionRelationId: decision.decisionRelationId, shareable: false } : {}),
84
+ });
85
+ }, [onReview, shownDecisions]);
57
86
  const attentionAssignments = useMemo(
58
87
  () => assignments
59
88
  .filter((assignment) => {
@@ -69,55 +98,35 @@ export function NewsroomOpsOverview({
69
98
  );
70
99
  const coreDesks = useMemo(() => desks.filter(desk => newsroomDeskKind(desk) === "core"), [desks]);
71
100
  const rotatingDesks = useMemo(() => desks.filter(desk => newsroomDeskKind(desk) === "rotating"), [desks]);
101
+ const rateDisabled = disabledReason ?? (showingSavedCopy ? "Confirming your sign-in. You can answer in a moment." : null);
72
102
 
73
103
  return (
74
104
  <div
75
- className="space-y-8 font-sans"
105
+ className="space-y-12 font-sans"
76
106
  data-news-desk-section="overview"
77
107
  data-newsroom-ops-home="true"
78
108
  >
79
- <div className="space-y-8" data-newsroom-overview-feeds>
80
- <div aria-label="Notification Center feed" className="newsroom-overview-feed-frame rounded-xl border bg-card p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" data-newsroom-overview-notifications role="region" tabIndex={0}>
109
+ <div className="space-y-12" data-newsroom-overview-feeds>
110
+ <div data-newsroom-overview-notifications>
81
111
  <NotificationCenter
82
112
  actorLabel={actorLabel}
83
- decisions={decisions}
113
+ decisions={shownDecisions}
84
114
  demo={demo}
115
+ fixture={notificationFixture}
85
116
  humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
117
+ onOpenReference={openReference}
118
+ onRateReference={rateReference}
119
+ predictionLoading={predictionState === "loading"}
120
+ previews={previews}
121
+ referenceDisabledReason={rateDisabled}
86
122
  references={canonicalReferences}
123
+ referencesLoading={loading && !canonicalReferences.length}
87
124
  />
88
125
  </div>
89
- <section aria-label="Needs attention" className="space-y-3">
90
- <h2 className="text-sm font-medium text-muted-foreground">Needs attention</h2>
91
- {pendingReferences.length || attentionAssignments.length ? (
126
+ {attentionAssignments.length ? (
127
+ <section aria-label="Open assignments" className="space-y-3">
128
+ <h2 className="text-sm font-medium text-muted-foreground">Open assignments</h2>
92
129
  <div className="space-y-2">
93
- {pendingReferences.map((reference) => {
94
- const lineageId = referenceLineageId(reference);
95
- const href = getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo);
96
- const active = pathname === href || pathname.startsWith(`${href}/`);
97
- return (
98
- <Link
99
- aria-current={active ? "true" : undefined}
100
- className={newsroomInboxRowClassName(active)}
101
- data-newsroom-inbox-item="reference"
102
- data-newsroom-list-row
103
- data-selected={active || undefined}
104
- data-newsroom-card
105
- data-newsroom-card-id={lineageId}
106
- href={href}
107
- key={lineageId}
108
- >
109
- <div className="min-w-0 flex-1 space-y-1">
110
- <p className="truncate text-sm font-medium text-foreground">{referenceDisplayTitle(reference)}</p>
111
- <p className="text-xs text-muted-foreground">Review source intake · {formatReferenceListDate(reference)}</p>
112
- <div data-needs-attention-suggestion={lineageId}>
113
- <CyclotronRow prediction={decisions.get(lineageId) ? predictionFromDecision(decisions.get(lineageId)!) : { state: "none" }} />
114
- </div>
115
- <p className="text-xs font-medium text-foreground">Open it to Accept or Reject in the review queue</p>
116
- </div>
117
- <Badge className="mt-0.5 shrink-0" variant="outline">Pending</Badge>
118
- </Link>
119
- );
120
- })}
121
130
  {attentionAssignments.map((assignment) => {
122
131
  const href = getNewsroomNavHref(`/newsroom/assignments/${encodeURIComponent(assignment.id)}`, demo);
123
132
  const active = pathname === href || pathname.startsWith(`${href}/`);
@@ -140,14 +149,8 @@ export function NewsroomOpsOverview({
140
149
  );
141
150
  })}
142
151
  </div>
143
- ) : (
144
- <Card>
145
- <CardContent className="p-4 text-sm text-muted-foreground">
146
- No pending references or open assignments. Check those destinations for other work.
147
- </CardContent>
148
- </Card>
149
- )}
150
- </section>
152
+ </section>
153
+ ) : null}
151
154
 
152
155
  {desks.length ? (
153
156
  <section aria-label="Sections" className="space-y-3" data-newsroom-ops-desks>
@@ -143,6 +143,8 @@ export function NewsroomOpsShell({ activeTab, appTitle, backHref = "/", backLabe
143
143
  media.addEventListener("change", change);
144
144
  return () => { document.removeEventListener("focusin", remember); media.removeEventListener("change", change); cancelAnimationFrame(frame); };
145
145
  }, []);
146
+ // The mobile top bar prefers the brand's compact mark, then scales the full logo; the serif title is the no-logo fallback.
147
+ const MastheadLogo = SITE_BRAND.components?.NewsroomLogoCompact ?? SITE_BRAND.components?.NewsroomLogo;
146
148
  const activePath = browserPathToInternalNewsroomPath(pathname ?? "/newsroom");
147
149
  const sidebarToggleLabel = collapsed ? "Expand sidebar" : "Collapse sidebar";
148
150
  const sidebarToggle = <button type="button" className="organizer-toggle" title={sidebarToggleLabel}
@@ -162,7 +164,7 @@ export function NewsroomOpsShell({ activeTab, appTitle, backHref = "/", backLabe
162
164
  <div className="organizer-workspace">
163
165
  <header className="organizer-topbar" data-newsroom-ops-topbar>
164
166
  {showNavigation && collapsed && sidebarToggle}
165
- <Link className="organizer-masthead" href={backHref} title={backLabel}>{appTitle}</Link>
167
+ <Link className="organizer-masthead" href={backHref} title={backLabel}>{MastheadLogo ? <MastheadLogo className="organizer-masthead-logo" /> : appTitle}</Link>
166
168
  <h1 data-newsroom-ops-title>{pageTitle}</h1><OrganizerSearch demo={demo} /><div className="organizer-header-actions">{headerActions}</div><Account placement="header" />
167
169
  </header>
168
170
  <main className="organizer-main">{demo && !session?.shell.dashboard?.realDataPreview && <p className="organizer-demo-notice">Demo workspace · synthetic examples</p>}{children}</main>
@@ -18,13 +18,12 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
18
18
  .organizer-workspace{display:flex;flex-direction:column;min-height:0;min-width:0;overflow:hidden;padding:0 16px}
19
19
  .organizer-topbar{height:48px;min-height:48px;display:flex;align-items:center;gap:16px;border-bottom:1px solid var(--org-line)}
20
20
  .organizer-masthead{font:900 28px var(--font-wordmark),Georgia,serif;white-space:nowrap;max-width:30vw;overflow:hidden;text-overflow:ellipsis}
21
+ .organizer-masthead-logo{display:block;height:24px;width:auto;max-width:36vw;overflow:visible}
21
22
  .organizer-topbar h1{font:400 12px var(--kb-theme-ui-font);flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:0}
22
23
  .organizer-toggle{display:flex;align-items:center;justify-content:center;width:36px;min-height:36px;padding:0;border:1px solid var(--org-line);flex-shrink:0}
23
24
  .organizer-sidebar-head{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}.organizer-sidebar-head .organizer-brand{flex:1;min-width:0}
24
25
  .organizer-header-actions{display:flex;align-items:center;gap:8px;min-width:0;max-width:45%;overflow-x:auto}
25
26
  .organizer-main{min-width:0;min-height:0;overflow:auto;overscroll-behavior:contain;flex:1;padding:16px 0}
26
- .newsroom-overview-feed-frame{height:auto}
27
- @media(min-width:640px){.newsroom-overview-feed-frame{height:38rem;overflow-y:auto}}
28
27
  .organizer-main>[data-category-steering]{min-height:0}.organizer-demo-notice{font-size:11px;color:var(--org-muted);margin-bottom:12px}
29
28
  .organizer-account{display:flex;align-items:center;gap:10px;min-height:44px;flex-shrink:0;border:1px solid var(--org-line);padding:8px;text-align:left;border-radius:3px}
30
29
  .organizer-account span{min-width:0}.organizer-account strong{display:block;font:600 11px var(--kb-theme-ui-font);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.organizer-account small{display:block;font-size:9px;color:var(--org-muted);margin-top:2px}
@@ -6,7 +6,7 @@ import { ReferenceCurationControls, useReferenceImport } from "./reference-curat
6
6
 
7
7
  import { ExternalLinkIcon, LayoutGridIcon, ListIcon, PlusIcon } from "lucide-react";
8
8
  import { usePathname } from "next/navigation";
9
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
9
+ import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
10
10
  import { Badge } from "./ui/badge";
11
11
  import { Button } from "./ui/button";
12
12
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
@@ -43,7 +43,11 @@ import { normalizeReferenceCurationStatus, referenceReasonCode, type ReferenceRe
43
43
  import { newsroomListRowClassName } from "../lib/newsroom-list-selection";
44
44
  import { ReferenceSourcePreview } from "./reference-source-preview";
45
45
  import { FEED_SURFACE_PROPS, FEED_SURFACE_STYLE, ReferenceFeed } from "./reference-feed";
46
- import { partitionReferenceFeed, type ReferenceFeedTabKey } from "../lib/reference-feed-ranking";
46
+ import { ReferenceCapturePanel } from "./reference-capture-panel";
47
+ import { useReferenceCapture, type CorrectCaptureAction } from "./reference-capture-client";
48
+ import { useRatingStore, type RatingController } from "./use-reference-ratings";
49
+ import { captureStatusesByLineage } from "../lib/reference-capture";
50
+ import { applyFeedOrderSnapshot, partitionReferenceFeed, takeFeedOrderSnapshot, type FeedOrderSnapshot, type ReferenceFeedTabKey } from "../lib/reference-feed-ranking";
47
51
  import { referencePreviewsByLineage, type ReferencePreview } from "../lib/reference-preview";
48
52
  import { isLocalPreviewEnabled } from "../lib/local-preview";
49
53
  import { chooseSyntheticRejects, withSyntheticRejects } from "../lib/local-preview-synthetic-rejects";
@@ -128,6 +132,8 @@ type NewsroomReferencesViewProps = {
128
132
  onReview: (reference: ReferenceRecord, action: ReferenceCurationAction, options?: ReferenceReviewOptions) => Promise<void> | void;
129
133
  referenceAttachments?: ReferenceAttachmentRecord[];
130
134
  references: ReferenceRecord[];
135
+ /** Saves an editor's pasted or corrected capture text and their verdict on it. Optional: without it the capture panel only reads. */
136
+ onCorrectCapture?: CorrectCaptureAction;
131
137
  /** True while the References are still being read: the feed shows card skeletons instead of an empty page. */
132
138
  loading?: boolean;
133
139
  /** The last view this browser saved. Shown, read only, until the live read arrives. */
@@ -291,6 +297,10 @@ function cardPrediction(decision: RelevanceDecisionView | undefined, predictionS
291
297
  }
292
298
 
293
299
  function ReferenceDetailPanel({
300
+ captureTeaserLine,
301
+ correctCapture,
302
+ ratings,
303
+ sectionChoices,
294
304
  countsAsTrainingLabel,
295
305
  delegated,
296
306
  decision,
@@ -306,6 +316,13 @@ function ReferenceDetailPanel({
306
316
  sectionSuggestion,
307
317
  sections = [],
308
318
  }: {
319
+ captureTeaserLine: number;
320
+ /** Saves a pasted or corrected capture (the `correctReferenceCapture` action). Without it the panel has no edit action. */
321
+ correctCapture?: CorrectCaptureAction;
322
+ /** The rating store shared with the grid cards. */
323
+ ratings?: RatingController;
324
+ /** Where this panel records the Section chosen for an accept, so the shared store's save can include it. */
325
+ sectionChoices?: MutableRefObject<Map<string, string | null>>;
309
326
  countsAsTrainingLabel?: boolean;
310
327
  delegated?: DelegatedCuration;
311
328
  decision?: RelevanceDecisionView;
@@ -333,6 +350,8 @@ function ReferenceDetailPanel({
333
350
  if (!sectionEdited.current) setChosenSectionId(reviewSectionId ?? null);
334
351
  }, [reviewSectionId]);
335
352
  const url = referenceUrl(reference);
353
+ useEffect(() => { sectionChoices?.current.set(lineageId, chosenSectionId); }, [chosenSectionId, lineageId, sectionChoices]);
354
+ const capture = useReferenceCapture(reference, previewAttachments, captureTeaserLine, correctCapture);
336
355
 
337
356
  async function decide(choice: ReviewChoice, review: ReviewCardDecision) {
338
357
  const action: ReferenceCurationAction = choice;
@@ -362,6 +381,7 @@ function ReferenceDetailPanel({
362
381
  initialNote={sourceNote}
363
382
  initialReason={sourceReason}
364
383
  itemId={lineageId}
384
+ ratings={ratings}
365
385
  onArchive={status === "archived" ? undefined : async () => { await onReview("archive"); }}
366
386
  onDecide={decide}
367
387
  prediction={cardPrediction(decision, predictionState)}
@@ -392,6 +412,8 @@ function ReferenceDetailPanel({
392
412
 
393
413
  <Separator />
394
414
 
415
+ <ReferenceCapturePanel capture={capture} />
416
+
395
417
  <section aria-label="Source preview" className="space-y-2">
396
418
  <h3 className="text-sm font-medium text-foreground">Source preview</h3>
397
419
  <ReferenceSourcePreview attachments={previewAttachments} sourceUri={reference.sourceUri} />
@@ -477,6 +499,7 @@ export function NewsroomReferencesView({
477
499
  referenceAttachments = [],
478
500
  references: liveReferences,
479
501
  loading = false,
502
+ onCorrectCapture,
480
503
  savedCopy = null,
481
504
  }: NewsroomReferencesViewProps) {
482
505
  const pathname = usePathname();
@@ -544,10 +567,21 @@ export function NewsroomReferencesView({
544
567
  );
545
568
 
546
569
  const canonicalReferences = useMemo(() => selectCanonicalReferenceRecords(references), [references]);
547
- const partition = useMemo(
570
+ const livePartition = useMemo(
548
571
  () => partitionReferenceFeed(canonicalReferences, decisions, { now: mountedAt, visitRated, tauDaysOverride }),
549
572
  [canonicalReferences, decisions, mountedAt, tauDaysOverride, visitRated],
550
573
  );
574
+ // The feed's order is taken ONCE per visit, when the feed has loaded (References and predictions), and then left alone. Selecting an
575
+ // item, rating one, a background refresh or a cache revalidation changes cards' content, never their places. Items that arrive later
576
+ // wait behind a "N new items: show" pill. (The Rated tab is a live list: it is the one place a new answer is meant to appear.)
577
+ const orderSnapshot = useRef<FeedOrderSnapshot | null>(null);
578
+ const [, bumpOrder] = useState(0);
579
+ const orderReady = !loading && !showingSavedCopy && !previewSkeleton && predictionState !== "loading" && canonicalReferences.length > 0;
580
+ if (!orderSnapshot.current && orderReady) orderSnapshot.current = takeFeedOrderSnapshot(livePartition);
581
+ const stable = orderSnapshot.current ? applyFeedOrderSnapshot(orderSnapshot.current, livePartition) : null;
582
+ const partition = stable?.partition ?? livePartition;
583
+ const showNewItems = () => { orderSnapshot.current = takeFeedOrderSnapshot(livePartition); bumpOrder((count) => count + 1); };
584
+ const [scrollTarget, setScrollTarget] = useState<{ id: string; nonce: number } | null>(null);
551
585
  const feedOrderIds = useMemo(
552
586
  () => (feedTab === "feed" ? [...partition.feed, ...partition.earlier] : feedTab === "rated" ? partition.rated : partition.earlier).map((item) => item.lineageId),
553
587
  [feedTab, partition],
@@ -635,7 +669,7 @@ export function NewsroomReferencesView({
635
669
  };
636
670
 
637
671
  const goNext = () => {
638
- if (nextLineageId) selectReference(nextLineageId);
672
+ if (nextLineageId) { selectReference(nextLineageId); setScrollTarget({ id: nextLineageId, nonce: Date.now() }); }
639
673
  else if (queue.length && !caughtUp) {
640
674
  // Past the last item: say so, rather than showing an empty page.
641
675
  setCaughtUp(true);
@@ -644,7 +678,7 @@ export function NewsroomReferencesView({
644
678
  } else if (!queue.length) setCaughtUp(true);
645
679
  };
646
680
  const goPrevious = () => {
647
- if (previousLineageId) selectReference(previousLineageId);
681
+ if (previousLineageId) { selectReference(previousLineageId); setScrollTarget({ id: previousLineageId, nonce: Date.now() }); }
648
682
  };
649
683
  const gestures = useQueueGestures({ onNext: goNext, onPrevious: goPrevious });
650
684
 
@@ -671,6 +705,47 @@ export function NewsroomReferencesView({
671
705
  ...(decision ? { decisionRelationId: decision.decisionRelationId, shareable: false } : {}),
672
706
  });
673
707
  };
708
+ // One rating store for the whole view: the grid cards and the detail pane read and write the same state, so a vote or a reason
709
+ // entered in either shows in both. Reason then vote, or vote then reason, both end up on one decision.
710
+ const sectionChoices = useRef<Map<string, string | null>>(new Map());
711
+ const referenceById = (lineageId: string) => selectedReferenceRecordByLineage(canonicalReferences, lineageId);
712
+ const ratings = useRatingStore({
713
+ save: async (lineageId, choice, detail) => {
714
+ const reference = referenceById(lineageId);
715
+ if (!reference) throw new Error("This reference is no longer in the list.");
716
+ markVisitRated(lineageId);
717
+ const decision = decisions?.get(lineageId);
718
+ const sectionId = choice === "accept" ? sectionChoices.current.get(lineageId) : null;
719
+ await onReview(reference, choice, {
720
+ note: detail.note || undefined,
721
+ reasonCode: choice === "reject" ? detail.reasonCode : null,
722
+ ...(sectionId ? { sectionId } : {}),
723
+ ...(decision ? { decisionRelationId: decision.decisionRelationId, shareable: false } : {}),
724
+ });
725
+ },
726
+ saved: (lineageId) => {
727
+ const status = normalizeReferenceCurationStatus(referenceById(lineageId)?.curationStatus);
728
+ return status === "accepted" ? "accept" : status === "rejected" ? "reject" : null;
729
+ },
730
+ seed: (lineageId) => {
731
+ const reference = referenceById(lineageId);
732
+ const status = normalizeReferenceCurationStatus(reference?.curationStatus);
733
+ return {
734
+ note: reference && status !== "pending" ? reference.curationStatusReason ?? "" : "",
735
+ reasonCode: reviewReasons?.get(lineageId) ?? (reference ? referenceReasonCode(reference) : null) ?? null,
736
+ };
737
+ },
738
+ });
739
+ const { statuses: captureStatuses, teaserLine } = useMemo(
740
+ () => captureStatusesByLineage(referenceAttachments, canonicalReferences.map(referenceLineageId)),
741
+ [canonicalReferences, referenceAttachments],
742
+ );
743
+ // Preview only: a fixture can carry captured text inline; say so, since production stores none for monitored References.
744
+ const previewCapturedText = useMemo(
745
+ () => isLocalPreviewEnabled() && referenceAttachments.some((attachment) => attachment.role === "extracted_text" && String(attachment.storagePath ?? "").startsWith("preview://")),
746
+ [referenceAttachments],
747
+ );
748
+ const showCaptures = !showingSavedCopy && !loading && !previewSkeleton;
674
749
  const feedDisabledReason = disabled
675
750
  ? "Editor access is required to change this reference."
676
751
  : showingSavedCopy ? "Confirming your sign-in. You can answer in a moment." : null;
@@ -689,9 +764,14 @@ export function NewsroomReferencesView({
689
764
  ? "No references match this filter."
690
765
  : "No private references imported yet.";
691
766
 
767
+ // The grid must not move when a detail opens, so in the feed the detail pane's place is reserved all the time (from the md breakpoint
768
+ // up): opening it changes nothing, and the grid's column count follows the width it has beside that place, never the selection. Below
769
+ // md the detail is a sheet over the grid. There is no overlay mode: an overlay would hide the cards Previous / Next scrolls to.
770
+
692
771
  return (
693
772
  <div
694
773
  className="flex min-h-0 flex-1 flex-col gap-4 md:max-h-[calc(100dvh-8.5rem)] md:flex-row"
774
+ data-detail-reserved={view === "feed" ? "true" : undefined}
695
775
  data-news-desk-section="references"
696
776
  data-detail-open={selectedReference || routeLineageId ? "true" : "false"}
697
777
  >
@@ -744,6 +824,11 @@ export function NewsroomReferencesView({
744
824
  Preview only: {synthetic.changed} of these predictions are made up to show the Reject end of the feed. Real data has none yet.
745
825
  </p>
746
826
  ) : null}
827
+ {previewCapturedText ? (
828
+ <p className="rounded-2xl bg-[var(--feed-card)] p-3 text-sm" data-preview-captured-text role="status" {...FEED_SURFACE_PROPS}>
829
+ Preview only: the captured text here was fetched live for this preview. In the newsroom record none of these References has stored text yet.
830
+ </p>
831
+ ) : null}
747
832
  {predictionState === "loading" && view === "list" ? <p role="status">Loading predictions…</p> : null}
748
833
  {predictionState === "error" ? (
749
834
  <div role="alert" className="space-y-2 rounded-2xl bg-[var(--feed-card)] p-4" {...FEED_SURFACE_PROPS}>
@@ -773,7 +858,21 @@ export function NewsroomReferencesView({
773
858
  </div>
774
859
 
775
860
  {view === "feed" ? (
776
- <ScrollArea className="min-h-0 flex-1" data-reference-feed-scroll>
861
+ <div className="relative flex min-h-0 flex-1 flex-col" {...FEED_SURFACE_PROPS}>
862
+ {stable && stable.newCount > 0 && feedTab === "feed" ? (
863
+ // Floats over the top of the grid, outside the scrolling content: announcing new items never moves a card.
864
+ <div className="pointer-events-none absolute inset-x-0 top-2 z-20 flex justify-center">
865
+ <button
866
+ className="pointer-events-auto inline-flex min-h-9 items-center rounded-full bg-[var(--feed-control)] px-4 text-sm font-medium text-foreground shadow-lg outline-none focus-visible:ring-2 focus-visible:ring-ring"
867
+ data-feed-new-items
868
+ onClick={showNewItems}
869
+ type="button"
870
+ >
871
+ {stable.newCount} new {stable.newCount === 1 ? "item" : "items"}: show
872
+ </button>
873
+ </div>
874
+ ) : null}
875
+ <ScrollArea className="min-h-0 flex-1 [overflow-anchor:none]" data-reference-feed-scroll>
777
876
  <div className="pb-6 pr-1">
778
877
  <ReferenceFeed
779
878
  disabledReason={feedDisabledReason}
@@ -786,6 +885,11 @@ export function NewsroomReferencesView({
786
885
  partition={partition}
787
886
  predictionLoading={predictionState === "loading"}
788
887
  previews={previews}
888
+ captures={showCaptures ? captureStatuses : undefined}
889
+ groups={stable?.groups}
890
+ leadId={stable?.leadId}
891
+ scrollTarget={scrollTarget}
892
+ ratings={ratings}
789
893
  selectedLineageId={selectedLineageId}
790
894
  stale={showingSavedCopy || (loading && references.length > 0)}
791
895
  tab={feedTab}
@@ -793,6 +897,7 @@ export function NewsroomReferencesView({
793
897
  />
794
898
  </div>
795
899
  </ScrollArea>
900
+ </div>
796
901
  ) : (
797
902
  <>
798
903
  <Tabs
@@ -877,7 +982,6 @@ export function NewsroomReferencesView({
877
982
  <div
878
983
  className={cn(
879
984
  "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
985
  )}
882
986
  data-newsroom-list-detail-shell
883
987
  data-news-desk-section="references"
@@ -893,6 +997,10 @@ export function NewsroomReferencesView({
893
997
  {selectedReference ? (
894
998
  <ReferenceDetailPanel
895
999
  key={referenceLineageId(selectedReference)}
1000
+ captureTeaserLine={teaserLine}
1001
+ correctCapture={onCorrectCapture}
1002
+ ratings={ratings}
1003
+ sectionChoices={sectionChoices}
896
1004
  predictionState={predictionState}
897
1005
  reviewReason={reviewReasons?.get(referenceLineageId(selectedReference))}
898
1006
  reviewSectionId={reviewSectionIds?.get(referenceLineageId(selectedReference))}
@@ -935,6 +1043,10 @@ export function NewsroomReferencesView({
935
1043
  <div className="min-w-0" data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}><ScrollArea className="max-h-[70dvh]">
936
1044
  <ReferenceDetailPanel
937
1045
  key={referenceLineageId(selectedReference)}
1046
+ captureTeaserLine={teaserLine}
1047
+ correctCapture={onCorrectCapture}
1048
+ ratings={ratings}
1049
+ sectionChoices={sectionChoices}
938
1050
  predictionState={predictionState}
939
1051
  reviewReason={reviewReasons?.get(referenceLineageId(selectedReference))}
940
1052
  reviewSectionId={reviewSectionIds?.get(referenceLineageId(selectedReference))}