@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.
- package/amplify/data/schema.js +2 -0
- package/amplify/data/schema.ts +2 -0
- package/amplify/functions/monitoring-view/schema.js +6 -0
- package/amplify/functions/monitoring-view/schema.ts +6 -0
- package/amplify/functions/monitoring-view/wire.js +1 -0
- package/amplify/functions/monitoring-view/wire.ts +1 -0
- package/amplify/functions/proposal-actions/handler.py +137 -0
- package/amplify/functions/proposal-actions/resource.js +15 -0
- package/amplify/functions/proposal-actions/resource.ts +22 -0
- package/amplify/functions/proposal-actions/schema.js +106 -0
- package/amplify/functions/proposal-actions/schema.ts +108 -0
- package/amplify/functions/proposal-actions/wire.js +10 -0
- package/amplify/functions/proposal-actions/wire.ts +13 -0
- package/amplify/plugins/reader-steering/function/handler.py +12 -69
- package/amplify/plugins/reader-steering/papyrus.plugin.js +5 -5
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +5 -5
- package/amplify/plugins/reader-steering/schema.js +0 -95
- package/amplify/plugins/reader-steering/schema.ts +0 -95
- package/amplify/site-backend.js +4 -0
- package/amplify/site-backend.ts +4 -0
- package/app/.well-known/papyrus-cli.json/route.ts +9 -0
- package/app/globals.css +31 -35
- package/app/tailwind.css +2 -0
- package/components/monitoring-overview-view.tsx +12 -7
- package/components/monitoring-panel.tsx +33 -3
- package/components/newsroom-mobile-navigation.tsx +23 -0
- package/components/newsroom-ops-shell.tsx +8 -10
- package/components/newsroom-organizer.css +1 -0
- package/components/newsroom-references-view.tsx +141 -257
- package/components/notification-center.tsx +1 -1
- package/components/papyrus-console-frame.tsx +66 -0
- package/components/papyrus-console-shell.tsx +12 -41
- package/components/papyrus-private-chat-panel.tsx +50 -16
- package/components/proposals-panel.tsx +110 -0
- package/components/proposals-view.tsx +93 -0
- package/components/reference-review-card.tsx +383 -0
- package/components/topic-steering-workspace.tsx +13 -2
- package/components/use-console-visibility.ts +47 -0
- package/lib/cli-login-discovery.ts +78 -0
- package/lib/monitoring-client.ts +11 -2
- package/lib/monitoring-overview-fixture.ts +2 -2
- package/lib/monitoring-overview.ts +1 -2
- package/lib/newsroom-chat-client.ts +30 -0
- package/lib/newsroom-chat-identity.ts +39 -0
- package/lib/papyrus-hosted-chat.ts +62 -80
- package/lib/papyrus-private-chat-client.ts +7 -4
- package/lib/proposals-admin.ts +115 -0
- package/lib/prospectus-session.ts +2 -0
- package/lib/prospectus-sign-in-routes.ts +19 -2
- package/lib/prospectus-site-identity.ts +7 -0
- package/lib/relevance-decisions.ts +9 -0
- package/lib/review-requests.ts +4 -2
- package/package.json +12 -1
- package/routes.manifest.json +12 -1
- package/src/sync.mjs +11 -2
- package/src/with-papyrus.d.mts +1 -0
- package/src/with-papyrus.mjs +17 -1
- /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'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
|
+
}
|
package/lib/monitoring-client.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
+
}
|