@anthusai/papyrus 5.43.0-next.4 → 5.43.0-next.41

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 (118) 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 +16 -8
  6. package/amplify/functions/content-actions/handler.py +8 -0
  7. package/amplify/functions/content-actions/requirements.txt +2 -0
  8. package/amplify/functions/content-actions/resource.js +1 -1
  9. package/amplify/functions/content-actions/resource.ts +1 -1
  10. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  11. package/amplify/functions/monitoring-view/schema.js +16 -0
  12. package/amplify/functions/monitoring-view/schema.ts +16 -0
  13. package/amplify/functions/procedure-action/handler.ts +2 -10
  14. package/amplify/functions/proposal-actions/proposals.py +20 -3
  15. package/amplify/plugins/reader-steering/function/handler.py +1 -3
  16. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  17. package/app/api/newsroom/source-frameable/route.ts +12 -0
  18. package/app/api/newsroom/source-icon/route.ts +20 -0
  19. package/app/newsroom/[section]/[...selection]/page.tsx +2 -0
  20. package/app/newsroom/[section]/page.tsx +1 -0
  21. package/bin/papyrus-app.mjs +12 -1
  22. package/bin/papyrus-infra.mjs +21 -1
  23. package/components/auto-accepted-badge.tsx +21 -0
  24. package/components/conversation-context.tsx +52 -0
  25. package/components/conversation-provider.tsx +20 -0
  26. package/components/conversation-thread.tsx +174 -0
  27. package/components/decision-control.tsx +306 -0
  28. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  29. package/components/markdown-context.tsx +412 -0
  30. package/components/monitoring-overview-view.tsx +143 -21
  31. package/components/monitoring-panel.tsx +64 -4
  32. package/components/needs-me-bodies.tsx +302 -0
  33. package/components/needs-me-detail.tsx +24 -0
  34. package/components/needs-me-feed.tsx +594 -0
  35. package/components/needs-me-reference-detail.tsx +79 -0
  36. package/components/needs-me-shared.tsx +48 -0
  37. package/components/news-desk-client-provider.tsx +24 -3
  38. package/components/news-desk-page.tsx +2 -1
  39. package/components/news-desk-taxonomy-client.ts +253 -31
  40. package/components/newsroom-access-gate.tsx +13 -20
  41. package/components/newsroom-board-skeleton.tsx +20 -0
  42. package/components/newsroom-board-view.tsx +2 -6
  43. package/components/newsroom-client-shell.tsx +2 -1
  44. package/components/newsroom-ops-overview.tsx +88 -73
  45. package/components/newsroom-ops-shell.tsx +8 -28
  46. package/components/newsroom-organizer.css +127 -3
  47. package/components/newsroom-references-view.tsx +334 -158
  48. package/components/notification-cards.tsx +370 -0
  49. package/components/notification-center.tsx +376 -213
  50. package/components/papyrus-console-shell.tsx +2 -1
  51. package/components/proposals-panel.tsx +21 -24
  52. package/components/proposals-view.tsx +4 -2
  53. package/components/reader-auth-control.tsx +3 -1
  54. package/components/reference-capture-client.ts +138 -0
  55. package/components/reference-capture-panel.tsx +268 -0
  56. package/components/reference-conversation.tsx +43 -0
  57. package/components/reference-feed.tsx +514 -0
  58. package/components/reference-review-card.tsx +62 -197
  59. package/components/reference-source-preview.tsx +27 -5
  60. package/components/reference-stories.tsx +34 -0
  61. package/components/references-page-loading.tsx +61 -0
  62. package/components/references-prefetch.ts +52 -0
  63. package/components/story-reporting-trail-client.ts +45 -8
  64. package/components/story-reporting-trail.tsx +24 -13
  65. package/components/topic-steering-workspace.tsx +50 -439
  66. package/components/ui/auto-grow-textarea.tsx +18 -0
  67. package/components/use-conversation.ts +84 -0
  68. package/components/use-media-query.ts +16 -0
  69. package/components/use-reference-preview-attachments.ts +101 -0
  70. package/components/use-reference-ratings.ts +189 -0
  71. package/infra/amplify-app-shell.js +18 -2
  72. package/infra/amplify-app-shell.ts +18 -2
  73. package/infra/chat-stack.js +222 -0
  74. package/infra/chat-stack.ts +250 -0
  75. package/infra/chat.test.js +142 -0
  76. package/infra/chat.test.ts +151 -0
  77. package/infra/index.js +1 -0
  78. package/infra/site-config.js +58 -1
  79. package/infra/site-config.ts +78 -1
  80. package/lib/category-repository.ts +6 -1
  81. package/lib/content-actions-client.ts +20 -0
  82. package/lib/conversation.ts +154 -0
  83. package/lib/local-preview-feed-fixture.ts +63 -0
  84. package/lib/local-preview-seed.ts +24 -2
  85. package/lib/local-preview-synthetic-rejects.ts +41 -0
  86. package/lib/monitoring-client.ts +29 -2
  87. package/lib/monitoring-overview.ts +52 -1
  88. package/lib/monitoring-paging.ts +58 -0
  89. package/lib/needs-me-items.ts +289 -0
  90. package/lib/needs-me-ranking.ts +315 -0
  91. package/lib/newsroom-all-pages.ts +21 -0
  92. package/lib/newsroom-chat-identity.ts +2 -1
  93. package/lib/newsroom-index-filters.ts +18 -0
  94. package/lib/newsroom-nav.ts +1 -1
  95. package/lib/newsroom-organizer-nav.ts +2 -1
  96. package/lib/newsroom-references.ts +25 -1
  97. package/lib/notification-feed.ts +212 -0
  98. package/lib/proposals-client.ts +29 -0
  99. package/lib/reader-subject.ts +3 -2
  100. package/lib/reference-capture.ts +294 -0
  101. package/lib/reference-feed-cache.ts +170 -0
  102. package/lib/reference-feed-ranking.ts +236 -0
  103. package/lib/reference-policy.ts +90 -0
  104. package/lib/reference-preview.ts +112 -0
  105. package/lib/relevance-decisions.ts +6 -2
  106. package/lib/review-client.ts +71 -5
  107. package/lib/review-drafts.ts +277 -0
  108. package/lib/review-fixture.ts +56 -2
  109. package/lib/review-requests.ts +89 -24
  110. package/lib/site-brand.ts +2 -0
  111. package/lib/source-frameable.ts +77 -0
  112. package/lib/source-icon.ts +104 -0
  113. package/lib/staff-groups.ts +23 -0
  114. package/lib/staging-gate.ts +2 -2
  115. package/lib/story-reporting-trail.ts +33 -2
  116. package/package.json +45 -2
  117. package/routes.manifest.json +23 -1
  118. package/components/newsroom-overview-skeleton.tsx +0 -25
@@ -0,0 +1,18 @@
1
+ "use client";
2
+
3
+ import { useLayoutEffect, useRef, type TextareaHTMLAttributes } from "react";
4
+
5
+ /**
6
+ * A textarea as tall as its text. It never scrolls inside itself, so on a phone a swipe over it scrolls the page instead of
7
+ * getting caught in the box. (CSS `field-sizing: content` does the same where supported; the height is set here for the rest.)
8
+ */
9
+ export function AutoGrowTextarea({ className, style, value, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
10
+ const ref = useRef<HTMLTextAreaElement>(null);
11
+ useLayoutEffect(() => {
12
+ const element = ref.current;
13
+ if (!element) return;
14
+ element.style.height = "auto";
15
+ element.style.height = `${element.scrollHeight}px`;
16
+ }, [value]);
17
+ return <textarea {...props} className={className} ref={ref} rows={props.rows ?? 2} style={{ ...style, overflow: "hidden", resize: "none" }} value={value} />;
18
+ }
@@ -0,0 +1,84 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import type { ConversationAnchor, ConversationMessage } from "../lib/conversation";
5
+ import { useConversationStore } from "./conversation-context";
6
+
7
+ export type ConversationState = "loading" | "ready" | "error";
8
+
9
+ /**
10
+ * One conversation's Messages. Posting shows the Message at once (marked pending) and swaps in the saved one; a failed save removes
11
+ * it again and rejects, so the caller can give the typed text back.
12
+ */
13
+ export function useConversation(anchor: ConversationAnchor) {
14
+ const store = useConversationStore();
15
+ const [messages, setMessages] = useState<ConversationMessage[]>([]);
16
+ const [state, setState] = useState<ConversationState>("loading");
17
+ const [error, setError] = useState<string | null>(null);
18
+ const [attempt, setAttempt] = useState(0);
19
+ const pendingCount = useRef(0);
20
+ const anchorRef = useRef(anchor);
21
+ anchorRef.current = anchor;
22
+ const anchorKey = `${anchor.kind}#${anchor.lineageId ?? ""}`;
23
+
24
+ useEffect(() => {
25
+ if (!store) return;
26
+ let active = true;
27
+ setState("loading");
28
+ setError(null);
29
+ setMessages([]);
30
+ store.load(anchorRef.current).then((loaded) => {
31
+ if (!active) return;
32
+ setMessages((current) => [...loaded, ...current.filter((message) => message.pending)]);
33
+ setState("ready");
34
+ }).catch((failure) => {
35
+ if (!active) return;
36
+ setError(failure instanceof Error ? failure.message : "The conversation could not be loaded.");
37
+ setState("error");
38
+ });
39
+ return () => { active = false; };
40
+ }, [store, anchorKey, attempt]);
41
+
42
+ const retry = useCallback(() => setAttempt((current) => current + 1), []);
43
+
44
+ const post = useCallback(async (body: string, parentMessageId: string | null) => {
45
+ if (!store) throw new Error("Messages are not available here.");
46
+ pendingCount.current += 1;
47
+ const pendingId = `pending-${pendingCount.current}`;
48
+ const pending: ConversationMessage = {
49
+ id: pendingId,
50
+ parentMessageId,
51
+ authorLabel: store.authorLabel,
52
+ content: body.trim(),
53
+ createdAt: new Date().toISOString(),
54
+ sequenceNumber: Number.MAX_SAFE_INTEGER - 1000 + pendingCount.current,
55
+ pending: true,
56
+ };
57
+ setMessages((current) => [...current, pending]);
58
+ try {
59
+ const saved = await store.append({ anchor: anchorRef.current, body, parentMessageId });
60
+ setMessages((current) => current.map((message) => (message.id === pendingId ? saved : message)));
61
+ } catch (failure) {
62
+ setMessages((current) => current.filter((message) => message.id !== pendingId));
63
+ throw failure instanceof Error ? failure : new Error("Your message could not be saved.");
64
+ }
65
+ }, [store]);
66
+
67
+ return { available: Boolean(store), messages, state, error, retry, post };
68
+ }
69
+
70
+ /** Message counts per Reference lineage, for the feed cards. A failed read just shows no counts. */
71
+ export function useReferenceMessageCounts(enabled: boolean) {
72
+ const store = useConversationStore();
73
+ const [counts, setCounts] = useState<ReadonlyMap<string, number>>(() => new Map());
74
+ useEffect(() => {
75
+ if (!store || !enabled) return;
76
+ let active = true;
77
+ store.counts().then((loaded) => { if (active) setCounts((current) => new Map([...loaded, ...current])); }).catch(() => undefined);
78
+ return () => { active = false; };
79
+ }, [store, enabled]);
80
+ const setCount = useCallback((lineageId: string, count: number) => {
81
+ setCounts((current) => (current.get(lineageId) === count || (count === 0 && !current.has(lineageId)) ? current : new Map(current).set(lineageId, count)));
82
+ }, []);
83
+ return { counts, setCount };
84
+ }
@@ -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,189 @@
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 "./decision-control";
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 (the backend refuses one without): words or a reason code, whichever the surface asks for. With neither it
42
+ * waits as `pending` until one arrives; a typed reason is 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, sticky, saveNote }: {
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
+ /**
51
+ * True for an item whose decision, once made, cannot be sent again (an AI draft moves its Proposal, a review request closes, a held email is
52
+ * queued). A reason typed after such a decision goes to `saveNote` instead, onto the decision that exists.
53
+ */
54
+ sticky?: (id: string) => boolean;
55
+ /** Saves the reason of an item that is already decided and sticky. Throws a sentence when it cannot. */
56
+ saveNote?: (id: string, note: string) => Promise<void>;
57
+ }): RatingController {
58
+ const [items, setItems] = useState<Record<string, ItemRating>>({});
59
+ const itemsRef = useRef(items);
60
+ const latest = useRef({ save, saved, seed, needsReason, sticky, saveNote });
61
+ latest.current = { save, saved, seed, needsReason, sticky, saveNote };
62
+ const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
63
+ const inFlight = useRef<Set<string>>(new Set());
64
+ const dirty = useRef<Set<string>>(new Set());
65
+ const decidedHere = useRef<Set<string>>(new Set());
66
+ const noteInFlight = useRef<Set<string>>(new Set());
67
+
68
+ const patch = useCallback((id: string, change: Partial<ItemRating>) => {
69
+ const next = { ...itemsRef.current, [id]: { ...(itemsRef.current[id] ?? EMPTY), ...change } };
70
+ itemsRef.current = next;
71
+ setItems(next);
72
+ }, []);
73
+
74
+ // What a reader of the store sees: the touched value, else what is saved. (A saved reason therefore shows on the card too.)
75
+ const get = useCallback((id: string): ItemRating => {
76
+ const item = items[id] ?? EMPTY;
77
+ const seeded = latest.current.seed(id);
78
+ return { ...item, note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
79
+ }, [items]);
80
+
81
+ const values = (id: string) => {
82
+ const item = itemsRef.current[id] ?? EMPTY;
83
+ const seeded = latest.current.seed(id);
84
+ return { note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
85
+ };
86
+
87
+ /** True once a sticky item has its decision: from then on only its reason can change. */
88
+ const finalDecision = (id: string) => Boolean(latest.current.sticky?.(id) && latest.current.saveNote && (decidedHere.current.has(id) || latest.current.saved(id)));
89
+
90
+ /** A reason typed after the decision: saved onto it. The text stays in the store until it is saved, and a failed save never drops it. */
91
+ const saveNoteOnly = useCallback(async (id: string): Promise<void> => {
92
+ if (noteInFlight.current.has(id)) { dirty.current.add(`note:${id}`); return; }
93
+ noteInFlight.current.add(id);
94
+ dirty.current.delete(`note:${id}`);
95
+ const started = itemsRef.current[id]?.note;
96
+ const { note } = values(id);
97
+ patch(id, { feedback: { phase: "saving" } });
98
+ try {
99
+ await latest.current.saveNote!(id, note.trim());
100
+ const now = itemsRef.current[id];
101
+ patch(id, { feedback: { phase: "saved" }, ...(now?.note === started ? { note: undefined } : {}) });
102
+ } catch (failure) {
103
+ dirty.current.add(`note:${id}`); // still unsaved: tapping away, or the next pause, tries again
104
+ patch(id, { feedback: { phase: "error", message: failure instanceof Error ? failure.message : "Your note could not be saved. It is kept here; keep typing or tap away to try again." } });
105
+ } finally {
106
+ noteInFlight.current.delete(id);
107
+ }
108
+ if (itemsRef.current[id]?.feedback.phase !== "error" && dirty.current.delete(`note:${id}`)) void saveNoteOnly(id);
109
+ }, [patch]);
110
+
111
+ const commit = useCallback(async (id: string, choice: ReviewChoice) => {
112
+ if (finalDecision(id)) { void saveNoteOnly(id); return; }
113
+ const { note, reasonCode } = values(id);
114
+ // A typed reason is a reason, so a Reject with words goes at once rather than waiting. It carries no
115
+ // code the editor did not pick: the backend reads the words for the code they name, and files words
116
+ // that name none under "other". Only a Reject with neither words nor a code waits for one.
117
+ if (choice === "reject" && !reasonCode && !note.trim() && (latest.current.needsReason?.(id) ?? true)) { patch(id, { pending: "reject", askReason: true }); return; }
118
+ if (inFlight.current.has(id)) { dirty.current.add(id); return; }
119
+ inFlight.current.add(id);
120
+ dirty.current.delete(id);
121
+ const started = itemsRef.current[id] ?? EMPTY;
122
+ patch(id, { pending: choice, askReason: false, feedback: { phase: "saving" } });
123
+ try {
124
+ await latest.current.save(id, choice, { note: note.trim(), reasonCode: choice === "reject" ? reasonCode : null });
125
+ if (latest.current.sticky?.(id)) decidedHere.current.add(id);
126
+ // What was saved now follows the record again (another editor's update shows); anything typed meanwhile is kept. While another
127
+ // save is due (changed mid-save), the store keeps everything: the record may not show this save yet, and the next one sends it.
128
+ const now = itemsRef.current[id] ?? EMPTY;
129
+ const follow = !dirty.current.has(id);
130
+ patch(id, { pending: null, feedback: { phase: "saved" }, ...(follow && now.note === started.note ? { note: undefined } : {}), ...(follow && now.reasonCode === started.reasonCode ? { reasonCode: undefined } : {}) });
131
+ } catch (failure) {
132
+ // The typed reason stays in the store; only the vote is undone.
133
+ patch(id, { pending: null, feedback: { phase: "error", message: failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry." } });
134
+ } finally {
135
+ inFlight.current.delete(id);
136
+ }
137
+ // Changed while saving (a reason chip tapped mid-save): save again. The record may not show the vote yet, so fall back to it.
138
+ if (dirty.current.has(id)) { dirty.current.delete(id); const again = itemsRef.current[id]?.pending ?? latest.current.saved(id) ?? choice; if (again) void commit(id, again); }
139
+ }, [patch, saveNoteOnly]);
140
+
141
+ const effective = (id: string): ReviewChoice | null => itemsRef.current[id]?.pending ?? latest.current.saved(id);
142
+
143
+ const choose = useCallback((id: string, choice: ReviewChoice) => {
144
+ if (finalDecision(id)) return; // a decision that cannot be sent again is only ever given a reason, never a second vote
145
+ if (effective(id) === choice && !itemsRef.current[id]?.askReason) return; // the chosen answer never unselects itself
146
+ void commit(id, choice);
147
+ }, [commit]);
148
+
149
+ const setReason = useCallback((id: string, code: ReferenceRejectionReasonCode) => {
150
+ patch(id, { reasonCode: code });
151
+ // Vote then reason: onto the existing (or waiting) decision. Reason then vote: stored, and the vote carries it.
152
+ if (effective(id) === "reject") void commit(id, "reject");
153
+ }, [commit, patch]);
154
+
155
+ const setNote = useCallback((id: string, text: string) => {
156
+ patch(id, { note: text, feedback: { phase: "idle" } });
157
+ dirty.current.add(`note:${id}`);
158
+ clearTimeout(timers.current[id]);
159
+ timers.current[id] = setTimeout(() => {
160
+ const choice = effective(id);
161
+ if (choice && dirty.current.delete(`note:${id}`)) void commit(id, choice);
162
+ }, NOTE_AUTOSAVE_MS);
163
+ }, [commit, patch]);
164
+
165
+ const commitNote = useCallback((id: string) => {
166
+ clearTimeout(timers.current[id]);
167
+ const choice = effective(id);
168
+ if (choice && dirty.current.delete(`note:${id}`)) void commit(id, choice);
169
+ }, [commit]);
170
+
171
+ const cancelReason = useCallback((id: string) => patch(id, { pending: null, askReason: false }), [patch]);
172
+
173
+ // Leaving the page (or closing the view) saves what was typed and not yet saved, rather than dropping it.
174
+ const leaving = useRef<() => void>(() => undefined);
175
+ leaving.current = () => {
176
+ for (const key of [...dirty.current]) {
177
+ if (!key.startsWith("note:")) continue;
178
+ const id = key.slice(5);
179
+ const choice = effective(id);
180
+ if (choice && dirty.current.delete(key)) void commit(id, choice);
181
+ }
182
+ };
183
+ useEffect(() => () => {
184
+ for (const timer of Object.values(timers.current)) clearTimeout(timer);
185
+ leaving.current();
186
+ }, []);
187
+
188
+ return { get, choose, setReason, setNote, commitNote, cancelReason };
189
+ }
@@ -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,