@anthusai/papyrus 5.42.0 → 5.43.0-next.1

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 (58) hide show
  1. package/amplify/data/schema.js +2 -0
  2. package/amplify/data/schema.ts +2 -0
  3. package/amplify/functions/monitoring-view/schema.js +6 -0
  4. package/amplify/functions/monitoring-view/schema.ts +6 -0
  5. package/amplify/functions/monitoring-view/wire.js +1 -0
  6. package/amplify/functions/monitoring-view/wire.ts +1 -0
  7. package/amplify/functions/proposal-actions/handler.py +137 -0
  8. package/amplify/functions/proposal-actions/resource.js +15 -0
  9. package/amplify/functions/proposal-actions/resource.ts +22 -0
  10. package/amplify/functions/proposal-actions/schema.js +106 -0
  11. package/amplify/functions/proposal-actions/schema.ts +108 -0
  12. package/amplify/functions/proposal-actions/wire.js +10 -0
  13. package/amplify/functions/proposal-actions/wire.ts +13 -0
  14. package/amplify/plugins/reader-steering/function/handler.py +12 -69
  15. package/amplify/plugins/reader-steering/papyrus.plugin.js +5 -5
  16. package/amplify/plugins/reader-steering/papyrus.plugin.ts +5 -5
  17. package/amplify/plugins/reader-steering/schema.js +0 -95
  18. package/amplify/plugins/reader-steering/schema.ts +0 -95
  19. package/amplify/site-backend.js +4 -0
  20. package/amplify/site-backend.ts +4 -0
  21. package/app/.well-known/papyrus-cli.json/route.ts +9 -0
  22. package/app/globals.css +31 -35
  23. package/app/tailwind.css +2 -0
  24. package/components/monitoring-overview-view.tsx +12 -7
  25. package/components/monitoring-panel.tsx +33 -3
  26. package/components/newsroom-mobile-navigation.tsx +23 -0
  27. package/components/newsroom-ops-shell.tsx +8 -10
  28. package/components/newsroom-organizer.css +1 -0
  29. package/components/newsroom-references-view.tsx +141 -257
  30. package/components/notification-center.tsx +1 -1
  31. package/components/papyrus-console-frame.tsx +66 -0
  32. package/components/papyrus-console-shell.tsx +12 -41
  33. package/components/papyrus-private-chat-panel.tsx +50 -16
  34. package/components/proposals-panel.tsx +110 -0
  35. package/components/proposals-view.tsx +93 -0
  36. package/components/reference-review-card.tsx +383 -0
  37. package/components/topic-steering-workspace.tsx +13 -2
  38. package/components/use-console-visibility.ts +47 -0
  39. package/lib/cli-login-discovery.ts +78 -0
  40. package/lib/monitoring-client.ts +11 -2
  41. package/lib/monitoring-overview-fixture.ts +2 -2
  42. package/lib/monitoring-overview.ts +1 -2
  43. package/lib/newsroom-chat-client.ts +30 -0
  44. package/lib/newsroom-chat-identity.ts +39 -0
  45. package/lib/papyrus-hosted-chat.ts +62 -80
  46. package/lib/papyrus-private-chat-client.ts +7 -4
  47. package/lib/proposals-admin.ts +115 -0
  48. package/lib/prospectus-session.ts +2 -0
  49. package/lib/prospectus-sign-in-routes.ts +19 -2
  50. package/lib/prospectus-site-identity.ts +7 -0
  51. package/lib/relevance-decisions.ts +9 -0
  52. package/lib/review-requests.ts +4 -2
  53. package/package.json +12 -1
  54. package/routes.manifest.json +12 -1
  55. package/src/sync.mjs +11 -2
  56. package/src/with-papyrus.d.mts +1 -0
  57. package/src/with-papyrus.mjs +17 -1
  58. /package/amplify/{plugins/reader-steering/function → functions/proposal-actions}/proposals.py +0 -0
@@ -0,0 +1,383 @@
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
+ * One reusable card for answering a single question: Accept or Reject. The Cyclotron's row is
49
+ * read-only; the human row is the real control and its answer is final. Saves on tap.
50
+ * Written so that story review can reuse it later: it knows nothing about references.
51
+ */
52
+ export function ReviewCard({
53
+ children,
54
+ decided,
55
+ disabledReason,
56
+ initialNote = "",
57
+ initialReason = null,
58
+ itemId,
59
+ onArchive,
60
+ onDecide,
61
+ prediction,
62
+ summary,
63
+ title,
64
+ titleHref,
65
+ }: {
66
+ children?: ReactNode;
67
+ /** The saved human answer, or null while the item is undecided. */
68
+ decided: ReviewChoice | null;
69
+ /** When set, the controls explain in words why they cannot change anything. */
70
+ disabledReason?: string | null;
71
+ initialNote?: string;
72
+ initialReason?: ReferenceRejectionReasonCode | null;
73
+ itemId: string;
74
+ onArchive?: () => Promise<void> | void;
75
+ onDecide: (choice: ReviewChoice, decision: ReviewCardDecision) => Promise<void> | void;
76
+ prediction: CardPrediction;
77
+ summary?: string | null;
78
+ title: string;
79
+ titleHref?: string | null;
80
+ }) {
81
+ const [draft, setDraft] = useState<ReviewChoice | null>(null);
82
+ const [note, setNote] = useState(initialNote);
83
+ const [reason, setReason] = useState<ReferenceRejectionReasonCode | null>(initialReason);
84
+ const [saving, setSaving] = useState(false);
85
+ const [saved, setSaved] = useState(false);
86
+ const [error, setError] = useState<string | null>(null);
87
+ const inFlight = useRef(false);
88
+ const noteTouched = useRef(false);
89
+ const reasonTouched = useRef(false);
90
+ const savedNote = useRef(initialNote);
91
+
92
+ // Saved values from outside (another editor, a reload) refresh whatever the human has not touched.
93
+ useEffect(() => {
94
+ savedNote.current = initialNote;
95
+ if (!noteTouched.current) setNote(initialNote);
96
+ }, [initialNote]);
97
+ useEffect(() => {
98
+ if (!reasonTouched.current) setReason(initialReason);
99
+ }, [initialReason]);
100
+
101
+ const chosen = draft ?? decided;
102
+
103
+ async function save(choice: ReviewChoice, nextReason: ReferenceRejectionReasonCode | null, nextNote: string) {
104
+ if (disabledReason || inFlight.current) return;
105
+ if (choice === "reject" && !nextReason) return;
106
+ inFlight.current = true;
107
+ setSaving(true);
108
+ setSaved(false);
109
+ setError(null);
110
+ try {
111
+ await onDecide(choice, { note: nextNote.trim(), reasonCode: choice === "reject" ? nextReason : null });
112
+ savedNote.current = nextNote.trim();
113
+ noteTouched.current = false;
114
+ reasonTouched.current = false;
115
+ setDraft(null);
116
+ setSaved(true);
117
+ } catch (failure) {
118
+ setDraft(null);
119
+ setError(failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry.");
120
+ } finally {
121
+ inFlight.current = false;
122
+ setSaving(false);
123
+ }
124
+ }
125
+
126
+ function choose(choice: ReviewChoice) {
127
+ if (disabledReason) return;
128
+ if (chosen === choice && draft === null) return; // the selected answer never unselects itself
129
+ setError(null);
130
+ setDraft(choice);
131
+ void save(choice, reason, note);
132
+ }
133
+
134
+ function chooseReason(code: ReferenceRejectionReasonCode) {
135
+ if (disabledReason) return;
136
+ setReason(code);
137
+ reasonTouched.current = true;
138
+ if (chosen === "reject") void save("reject", code, note);
139
+ }
140
+
141
+ function commitNote() {
142
+ if (!decided || disabledReason || note.trim() === savedNote.current.trim()) return;
143
+ void save(decided, reason, note);
144
+ }
145
+
146
+ // Desktop shortcuts: A accepts, R rejects. Never while typing.
147
+ useEffect(() => {
148
+ function onKey(event: KeyboardEvent) {
149
+ const target = event.target as HTMLElement | null;
150
+ if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
151
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
152
+ if (event.key === "a" || event.key === "A") choose("accept");
153
+ if (event.key === "r" || event.key === "R") choose("reject");
154
+ }
155
+ window.addEventListener("keydown", onKey);
156
+ return () => window.removeEventListener("keydown", onKey);
157
+ });
158
+
159
+ const predictedMarker = (choice: ReviewChoice) => {
160
+ if (prediction.state !== "ready" || prediction.choice !== choice) return null;
161
+ return prediction.choice;
162
+ };
163
+ const matched = decided && prediction.state === "ready" ? prediction.choice === decided : null;
164
+
165
+ return (
166
+ <section aria-label="Review" className="space-y-4" data-review-card={itemId}>
167
+ <header className="space-y-1">
168
+ <h2 className="text-lg font-semibold leading-snug text-foreground">{title}</h2>
169
+ {titleHref ? (
170
+ <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">
171
+ <span className="truncate">{titleHref}</span>
172
+ <ExternalLinkIcon aria-hidden="true" className="size-3.5 shrink-0" />
173
+ </a>
174
+ ) : null}
175
+ {summary ? <p className="text-sm text-muted-foreground">{summary}</p> : null}
176
+ </header>
177
+
178
+ <div className="space-y-2" data-review-row="cyclotron">
179
+ <div className="flex items-center gap-3">
180
+ <span className="w-20 shrink-0 text-sm font-semibold">Cyclotron</span>
181
+ {prediction.state === "ready" ? (
182
+ <div className="flex flex-1 gap-2">
183
+ {(["accept", "reject"] as const).map((choice) => {
184
+ const predicted = predictedMarker(choice) !== null;
185
+ const verdict = predicted && matched !== null ? (matched ? "match" : "miss") : null;
186
+ return (
187
+ <span
188
+ className={cn(
189
+ "inline-flex min-h-10 flex-1 items-center justify-center gap-1.5 rounded-md border px-3 text-sm",
190
+ predicted ? "border-foreground bg-muted font-semibold" : "border-dashed text-muted-foreground",
191
+ verdict === "match" && "border-green-700 bg-green-100 text-green-950",
192
+ verdict === "miss" && "border-red-700 bg-red-100 text-red-950",
193
+ )}
194
+ data-cyclotron-choice={choice}
195
+ data-predicted={predicted || undefined}
196
+ data-verdict={verdict ?? undefined}
197
+ key={choice}
198
+ >
199
+ {verdict === "match" ? <CircleCheckIcon aria-hidden="true" className="size-4" /> : null}
200
+ {verdict === "miss" ? <CircleXIcon aria-hidden="true" className="size-4" /> : null}
201
+ {CHOICE_LABEL[choice]}
202
+ {predicted && percent(prediction.confidence) ? <small className="text-xs font-normal">{percent(prediction.confidence)}</small> : null}
203
+ {verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
204
+ </span>
205
+ );
206
+ })}
207
+ </div>
208
+ ) : (
209
+ <p className="text-sm text-muted-foreground" data-cyclotron-state={prediction.state}>
210
+ {prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
211
+ </p>
212
+ )}
213
+ </div>
214
+ </div>
215
+
216
+ <div className="space-y-2" data-review-row="you">
217
+ <div className="flex items-center gap-3">
218
+ <span className="w-20 shrink-0 text-sm font-semibold">You</span>
219
+ <div className="flex flex-1 gap-2">
220
+ {(["accept", "reject"] as const).map((choice) => {
221
+ const on = chosen === choice;
222
+ return (
223
+ <Button
224
+ aria-disabled={disabledReason ? true : undefined}
225
+ aria-pressed={on}
226
+ className="min-h-14 flex-1 text-base"
227
+ data-review-choice={choice}
228
+ key={choice}
229
+ onClick={() => choose(choice)}
230
+ type="button"
231
+ variant={on ? (choice === "accept" ? "default" : "destructive") : "outline"}
232
+ >
233
+ {on ? <CheckIcon aria-hidden="true" className="size-5" /> : null}
234
+ {CHOICE_LABEL[choice]}
235
+ </Button>
236
+ );
237
+ })}
238
+ </div>
239
+ </div>
240
+ <div className="min-h-5 pl-[5.75rem] text-sm" role="status">
241
+ {saving ? "Saving…" : saved ? (
242
+ <span className="inline-flex items-center gap-1" data-review-saved><CheckIcon aria-hidden="true" className="size-4" />Saved</span>
243
+ ) : null}
244
+ </div>
245
+ {disabledReason ? <p className="text-sm text-muted-foreground" data-review-disabled-reason>{disabledReason}</p> : null}
246
+ {error ? <p className="text-sm text-destructive" role="alert">{error}</p> : null}
247
+ </div>
248
+
249
+ <label className="block text-sm font-medium">
250
+ Why? (optional)
251
+ <textarea
252
+ aria-label="Why? (optional)"
253
+ className="mt-1 block min-h-24 w-full rounded-md border bg-background p-3 text-base font-normal"
254
+ onBlur={commitNote}
255
+ onChange={(event) => { setNote(event.target.value); noteTouched.current = true; setSaved(false); }}
256
+ placeholder="Say what made this an easy yes or a clear no. Your note teaches the Cyclotron."
257
+ value={note}
258
+ />
259
+ </label>
260
+
261
+ {chosen === "reject" ? (
262
+ <div className="space-y-2" data-review-reasons>
263
+ <p className="text-sm font-medium">Reason for rejecting</p>
264
+ <div className="flex flex-wrap gap-2">
265
+ {REFERENCE_REJECTION_REASON_CODES.map((code) => (
266
+ <Button
267
+ aria-disabled={disabledReason ? true : undefined}
268
+ aria-pressed={reason === code}
269
+ className="min-h-10"
270
+ data-review-reason={code}
271
+ key={code}
272
+ onClick={() => chooseReason(code)}
273
+ type="button"
274
+ variant={reason === code ? "default" : "outline"}
275
+ >
276
+ {reason === code ? <CheckIcon aria-hidden="true" className="size-4" /> : null}
277
+ {REASON_LABELS[code]}
278
+ </Button>
279
+ ))}
280
+ </div>
281
+ {!reason ? <p className="text-sm text-muted-foreground">Choose a reason to save your Reject.</p> : null}
282
+ </div>
283
+ ) : null}
284
+
285
+ {children}
286
+
287
+ {onArchive ? (
288
+ <Button aria-label="Archive reference" onClick={() => { if (!disabledReason) void onArchive(); }} size="sm" type="button" variant="ghost">
289
+ <ArchiveIcon aria-hidden="true" className="size-4" />
290
+ Archive
291
+ </Button>
292
+ ) : null}
293
+ </section>
294
+ );
295
+ }
296
+
297
+ /** Sticky Previous / position / Next bar for working through a queue. */
298
+ export function ReviewQueueBar({
299
+ caughtUp = false,
300
+ onNext,
301
+ onPrevious,
302
+ position,
303
+ total,
304
+ }: {
305
+ caughtUp?: boolean;
306
+ onNext: () => void;
307
+ onPrevious: () => void;
308
+ /** 1-based position, or null when the open item is not in the queue. */
309
+ position: number | null;
310
+ total: number;
311
+ }) {
312
+ const atStart = position === 1;
313
+ return (
314
+ <nav
315
+ aria-label="Review queue"
316
+ className="sticky bottom-0 z-10 flex shrink-0 items-center gap-2 border-t border-border bg-card px-3 py-2"
317
+ data-review-queue-bar
318
+ >
319
+ <Button
320
+ aria-disabled={atStart || undefined}
321
+ aria-label="Previous"
322
+ className={cn("min-h-12 flex-1 text-base", atStart && "opacity-60")}
323
+ onClick={() => { if (!atStart) onPrevious(); }}
324
+ type="button"
325
+ variant="outline"
326
+ >
327
+ <ChevronLeftIcon aria-hidden="true" className="size-5" />
328
+ Previous
329
+ </Button>
330
+ <span className="min-w-20 text-center text-sm" data-review-queue-position>
331
+ {caughtUp ? "All caught up" : position ? `${position} of ${total}` : `${total} in list`}
332
+ </span>
333
+ <Button
334
+ aria-disabled={caughtUp || undefined}
335
+ aria-label="Next"
336
+ className={cn("min-h-12 flex-1 text-base", caughtUp && "opacity-60")}
337
+ onClick={() => { if (!caughtUp) onNext(); }}
338
+ type="button"
339
+ variant="outline"
340
+ >
341
+ Next
342
+ <ChevronRightIcon aria-hidden="true" className="size-5" />
343
+ </Button>
344
+ </nav>
345
+ );
346
+ }
347
+
348
+ /** Swipe left for Next, right for Previous; ← and → on a keyboard. Never required. */
349
+ export function useQueueGestures({ onNext, onPrevious }: { onNext: () => void; onPrevious: () => void }) {
350
+ const start = useRef<{ x: number; y: number } | null>(null);
351
+ const handlers = useRef({ onNext, onPrevious });
352
+ handlers.current = { onNext, onPrevious };
353
+
354
+ useEffect(() => {
355
+ function onKey(event: KeyboardEvent) {
356
+ const target = event.target as HTMLElement | null;
357
+ if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
358
+ if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return;
359
+ if (event.key === "ArrowRight") handlers.current.onNext();
360
+ if (event.key === "ArrowLeft") handlers.current.onPrevious();
361
+ }
362
+ window.addEventListener("keydown", onKey);
363
+ return () => window.removeEventListener("keydown", onKey);
364
+ }, []);
365
+
366
+ return {
367
+ onPointerDown(event: ReactPointerEvent) {
368
+ if (event.pointerType !== "touch") return;
369
+ start.current = { x: event.clientX, y: event.clientY };
370
+ },
371
+ onPointerUp(event: ReactPointerEvent) {
372
+ const origin = start.current;
373
+ start.current = null;
374
+ if (!origin || event.pointerType !== "touch") return;
375
+ const dx = event.clientX - origin.x;
376
+ const dy = event.clientY - origin.y;
377
+ if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 2) return;
378
+ if (dx < 0) handlers.current.onNext();
379
+ else handlers.current.onPrevious();
380
+ },
381
+ style: { touchAction: "pan-y" } as const,
382
+ };
383
+ }
@@ -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
+ }
@@ -0,0 +1,78 @@
1
+ import { NextResponse, type NextRequest } from "next/server";
2
+ import { readAmplifyOutputsFile } from "./amplify-outputs-path";
3
+ import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfiguredResponse } from "./site-origin";
4
+
5
+ /**
6
+ * The public values `papyrus login <site>` needs, served at `/.well-known/papyrus-cli.json`.
7
+ * Every value is public (Cognito pool/client ids, hosted-UI domain, API URL). Nothing here is a
8
+ * secret, and the file only exists for a site whose web client registers the CLI callback.
9
+ */
10
+ export const CLI_LOGIN_PATH = "/.well-known/papyrus-cli.json";
11
+
12
+ export interface CliLoginDocument {
13
+ publication: string;
14
+ issuer: string;
15
+ client_id: string;
16
+ oauth_domain: string;
17
+ graphql_endpoint: string;
18
+ callback_uri: string;
19
+ }
20
+
21
+ export interface AmplifyOutputsForCliLogin {
22
+ auth?: {
23
+ user_pool_id?: string;
24
+ user_pool_client_id?: string;
25
+ aws_region?: string;
26
+ oauth?: { domain?: string; redirect_sign_in_uri?: string[] };
27
+ };
28
+ data?: { url?: string };
29
+ }
30
+
31
+ /**
32
+ * The CLI signs in through the site's existing public web app client, which every app-shell site
33
+ * registers with this callback (infra/amplify-app-shell/lib/site-config.ts). No separate client exists.
34
+ */
35
+ export const CLI_CALLBACK_URI = "http://localhost:3001/";
36
+
37
+ export function cliLoginDocumentFromOutputs(outputs: AmplifyOutputsForCliLogin | null, siteOrigin: string): CliLoginDocument | null {
38
+ const userPoolId = outputs?.auth?.user_pool_id;
39
+ const clientId = outputs?.auth?.user_pool_client_id;
40
+ const domain = outputs?.auth?.oauth?.domain;
41
+ const endpoint = outputs?.data?.url;
42
+ if (!userPoolId || !clientId || !domain || !endpoint || !endpoint.startsWith("https://")) return null;
43
+ // Only when the client really registers the callback; otherwise the sign-in could never complete.
44
+ if (!outputs?.auth?.oauth?.redirect_sign_in_uri?.includes(CLI_CALLBACK_URI)) return null;
45
+ const region = outputs?.auth?.aws_region ?? userPoolId.split("_")[0];
46
+ return {
47
+ publication: siteOrigin,
48
+ issuer: `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`,
49
+ client_id: clientId,
50
+ oauth_domain: `https://${domain}`,
51
+ graphql_endpoint: endpoint,
52
+ callback_uri: CLI_CALLBACK_URI,
53
+ };
54
+ }
55
+
56
+ export interface CliLoginDiscoveryOptions {
57
+ readOutputs?: () => AmplifyOutputsForCliLogin | null;
58
+ environment?: Record<string, string | undefined>;
59
+ }
60
+
61
+ export function handleCliLoginDiscovery(request: Pick<NextRequest, "nextUrl">, options: CliLoginDiscoveryOptions = {}): NextResponse {
62
+ let origin: string;
63
+ try {
64
+ origin = publicSiteOrigin(request, options.environment ?? process.env);
65
+ } catch (error) {
66
+ if (error instanceof SiteOriginNotConfiguredError) return siteOriginNotConfiguredResponse(error);
67
+ throw error;
68
+ }
69
+ const outputs = (options.readOutputs ?? (() => readAmplifyOutputsFile() as AmplifyOutputsForCliLogin | null))();
70
+ const document = cliLoginDocumentFromOutputs(outputs, origin);
71
+ if (!document) {
72
+ return NextResponse.json(
73
+ { error: "cli_login_not_configured", message: "This site does not register the CLI sign-in callback." },
74
+ { status: 404, headers: { "cache-control": "public, max-age=60" } },
75
+ );
76
+ }
77
+ return NextResponse.json(document, { headers: { "cache-control": "public, max-age=300" } });
78
+ }
@@ -10,7 +10,7 @@ import type {
10
10
  MonitoringResult,
11
11
  } from "./monitoring-overview";
12
12
 
13
- type QueryField = "monitoringOverview" | "monitoringEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences";
13
+ type QueryField = "monitoringOverview" | "monitoringEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences" | "monitoringQueueEmailReference";
14
14
  type GraphQLResult = { data?: Partial<Record<QueryField, unknown>>; errors?: { message?: string; errorType?: string }[] };
15
15
 
16
16
  async function query<T>(field: QueryField, queryText: string, variables: Record<string, string | number>): Promise<T | { error: "forbidden" } | { state: "unavailable" }> {
@@ -74,10 +74,19 @@ export async function importMonitoringEmailCandidates(
74
74
  } catch {
75
75
  return { imported: references.length, references, error: "The import response could not be read. Completed links are safe to retry." };
76
76
  }
77
- if (!response?.ok || !Array.isArray(response.references)) {
77
+ if (!response?.ok || !Array.isArray(response.references) || response.references.length !== batch.length) {
78
78
  return { imported: references.length, references, error: "The import was not accepted. Check your editor access and retry." };
79
79
  }
80
80
  references.push(...response.references);
81
+ for (const [index, reference] of response.references.entries()) {
82
+ const queued = await query<{ queued: boolean }>("monitoringQueueEmailReference",
83
+ `mutation MonitoringQueueEmailReference($input: AWSJSON!) { monitoringQueueEmailReference(input: $input) }`,
84
+ { input: JSON.stringify({ receiptId, ...batch[index], referenceId: reference.id }) });
85
+ if (!("queued" in queued) || !queued.queued) {
86
+ return { imported: references.length, references,
87
+ error: "References were registered, but predictions could not be queued. Retry these selected links safely." };
88
+ }
89
+ }
81
90
  }
82
91
  return { imported: references.length, references };
83
92
  }
@@ -5,7 +5,7 @@ export const FIXTURE_MONITORING_OVERVIEW: MonitoringOverview = {
5
5
  state: "ready",
6
6
  asOf: "2026-10-21T02:40:00Z",
7
7
  scope: { mode: "publication", publicationId: "desk-a", site: "a.example.test", canViewAll: true },
8
- feeds: [{ id: "hn", sourceUrl: "https://news.ycombinator.com/rss", intervalSeconds: 900, limit: 30, enabled: true,
8
+ feeds: [{ id: "hn", sourceUrl: "https://news.ycombinator.com/rss", intervalSeconds: 900, enabled: true,
9
9
  lastStatus: "complete", lastFinishedAt: "2026-10-21T02:33:00Z", nextDueAt: "2026-10-21T02:50:00Z" }],
10
10
  runs: [
11
11
  { runId: "r3", startedAt: "2026-10-21T02:33:00.000000Z", status: "complete", new: 2, revised: 1, unchanged: 27, itemCount: 30, errorCount: 0, errors: [] },
@@ -14,7 +14,7 @@ export const FIXTURE_MONITORING_OVERVIEW: MonitoringOverview = {
14
14
  ],
15
15
  routerRuns: [{ runId: "q2", startedAt: "2026-10-21T02:37:00.000000Z", mode: "live", status: "stopped", stoppedBy: "request_ceiling_reached",
16
16
  counts: { examined: 3, decided: 1, delivered: 1, deliveryFailed: 1, routed: 1 }, errorCount: 0 }],
17
- router: { mode: "live", feedback: true, enabled: true, maxItemsPerRun: 5 },
17
+ router: { mode: "live", feedback: true, enabled: true },
18
18
  usage: { day: "2026-10-21", decisionsToday: 2, decisionsTotal: 3, historyComplete: true },
19
19
  hiddenNotRelevant: 1,
20
20
  items: [
@@ -106,7 +106,6 @@ export type MonitoringFeed = {
106
106
  id: string;
107
107
  sourceUrl: string | null;
108
108
  intervalSeconds: number | null;
109
- limit: number | null;
110
109
  enabled: boolean;
111
110
  lastStatus: string | null;
112
111
  lastFinishedAt: string | null;
@@ -121,7 +120,7 @@ export type MonitoringOverview = {
121
120
  feeds: MonitoringFeed[];
122
121
  runs: MonitoringRun[];
123
122
  routerRuns: MonitoringRouterRun[];
124
- router: { mode: string; feedback: boolean; enabled: boolean; maxItemsPerRun: number | null } | null;
123
+ router: { mode: string; feedback: boolean; enabled: boolean } | null;
125
124
  usage: {
126
125
  day: string;
127
126
  decisionsToday: number;
@@ -0,0 +1,30 @@
1
+ export type NewsroomChatFetch = typeof fetch;
2
+
3
+ export function createNewsroomChatFetch(
4
+ accessToken: () => Promise<string | null>,
5
+ fetcher: NewsroomChatFetch,
6
+ ): NewsroomChatFetch {
7
+ return async (input, init) => {
8
+ if (typeof input !== "string" || (!input.startsWith("/api/newsroom/chat/") && !input.startsWith("/api/auth/prospectus/"))) {
9
+ throw new Error("Newsroom credentials can only be sent to this site's chat identity routes.");
10
+ }
11
+ const token = await accessToken();
12
+ if (!token) throw new Error("sign_in_required");
13
+ const headers = new Headers(init?.headers);
14
+ headers.set("authorization", `Bearer ${token}`);
15
+ return fetcher(input, { ...init, headers, credentials: "same-origin", cache: "no-store" });
16
+ };
17
+ }
18
+
19
+ export const newsroomChatFetch: NewsroomChatFetch = createNewsroomChatFetch(async () => {
20
+ const { fetchAuthSession } = await import("aws-amplify/auth");
21
+ const session = await fetchAuthSession();
22
+ return session.tokens?.accessToken?.toString() ?? null;
23
+ }, (input, init) => globalThis.fetch(input, init));
24
+
25
+ export async function connectNewsroomChat(returnPath: string, fetcher: NewsroomChatFetch = newsroomChatFetch): Promise<string> {
26
+ const response = await fetcher(`/api/auth/prospectus/sign-in?newsroomChat=true&returnTo=${encodeURIComponent(returnPath)}`, { method: "POST" });
27
+ const body = await response.json() as { authorizationUrl?: string; message?: string };
28
+ if (!response.ok || !body.authorizationUrl) throw new Error(body.message ?? "Private chat could not connect to your publishing account.");
29
+ return body.authorizationUrl;
30
+ }