@anthusai/papyrus 5.44.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 (48) 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/amplify/functions/model-attachment-upload/handler.ts +1 -0
  7. package/amplify/functions/monitoring-view/schema.js +8 -0
  8. package/amplify/functions/monitoring-view/schema.ts +8 -0
  9. package/bin/papyrus-app.mjs +12 -1
  10. package/components/monitoring-overview-view.tsx +52 -6
  11. package/components/monitoring-panel.tsx +61 -3
  12. package/components/news-desk-client-provider.tsx +16 -1
  13. package/components/news-desk-page.tsx +2 -1
  14. package/components/newsroom-access-gate.tsx +7 -5
  15. package/components/newsroom-client-shell.tsx +2 -1
  16. package/components/newsroom-ops-overview.tsx +63 -60
  17. package/components/newsroom-ops-shell.tsx +4 -2
  18. package/components/newsroom-organizer.css +49 -2
  19. package/components/newsroom-references-view.tsx +296 -23
  20. package/components/notification-cards.tsx +364 -0
  21. package/components/notification-center.tsx +319 -331
  22. package/components/reference-capture-client.ts +110 -0
  23. package/components/reference-capture-panel.tsx +180 -0
  24. package/components/reference-feed.tsx +502 -0
  25. package/components/reference-review-card.tsx +32 -80
  26. package/components/references-page-loading.tsx +61 -0
  27. package/components/references-prefetch.ts +52 -0
  28. package/components/review-rating-grid.tsx +177 -0
  29. package/components/topic-steering-workspace.tsx +43 -9
  30. package/components/use-reference-ratings.ts +132 -0
  31. package/lib/category-repository.ts +6 -1
  32. package/lib/content-actions-client.ts +18 -0
  33. package/lib/local-preview-feed-fixture.ts +63 -0
  34. package/lib/local-preview-synthetic-rejects.ts +41 -0
  35. package/lib/monitoring-client.ts +16 -2
  36. package/lib/monitoring-overview.ts +20 -1
  37. package/lib/monitoring-paging.ts +58 -0
  38. package/lib/newsroom-nav.ts +1 -1
  39. package/lib/newsroom-organizer-nav.ts +1 -1
  40. package/lib/notification-feed.ts +250 -0
  41. package/lib/reference-capture.ts +293 -0
  42. package/lib/reference-feed-cache.ts +170 -0
  43. package/lib/reference-feed-ranking.ts +235 -0
  44. package/lib/reference-preview.ts +112 -0
  45. package/lib/review-drafts.ts +8 -3
  46. package/lib/site-brand.ts +2 -0
  47. package/package.json +17 -1
  48. 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":
@@ -41,6 +41,7 @@ const ALLOWED_ROLES = new Set([
41
41
  "item_excerpt",
42
42
  "message_body",
43
43
  "metadata",
44
+ "preview",
44
45
  "ontology_concept_profile",
45
46
  "ontology_relation_explanation",
46
47
  "published_item_body",
@@ -14,6 +14,14 @@ export const monitoringViewSchemaDefinitions = {
14
14
  .returns(a.json())
15
15
  .authorization((allow) => [allow.groups(editors)])
16
16
  .handler(functionHandler("monitoringView", monitoringView)),
17
+ // One more page of one Monitoring list (runs, routerRuns, items, emailSubmissions), after a cursor from
18
+ // the overview or a previous page. filter "held" lists only held email submissions, restarting from the newest.
19
+ monitoringPage: a
20
+ .query()
21
+ .arguments({ list: a.string().required(), cursor: a.string(), filter: a.string(), scope: a.string() })
22
+ .returns(a.json())
23
+ .authorization((allow) => [allow.groups(editors)])
24
+ .handler(functionHandler("monitoringView", monitoringView)),
17
25
  monitoringEmailSubmission: a
18
26
  .query()
19
27
  .arguments({ receiptId: a.string().required() })
@@ -16,6 +16,14 @@ export const monitoringViewSchemaDefinitions = {
16
16
  .returns(a.json())
17
17
  .authorization((allow) => [allow.groups(editors)])
18
18
  .handler(functionHandler("monitoringView", monitoringView)),
19
+ // One more page of one Monitoring list (runs, routerRuns, items, emailSubmissions), after a cursor from
20
+ // the overview or a previous page. filter "held" lists only held email submissions, restarting from the newest.
21
+ monitoringPage: a
22
+ .query()
23
+ .arguments({ list: a.string().required(), cursor: a.string(), filter: a.string(), scope: a.string() })
24
+ .returns(a.json())
25
+ .authorization((allow) => [allow.groups(editors)])
26
+ .handler(functionHandler("monitoringView", monitoringView)),
19
27
  monitoringEmailSubmission: a
20
28
  .query()
21
29
  .arguments({ receiptId: a.string().required() })
@@ -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
  }
@@ -18,6 +18,7 @@ import {
18
18
  type EmailDecisionResult,
19
19
  type EmailQuarantine,
20
20
  type MonitoringItem,
21
+ type MonitoringListName,
21
22
  type MonitoringEmailDetails,
22
23
  type MonitoringEmailArticleResolution,
23
24
  type EmailReferenceImportItem,
@@ -30,6 +31,27 @@ import {
30
31
 
31
32
  export type MonitoringMode = "publication" | "all";
32
33
 
34
+ /** How a paged list loads more: the page owns the cursors and the requests, the view only shows the button. */
35
+ export type MonitoringPaging = {
36
+ /** Loads the next page of a list; the page appends it. */
37
+ onLoadMore?: (list: MonitoringListName) => void;
38
+ /** The list whose next page is being fetched. */
39
+ loadingList?: MonitoringListName | null;
40
+ /** Show only email submissions held for review (restarts the email list from its newest held message). */
41
+ heldOnly?: boolean;
42
+ onHeldOnlyChange?: (heldOnly: boolean) => void;
43
+ };
44
+
45
+ function LoadMore({ list, cursor, paging }: { list: MonitoringListName; cursor: string | null | undefined; paging?: MonitoringPaging }) {
46
+ if (!cursor || !paging?.onLoadMore) return null;
47
+ const loading = paging.loadingList === list;
48
+ return (
49
+ <button type="button" data-monitoring-load-more={list} disabled={loading} onClick={() => paging.onLoadMore?.(list)}>
50
+ {loading ? "Loading…" : "Load more"}
51
+ </button>
52
+ );
53
+ }
54
+
33
55
  const STATUS_WORDS: Record<string, string> = { complete: "Complete", partial: "Partial", stopped: "Stopped", failed: "Failed", error: "Error" };
34
56
  const status = (value: string) => STATUS_WORDS[value] ?? value;
35
57
  const STOP_WORDS: Record<string, string> = {
@@ -102,8 +124,9 @@ function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string
102
124
  );
103
125
  }
104
126
 
105
- export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates, onDecide }: {
127
+ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates, onDecide, cursor, paging }: {
106
128
  items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen?: (receiptId: string) => void;
129
+ cursor?: string | null; paging?: MonitoringPaging;
107
130
  onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
108
131
  onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
109
132
  onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
@@ -121,6 +144,7 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
121
144
  const receiptKey = receiptId ?? "";
122
145
  const quarantine = (detail && "submission" in detail ? decidedByReceipt[receiptKey] ?? detail.submission.quarantine : null);
123
146
  const accepted = emailSubmissionAccepted(quarantine);
147
+ const groupInfo = detail && "submission" in detail ? items.find((item) => item.receiptId === detail.submission.receiptId)?.group : undefined;
124
148
  const reason = reasonByReceipt[receiptKey] ?? "";
125
149
  const busyDecision = busyDecisionByReceipt[receiptKey] ?? false;
126
150
  const selected = selectedByReceipt[receiptKey] ?? {};
@@ -242,9 +266,23 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
242
266
  return (
243
267
  <article className="news-desk-doctrine-card" data-monitoring-section="email-submissions">
244
268
  <div className="news-desk-doctrine-card__header"><div><h3>Email submissions</h3><span>Untrusted source material held privately for editor review</span></div></div>
269
+ {paging?.onHeldOnlyChange ? (
270
+ <div className="news-desk-monitoring__scope" role="group" aria-label="Filter email submissions">
271
+ <button type="button" aria-pressed={!paging.heldOnly} data-email-filter="all" onClick={() => paging.onHeldOnlyChange?.(false)}>All</button>
272
+ <button type="button" aria-pressed={paging.heldOnly === true} data-email-filter="held" onClick={() => paging.onHeldOnlyChange?.(true)}>Held for review</button>
273
+ </div>
274
+ ) : null}
245
275
  {items.length ? <ul className="news-desk-monitoring__list">
246
276
  {items.map((item) => <li key={item.receiptId} data-email-submission={item.receiptId}>
247
277
  <strong>{item.subject || "(no subject)"}</strong>
278
+ {item.group ? <details data-email-group={item.group.fingerprint}>
279
+ <summary>Received {item.group.count} times · latest {formatUtc(item.observedAt)}</summary>
280
+ <ol>
281
+ {item.group.copies.map((copy) => <li key={copy.receiptId} data-email-copy={copy.receiptId}>
282
+ {formatUtc(copy.observedAt)} · {copy.quarantine ? QUARANTINE_LABELS[copy.quarantine.state] : copy.status}
283
+ </li>)}
284
+ </ol>
285
+ </details> : null}
248
286
  <span>{formatUtc(item.observedAt)} · {item.linkCount} candidate links · {item.reviewRequired
249
287
  ? `${item.linkCount} await editor import` : `${item.processedCount} queued`}</span>
250
288
  {item.reviewRequired ? <span>Authentication warnings: private review required; nothing was routed automatically.</span> : null}
@@ -256,7 +294,8 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
256
294
  {item.quarantine ? <span data-email-quarantine-state={item.quarantine.state}>{QUARANTINE_LABELS[item.quarantine.state]}</span> : null}
257
295
  {onOpen ? <button type="button" onClick={() => onOpen(item.receiptId)}>Read email and review candidate links</button> : null}
258
296
  </li>)}
259
- </ul> : <p>No email submissions are available for this publication.</p>}
297
+ </ul> : <p>{paging?.heldOnly ? "No email submissions are held for review." : "No email submissions are available for this publication."}</p>}
298
+ <LoadMore list="emailSubmissions" cursor={cursor} paging={paging} />
260
299
  {detail && "submission" in detail ? (
261
300
  <section aria-label="Email submission details" data-email-submission-details={detail.submission.receiptId}>
262
301
  <h4>{detail.submission.subject || "Submitted email"}</h4>
@@ -266,6 +305,7 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
266
305
  <h5>{QUARANTINE_LABELS[quarantine.state]}</h5>
267
306
  {quarantine.state === "quarantined" ? <>
268
307
  <p>This message was held privately and nothing was routed. Accept it to queue its links as if it had been accepted, or Reject it to close it. Sender identity and authentication signals never grant access; the decision is yours and is recorded.</p>
308
+ {groupInfo ? <p data-email-group-note>One decision applies to all {groupInfo.count} copies of this email.</p> : null}
269
309
  <label>Why? (optional)
270
310
  <textarea value={reason} rows={3} maxLength={500} style={{ display: "block", width: "100%" }}
271
311
  onChange={(event) => setReasonByReceipt((current) => ({ ...current, [receiptKey]: event.target.value }))} />
@@ -278,7 +318,7 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
278
318
  </button>
279
319
  </> : null}
280
320
  {quarantine.decision ? <p data-email-decision-record>
281
- {quarantine.decision.action === "accept" ? "Accepted" : "Rejected"}
321
+ {quarantine.decision.action === "accept" ? "Accepted" : "Rejected"}{quarantine.decision.viaGroup ? " (one decision for every copy of this email)" : ""}
282
322
  {quarantine.decision.by ? ` by ${quarantine.decision.by}` : ""}{quarantine.decision.at ? ` on ${formatUtc(quarantine.decision.at)}` : ""}
283
323
  {quarantine.decision.reason ? `. Why: ${quarantine.decision.reason}` : "."}
284
324
  </p> : null}
@@ -323,8 +363,8 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
323
363
  );
324
364
  }
325
365
 
326
- function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates, onDecide }: {
327
- data: MonitoringOverview; demo: boolean; emailDetail: MonitoringEmailDetails | null; onEmailOpen: (id: string) => void;
366
+ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates, onDecide, paging }: {
367
+ data: MonitoringOverview; demo: boolean; paging?: MonitoringPaging; emailDetail: MonitoringEmailDetails | null; onEmailOpen: (id: string) => void;
328
368
  onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
329
369
  onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
330
370
  onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
@@ -337,6 +377,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
337
377
  ) : null}
338
378
 
339
379
  <EmailSubmissions items={data.emailSubmissions ?? []} detail={emailDetail} onOpen={onEmailOpen}
380
+ cursor={data.cursors?.emailSubmissions} paging={paging}
340
381
  onResolveArticleLink={demo ? undefined : onResolveArticleLink}
341
382
  onImportCandidates={demo ? undefined : onImportCandidates}
342
383
  onDecide={demo ? undefined : onDecide} />
@@ -397,6 +438,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
397
438
  ))}
398
439
  </ul>
399
440
  ) : <p>No collector runs recorded yet.</p>}
441
+ <LoadMore list="runs" cursor={data.cursors?.runs} paging={paging} />
400
442
  {data.routerRuns.length ? (
401
443
  <ul className="news-desk-monitoring__list" data-monitoring-router-runs>
402
444
  {data.routerRuns.map((run) => (
@@ -409,6 +451,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
409
451
  ))}
410
452
  </ul>
411
453
  ) : null}
454
+ <LoadMore list="routerRuns" cursor={data.cursors?.routerRuns} paging={paging} />
412
455
  </article>
413
456
 
414
457
  <article className="news-desk-doctrine-card" data-monitoring-section="queue">
@@ -418,6 +461,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
418
461
  {data.items.map((item) => <QueueItem key={`${item.monitorId ?? ""}-${item.sourceId}-${item.revision}`} item={item} ownId={data.scope.publicationId} demo={demo} />)}
419
462
  </ul>
420
463
  ) : <p>No items in the queue.</p>}
464
+ <LoadMore list="items" cursor={data.cursors?.items} paging={paging} />
421
465
  {data.hiddenNotRelevant > 0 ? (
422
466
  <p className="news-desk-delegation-note" data-monitoring-hidden>
423
467
  {data.hiddenNotRelevant} {data.hiddenNotRelevant === 1 ? "item" : "items"} judged not relevant to this publication {data.hiddenNotRelevant === 1 ? "is" : "are"} not listed.
@@ -448,7 +492,9 @@ export function MonitoringOverviewView({
448
492
  onResolveArticleLink,
449
493
  onImportCandidates,
450
494
  onDecide,
495
+ paging,
451
496
  }: {
497
+ paging?: MonitoringPaging;
452
498
  result: MonitoringResult | null;
453
499
  canViewAll: boolean;
454
500
  mode: MonitoringMode;
@@ -475,7 +521,7 @@ export function MonitoringOverviewView({
475
521
  body = <Message kind="empty" title="Nothing has been collected yet">No feed is configured and no run has been recorded. Items appear here after the collector&apos;s first run.</Message>;
476
522
  } else {
477
523
  body = <Ready data={result} demo={demo} emailDetail={emailDetail} onEmailOpen={onEmailOpen}
478
- onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} onDecide={onDecide} />;
524
+ onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} onDecide={onDecide} paging={paging} />;
479
525
  }
480
526
  const scopeLine = result && "scope" in result
481
527
  ? (result.scope.mode === "all" ? "Showing the whole shared pipeline, every publication." : `Showing what is relevant to ${result.scope.publicationId ?? "this publication"}.`)
@@ -7,10 +7,18 @@ import {
7
7
  importMonitoringEmailCandidates,
8
8
  loadMonitoringEmailSubmission,
9
9
  loadMonitoringOverview,
10
+ loadMonitoringPage,
10
11
  resolveMonitoringEmailArticleLink,
11
12
  } from "../lib/monitoring-client";
12
13
  import { FIXTURE_ALL_PIPELINE_OVERVIEW, FIXTURE_MONITORING_OVERVIEW } from "../lib/monitoring-overview-fixture";
13
- import type { EmailReferenceImportItem, MonitoringEmailDetails, MonitoringResult } from "../lib/monitoring-overview";
14
+ import { withLoadedPages } from "../lib/monitoring-paging";
15
+ import type {
16
+ EmailReferenceImportItem,
17
+ MonitoringEmailDetails,
18
+ MonitoringListName,
19
+ MonitoringResult,
20
+ } from "../lib/monitoring-overview";
21
+ import type { MonitoringLoadedPages } from "../lib/monitoring-paging";
14
22
  import { EmailSubmissions, MonitoringOverviewView, type MonitoringMode } from "./monitoring-overview-view";
15
23
 
16
24
  const REFRESH_MILLISECONDS = 60_000;
@@ -50,6 +58,18 @@ function MonitoringOverviewPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo
50
58
  const [mode, setMode] = useState<MonitoringMode>("publication");
51
59
  const [result, setResult] = useState<MonitoringResult | null>(null);
52
60
  const [emailDetail, setEmailDetail] = useState<MonitoringEmailDetails | null>(null);
61
+ // Pages the editor loaded beyond the newest page of each list. The 60 second refresh re-reads only the newest
62
+ // page, so what was loaded stays on screen.
63
+ const [loaded, setLoaded] = useState<MonitoringLoadedPages>({});
64
+ const [heldOnly, setHeldOnly] = useState(false);
65
+ const [held, setHeld] = useState<MonitoringLoadedPages["emailSubmissions"] | null>(null);
66
+ const [loadingList, setLoadingList] = useState<MonitoringListName | null>(null);
67
+
68
+ useEffect(() => {
69
+ setLoaded({});
70
+ setHeldOnly(false);
71
+ setHeld(null);
72
+ }, [mode]);
53
73
 
54
74
  useEffect(() => {
55
75
  if (isDemo) {
@@ -80,13 +100,51 @@ function MonitoringOverviewPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo
80
100
  setEmailDetail(next);
81
101
  };
82
102
 
103
+ // Appends the next page of a list, or (held filter) the next page of held messages.
104
+ const loadMore = async (list: MonitoringListName) => {
105
+ if (isDemo || !result || !("scope" in result) || loadingList) return;
106
+ const current = withLoadedPages(result as never, loaded, heldOnly ? held : null);
107
+ const cursor = current.cursors?.[list];
108
+ if (!cursor) return;
109
+ setLoadingList(list);
110
+ try {
111
+ const page = await loadMonitoringPage(list, cursor, mode, heldOnly && list === "emailSubmissions");
112
+ if (!("rows" in page)) return;
113
+ if (heldOnly && list === "emailSubmissions") {
114
+ setHeld((existing) => ({ rows: [...(existing?.rows ?? []), ...page.rows] as never, cursor: page.cursor }));
115
+ } else {
116
+ setLoaded((existing) => ({ ...existing, [list]: { rows: [...(existing[list]?.rows ?? []), ...page.rows], cursor: page.cursor } }) as MonitoringLoadedPages);
117
+ }
118
+ } finally {
119
+ setLoadingList(null);
120
+ }
121
+ };
122
+
123
+ // Held for review restarts the email list at its newest held message, however old.
124
+ const changeHeldOnly = async (next: boolean) => {
125
+ setHeldOnly(next);
126
+ setHeld(null);
127
+ if (!next || isDemo) return;
128
+ setLoadingList("emailSubmissions");
129
+ try {
130
+ const page = await loadMonitoringPage("emailSubmissions", null, mode, true);
131
+ if ("rows" in page) setHeld({ rows: page.rows as never, cursor: page.cursor });
132
+ else setHeldOnly(false);
133
+ } finally {
134
+ setLoadingList(null);
135
+ }
136
+ };
137
+
138
+ const shown = result && "scope" in result ? withLoadedPages(result as never, loaded, heldOnly ? held ?? { rows: [], cursor: null } : null) : result;
139
+
83
140
  const resolveEmailArticleLink = (receiptId: string, candidateIndex: number) =>
84
141
  resolveMonitoringEmailArticleLink(receiptId, candidateIndex);
85
142
 
86
143
  const importEmailCandidates = (receiptId: string, candidates: EmailReferenceImportItem[]) =>
87
144
  importMonitoringEmailCandidates(receiptId, candidates);
88
145
 
89
- return <MonitoringOverviewView result={result} canViewAll={isAdmin} mode={mode} onModeChange={setMode} demo={isDemo}
146
+ return <MonitoringOverviewView result={shown} canViewAll={isAdmin} mode={mode} onModeChange={setMode} demo={isDemo}
90
147
  emailDetail={emailDetail} onEmailOpen={isDemo ? () => {} : openEmail} onResolveArticleLink={isDemo ? undefined : resolveEmailArticleLink}
91
- onImportCandidates={importEmailCandidates} onDecide={isDemo ? undefined : decideMonitoringEmailSubmission} />;
148
+ onImportCandidates={importEmailCandidates} onDecide={isDemo ? undefined : decideMonitoringEmailSubmission}
149
+ paging={isDemo ? undefined : { onLoadMore: (list) => void loadMore(list), loadingList, heldOnly, onHeldOnlyChange: (next) => void changeHeldOnly(next) }} />;
92
150
  }
@@ -6,6 +6,9 @@ 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, wantsReferencesHeadStart } from "./references-prefetch";
10
+ import { SITE_BRAND } from "../lib/site-brand";
11
+ import { clearSavedNewsroomDisplay, loadSessionDisplay, saveSessionDisplay } from "../lib/reference-feed-cache";
9
12
  import {
10
13
  loadEditorAssignmentsData,
11
14
  loadEditorDoctrineRecordsData,
@@ -75,7 +78,12 @@ export function NewsDeskClientProvider({
75
78
  ));
76
79
  return;
77
80
  }
78
- setShell((current) => beginAccessCheck(current));
81
+ // A recent confirmed session is shown (name, "loading" instead of "checking") while the real check runs again.
82
+ // Display only: the check below still decides access, and the backend authorizes every call.
83
+ const remembered = loadSessionDisplay(SITE_BRAND.id);
84
+ setShell((current) => (remembered && !current.dashboard
85
+ ? { ...beginAccessCheck(current), phase: "loadingDesk" as const, auth: { status: "signedIn" as const, label: remembered.label } }
86
+ : beginAccessCheck(current)));
79
87
 
80
88
  try {
81
89
  await ensureDevSandboxEditorSession(devSandboxEditorAuth);
@@ -88,6 +96,8 @@ export function NewsDeskClientProvider({
88
96
  if (bootstrapSequenceRef.current !== sequence) return;
89
97
 
90
98
  if (access.status === "signedOut") {
99
+ clearSavedNewsroomDisplay(SITE_BRAND.id);
100
+ discardReferencesHeadStart();
91
101
  setShell((current) => resolveSignedOut(current, access.auth));
92
102
  return;
93
103
  }
@@ -100,7 +110,10 @@ export function NewsDeskClientProvider({
100
110
  return;
101
111
  }
102
112
 
113
+ saveSessionDisplay(SITE_BRAND.id, { label: access.auth.label, isAdmin: access.isAdmin });
103
114
  setShell((current) => beginDeskLoad(current, access.auth));
115
+ // The References reads do not depend on the summary dashboard: start them now, side by side with it.
116
+ if (typeof window !== "undefined" && wantsReferencesHeadStart(window.location.pathname)) prefetchReferencesFeed();
104
117
 
105
118
  try {
106
119
  const dashboard = await loadEditorNewsDeskDashboard({ isAdmin: access.isAdmin });
@@ -183,6 +196,8 @@ export function NewsDeskClientProvider({
183
196
  const unsubscribe = Hub.listen("auth", ({ payload }) => {
184
197
  if (payload.event === "signedOut") {
185
198
  bootstrapSequenceRef.current += 1;
199
+ clearSavedNewsroomDisplay(SITE_BRAND.id);
200
+ discardReferencesHeadStart();
186
201
  setShell((current) => resolveSignedOut(current, { status: "signedOut", label: "Signed out" }));
187
202
  return;
188
203
  }
@@ -1,3 +1,4 @@
1
+ import { withReferenceFeedFixture } from "../lib/local-preview-feed-fixture";
1
2
  import { resolveNewsDeskTab } from "../lib/newsroom-section-routing";
2
3
  import { NewsDeskWorkspace, type NewsDeskTab } from "./topic-steering-workspace";
3
4
  import {
@@ -67,7 +68,7 @@ export async function NewsDeskPage({ section: routeSection, selectionPath, searc
67
68
  const analysisProfiles = await loadAnalysisProfileSummaries(getBrandAnalysisProfilesPath(brand));
68
69
  const configuredCorpora = await loadConfiguredCorpusSummaries(getBrandSteeringConfigPath(brand));
69
70
  const dashboard = useDemoDashboard
70
- ? createDemoCategorySteeringDashboard(brand.id, getBrandNewsroomSectionsConfigPath(brand))
71
+ ? withReferenceFeedFixture(createDemoCategorySteeringDashboard(brand.id, getBrandNewsroomSectionsConfigPath(brand)))
71
72
  : await loadCategorySteeringDashboard();
72
73
  return <NewsDeskWorkspace analysisProfiles={analysisProfiles} configuredCorpora={configuredCorpora} dashboard={dashboard} initialSelection={initialSelection} initialTab={initialTab} />;
73
74
  }
@@ -9,24 +9,26 @@ import { NewsroomOpsShell } from "./newsroom-ops-shell";
9
9
  import { NewsroomOverviewSkeleton } from "./newsroom-overview-skeleton";
10
10
  import { ReaderAuthControl } from "./reader-auth-control";
11
11
  import { buttonVariants } from "./ui/button";
12
+ import { ReferencesPageLoading } from "./references-page-loading";
12
13
 
13
- export function NewsDeskAccessGate({ shell, showSectionTabs = false }: { shell: NewsDeskShellState | null; showSectionTabs?: boolean }) {
14
+ export function NewsDeskAccessGate({ initialTab, shell, showSectionTabs = false }: { initialTab?: string; shell: NewsDeskShellState | null; showSectionTabs?: boolean }) {
14
15
  const accessPhase = shell?.phase ?? "checkingAccess";
15
16
  const pending = accessPhase === "checkingAccess" || accessPhase === "loadingDesk";
17
+ const referencesPage = initialTab === "references";
16
18
 
17
19
  return (
18
20
  <NewsroomOpsShell
19
- activeTab="overview"
21
+ activeTab={referencesPage ? "references" : "overview"}
20
22
  appTitle={SITE_BRAND.appTitle}
21
23
  backHref="/"
22
24
  backLabel={SITE_BRAND.backToHomeLabel}
23
25
  headerActions={(
24
26
  <Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }))} href="/settings">Settings</Link>
25
27
  )}
26
- pageTitle={showSectionTabs ? "Notification Center" : "Newsroom"}
27
- showNavigation={showSectionTabs}
28
+ pageTitle={referencesPage ? "References" : showSectionTabs ? "Notification Center" : "Newsroom"}
29
+ showNavigation={showSectionTabs || referencesPage}
28
30
  >
29
- {pending ? (showSectionTabs ? <NewsroomOverviewSkeleton /> : (
31
+ {pending && referencesPage ? <ReferencesPageLoading /> : pending ? (showSectionTabs ? <NewsroomOverviewSkeleton /> : (
30
32
  <div aria-label="Loading newsroom" className="space-y-4" data-newsroom-page-skeleton role="status">
31
33
  <div className="h-6 w-48 rounded bg-muted" />
32
34
  <div className="h-4 w-3/4 rounded bg-muted" />
@@ -2,13 +2,14 @@ import { NewsDeskClientProvider } from "./news-desk-client-provider";
2
2
  import { createDemoCategorySteeringDashboard } from "../lib/category-repository";
3
3
  import { resolveDevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
4
4
  import { isLocalPreviewEnabled } from "../lib/local-preview";
5
+ import { withReferenceFeedFixture } from "../lib/local-preview-feed-fixture";
5
6
  import { getBrandNewsroomSectionsConfigPath, resolveActiveSiteBrand } from "../lib/site-brand-tenant";
6
7
 
7
8
  export function NewsroomClientShell({ children }: Readonly<{ children: React.ReactNode }>) {
8
9
  const devSandboxEditorAuth = resolveDevSandboxEditorAuth();
9
10
  const activeBrand = resolveActiveSiteBrand();
10
11
  const localPreviewDashboard = isLocalPreviewEnabled()
11
- ? createDemoCategorySteeringDashboard(activeBrand.id, getBrandNewsroomSectionsConfigPath(activeBrand))
12
+ ? withReferenceFeedFixture(createDemoCategorySteeringDashboard(activeBrand.id, getBrandNewsroomSectionsConfigPath(activeBrand)))
12
13
  : null;
13
14
 
14
15
  return (