@anthusai/papyrus 5.43.0-next.2 → 5.43.0-next.21

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 (85) 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/category-action/handler.ts +2 -1
  6. package/amplify/functions/content-actions/handler.py +8 -0
  7. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  8. package/amplify/functions/monitoring-view/schema.js +16 -0
  9. package/amplify/functions/monitoring-view/schema.ts +16 -0
  10. package/amplify/functions/procedure-action/handler.ts +2 -10
  11. package/amplify/functions/proposal-actions/proposals.py +20 -3
  12. package/amplify/plugins/reader-steering/function/handler.py +1 -3
  13. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  14. package/bin/papyrus-app.mjs +12 -1
  15. package/bin/papyrus-infra.mjs +21 -1
  16. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  17. package/components/monitoring-overview-view.tsx +143 -21
  18. package/components/monitoring-panel.tsx +64 -4
  19. package/components/needs-me-bodies.tsx +290 -0
  20. package/components/needs-me-detail.tsx +23 -0
  21. package/components/needs-me-feed.tsx +561 -0
  22. package/components/needs-me-reference-detail.tsx +77 -0
  23. package/components/needs-me-shared.tsx +39 -0
  24. package/components/news-desk-client-provider.tsx +24 -3
  25. package/components/news-desk-page.tsx +2 -1
  26. package/components/news-desk-taxonomy-client.ts +2 -1
  27. package/components/newsroom-access-gate.tsx +7 -5
  28. package/components/newsroom-client-shell.tsx +2 -1
  29. package/components/newsroom-ops-overview.tsx +64 -55
  30. package/components/newsroom-ops-shell.tsx +4 -2
  31. package/components/newsroom-organizer.css +60 -2
  32. package/components/newsroom-references-view.tsx +301 -138
  33. package/components/notification-cards.tsx +369 -0
  34. package/components/notification-center.tsx +324 -213
  35. package/components/papyrus-console-shell.tsx +2 -1
  36. package/components/proposals-panel.tsx +21 -24
  37. package/components/proposals-view.tsx +4 -2
  38. package/components/reference-capture-client.ts +110 -0
  39. package/components/reference-capture-panel.tsx +180 -0
  40. package/components/reference-feed.tsx +503 -0
  41. package/components/reference-review-card.tsx +110 -141
  42. package/components/references-page-loading.tsx +61 -0
  43. package/components/references-prefetch.ts +52 -0
  44. package/components/review-rating-grid.tsx +180 -0
  45. package/components/topic-steering-workspace.tsx +44 -9
  46. package/components/use-media-query.ts +16 -0
  47. package/components/use-reference-preview-attachments.ts +101 -0
  48. package/components/use-reference-ratings.ts +134 -0
  49. package/infra/amplify-app-shell.js +18 -2
  50. package/infra/amplify-app-shell.ts +18 -2
  51. package/infra/chat-stack.js +222 -0
  52. package/infra/chat-stack.ts +250 -0
  53. package/infra/chat.test.js +142 -0
  54. package/infra/chat.test.ts +151 -0
  55. package/infra/index.js +1 -0
  56. package/infra/site-config.js +58 -1
  57. package/infra/site-config.ts +78 -1
  58. package/lib/category-repository.ts +6 -1
  59. package/lib/content-actions-client.ts +18 -0
  60. package/lib/local-preview-feed-fixture.ts +63 -0
  61. package/lib/local-preview-synthetic-rejects.ts +41 -0
  62. package/lib/monitoring-client.ts +29 -2
  63. package/lib/monitoring-overview.ts +52 -1
  64. package/lib/monitoring-paging.ts +58 -0
  65. package/lib/needs-me-items.ts +289 -0
  66. package/lib/needs-me-ranking.ts +315 -0
  67. package/lib/newsroom-chat-identity.ts +2 -1
  68. package/lib/newsroom-nav.ts +1 -1
  69. package/lib/newsroom-organizer-nav.ts +2 -1
  70. package/lib/notification-feed.ts +212 -0
  71. package/lib/proposals-client.ts +29 -0
  72. package/lib/reader-subject.ts +3 -2
  73. package/lib/reference-capture.ts +293 -0
  74. package/lib/reference-feed-cache.ts +170 -0
  75. package/lib/reference-feed-ranking.ts +235 -0
  76. package/lib/reference-preview.ts +112 -0
  77. package/lib/review-client.ts +55 -5
  78. package/lib/review-drafts.ts +252 -0
  79. package/lib/review-fixture.ts +56 -2
  80. package/lib/review-requests.ts +64 -24
  81. package/lib/site-brand.ts +2 -0
  82. package/lib/staff-groups.ts +23 -0
  83. package/lib/staging-gate.ts +2 -2
  84. package/package.json +29 -1
  85. package/routes.manifest.json +1 -1
@@ -94,6 +94,10 @@ import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
94
94
  import { ReferenceSourcePreview } from "./reference-source-preview";
95
95
  import type { ReaderAuthSnapshot } from "./reader-auth-state";
96
96
  import { NewsDeskAccessGate } from "./newsroom-access-gate";
97
+ import { correctReferenceCapture } from "../lib/content-actions-client";
98
+ import { loadMessagesWithHeadStart, loadReferencesWithHeadStart, loadRelationsWithHeadStart } from "./references-prefetch";
99
+ import { loadFeedSnapshot, saveFeedSnapshot, type FeedSnapshot } from "../lib/reference-feed-cache";
100
+ import { referencePreviewsByLineage } from "../lib/reference-preview";
97
101
  import { NewsroomOpsOverview } from "./newsroom-ops-overview";
98
102
  import { NewsroomCurationBootstrap } from "./newsroom-curation-bootstrap";
99
103
  import { NewsroomReferencesView } from "./newsroom-references-view";
@@ -696,7 +700,7 @@ export function NewsDeskWorkspace({
696
700
  }
697
701
 
698
702
  if (dashboard?.isPublicSkeleton && session?.shell.phase !== "ready") {
699
- return <NewsDeskAccessGate shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
703
+ return <NewsDeskAccessGate initialTab={initialTab} shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
700
704
  }
701
705
 
702
706
  if (dashboard) {
@@ -716,7 +720,7 @@ export function NewsDeskWorkspace({
716
720
  );
717
721
  }
718
722
 
719
- return <NewsDeskAccessGate shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
723
+ return <NewsDeskAccessGate initialTab={initialTab} shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
720
724
  }
721
725
 
722
726
  function NewsDeskDashboard({
@@ -777,9 +781,16 @@ function NewsDeskDashboard({
777
781
  const [referenceAttachments, setReferenceAttachments] = useState(dashboard.referenceAttachments);
778
782
  const [messages, setMessages] = useState(dashboard.messages);
779
783
  const [semanticRelations, setSemanticRelations] = useState(dashboard.semanticRelations);
784
+ const [savedFeedCopy, setSavedFeedCopy] = useState<Pick<FeedSnapshot, "references" | "previews" | "decisions"> | null>(null);
785
+ useEffect(() => {
786
+ // Display only: the last view this browser saved, shown until the live read arrives.
787
+ if (dashboard.isDemo) return;
788
+ const snapshot = loadFeedSnapshot(SITE_BRAND.id);
789
+ if (snapshot) setSavedFeedCopy(snapshot);
790
+ }, [dashboard.isDemo]);
780
791
  const cyclotronPreviewFixtures = useMemo(
781
- () => (isLocalPreviewEnabled() ? createLocalPreviewCyclotronFixtures(references) : null),
782
- [references],
792
+ () => (isLocalPreviewEnabled() && !semanticRelations.some((relation) => relation.predicate === "relevance_decision_is") ? createLocalPreviewCyclotronFixtures(references) : null),
793
+ [references, semanticRelations],
783
794
  );
784
795
  const relevanceDecisions = useMemo(
785
796
  () => currentRelevanceDecisions(cyclotronPreviewFixtures ? [...semanticRelations, ...cyclotronPreviewFixtures.relations] : semanticRelations),
@@ -1215,7 +1226,7 @@ function NewsDeskDashboard({
1215
1226
  useEffect(() => {
1216
1227
  if (!canLoadPrivateCorpus || hasHydratedReferences) return;
1217
1228
  let active = true;
1218
- void loadEditorReferencesData()
1229
+ void loadReferencesWithHeadStart()
1219
1230
  .then(({ references: nextReferences, referenceAttachments }) => {
1220
1231
  if (!active) return;
1221
1232
  setReferences(nextReferences);
@@ -1322,12 +1333,12 @@ function NewsDeskDashboard({
1322
1333
  refreshNewsroomSections,
1323
1334
  ]);
1324
1335
 
1325
- const needsReferenceReviewData = activeTab === "references" || activeTab === "assignments" || activeTab === "messages";
1336
+ const needsReferenceReviewData = activeTab === "overview" || activeTab === "references" || activeTab === "assignments" || activeTab === "messages";
1326
1337
  const selectedReviewLineageId = pathname ? pathnameReferenceLineageId ?? "" : initialSelection.reference ?? "";
1327
1338
  const referenceReviewData = useReferenceReviewData({
1328
1339
  enabled: canLoadPrivateCorpus && needsReferenceReviewData && !(activeTab === "references" && selectedReviewLineageId),
1329
- loadRelations: loadEditorSemanticRelationsData,
1330
- loadMessages: loadEditorMessagesData,
1340
+ loadRelations: loadRelationsWithHeadStart,
1341
+ loadMessages: loadMessagesWithHeadStart,
1331
1342
  onRelations: setSemanticRelations,
1332
1343
  onMessages: setMessages,
1333
1344
  });
@@ -1351,6 +1362,18 @@ function NewsDeskDashboard({
1351
1362
  ? selectedReviewRead.lineageId === selectedReviewLineageId ? selectedReviewRead.state : "loading"
1352
1363
  : referenceReviewData.state;
1353
1364
  const predictionError = selectedReviewIsCurrent ? selectedReviewRead.error : referenceReviewData.error;
1365
+ useEffect(() => {
1366
+ // Keep a display copy for the next visit once everything the feed shows has been read.
1367
+ if (dashboard.isDemo || (activeTab !== "references" && activeTab !== "overview") || !hasHydratedReferences || predictionState !== "ready" || !references.length) return;
1368
+ const timer = window.setTimeout(() => {
1369
+ saveFeedSnapshot(SITE_BRAND.id, {
1370
+ references: selectCanonicalReferenceRecords(references),
1371
+ previews: referencePreviewsByLineage(referenceAttachments),
1372
+ decisions: relevanceDecisions,
1373
+ });
1374
+ }, 800);
1375
+ return () => window.clearTimeout(timer);
1376
+ }, [activeTab, dashboard.isDemo, hasHydratedReferences, predictionState, referenceAttachments, references, relevanceDecisions]);
1354
1377
  const retryReferenceReviewData = () => { referenceReviewData.retry(); setSelectedReviewAttempt((current) => current + 1); };
1355
1378
 
1356
1379
  const applyReferenceRecord = useCallback((nextReference: ReferenceRecord) => {
@@ -3035,7 +3058,7 @@ function NewsDeskDashboard({
3035
3058
  headerActions={(
3036
3059
  <>
3037
3060
  {dashboard.isDemo ? (
3038
- <Badge variant="outline">Demo</Badge>
3061
+ dashboard.realDataPreview ? null : <Badge variant="outline">Demo</Badge>
3039
3062
  ) : (
3040
3063
  <Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }))} href="/settings">Settings</Link>
3041
3064
  )}
@@ -3081,9 +3104,18 @@ function NewsDeskDashboard({
3081
3104
  assignments={assignments}
3082
3105
  decisions={relevanceDecisions}
3083
3106
  demo={Boolean(dashboard.isDemo)}
3107
+ disabledReason={controlsDisabled ? "Editor access is required to change this reference." : null}
3084
3108
  humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
3109
+ loading={!dashboard.isDemo && !hasHydratedReferences}
3085
3110
  newsroomSections={newsroomSections}
3111
+ notificationFixture={dashboard.notificationFixture ?? null}
3112
+ onCorrectCapture={dashboard.isDemo ? undefined : (input) => correctReferenceCapture(input)}
3113
+ onReview={(reference, action, options) => runReferenceCurationAction(reference, action, options?.note, options?.reasonCode,
3114
+ options?.decisionRelationId ? { decisionRelationId: options.decisionRelationId, shareable: options.shareable === true } : undefined)}
3115
+ predictionState={referenceReviewData.state}
3116
+ referenceAttachments={referenceAttachments}
3086
3117
  references={references}
3118
+ savedCopy={savedFeedCopy}
3087
3119
  />
3088
3120
  ) : null}
3089
3121
  {activeTab === "search" ? (
@@ -3182,6 +3214,9 @@ function NewsDeskDashboard({
3182
3214
  options?.sectionId)}
3183
3215
  referenceAttachments={referenceAttachments}
3184
3216
  references={references}
3217
+ loading={!dashboard.isDemo && !hasHydratedReferences}
3218
+ onCorrectCapture={dashboard.isDemo ? undefined : (input) => correctReferenceCapture(input)}
3219
+ savedCopy={savedFeedCopy}
3185
3220
  />
3186
3221
  </NewsroomCurationBootstrap>
3187
3222
  ) : null}
@@ -0,0 +1,16 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ /** True while the media query matches. False on the server and before the first effect, so the first paint is the wide layout. */
6
+ export function useMediaQuery(query: string): boolean {
7
+ const [matches, setMatches] = useState(false);
8
+ useEffect(() => {
9
+ const mediaQuery = window.matchMedia(query);
10
+ const update = () => setMatches(mediaQuery.matches);
11
+ update();
12
+ mediaQuery.addEventListener("change", update);
13
+ return () => mediaQuery.removeEventListener("change", update);
14
+ }, [query]);
15
+ return matches;
16
+ }
@@ -0,0 +1,101 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import type { ReferenceAttachmentRecord, ReferenceRecord } from "../lib/category-repository";
5
+ import { referenceLineageId } from "../lib/newsroom-references";
6
+ import { loadReferenceAttachmentsForLineageId, loadStoragePathUrl } from "./news-desk-taxonomy-client";
7
+
8
+ function normalizeReferenceDetailHttpUri(value: string | null | undefined): string | null {
9
+ const trimmed = value?.trim();
10
+ if (!trimmed) return null;
11
+ if (/^https?:\/\//i.test(trimmed)) return trimmed;
12
+ return null;
13
+ }
14
+
15
+ /** The attachments of one Reference (seeded, else read), with stored files turned into links. Shared by the References detail pane and the Needs me detail pane. */
16
+ export function useReferencePreviewAttachments(
17
+ reference: ReferenceRecord | null,
18
+ referenceAttachments: ReferenceAttachmentRecord[],
19
+ ) {
20
+ const lineageId = reference ? referenceLineageId(reference) : "";
21
+ const seededAttachments = useMemo(
22
+ () => referenceAttachments.filter((attachment) => attachment.referenceLineageId === lineageId),
23
+ [lineageId, referenceAttachments],
24
+ );
25
+ const [attachments, setAttachments] = useState<ReferenceAttachmentRecord[]>(seededAttachments);
26
+ const [attachmentLinksById, setAttachmentLinksById] = useState<Record<string, string>>({});
27
+
28
+ useEffect(() => {
29
+ setAttachments(seededAttachments);
30
+ }, [seededAttachments]);
31
+
32
+ useEffect(() => {
33
+ if (!lineageId) return;
34
+ if (seededAttachments.length > 0) return;
35
+ let active = true;
36
+ void loadReferenceAttachmentsForLineageId(lineageId)
37
+ .then((loaded) => {
38
+ if (!active || !loaded.length) return;
39
+ setAttachments(loaded);
40
+ })
41
+ .catch(() => undefined);
42
+ return () => {
43
+ active = false;
44
+ };
45
+ }, [lineageId, seededAttachments.length]);
46
+
47
+ useEffect(() => {
48
+ if (!attachments.length) {
49
+ setAttachmentLinksById({});
50
+ return;
51
+ }
52
+ let active = true;
53
+ const staticLinks = new Map<string, string>();
54
+ const storageLookups: Array<{ id: string; storagePath: string }> = [];
55
+
56
+ for (const attachment of attachments) {
57
+ if (attachment.storagePath) {
58
+ storageLookups.push({ id: attachment.id, storagePath: attachment.storagePath });
59
+ continue;
60
+ }
61
+ const sourceHref = normalizeReferenceDetailHttpUri(attachment.sourceUri);
62
+ if (sourceHref) staticLinks.set(attachment.id, sourceHref);
63
+ }
64
+
65
+ if (!storageLookups.length) {
66
+ setAttachmentLinksById(Object.fromEntries(staticLinks.entries()));
67
+ return () => {
68
+ active = false;
69
+ };
70
+ }
71
+
72
+ setAttachmentLinksById(Object.fromEntries(staticLinks.entries()));
73
+ void Promise.all(storageLookups.map(async (lookup) => {
74
+ const result = await loadStoragePathUrl(lookup.storagePath);
75
+ return { id: lookup.id, url: result.url };
76
+ }))
77
+ .then((resolved) => {
78
+ if (!active) return;
79
+ const nextLinks = Object.fromEntries(staticLinks.entries());
80
+ for (const entry of resolved) {
81
+ if (entry.url) nextLinks[entry.id] = entry.url;
82
+ }
83
+ setAttachmentLinksById(nextLinks);
84
+ })
85
+ .catch(() => undefined);
86
+
87
+ return () => {
88
+ active = false;
89
+ };
90
+ }, [attachments]);
91
+
92
+ const previewAttachments = useMemo(
93
+ () => attachments.map((attachment) => ({
94
+ ...attachment,
95
+ sourceUri: attachmentLinksById[attachment.id] ?? attachment.sourceUri,
96
+ })),
97
+ [attachmentLinksById, attachments],
98
+ );
99
+
100
+ return previewAttachments;
101
+ }
@@ -0,0 +1,134 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import type { ReferenceRejectionReasonCode } from "../lib/reference-policy";
5
+ import type { ReviewChoice } from "./reference-review-card";
6
+ import type { RatingFeedback } from "./review-rating-grid";
7
+
8
+ /** How long after the last keystroke a typed reason is saved on its own. */
9
+ export const NOTE_AUTOSAVE_MS = 1200;
10
+
11
+ export type ItemRating = {
12
+ /** The reason as typed, else as saved. In the store itself it stays undefined until touched, so an untouched field follows the record. */
13
+ note?: string;
14
+ reasonCode?: ReferenceRejectionReasonCode | null;
15
+ /** A vote that is waiting for a reason (Reject needs one) or is still saving. */
16
+ pending: ReviewChoice | null;
17
+ /** True while a Reject is waiting for its reason. */
18
+ askReason: boolean;
19
+ feedback: RatingFeedback;
20
+ };
21
+
22
+ const EMPTY: ItemRating = { pending: null, askReason: false, feedback: { phase: "idle" } };
23
+
24
+ export type RatingSave = (id: string, choice: ReviewChoice, detail: { note: string; reasonCode: ReferenceRejectionReasonCode | null }) => Promise<void> | void;
25
+
26
+ export type RatingController = {
27
+ get: (id: string) => ItemRating;
28
+ choose: (id: string, choice: ReviewChoice) => void;
29
+ setReason: (id: string, code: ReferenceRejectionReasonCode) => void;
30
+ setNote: (id: string, text: string) => void;
31
+ /** Save a typed reason now (on blur). */
32
+ commitNote: (id: string) => void;
33
+ cancelReason: (id: string) => void;
34
+ };
35
+
36
+ /**
37
+ * One store for the rating state of every item, shared by the grid card and the detail pane so a vote or a reason typed in either
38
+ * shows in both at once. `saved(id)` is the saved human answer; `seed(id)` the saved note and reason code.
39
+ * vote, then reason -> the reason is saved onto the existing decision (on blur, or after a short pause)
40
+ * reason, then vote -> the vote carries the reason
41
+ * A Reject needs a reason code (the backend refuses one without), so it waits as `pending` until one is chosen; a typed reason is
42
+ * kept in the store until it is saved and is never dropped by a failed save.
43
+ */
44
+ export function useRatingStore({ save, saved, seed, needsReason }: {
45
+ save: RatingSave;
46
+ saved: (id: string) => ReviewChoice | null;
47
+ seed: (id: string) => { note: string; reasonCode: ReferenceRejectionReasonCode | null };
48
+ /** Whether a Reject on this item must wait for a reason code. Default yes (a Reference); an AI draft or an email asks nothing. */
49
+ needsReason?: (id: string) => boolean;
50
+ }): RatingController {
51
+ const [items, setItems] = useState<Record<string, ItemRating>>({});
52
+ const itemsRef = useRef(items);
53
+ const latest = useRef({ save, saved, seed, needsReason });
54
+ latest.current = { save, saved, seed, needsReason };
55
+ const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
56
+ const inFlight = useRef<Set<string>>(new Set());
57
+ const dirty = useRef<Set<string>>(new Set());
58
+
59
+ const patch = useCallback((id: string, change: Partial<ItemRating>) => {
60
+ const next = { ...itemsRef.current, [id]: { ...(itemsRef.current[id] ?? EMPTY), ...change } };
61
+ itemsRef.current = next;
62
+ setItems(next);
63
+ }, []);
64
+
65
+ // What a reader of the store sees: the touched value, else what is saved. (A saved reason therefore shows on the card too.)
66
+ const get = useCallback((id: string): ItemRating => {
67
+ const item = items[id] ?? EMPTY;
68
+ const seeded = latest.current.seed(id);
69
+ return { ...item, note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
70
+ }, [items]);
71
+
72
+ const values = (id: string) => {
73
+ const item = itemsRef.current[id] ?? EMPTY;
74
+ const seeded = latest.current.seed(id);
75
+ return { note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
76
+ };
77
+
78
+ const commit = useCallback(async (id: string, choice: ReviewChoice) => {
79
+ const { note, reasonCode } = values(id);
80
+ if (choice === "reject" && !reasonCode && (latest.current.needsReason?.(id) ?? true)) { patch(id, { pending: "reject", askReason: true }); return; }
81
+ if (inFlight.current.has(id)) { dirty.current.add(id); return; }
82
+ inFlight.current.add(id);
83
+ dirty.current.delete(id);
84
+ const started = itemsRef.current[id] ?? EMPTY;
85
+ patch(id, { pending: choice, askReason: false, feedback: { phase: "saving" } });
86
+ try {
87
+ await latest.current.save(id, choice, { note: note.trim(), reasonCode: choice === "reject" ? reasonCode : null });
88
+ // What was saved now follows the record again (another editor's update shows); anything typed meanwhile is kept.
89
+ const now = itemsRef.current[id] ?? EMPTY;
90
+ patch(id, { pending: null, feedback: { phase: "saved" }, ...(now.note === started.note ? { note: undefined } : {}), ...(now.reasonCode === started.reasonCode ? { reasonCode: undefined } : {}) });
91
+ } catch (failure) {
92
+ // The typed reason stays in the store; only the vote is undone.
93
+ patch(id, { pending: null, feedback: { phase: "error", message: failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry." } });
94
+ } finally {
95
+ inFlight.current.delete(id);
96
+ }
97
+ if (dirty.current.has(id)) { dirty.current.delete(id); const again = itemsRef.current[id]?.pending ?? latest.current.saved(id); if (again) void commit(id, again); }
98
+ }, [patch]);
99
+
100
+ const effective = (id: string): ReviewChoice | null => itemsRef.current[id]?.pending ?? latest.current.saved(id);
101
+
102
+ const choose = useCallback((id: string, choice: ReviewChoice) => {
103
+ if (effective(id) === choice && !itemsRef.current[id]?.askReason) return; // the chosen answer never unselects itself
104
+ void commit(id, choice);
105
+ }, [commit]);
106
+
107
+ const setReason = useCallback((id: string, code: ReferenceRejectionReasonCode) => {
108
+ patch(id, { reasonCode: code });
109
+ // Vote then reason: onto the existing (or waiting) decision. Reason then vote: stored, and the vote carries it.
110
+ if (effective(id) === "reject") void commit(id, "reject");
111
+ }, [commit, patch]);
112
+
113
+ const setNote = useCallback((id: string, text: string) => {
114
+ patch(id, { note: text, feedback: { phase: "idle" } });
115
+ dirty.current.add(`note:${id}`);
116
+ clearTimeout(timers.current[id]);
117
+ timers.current[id] = setTimeout(() => {
118
+ const choice = effective(id);
119
+ if (choice && dirty.current.delete(`note:${id}`)) void commit(id, choice);
120
+ }, NOTE_AUTOSAVE_MS);
121
+ }, [commit, patch]);
122
+
123
+ const commitNote = useCallback((id: string) => {
124
+ clearTimeout(timers.current[id]);
125
+ const choice = effective(id);
126
+ if (choice && dirty.current.delete(`note:${id}`)) void commit(id, choice);
127
+ }, [commit]);
128
+
129
+ const cancelReason = useCallback((id: string) => patch(id, { pending: null, askReason: false }), [patch]);
130
+
131
+ useEffect(() => () => { for (const timer of Object.values(timers.current)) clearTimeout(timer); }, []);
132
+
133
+ return { get, choose, setReason, setNote, commitNote, cancelReason };
134
+ }
@@ -6,10 +6,26 @@ import { addCloudEmail } from "./cloud-email.js";
6
6
  import { parseCloudEmail } from "./cloud-email-config.js";
7
7
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
8
8
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
9
- import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
9
+ import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveChatStackName, chatExportName, CHAT_EXPORTED_RESOURCES, CHAT_ENVIRONMENT_NAMES, CHAT_PRIVATE_ASSISTANT_ID, CHAT_PRIVATE_ASSISTANT_NAME, resolveStoragePreviewPrefix } from "./site-config.js";
10
10
  function environmentVariables(variables) {
11
11
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
12
12
  }
13
+ /**
14
+ * The production branch's private chat environment. Only a site with an enabled `chat` block gets the flag and the
15
+ * resource names (imported from the chat data stack's exports, so that stack must be deployed first); every other
16
+ * site keeps the flag "false" and no chat variables, and its newsroom serves neither the chat route nor the UI.
17
+ */
18
+ function chatEnvironment(config) {
19
+ const chatStackName = resolveChatStackName(config);
20
+ if (!chatStackName)
21
+ return { PAPYRUS_PRIVATE_CHAT_ENABLED: "false" };
22
+ return {
23
+ PAPYRUS_PRIVATE_CHAT_ENABLED: "true",
24
+ ...Object.fromEntries(CHAT_EXPORTED_RESOURCES.map((resource) => [CHAT_ENVIRONMENT_NAMES[resource], Fn.importValue(chatExportName(chatStackName, resource))])),
25
+ PAPYRUS_CHAT_PRIVATE_BOT_ID: CHAT_PRIVATE_ASSISTANT_ID,
26
+ PAPYRUS_CHAT_PRIVATE_BOT_NAME: CHAT_PRIVATE_ASSISTANT_NAME,
27
+ };
28
+ }
13
29
  function readerCustomHeaders(rules) {
14
30
  if (!rules)
15
31
  return undefined;
@@ -199,7 +215,7 @@ export class AmplifyAppShellStack extends Stack {
199
215
  environmentVariables: environmentVariables({
200
216
  ...config.cms.environment,
201
217
  ...emailEnvironment,
202
- PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false",
218
+ ...(config.chat?.enabled ? chatEnvironment(config) : { PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false" }),
203
219
  PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
204
220
  ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
205
221
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
@@ -7,12 +7,28 @@ import { addCloudEmail } from "./cloud-email";
7
7
  import { parseCloudEmail } from "./cloud-email-config";
8
8
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
9
9
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
10
- import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
10
+ import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveChatStackName, chatExportName, CHAT_EXPORTED_RESOURCES, CHAT_ENVIRONMENT_NAMES, CHAT_PRIVATE_ASSISTANT_ID, CHAT_PRIVATE_ASSISTANT_NAME, resolveStoragePreviewPrefix } from "./site-config";
11
11
 
12
12
  function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
13
13
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
14
14
  }
15
15
 
16
+ /**
17
+ * The production branch's private chat environment. Only a site with an enabled `chat` block gets the flag and the
18
+ * resource names (imported from the chat data stack's exports, so that stack must be deployed first); every other
19
+ * site keeps the flag "false" and no chat variables, and its newsroom serves neither the chat route nor the UI.
20
+ */
21
+ function chatEnvironment(config: AmplifyAppShellSiteConfig): Record<string, string> {
22
+ const chatStackName = resolveChatStackName(config);
23
+ if (!chatStackName) return { PAPYRUS_PRIVATE_CHAT_ENABLED: "false" };
24
+ return {
25
+ PAPYRUS_PRIVATE_CHAT_ENABLED: "true",
26
+ ...Object.fromEntries(CHAT_EXPORTED_RESOURCES.map((resource) => [CHAT_ENVIRONMENT_NAMES[resource], Fn.importValue(chatExportName(chatStackName, resource))])),
27
+ PAPYRUS_CHAT_PRIVATE_BOT_ID: CHAT_PRIVATE_ASSISTANT_ID,
28
+ PAPYRUS_CHAT_PRIVATE_BOT_NAME: CHAT_PRIVATE_ASSISTANT_NAME,
29
+ };
30
+ }
31
+
16
32
  function readerCustomHeaders(rules: ReaderCustomHeaderRule[] | undefined): string | undefined {
17
33
  if (!rules) return undefined;
18
34
  const lines = ["customHeaders:"];
@@ -221,7 +237,7 @@ export class AmplifyAppShellStack extends Stack {
221
237
  environmentVariables: environmentVariables({
222
238
  ...config.cms.environment,
223
239
  ...emailEnvironment,
224
- PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false",
240
+ ...(config.chat?.enabled ? chatEnvironment(config) : { PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false" }),
225
241
  PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
226
242
  ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
227
243
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,