@anthusai/papyrus 5.42.1 → 5.43.0-next.10

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 (83) hide show
  1. package/amplify/data/schema.js +2 -0
  2. package/amplify/data/schema.ts +2 -0
  3. package/amplify/functions/category-action/handler.ts +2 -1
  4. package/amplify/functions/monitoring-view/schema.js +22 -0
  5. package/amplify/functions/monitoring-view/schema.ts +22 -0
  6. package/amplify/functions/monitoring-view/wire.js +1 -0
  7. package/amplify/functions/monitoring-view/wire.ts +1 -0
  8. package/amplify/functions/procedure-action/handler.ts +2 -10
  9. package/amplify/functions/proposal-actions/handler.py +137 -0
  10. package/amplify/{plugins/reader-steering/function → functions/proposal-actions}/proposals.py +20 -3
  11. package/amplify/functions/proposal-actions/resource.js +15 -0
  12. package/amplify/functions/proposal-actions/resource.ts +22 -0
  13. package/amplify/functions/proposal-actions/schema.js +106 -0
  14. package/amplify/functions/proposal-actions/schema.ts +108 -0
  15. package/amplify/functions/proposal-actions/wire.js +10 -0
  16. package/amplify/functions/proposal-actions/wire.ts +13 -0
  17. package/amplify/plugins/reader-steering/function/handler.py +13 -72
  18. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  19. package/amplify/plugins/reader-steering/papyrus.plugin.js +5 -5
  20. package/amplify/plugins/reader-steering/papyrus.plugin.ts +5 -5
  21. package/amplify/plugins/reader-steering/schema.js +0 -95
  22. package/amplify/plugins/reader-steering/schema.ts +0 -95
  23. package/amplify/site-backend.js +4 -0
  24. package/amplify/site-backend.ts +4 -0
  25. package/app/.well-known/papyrus-cli.json/route.ts +9 -0
  26. package/app/globals.css +31 -35
  27. package/app/tailwind.css +2 -0
  28. package/bin/papyrus-infra.mjs +21 -1
  29. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  30. package/components/monitoring-overview-view.tsx +140 -23
  31. package/components/monitoring-panel.tsx +96 -6
  32. package/components/news-desk-client-provider.tsx +8 -2
  33. package/components/news-desk-taxonomy-client.ts +2 -1
  34. package/components/newsroom-mobile-navigation.tsx +23 -0
  35. package/components/newsroom-ops-overview.tsx +6 -1
  36. package/components/newsroom-ops-shell.tsx +8 -10
  37. package/components/newsroom-organizer.css +1 -0
  38. package/components/newsroom-references-view.tsx +139 -257
  39. package/components/notification-center.tsx +345 -63
  40. package/components/papyrus-console-frame.tsx +66 -0
  41. package/components/papyrus-console-shell.tsx +14 -42
  42. package/components/papyrus-private-chat-panel.tsx +50 -16
  43. package/components/proposals-panel.tsx +107 -0
  44. package/components/proposals-view.tsx +95 -0
  45. package/components/reference-review-card.tsx +400 -0
  46. package/components/topic-steering-workspace.tsx +13 -2
  47. package/components/use-console-visibility.ts +47 -0
  48. package/infra/amplify-app-shell.js +18 -2
  49. package/infra/amplify-app-shell.ts +18 -2
  50. package/infra/chat-stack.js +222 -0
  51. package/infra/chat-stack.ts +250 -0
  52. package/infra/chat.test.js +142 -0
  53. package/infra/chat.test.ts +151 -0
  54. package/infra/index.js +1 -0
  55. package/infra/site-config.js +58 -1
  56. package/infra/site-config.ts +78 -1
  57. package/lib/cli-login-discovery.ts +78 -0
  58. package/lib/monitoring-client.ts +39 -3
  59. package/lib/monitoring-overview-fixture.ts +2 -2
  60. package/lib/monitoring-overview.ts +47 -3
  61. package/lib/monitoring-paging.ts +58 -0
  62. package/lib/newsroom-chat-client.ts +30 -0
  63. package/lib/newsroom-chat-identity.ts +40 -0
  64. package/lib/newsroom-organizer-nav.ts +1 -0
  65. package/lib/papyrus-hosted-chat.ts +62 -80
  66. package/lib/papyrus-private-chat-client.ts +7 -4
  67. package/lib/proposals-admin.ts +115 -0
  68. package/lib/proposals-client.ts +29 -0
  69. package/lib/prospectus-session.ts +2 -0
  70. package/lib/prospectus-sign-in-routes.ts +19 -2
  71. package/lib/prospectus-site-identity.ts +7 -0
  72. package/lib/reader-subject.ts +3 -2
  73. package/lib/relevance-decisions.ts +9 -0
  74. package/lib/review-client.ts +38 -1
  75. package/lib/review-drafts.ts +231 -0
  76. package/lib/review-fixture.ts +36 -2
  77. package/lib/review-requests.ts +66 -24
  78. package/lib/staff-groups.ts +23 -0
  79. package/lib/staging-gate.ts +2 -2
  80. package/package.json +16 -1
  81. package/routes.manifest.json +12 -1
  82. package/src/sync.mjs +11 -2
  83. package/src/with-papyrus.mjs +1 -1
@@ -0,0 +1,400 @@
1
+ "use client";
2
+
3
+ import {
4
+ ArchiveIcon,
5
+ CheckIcon,
6
+ ChevronLeftIcon,
7
+ ChevronRightIcon,
8
+ CircleCheckIcon,
9
+ CircleXIcon,
10
+ ExternalLinkIcon,
11
+ } from "lucide-react";
12
+ import { useEffect, useRef, useState, type ReactNode, type PointerEvent as ReactPointerEvent } from "react";
13
+ import { Button } from "./ui/button";
14
+ import { cn } from "../lib/utils";
15
+ import { REFERENCE_REJECTION_REASON_CODES, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
16
+
17
+ export type ReviewChoice = "accept" | "reject";
18
+
19
+ /** The Cyclotron's answer in the card's own vocabulary: Accept or Reject. */
20
+ export type CardPrediction =
21
+ | { state: "ready"; choice: ReviewChoice; confidence: number | null }
22
+ | { state: "none" }
23
+ | { state: "loading" }
24
+ | { state: "error" };
25
+
26
+ export type ReviewCardDecision = {
27
+ note: string;
28
+ reasonCode: ReferenceRejectionReasonCode | null;
29
+ };
30
+
31
+ const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
32
+ out_of_scope: "Out of scope",
33
+ policy_exclusion: "Policy exclusion",
34
+ duplicate: "Duplicate",
35
+ low_quality: "Low quality",
36
+ unavailable: "Unavailable",
37
+ provenance: "Provenance",
38
+ other: "Other",
39
+ };
40
+
41
+ const CHOICE_LABEL: Record<ReviewChoice, string> = { accept: "Accept", reject: "Reject" };
42
+
43
+ function percent(confidence: number | null): string | null {
44
+ return confidence === null ? null : `${Math.round(confidence * 100)}%`;
45
+ }
46
+
47
+ /**
48
+ * The Cyclotron's read-only row: its Accept or Reject highlighted with its confidence, and a check or cross once a person has answered.
49
+ * Shared by the review card and by every list that shows a suggestion before a decision.
50
+ */
51
+ export function CyclotronRow({ decided = null, prediction }: { decided?: ReviewChoice | null; prediction: CardPrediction }) {
52
+ const matched = decided && prediction.state === "ready" ? prediction.choice === decided : null;
53
+ return (
54
+ <div className="space-y-2" data-review-row="cyclotron">
55
+ <div className="flex items-center gap-3">
56
+ <span className="w-20 shrink-0 text-sm font-semibold">Cyclotron</span>
57
+ {prediction.state === "ready" ? (
58
+ <div className="flex flex-1 gap-2">
59
+ {(["accept", "reject"] as const).map((choice) => {
60
+ const predicted = prediction.state === "ready" && prediction.choice === choice;
61
+ const verdict = predicted && matched !== null ? (matched ? "match" : "miss") : null;
62
+ return (
63
+ <span
64
+ className={cn(
65
+ "inline-flex min-h-10 flex-1 items-center justify-center gap-1.5 rounded-md border px-3 text-sm",
66
+ predicted ? "border-foreground bg-muted font-semibold" : "border-dashed text-muted-foreground",
67
+ verdict === "match" && "border-green-700 bg-green-100 text-green-950",
68
+ verdict === "miss" && "border-red-700 bg-red-100 text-red-950",
69
+ )}
70
+ data-cyclotron-choice={choice}
71
+ data-predicted={predicted || undefined}
72
+ data-verdict={verdict ?? undefined}
73
+ key={choice}
74
+ >
75
+ {verdict === "match" ? <CircleCheckIcon aria-hidden="true" className="size-4" /> : null}
76
+ {verdict === "miss" ? <CircleXIcon aria-hidden="true" className="size-4" /> : null}
77
+ {CHOICE_LABEL[choice]}
78
+ {predicted && percent(prediction.confidence) ? <small className="text-xs font-normal">{percent(prediction.confidence)}</small> : null}
79
+ {verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
80
+ </span>
81
+ );
82
+ })}
83
+ </div>
84
+ ) : (
85
+ <p className="text-sm text-muted-foreground" data-cyclotron-state={prediction.state}>
86
+ {prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
87
+ </p>
88
+ )}
89
+ </div>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ /** The two answers, large enough to tap: Accept and Reject, the chosen one filled and checked (never colour alone). */
95
+ export function ChoiceButtons({ chosen, disabled = false, onChoose }: { chosen: ReviewChoice | null; disabled?: boolean; onChoose: (choice: ReviewChoice) => void }) {
96
+ return (
97
+ <div className="flex flex-1 gap-2">
98
+ {(["accept", "reject"] as const).map((choice) => {
99
+ const on = chosen === choice;
100
+ return (
101
+ <Button
102
+ aria-disabled={disabled ? true : undefined}
103
+ aria-pressed={on}
104
+ className="min-h-14 flex-1 text-base"
105
+ data-review-choice={choice}
106
+ key={choice}
107
+ onClick={() => onChoose(choice)}
108
+ type="button"
109
+ variant={on ? (choice === "accept" ? "default" : "destructive") : "outline"}
110
+ >
111
+ {on ? <CheckIcon aria-hidden="true" className="size-5" /> : null}
112
+ {CHOICE_LABEL[choice]}
113
+ </Button>
114
+ );
115
+ })}
116
+ </div>
117
+ );
118
+ }
119
+
120
+ /** A reference's relevance decision in the card's own vocabulary. */
121
+ export function predictionFromDecision(decision: { label: string; positiveLabel: string | null; confidence: number | null }): CardPrediction {
122
+ return { state: "ready", choice: decision.label === (decision.positiveLabel ?? "include") ? "accept" : "reject", confidence: decision.confidence };
123
+ }
124
+
125
+ /**
126
+ * One reusable card for answering a single question: Accept or Reject. The Cyclotron's row is
127
+ * read-only; the human row is the real control and its answer is final. Saves on tap.
128
+ * Written so that story review can reuse it later: it knows nothing about references.
129
+ */
130
+ export function ReviewCard({
131
+ children,
132
+ decided,
133
+ disabledReason,
134
+ initialNote = "",
135
+ initialReason = null,
136
+ itemId,
137
+ onArchive,
138
+ onDecide,
139
+ prediction,
140
+ summary,
141
+ title,
142
+ titleHref,
143
+ }: {
144
+ children?: ReactNode;
145
+ /** The saved human answer, or null while the item is undecided. */
146
+ decided: ReviewChoice | null;
147
+ /** When set, the controls explain in words why they cannot change anything. */
148
+ disabledReason?: string | null;
149
+ initialNote?: string;
150
+ initialReason?: ReferenceRejectionReasonCode | null;
151
+ itemId: string;
152
+ onArchive?: () => Promise<void> | void;
153
+ onDecide: (choice: ReviewChoice, decision: ReviewCardDecision) => Promise<void> | void;
154
+ prediction: CardPrediction;
155
+ summary?: string | null;
156
+ title: string;
157
+ titleHref?: string | null;
158
+ }) {
159
+ const [draft, setDraft] = useState<ReviewChoice | null>(null);
160
+ const [note, setNote] = useState(initialNote);
161
+ const [reason, setReason] = useState<ReferenceRejectionReasonCode | null>(initialReason);
162
+ const [saving, setSaving] = useState(false);
163
+ const [saved, setSaved] = useState(false);
164
+ const [error, setError] = useState<string | null>(null);
165
+ const inFlight = useRef(false);
166
+ const noteTouched = useRef(false);
167
+ const reasonTouched = useRef(false);
168
+ const savedNote = useRef(initialNote);
169
+
170
+ // Saved values from outside (another editor, a reload) refresh whatever the human has not touched.
171
+ useEffect(() => {
172
+ savedNote.current = initialNote;
173
+ if (!noteTouched.current) setNote(initialNote);
174
+ }, [initialNote]);
175
+ useEffect(() => {
176
+ if (!reasonTouched.current) setReason(initialReason);
177
+ }, [initialReason]);
178
+
179
+ const chosen = draft ?? decided;
180
+
181
+ async function save(choice: ReviewChoice, nextReason: ReferenceRejectionReasonCode | null, nextNote: string) {
182
+ if (disabledReason || inFlight.current) return;
183
+ if (choice === "reject" && !nextReason) return;
184
+ inFlight.current = true;
185
+ setSaving(true);
186
+ setSaved(false);
187
+ setError(null);
188
+ try {
189
+ await onDecide(choice, { note: nextNote.trim(), reasonCode: choice === "reject" ? nextReason : null });
190
+ savedNote.current = nextNote.trim();
191
+ noteTouched.current = false;
192
+ reasonTouched.current = false;
193
+ setDraft(null);
194
+ setSaved(true);
195
+ } catch (failure) {
196
+ setDraft(null);
197
+ setError(failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry.");
198
+ } finally {
199
+ inFlight.current = false;
200
+ setSaving(false);
201
+ }
202
+ }
203
+
204
+ function choose(choice: ReviewChoice) {
205
+ if (disabledReason) return;
206
+ if (chosen === choice && draft === null) return; // the selected answer never unselects itself
207
+ setError(null);
208
+ setDraft(choice);
209
+ void save(choice, reason, note);
210
+ }
211
+
212
+ function chooseReason(code: ReferenceRejectionReasonCode) {
213
+ if (disabledReason) return;
214
+ setReason(code);
215
+ reasonTouched.current = true;
216
+ if (chosen === "reject") void save("reject", code, note);
217
+ }
218
+
219
+ function commitNote() {
220
+ if (!decided || disabledReason || note.trim() === savedNote.current.trim()) return;
221
+ void save(decided, reason, note);
222
+ }
223
+
224
+ // Desktop shortcuts: A accepts, R rejects. Never while typing.
225
+ useEffect(() => {
226
+ function onKey(event: KeyboardEvent) {
227
+ const target = event.target as HTMLElement | null;
228
+ if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
229
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
230
+ if (event.key === "a" || event.key === "A") choose("accept");
231
+ if (event.key === "r" || event.key === "R") choose("reject");
232
+ }
233
+ window.addEventListener("keydown", onKey);
234
+ return () => window.removeEventListener("keydown", onKey);
235
+ });
236
+
237
+ return (
238
+ <section aria-label="Review" className="space-y-4" data-review-card={itemId}>
239
+ <header className="space-y-1">
240
+ <h2 className="text-lg font-semibold leading-snug text-foreground">{title}</h2>
241
+ {titleHref ? (
242
+ <a className="inline-flex max-w-full items-center gap-1 truncate text-sm text-primary underline-offset-4 hover:underline" href={titleHref} rel="noreferrer" target="_blank">
243
+ <span className="truncate">{titleHref}</span>
244
+ <ExternalLinkIcon aria-hidden="true" className="size-3.5 shrink-0" />
245
+ </a>
246
+ ) : null}
247
+ {summary ? <p className="text-sm text-muted-foreground">{summary}</p> : null}
248
+ </header>
249
+
250
+ <CyclotronRow decided={decided} prediction={prediction} />
251
+
252
+ <div className="space-y-2" data-review-row="you">
253
+ <div className="flex items-center gap-3">
254
+ <span className="w-20 shrink-0 text-sm font-semibold">You</span>
255
+ <ChoiceButtons chosen={chosen} disabled={Boolean(disabledReason)} onChoose={choose} />
256
+ </div>
257
+ <div className="min-h-5 pl-[5.75rem] text-sm" role="status">
258
+ {saving ? "Saving…" : saved ? (
259
+ <span className="inline-flex items-center gap-1" data-review-saved><CheckIcon aria-hidden="true" className="size-4" />Saved</span>
260
+ ) : null}
261
+ </div>
262
+ {disabledReason ? <p className="text-sm text-muted-foreground" data-review-disabled-reason>{disabledReason}</p> : null}
263
+ {error ? <p className="text-sm text-destructive" role="alert">{error}</p> : null}
264
+ </div>
265
+
266
+ <label className="block text-sm font-medium">
267
+ Why? (optional)
268
+ <textarea
269
+ aria-label="Why? (optional)"
270
+ className="mt-1 block min-h-24 w-full rounded-md border bg-background p-3 text-base font-normal"
271
+ onBlur={commitNote}
272
+ onChange={(event) => { setNote(event.target.value); noteTouched.current = true; setSaved(false); }}
273
+ placeholder="Say what made this an easy yes or a clear no. Your note teaches the Cyclotron."
274
+ value={note}
275
+ />
276
+ </label>
277
+
278
+ {chosen === "reject" ? (
279
+ <div className="space-y-2" data-review-reasons>
280
+ <p className="text-sm font-medium">Reason for rejecting</p>
281
+ <div className="flex flex-wrap gap-2">
282
+ {REFERENCE_REJECTION_REASON_CODES.map((code) => (
283
+ <Button
284
+ aria-disabled={disabledReason ? true : undefined}
285
+ aria-pressed={reason === code}
286
+ className="min-h-10"
287
+ data-review-reason={code}
288
+ key={code}
289
+ onClick={() => chooseReason(code)}
290
+ type="button"
291
+ variant={reason === code ? "default" : "outline"}
292
+ >
293
+ {reason === code ? <CheckIcon aria-hidden="true" className="size-4" /> : null}
294
+ {REASON_LABELS[code]}
295
+ </Button>
296
+ ))}
297
+ </div>
298
+ {!reason ? <p className="text-sm text-muted-foreground">Choose a reason to save your Reject.</p> : null}
299
+ </div>
300
+ ) : null}
301
+
302
+ {children}
303
+
304
+ {onArchive ? (
305
+ <Button aria-label="Archive reference" onClick={() => { if (!disabledReason) void onArchive(); }} size="sm" type="button" variant="ghost">
306
+ <ArchiveIcon aria-hidden="true" className="size-4" />
307
+ Archive
308
+ </Button>
309
+ ) : null}
310
+ </section>
311
+ );
312
+ }
313
+
314
+ /** Sticky Previous / position / Next bar for working through a queue. */
315
+ export function ReviewQueueBar({
316
+ caughtUp = false,
317
+ onNext,
318
+ onPrevious,
319
+ position,
320
+ total,
321
+ }: {
322
+ caughtUp?: boolean;
323
+ onNext: () => void;
324
+ onPrevious: () => void;
325
+ /** 1-based position, or null when the open item is not in the queue. */
326
+ position: number | null;
327
+ total: number;
328
+ }) {
329
+ const atStart = position === 1;
330
+ return (
331
+ <nav
332
+ aria-label="Review queue"
333
+ className="sticky bottom-0 z-10 flex shrink-0 items-center gap-2 border-t border-border bg-card px-3 py-2"
334
+ data-review-queue-bar
335
+ >
336
+ <Button
337
+ aria-disabled={atStart || undefined}
338
+ aria-label="Previous"
339
+ className={cn("min-h-12 flex-1 text-base", atStart && "opacity-60")}
340
+ onClick={() => { if (!atStart) onPrevious(); }}
341
+ type="button"
342
+ variant="outline"
343
+ >
344
+ <ChevronLeftIcon aria-hidden="true" className="size-5" />
345
+ Previous
346
+ </Button>
347
+ <span className="min-w-20 text-center text-sm" data-review-queue-position>
348
+ {caughtUp ? "All caught up" : position ? `${position} of ${total}` : `${total} in list`}
349
+ </span>
350
+ <Button
351
+ aria-disabled={caughtUp || undefined}
352
+ aria-label="Next"
353
+ className={cn("min-h-12 flex-1 text-base", caughtUp && "opacity-60")}
354
+ onClick={() => { if (!caughtUp) onNext(); }}
355
+ type="button"
356
+ variant="outline"
357
+ >
358
+ Next
359
+ <ChevronRightIcon aria-hidden="true" className="size-5" />
360
+ </Button>
361
+ </nav>
362
+ );
363
+ }
364
+
365
+ /** Swipe left for Next, right for Previous; ← and → on a keyboard. Never required. */
366
+ export function useQueueGestures({ onNext, onPrevious }: { onNext: () => void; onPrevious: () => void }) {
367
+ const start = useRef<{ x: number; y: number } | null>(null);
368
+ const handlers = useRef({ onNext, onPrevious });
369
+ handlers.current = { onNext, onPrevious };
370
+
371
+ useEffect(() => {
372
+ function onKey(event: KeyboardEvent) {
373
+ const target = event.target as HTMLElement | null;
374
+ if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
375
+ if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return;
376
+ if (event.key === "ArrowRight") handlers.current.onNext();
377
+ if (event.key === "ArrowLeft") handlers.current.onPrevious();
378
+ }
379
+ window.addEventListener("keydown", onKey);
380
+ return () => window.removeEventListener("keydown", onKey);
381
+ }, []);
382
+
383
+ return {
384
+ onPointerDown(event: ReactPointerEvent) {
385
+ if (event.pointerType !== "touch") return;
386
+ start.current = { x: event.clientX, y: event.clientY };
387
+ },
388
+ onPointerUp(event: ReactPointerEvent) {
389
+ const origin = start.current;
390
+ start.current = null;
391
+ if (!origin || event.pointerType !== "touch") return;
392
+ const dx = event.clientX - origin.x;
393
+ const dy = event.clientY - origin.y;
394
+ if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 2) return;
395
+ if (dx < 0) handlers.current.onNext();
396
+ else handlers.current.onPrevious();
397
+ },
398
+ style: { touchAction: "pan-y" } as const,
399
+ };
400
+ }
@@ -17,6 +17,7 @@ import { EditorialChecksAdminPanel } from "./editorial-checks-admin-panel";
17
17
  import { AutomationPanel } from "./automation-panel";
18
18
  import { MonitoringPanel } from "./monitoring-panel";
19
19
  import { ModerationPanel } from "./moderation-panel";
20
+ import { ProposalsPanel } from "./proposals-panel";
20
21
  import { NewsletterPanel } from "./newsletter-panel";
21
22
  import { SignupEntriesPanel } from "./signup-entries-panel";
22
23
  import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
@@ -256,7 +257,7 @@ type AssignmentAction = "claim" | "release" | "complete" | "cancel" | "reopen" |
256
257
  type ReportingPacketReviewDecision = "select" | "merge" | "brief" | "hold" | "kill";
257
258
  type AssignmentDeskViewMode = "queue" | "budget";
258
259
  type UserRoleAction = "grant" | "revoke";
259
- type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "automations" | "delegations" | "signups" | "newsletter" | "moderation" | "editorial-checks" | "monitoring";
260
+ type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "automations" | "delegations" | "signups" | "newsletter" | "moderation" | "proposals" | "editorial-checks" | "monitoring";
260
261
  export type NewsDeskTab = "overview" | "desks" | "topics" | "concepts" | "references" | "messages" | "assignments" | "administration" | "search";
261
262
  type LexicalRuleScope = "publication" | "corpus" | "classifier" | "category";
262
263
  type AnalysisReindexMode = "online-update" | "classifier-retrain" | "scoped-topic-rebuild" | "entity-graph-rebuild" | "generated-analysis-rebuild";
@@ -4398,6 +4399,15 @@ function AdministrationDeskView({
4398
4399
  <span>Subscribers, issues and delivery for the site&apos;s email</span>
4399
4400
  </Link>
4400
4401
  ) : null}
4402
+ <Link
4403
+ href={administrationPanelHref("proposals", isDemo)}
4404
+ data-news-desk-admin-nav="proposals"
4405
+ data-active={administrationPanel === "proposals" || undefined}
4406
+ onClick={() => onPanelChange("proposals")}
4407
+ >
4408
+ <strong>Proposals</strong>
4409
+ <span>Story drafts and changes that AI proposes</span>
4410
+ </Link>
4401
4411
  {readerPluginEnabled() ? (
4402
4412
  <Link
4403
4413
  href={administrationPanelHref("moderation", isDemo)}
@@ -4413,6 +4423,7 @@ function AdministrationDeskView({
4413
4423
  <div className="news-desk-settings-panel">
4414
4424
  {administrationPanel === "signups" ? <SignupEntriesPanel /> : null}
4415
4425
  {administrationPanel === "newsletter" ? <NewsletterPanel isAdmin={canManageUsers} isDemo={isDemo} /> : null}
4426
+ {administrationPanel === "proposals" ? <ProposalsPanel /> : null}
4416
4427
  {administrationPanel === "moderation" ? <ModerationPanel /> : null}
4417
4428
  {administrationPanel === "editorial-checks" ? <EditorialChecksAdminPanel /> : null}
4418
4429
  {administrationPanel === "automations" ? <AutomationPanel isDemo={isDemo} /> : null}
@@ -12890,7 +12901,7 @@ function readerPluginEnabled(): boolean {
12890
12901
  }
12891
12902
 
12892
12903
  function normalizeAdministrationPanel(value: string | null | undefined, demo = false): AdministrationPanel {
12893
- if (value === "policies" || value === "sections" || value === "procedures" || value === "automations" || value === "delegations" || value === "editorial-checks" || value === "monitoring") return value;
12904
+ if (value === "policies" || value === "sections" || value === "procedures" || value === "automations" || value === "delegations" || value === "editorial-checks" || value === "monitoring" || value === "proposals") return value;
12894
12905
  if (value === "signups" && signupPluginEnabled()) return value;
12895
12906
  if (value === "newsletter" && newsletterPluginEnabled(demo)) return value;
12896
12907
  if (value === "moderation" && readerPluginEnabled()) return value;
@@ -0,0 +1,47 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+
5
+ export function useConsoleVisibility({ available, persist = true }: { available: boolean; persist?: boolean }) {
6
+ const [open, setOpen] = useState(false);
7
+ const visible = useRef(false);
8
+ const entry = useRef<string | null>(null);
9
+ const setVisible = useCallback((next: boolean) => {
10
+ visible.current = next;
11
+ setOpen(next);
12
+ if (persist) try { localStorage.setItem("papyrus:console-open", String(next)); } catch { /* Optional preference. */ }
13
+ }, [persist]);
14
+ const openConsole = useCallback(() => {
15
+ if (!available || visible.current) return;
16
+ const existing = history.state?.papyrusMobileChat;
17
+ if (typeof existing === "string") entry.current = existing;
18
+ else if (matchMedia("(max-width: 900px)").matches) {
19
+ entry.current = crypto.randomUUID();
20
+ history.pushState({ ...history.state, papyrusMobileChat: entry.current }, "", location.href);
21
+ }
22
+ setVisible(true);
23
+ }, [available, setVisible]);
24
+ const closeConsole = useCallback(() => {
25
+ if (entry.current && history.state?.papyrusMobileChat === entry.current) history.back();
26
+ else setVisible(false);
27
+ }, [setVisible]);
28
+ const toggleOpen = useCallback(() => {
29
+ if (visible.current) closeConsole();
30
+ else openConsole();
31
+ }, [closeConsole, openConsole]);
32
+ useEffect(() => {
33
+ if (!available) {
34
+ visible.current = false;
35
+ setOpen(false);
36
+ return;
37
+ }
38
+ const pop = (event: PopStateEvent) => {
39
+ if (entry.current) setVisible(event.state?.papyrusMobileChat === entry.current);
40
+ };
41
+ window.addEventListener("popstate", pop);
42
+ if (typeof history.state?.papyrusMobileChat === "string") openConsole();
43
+ else if (persist) try { if (localStorage.getItem("papyrus:console-open") === "true") openConsole(); } catch { /* Optional preference. */ }
44
+ return () => window.removeEventListener("popstate", pop);
45
+ }, [available, openConsole, persist, setVisible]);
46
+ return { open, openConsole, closeConsole, toggleOpen };
47
+ }
@@ -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,