@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.
- package/amplify/data/schema.js +2 -0
- package/amplify/data/schema.ts +2 -0
- package/amplify/functions/category-action/handler.ts +2 -1
- package/amplify/functions/monitoring-view/schema.js +22 -0
- package/amplify/functions/monitoring-view/schema.ts +22 -0
- package/amplify/functions/monitoring-view/wire.js +1 -0
- package/amplify/functions/monitoring-view/wire.ts +1 -0
- package/amplify/functions/procedure-action/handler.ts +2 -10
- package/amplify/functions/proposal-actions/handler.py +137 -0
- package/amplify/{plugins/reader-steering/function → functions/proposal-actions}/proposals.py +20 -3
- 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 +13 -72
- package/amplify/plugins/reader-steering/function/moderation.py +19 -0
- 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/bin/papyrus-infra.mjs +21 -1
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/monitoring-overview-view.tsx +140 -23
- package/components/monitoring-panel.tsx +96 -6
- package/components/news-desk-client-provider.tsx +8 -2
- package/components/news-desk-taxonomy-client.ts +2 -1
- package/components/newsroom-mobile-navigation.tsx +23 -0
- package/components/newsroom-ops-overview.tsx +6 -1
- package/components/newsroom-ops-shell.tsx +8 -10
- package/components/newsroom-organizer.css +1 -0
- package/components/newsroom-references-view.tsx +139 -257
- package/components/notification-center.tsx +345 -63
- package/components/papyrus-console-frame.tsx +66 -0
- package/components/papyrus-console-shell.tsx +14 -42
- package/components/papyrus-private-chat-panel.tsx +50 -16
- package/components/proposals-panel.tsx +107 -0
- package/components/proposals-view.tsx +95 -0
- package/components/reference-review-card.tsx +400 -0
- package/components/topic-steering-workspace.tsx +13 -2
- package/components/use-console-visibility.ts +47 -0
- package/infra/amplify-app-shell.js +18 -2
- package/infra/amplify-app-shell.ts +18 -2
- package/infra/chat-stack.js +222 -0
- package/infra/chat-stack.ts +250 -0
- package/infra/chat.test.js +142 -0
- package/infra/chat.test.ts +151 -0
- package/infra/index.js +1 -0
- package/infra/site-config.js +58 -1
- package/infra/site-config.ts +78 -1
- package/lib/cli-login-discovery.ts +78 -0
- package/lib/monitoring-client.ts +39 -3
- package/lib/monitoring-overview-fixture.ts +2 -2
- package/lib/monitoring-overview.ts +47 -3
- package/lib/monitoring-paging.ts +58 -0
- package/lib/newsroom-chat-client.ts +30 -0
- package/lib/newsroom-chat-identity.ts +40 -0
- package/lib/newsroom-organizer-nav.ts +1 -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/proposals-client.ts +29 -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/reader-subject.ts +3 -2
- package/lib/relevance-decisions.ts +9 -0
- package/lib/review-client.ts +38 -1
- package/lib/review-drafts.ts +231 -0
- package/lib/review-fixture.ts +36 -2
- package/lib/review-requests.ts +66 -24
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/package.json +16 -1
- package/routes.manifest.json +12 -1
- package/src/sync.mjs +11 -2
- 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'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,
|