@anthusai/papyrus 5.43.0 → 5.44.0
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/functions/category-action/handler.ts +2 -1
- package/amplify/functions/monitoring-view/schema.js +8 -0
- package/amplify/functions/monitoring-view/schema.ts +8 -0
- package/amplify/functions/procedure-action/handler.ts +2 -10
- package/amplify/functions/proposal-actions/proposals.py +20 -3
- package/amplify/plugins/reader-steering/function/handler.py +1 -3
- package/amplify/plugins/reader-steering/function/moderation.py +19 -0
- package/bin/papyrus-infra.mjs +21 -1
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/monitoring-overview-view.tsx +95 -19
- package/components/monitoring-panel.tsx +4 -2
- package/components/news-desk-client-provider.tsx +8 -2
- package/components/news-desk-taxonomy-client.ts +2 -1
- package/components/newsroom-ops-overview.tsx +6 -1
- package/components/newsroom-references-view.tsx +2 -4
- package/components/notification-center.tsx +345 -63
- package/components/papyrus-console-shell.tsx +2 -1
- package/components/proposals-panel.tsx +21 -24
- package/components/proposals-view.tsx +4 -2
- package/components/reference-review-card.tsx +80 -63
- 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/monitoring-client.ts +14 -1
- package/lib/monitoring-overview.ts +33 -1
- package/lib/newsroom-chat-identity.ts +2 -1
- package/lib/newsroom-organizer-nav.ts +1 -0
- package/lib/proposals-client.ts +29 -0
- package/lib/reader-subject.ts +3 -2
- 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 +64 -24
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/package.json +4 -1
- package/routes.manifest.json +1 -1
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { CheckIcon, CircleCheckIcon, CircleDotIcon, CircleXIcon, ClockIcon, ExternalLinkIcon, FlagIcon, HourglassIcon, LockIcon } from "lucide-react";
|
|
3
4
|
import Link from "next/link";
|
|
4
|
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
5
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
5
6
|
import { Badge } from "./ui/badge";
|
|
6
7
|
import { Button } from "./ui/button";
|
|
7
8
|
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
9
|
+
import { proposalReceiptMessage, stateLabel, type ProposalReceipt } from "../lib/proposals-admin";
|
|
10
|
+
import type { DraftLoad, ReviewLoad } from "../lib/review-client";
|
|
11
|
+
import { acceptPath, batchAnswer, cannotMoveReason, draftDecisionOf, draftsToDecide, draftView, rejectPath, undecidedDraftIds, withDecision, type DraftChoice, type DraftView } from "../lib/review-drafts";
|
|
8
12
|
import {
|
|
9
|
-
cyclotronAutoDecided, cyclotronHeldForReview, formatWhen, isUnread, modeLabel,
|
|
13
|
+
cyclotronAutoDecided, cyclotronHeldForReview, draftIdsOf, formatWhen, isDraftReview, isUnread, modeLabel, nextText, proposalHref, sectionReviews,
|
|
10
14
|
steeringText, subjectHref, subjectLabel, summarize, summaryText,
|
|
11
|
-
type AnswerInput, type CyclotronEntry, type ReviewRequest,
|
|
15
|
+
type AnswerInput, type CyclotronEntry, type DraftDecision, type ReviewRequest,
|
|
12
16
|
} from "../lib/review-requests";
|
|
13
17
|
import type { ReferenceRecord } from "../lib/category-repository";
|
|
14
18
|
import type { RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
15
|
-
import { FIXTURE_REVIEW_REQUESTS } from "../lib/review-fixture";
|
|
19
|
+
import { FIXTURE_DRAFTS, FIXTURE_REVIEW_REQUESTS } from "../lib/review-fixture";
|
|
20
|
+
import { ChoiceButtons } from "./reference-review-card";
|
|
16
21
|
|
|
17
22
|
export type NotificationLoadState = "loading" | "ready" | "forbidden" | "unavailable";
|
|
23
|
+
export type DraftsLoadState = "loading" | "ready" | "failed";
|
|
24
|
+
export type DraftExtras = { why: string; notThisKind: boolean };
|
|
18
25
|
|
|
19
26
|
type ViewProps = {
|
|
20
27
|
loadState: NotificationLoadState;
|
|
@@ -27,33 +34,182 @@ type ViewProps = {
|
|
|
27
34
|
demo?: boolean;
|
|
28
35
|
busyId?: string | null;
|
|
29
36
|
error?: string | null;
|
|
37
|
+
/** The AI drafts that draft reviews are about, by proposal id, and which ids have no proposal on this site. */
|
|
38
|
+
drafts?: Readonly<Record<string, DraftView>>;
|
|
39
|
+
missingDrafts?: ReadonlySet<string>;
|
|
40
|
+
draftsState?: DraftsLoadState;
|
|
41
|
+
/** Keyed `<request id>#<draft id>`. */
|
|
42
|
+
draftErrors?: Readonly<Record<string, string>>;
|
|
43
|
+
draftBusyId?: string | null;
|
|
30
44
|
onAnswer?: (request: ReviewRequest, input: AnswerInput) => void;
|
|
45
|
+
onDecideDraft?: (request: ReviewRequest, draftId: string, choice: DraftChoice, extras: DraftExtras) => void;
|
|
46
|
+
onRetryDrafts?: () => void;
|
|
31
47
|
onMarkRead?: (requestId: string) => void;
|
|
32
48
|
onMarkAllRead?: () => void;
|
|
33
49
|
};
|
|
34
50
|
|
|
35
|
-
|
|
51
|
+
const CONTROL_ICON = { hard_gate: LockIcon, soft_review: ClockIcon, flag: FlagIcon } as const;
|
|
52
|
+
const NOT_THIS_KIND = { request: "Not this kind of thing", draft: "Not this kind of story" } as const;
|
|
53
|
+
|
|
54
|
+
function WhyBox({ busy, onChange, placeholder, value }: { busy: boolean; onChange: (value: string) => void; placeholder: string; value: string }) {
|
|
55
|
+
return (
|
|
56
|
+
<label className="block text-sm font-medium">
|
|
57
|
+
Why? (optional)
|
|
58
|
+
<textarea
|
|
59
|
+
aria-label="Why? (optional)"
|
|
60
|
+
className="mt-1 block min-h-20 w-full rounded-md border bg-background p-3 text-base font-normal"
|
|
61
|
+
onChange={(event) => onChange(event.target.value)}
|
|
62
|
+
placeholder={placeholder}
|
|
63
|
+
readOnly={busy}
|
|
64
|
+
value={value}
|
|
65
|
+
/>
|
|
66
|
+
</label>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function NotThisKind({ busy, label, onToggle, pressed }: { busy: boolean; label: string; onToggle: () => void; pressed: boolean }) {
|
|
71
|
+
return (
|
|
72
|
+
<div className="space-y-1" data-review-reasons>
|
|
73
|
+
<p className="text-sm font-medium">If you reject it</p>
|
|
74
|
+
<Button aria-disabled={busy || undefined} aria-pressed={pressed} className="min-h-10" data-review-reason="not_this_kind" onClick={() => { if (!busy) onToggle(); }} type="button" variant={pressed ? "default" : "outline"}>
|
|
75
|
+
{pressed ? <CheckIcon aria-hidden="true" className="size-4" /> : null}
|
|
76
|
+
{label}
|
|
77
|
+
</Button>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Accept or Reject on the request itself: a hard gate, a soft review without drafts, or a draft review whose drafts could not be loaded. */
|
|
83
|
+
function RequestControls({ request, busy, unread, onAnswer, onMarkRead }: { request: ReviewRequest; busy: boolean; unread: boolean; onAnswer?: ViewProps["onAnswer"]; onMarkRead?: ViewProps["onMarkRead"] }) {
|
|
84
|
+
const [note, setNote] = useState("");
|
|
85
|
+
const [notThisKind, setNotThisKind] = useState(false);
|
|
86
|
+
const flag = request.mode === "flag";
|
|
87
|
+
const act = (input: AnswerInput) => { if (!busy) onAnswer?.(request, { ...input, feedback: note.trim() || null }); };
|
|
88
|
+
return (
|
|
89
|
+
<div className="space-y-3" data-review-controls>
|
|
90
|
+
{flag ? (
|
|
91
|
+
<Button aria-disabled={busy || undefined} className="min-h-12 w-full text-base" onClick={() => act({ decision: "acknowledge" })} type="button" variant="outline">Acknowledge</Button>
|
|
92
|
+
) : (
|
|
93
|
+
<ChoiceButtons chosen={null} disabled={busy} onChoose={(choice) => act(choice === "accept" ? { decision: "approve" } : { decision: "reject", steer: notThisKind ? "not_this_kind" : null })} />
|
|
94
|
+
)}
|
|
95
|
+
<div aria-live="polite" className="min-h-5 text-sm" role="status">{busy ? "Saving…" : ""}</div>
|
|
96
|
+
<WhyBox busy={busy} onChange={setNote} placeholder={flag ? "Optional. Tell the step what to do differently." : "Optional. Write it before you tap Accept or Reject; it is saved with your answer."} value={note} />
|
|
97
|
+
{flag ? null : <NotThisKind busy={busy} label={NOT_THIS_KIND.request} onToggle={() => setNotThisKind((current) => !current)} pressed={notThisKind} />}
|
|
98
|
+
{unread ? <Button aria-disabled={busy || undefined} onClick={() => { if (!busy) onMarkRead?.(request.id); }} size="sm" variant="ghost">Mark read</Button> : null}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function DecidedLine({ choice, notThisKind, why }: { choice: "accept" | "reject"; notThisKind?: boolean; why?: string | null }) {
|
|
104
|
+
const Icon = choice === "accept" ? CircleCheckIcon : CircleXIcon;
|
|
105
|
+
return (
|
|
106
|
+
<p className="flex items-start gap-1.5 text-sm font-medium" data-draft-decided={choice}>
|
|
107
|
+
<Icon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
|
108
|
+
<span className="break-words">
|
|
109
|
+
{choice === "accept" ? "Accepted" : "Rejected"}{choice === "reject" && notThisKind ? " as not this kind of story" : ""}
|
|
110
|
+
{why ? <span className="font-normal text-muted-foreground">: {why}</span> : null}
|
|
111
|
+
</span>
|
|
112
|
+
</p>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** One AI draft, shown so it can be judged in place: headline, dek, section, evidence, sources and the way to the full draft. */
|
|
117
|
+
function DraftCard({ id, view, request, busy, error, onDecide }: {
|
|
118
|
+
id: string; view: DraftView | undefined; request: ReviewRequest; busy: boolean; error: string | null;
|
|
119
|
+
onDecide?: (draftId: string, choice: DraftChoice, extras: { why: string; notThisKind: boolean }) => void;
|
|
120
|
+
}) {
|
|
121
|
+
const [why, setWhy] = useState("");
|
|
122
|
+
const [notThisKind, setNotThisKind] = useState(false);
|
|
123
|
+
const link = request.links.find((entry) => entry.id === id);
|
|
124
|
+
const decision = draftDecisionOf(link, view, { by: "", at: "" });
|
|
125
|
+
const hold = view ? cannotMoveReason(view) : null;
|
|
126
|
+
if (!view) {
|
|
127
|
+
return (
|
|
128
|
+
<li className="rounded-lg border p-3 text-sm" data-review-draft={id} data-draft-state="loading" role="status">
|
|
129
|
+
<p className="font-medium">{link?.label ?? "Draft"}</p>
|
|
130
|
+
<p className="text-muted-foreground">Loading the draft…</p>
|
|
131
|
+
</li>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return (
|
|
135
|
+
<li className="space-y-3 rounded-lg border p-3" data-review-draft={id} data-draft-state={decision ? "decided" : "open"}>
|
|
136
|
+
<header className="space-y-1">
|
|
137
|
+
<h3 className="break-words text-base font-semibold leading-snug">{view.headline}</h3>
|
|
138
|
+
<p className="text-xs text-muted-foreground">
|
|
139
|
+
{view.kindLabel}{view.section ? ` · Section: ${view.section}` : ""} · Proposed by AI · {stateLabel(view.state, false)}
|
|
140
|
+
</p>
|
|
141
|
+
</header>
|
|
142
|
+
{view.dek ? <p className="break-words text-sm" data-draft-dek>{view.dek}</p> : null}
|
|
143
|
+
{view.evidence.length ? (
|
|
144
|
+
<ul aria-label="Evidence" className="space-y-0.5 text-sm" data-draft-evidence>
|
|
145
|
+
{view.evidence.map((line) => <li className="break-words" key={line}><span className="font-medium">Evidence:</span> {line}</li>)}
|
|
146
|
+
</ul>
|
|
147
|
+
) : null}
|
|
148
|
+
{view.sources.length ? (
|
|
149
|
+
<div className="space-y-1">
|
|
150
|
+
<p className="text-sm font-medium">Sources</p>
|
|
151
|
+
<ul aria-label="Sources" className="space-y-0.5 text-sm" data-draft-sources>
|
|
152
|
+
{view.sources.map((source, index) => (
|
|
153
|
+
<li className="break-words" key={`${source.title}-${index}`}>
|
|
154
|
+
{source.url ? (
|
|
155
|
+
<a className="inline-flex max-w-full items-center gap-1 text-primary underline underline-offset-4" href={source.url} rel="noreferrer" target="_blank">
|
|
156
|
+
<span className="break-words">{source.title}</span><ExternalLinkIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
|
157
|
+
</a>
|
|
158
|
+
) : source.title}
|
|
159
|
+
{source.date ? <span className="text-muted-foreground"> ({source.date})</span> : null}
|
|
160
|
+
</li>
|
|
161
|
+
))}
|
|
162
|
+
{view.moreSources ? <li className="text-muted-foreground">and {view.moreSources} more in the full draft</li> : null}
|
|
163
|
+
</ul>
|
|
164
|
+
</div>
|
|
165
|
+
) : null}
|
|
166
|
+
<Link className="inline-flex items-center gap-1 text-sm text-primary underline underline-offset-4" data-draft-open href={view.href}>
|
|
167
|
+
Open the full draft in Proposals<ExternalLinkIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
|
168
|
+
</Link>
|
|
169
|
+
{decision ? (
|
|
170
|
+
<DecidedLine choice={decision.choice} notThisKind={decision.notThisKind} why={decision.why} />
|
|
171
|
+
) : (
|
|
172
|
+
<div className="space-y-3" data-review-controls>
|
|
173
|
+
<ChoiceButtons chosen={null} disabled={busy || Boolean(hold)} onChoose={(choice) => { if (!busy && !hold) onDecide?.(id, choice, { why: why.trim(), notThisKind }); }} />
|
|
174
|
+
<div aria-live="polite" className="min-h-5 text-sm" role="status">{busy ? "Saving…" : ""}</div>
|
|
175
|
+
{hold ? <p className="text-sm" data-review-disabled-reason>{hold}</p> : null}
|
|
176
|
+
{error ? <p className="text-sm text-destructive" role="alert">{error}</p> : null}
|
|
177
|
+
<WhyBox busy={busy} onChange={setWhy} placeholder="Optional. Write it before you tap Accept or Reject; it is saved with your answer and teaches the AI." value={why} />
|
|
178
|
+
<NotThisKind busy={busy} label={NOT_THIS_KIND.draft} onToggle={() => setNotThisKind((current) => !current)} pressed={notThisKind} />
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</li>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function Entry({ request, unread, demo, busy, titles, deleted, drafts, missingDrafts, draftsState, draftErrors, draftBusyId, onAnswer, onMarkRead, onDecideDraft, onRetryDrafts }: {
|
|
36
186
|
request: ReviewRequest; unread: boolean; demo: boolean; busy: boolean; titles: Readonly<Record<string, string>>; deleted: ReadonlySet<string>;
|
|
37
|
-
|
|
187
|
+
drafts: Readonly<Record<string, DraftView>>; missingDrafts: ReadonlySet<string>; draftsState: DraftsLoadState; draftErrors: Readonly<Record<string, string>>; draftBusyId: string | null;
|
|
188
|
+
onAnswer?: ViewProps["onAnswer"]; onMarkRead?: ViewProps["onMarkRead"]; onDecideDraft?: ViewProps["onDecideDraft"]; onRetryDrafts?: () => void;
|
|
38
189
|
}) {
|
|
39
|
-
const [note, setNote] = useState("");
|
|
40
190
|
const href = subjectHref(request.subject, demo);
|
|
41
191
|
const open = request.state === "open";
|
|
42
192
|
const steering = steeringText(request);
|
|
43
|
-
const
|
|
193
|
+
const draftIds = draftIdsOf(request);
|
|
194
|
+
const draftReview = isDraftReview(request);
|
|
195
|
+
const available = draftIds.filter((id) => !missingDrafts.has(id));
|
|
196
|
+
const Icon = CONTROL_ICON[request.mode];
|
|
197
|
+
// A draft review is decided draft by draft. Only when none of its drafts can be opened here (an idea kept outside Proposals) does the request itself take Accept or Reject.
|
|
198
|
+
const requestLevel = open && (!draftReview || (draftsState === "ready" && available.length === 0));
|
|
199
|
+
const recorded = request.answer?.drafts ?? [];
|
|
44
200
|
return (
|
|
45
|
-
<li className="rounded-lg border p-3
|
|
201
|
+
<li className="space-y-3 rounded-lg border p-3" data-review-request={request.id} data-review-mode={request.mode} data-review-state={request.state} data-review-unread={unread || undefined}>
|
|
46
202
|
<div className="flex flex-wrap items-center gap-2">
|
|
47
|
-
<Badge variant="outline"><
|
|
48
|
-
{unread ? <Badge variant="default"><
|
|
203
|
+
<Badge variant="outline"><Icon aria-hidden="true" className="mr-1 size-3" />{modeLabel(request.mode)}</Badge>
|
|
204
|
+
{unread ? <Badge variant="default"><CircleDotIcon aria-hidden="true" className="mr-1 size-3" />New</Badge> : <span className="text-xs text-muted-foreground">Read</span>}
|
|
49
205
|
<strong className="text-sm">{request.reason}</strong>
|
|
50
206
|
</div>
|
|
51
207
|
<p className="text-xs text-muted-foreground">
|
|
52
208
|
Raised by {request.raisedBy} on {formatWhen(request.raisedAt)}
|
|
53
|
-
{" · "}About: {href ? <Link href={href} onClick={() => onMarkRead?.(request.id)}>{subjectLabel(request.subject, titles, deleted)}</Link> : <span>{subjectLabel(request.subject, titles, deleted)}</span>}
|
|
209
|
+
{draftReview ? null : <>{" · "}About: {href ? <Link href={href} onClick={() => onMarkRead?.(request.id)}>{subjectLabel(request.subject, titles, deleted)}</Link> : <span>{subjectLabel(request.subject, titles, deleted)}</span>}</>}
|
|
54
210
|
</p>
|
|
55
|
-
{request.ask ? <p className="text-sm">{request.ask}</p> : null}
|
|
56
|
-
{request.links.length ? (
|
|
211
|
+
{request.ask && !draftReview ? <p className="text-sm">{request.ask}</p> : null}
|
|
212
|
+
{!draftReview && request.links.length ? (
|
|
57
213
|
<ul className="text-xs space-y-0.5" aria-label="Items in this request">
|
|
58
214
|
{request.links.slice(0, 10).map((link, index) => {
|
|
59
215
|
const target = link.url ?? (link.type ? subjectHref({ type: link.type, id: link.id ?? "" }, demo) : null);
|
|
@@ -62,37 +218,44 @@ function Entry({ request, unread, demo, busy, titles, deleted, onAnswer, onMarkR
|
|
|
62
218
|
{request.links.length > 10 ? <li>and {request.links.length - 10} more</li> : null}
|
|
63
219
|
</ul>
|
|
64
220
|
) : null}
|
|
221
|
+
{draftReview && open ? (
|
|
222
|
+
<div className="space-y-2">
|
|
223
|
+
<p className="text-sm">Accept or Reject each draft. An accepted draft is approved in Proposals; a rejected one is declined there. Nothing is published by this page.</p>
|
|
224
|
+
{draftsState === "failed" ? (
|
|
225
|
+
<div className="space-y-2" role="alert">
|
|
226
|
+
<p className="text-sm">The drafts could not be loaded right now.</p>
|
|
227
|
+
<Button onClick={onRetryDrafts} size="sm" type="button" variant="outline">Try again</Button>
|
|
228
|
+
</div>
|
|
229
|
+
) : null}
|
|
230
|
+
<ul aria-label="Drafts in this request" className="space-y-3" data-review-drafts>
|
|
231
|
+
{draftsState === "failed" ? null : available.map((id) => (
|
|
232
|
+
<DraftCard
|
|
233
|
+
busy={draftBusyId === `${request.id}#${id}`} error={draftErrors[`${request.id}#${id}`] ?? null} id={id} key={id} request={request}
|
|
234
|
+
onDecide={(draftId, choice, extras) => onDecideDraft?.(request, draftId, choice, extras)} view={drafts[id]}
|
|
235
|
+
/>
|
|
236
|
+
))}
|
|
237
|
+
</ul>
|
|
238
|
+
{draftIds.length > available.length ? <p className="text-sm text-muted-foreground">{draftIds.length - available.length} of these drafts are not in Proposals on this site, so they cannot be opened here.</p> : null}
|
|
239
|
+
</div>
|
|
240
|
+
) : null}
|
|
241
|
+
{!open && recorded.length ? (
|
|
242
|
+
<ul aria-label="What you decided" className="space-y-1" data-review-decisions>
|
|
243
|
+
{recorded.map((entry) => (
|
|
244
|
+
<li key={entry.id}>
|
|
245
|
+
<DecidedLine choice={entry.choice} notThisKind={entry.notThisKind} why={entry.why} />
|
|
246
|
+
<Link className="ml-5 text-xs text-primary underline underline-offset-4" href={proposalHref(entry.id, demo)}>{entry.title ?? entry.id}</Link>
|
|
247
|
+
</li>
|
|
248
|
+
))}
|
|
249
|
+
</ul>
|
|
250
|
+
) : null}
|
|
65
251
|
<p className="text-sm" data-review-next>{nextText(request)}</p>
|
|
66
252
|
{steering ? <p className="text-sm" data-review-steering>{steering}</p> : null}
|
|
67
|
-
{
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
<textarea
|
|
72
|
-
className="mt-1 block w-full rounded-md border bg-background p-2 text-sm"
|
|
73
|
-
disabled={busy}
|
|
74
|
-
onChange={(event) => setNote(event.target.value)}
|
|
75
|
-
placeholder={request.mode === "flag" ? "Tell the step what to do differently" : "What should change, or what did you want instead?"}
|
|
76
|
-
rows={2}
|
|
77
|
-
value={note}
|
|
78
|
-
/>
|
|
79
|
-
</label>
|
|
80
|
-
<div className="flex flex-wrap gap-2">
|
|
81
|
-
{request.mode === "flag" ? (
|
|
82
|
-
<Button disabled={busy} onClick={() => act({ decision: "acknowledge" })} size="sm" variant="outline">Acknowledge</Button>
|
|
83
|
-
) : (
|
|
84
|
-
<>
|
|
85
|
-
<Button disabled={busy} onClick={() => act({ decision: "approve" })} size="sm" variant="outline">Approve</Button>
|
|
86
|
-
<Button disabled={busy} onClick={() => act({ decision: "reject" })} size="sm" variant="outline">Reject</Button>
|
|
87
|
-
</>
|
|
88
|
-
)}
|
|
89
|
-
<Button disabled={busy || !note.trim()} onClick={() => act({ decision: "feedback", steer: "adjust" })} size="sm" variant="outline">Steer with this note</Button>
|
|
90
|
-
<Button disabled={busy} onClick={() => act({ decision: "feedback", steer: "not_this_kind" })} size="sm" variant="outline">Not this kind of thing</Button>
|
|
91
|
-
{unread ? <Button disabled={busy} onClick={() => onMarkRead?.(request.id)} size="sm" variant="ghost">Mark read</Button> : null}
|
|
92
|
-
</div>
|
|
93
|
-
</div>
|
|
253
|
+
{requestLevel ? (
|
|
254
|
+
<RequestControls busy={busy} onAnswer={onAnswer} onMarkRead={onMarkRead} request={request} unread={unread} />
|
|
255
|
+
) : open ? (
|
|
256
|
+
unread ? <Button aria-disabled={busy || undefined} onClick={() => { if (!busy) onMarkRead?.(request.id); }} size="sm" variant="ghost">Mark read</Button> : null
|
|
94
257
|
) : unread ? (
|
|
95
|
-
<Button disabled={busy} onClick={() => onMarkRead?.(request.id)} size="sm" variant="ghost">Mark read</Button>
|
|
258
|
+
<Button aria-disabled={busy || undefined} onClick={() => { if (!busy) onMarkRead?.(request.id); }} size="sm" variant="ghost">Mark read</Button>
|
|
96
259
|
) : null}
|
|
97
260
|
</li>
|
|
98
261
|
);
|
|
@@ -103,7 +266,7 @@ function CyclotronLine({ entry, held }: { entry: CyclotronEntry; held: boolean }
|
|
|
103
266
|
return (
|
|
104
267
|
<li className="rounded-lg border p-3 space-y-1" data-cyclotron-entry={entry.lineageId} data-cyclotron-held={held || undefined}>
|
|
105
268
|
<div className="flex flex-wrap items-center gap-2">
|
|
106
|
-
<Badge variant="outline"
|
|
269
|
+
<Badge variant="outline">{held ? <HourglassIcon aria-hidden="true" className="mr-1 size-3" /> : <CircleCheckIcon aria-hidden="true" className="mr-1 size-3" />}{held ? "Held for review" : "Decided by the system"}</Badge>
|
|
107
270
|
<strong className="text-sm">{entry.title}</strong>
|
|
108
271
|
</div>
|
|
109
272
|
<p className="text-xs text-muted-foreground">
|
|
@@ -125,13 +288,21 @@ function Section({ id, title, count, children, empty }: { id: string; title: str
|
|
|
125
288
|
);
|
|
126
289
|
}
|
|
127
290
|
|
|
128
|
-
/** The CMS console home: what needs a person, what will
|
|
129
|
-
export function NotificationCenterView({
|
|
291
|
+
/** The CMS console home: what needs a person, what will close on its own, and what the system decided. Ignore-safe by design. */
|
|
292
|
+
export function NotificationCenterView({
|
|
293
|
+
loadState, requests, readIds, held, autoDecided, demo = false, storyTitles = {}, deletedStories = new Set<string>(), busyId = null, error = null,
|
|
294
|
+
drafts = {}, missingDrafts = new Set<string>(), draftsState = "ready", draftErrors = {}, draftBusyId = null,
|
|
295
|
+
onAnswer, onDecideDraft, onRetryDrafts, onMarkRead, onMarkAllRead,
|
|
296
|
+
}: ViewProps) {
|
|
130
297
|
const sections = useMemo(() => sectionReviews(requests), [requests]);
|
|
131
298
|
const unreadCount = requests.filter((request) => isUnread(request, readIds)).length;
|
|
132
|
-
const summary = summarize(sections, held, autoDecided, unreadCount);
|
|
299
|
+
const summary = summarize(sections, held, autoDecided, unreadCount, draftsToDecide(sections.draftBatches, drafts, missingDrafts));
|
|
133
300
|
const entry = (request: ReviewRequest) => (
|
|
134
|
-
<Entry
|
|
301
|
+
<Entry
|
|
302
|
+
busy={busyId === request.id} demo={demo} deleted={deletedStories} draftBusyId={draftBusyId} draftErrors={draftErrors} drafts={drafts} draftsState={draftsState}
|
|
303
|
+
key={request.id} missingDrafts={missingDrafts} onAnswer={onAnswer} onDecideDraft={onDecideDraft} onMarkRead={onMarkRead} onRetryDrafts={onRetryDrafts}
|
|
304
|
+
request={request} titles={storyTitles} unread={isUnread(request, readIds)}
|
|
305
|
+
/>
|
|
135
306
|
);
|
|
136
307
|
if (loadState === "loading") return (
|
|
137
308
|
<section aria-label="Loading Notification Center" className="space-y-4" data-notification-state="loading" role="status">
|
|
@@ -153,7 +324,7 @@ export function NotificationCenterView({ loadState, requests, readIds, held, aut
|
|
|
153
324
|
<h1 className="text-lg font-semibold">Notification Center</h1>
|
|
154
325
|
<p className="text-sm" data-notification-summary>{summaryText(summary)}</p>
|
|
155
326
|
<p className="text-xs text-muted-foreground">
|
|
156
|
-
|
|
327
|
+
Accept or Reject what you want to decide, and ignore the rest. An AI draft you leave alone stays in Proposals and nothing is published without an editor. A review with a deadline closes when the deadline passes, and flags never wait. Only a hard gate waits for you.
|
|
157
328
|
</p>
|
|
158
329
|
<p className="text-xs text-muted-foreground" data-notification-unread>
|
|
159
330
|
{unreadCount ? `${unreadCount} unread` : "Nothing unread"}
|
|
@@ -165,7 +336,10 @@ export function NotificationCenterView({ loadState, requests, readIds, held, aut
|
|
|
165
336
|
<Section count={sections.hardGates.length} empty="No hard gates. Nothing is blocked on you." id="hard-gates" title="Waiting for you: hard gates">
|
|
166
337
|
<ul className="space-y-2">{sections.hardGates.map(entry)}</ul>
|
|
167
338
|
</Section>
|
|
168
|
-
<Section count={sections.
|
|
339
|
+
<Section count={sections.draftBatches.length} empty="No AI drafts are waiting for review." id="drafts" title="AI drafts to review">
|
|
340
|
+
<ul className="space-y-3">{sections.draftBatches.map(entry)}</ul>
|
|
341
|
+
</Section>
|
|
342
|
+
<Section count={sections.softReviews.length} empty="No other reviews are open." id="soft-reviews" title="Other reviews, soonest deadline first">
|
|
169
343
|
<ul className="space-y-2">{sections.softReviews.map(entry)}</ul>
|
|
170
344
|
</Section>
|
|
171
345
|
<Section count={held.length} empty="No Cyclotron items are held for review." id="cyclotron" title="Cyclotron items routed to a person (low confidence)">
|
|
@@ -189,17 +363,39 @@ export function NotificationCenterView({ loadState, requests, readIds, held, aut
|
|
|
189
363
|
);
|
|
190
364
|
}
|
|
191
365
|
|
|
366
|
+
/** Everything the container reads and writes. The live one talks to the site's data API; tests pass a fake. */
|
|
367
|
+
export type NotificationApi = {
|
|
368
|
+
loadReviews: () => Promise<ReviewLoad>;
|
|
369
|
+
loadDrafts: (ids: string[]) => Promise<DraftLoad>;
|
|
370
|
+
loadStoryTitles: (ids: string[]) => Promise<{ titles: Record<string, string>; deleted: string[] }>;
|
|
371
|
+
answerReviewRequest: (request: ReviewRequest, input: AnswerInput, by: string) => Promise<ReviewRequest>;
|
|
372
|
+
saveDraftDecision: (request: ReviewRequest, draftId: string, decision: DraftDecision) => Promise<ReviewRequest>;
|
|
373
|
+
markReviewRead: (userKey: string, requestId: string) => Promise<void>;
|
|
374
|
+
transitionProposalPath: (proposalId: string, path: readonly string[]) => Promise<ProposalReceipt>;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const liveApi: NotificationApi = {
|
|
378
|
+
loadReviews: async () => (await import("../lib/review-client")).loadReviews(),
|
|
379
|
+
loadDrafts: async (ids) => (await import("../lib/review-client")).loadDrafts(ids),
|
|
380
|
+
loadStoryTitles: async (ids) => (await import("../lib/review-client")).loadStoryTitles(ids),
|
|
381
|
+
answerReviewRequest: async (request, input, by) => (await import("../lib/review-client")).answerReviewRequest(request, input, by),
|
|
382
|
+
saveDraftDecision: async (request, draftId, decision) => (await import("../lib/review-client")).saveDraftDecision(request, draftId, decision),
|
|
383
|
+
markReviewRead: async (userKey, requestId) => (await import("../lib/review-client")).markReviewRead(userKey, requestId),
|
|
384
|
+
transitionProposalPath: async (proposalId, path) => (await import("../lib/proposals-client")).transitionProposalPath(proposalId, path),
|
|
385
|
+
};
|
|
386
|
+
|
|
192
387
|
type ContainerProps = {
|
|
193
388
|
references: ReferenceRecord[];
|
|
194
389
|
decisions: ReadonlyMap<string, RelevanceDecisionView>;
|
|
195
390
|
humanCuratedReferenceLineageIds: ReadonlySet<string>;
|
|
196
391
|
actorLabel: string;
|
|
197
392
|
demo: boolean;
|
|
393
|
+
api?: NotificationApi;
|
|
198
394
|
};
|
|
199
395
|
|
|
200
396
|
const REFRESH_MILLISECONDS = 60_000;
|
|
201
397
|
|
|
202
|
-
export function NotificationCenter({ references, decisions, humanCuratedReferenceLineageIds, actorLabel, demo }: ContainerProps) {
|
|
398
|
+
export function NotificationCenter({ references, decisions, humanCuratedReferenceLineageIds, actorLabel, demo, api = liveApi }: ContainerProps) {
|
|
203
399
|
const [loadState, setLoadState] = useState<NotificationLoadState>("loading");
|
|
204
400
|
const [requests, setRequests] = useState<ReviewRequest[]>([]);
|
|
205
401
|
const [readIds, setReadIds] = useState<Set<string>>(new Set());
|
|
@@ -208,17 +404,45 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
|
|
|
208
404
|
const [error, setError] = useState<string | null>(null);
|
|
209
405
|
const [storyTitles, setStoryTitles] = useState<Record<string, string>>({});
|
|
210
406
|
const [deletedStories, setDeletedStories] = useState<ReadonlySet<string>>(new Set());
|
|
407
|
+
const [drafts, setDrafts] = useState<Record<string, DraftView>>({});
|
|
408
|
+
const [missingDrafts, setMissingDrafts] = useState<ReadonlySet<string>>(new Set());
|
|
409
|
+
const [draftsState, setDraftsState] = useState<DraftsLoadState>("loading");
|
|
410
|
+
const [draftErrors, setDraftErrors] = useState<Record<string, string>>({});
|
|
411
|
+
const [draftBusyId, setDraftBusyId] = useState<string | null>(null);
|
|
412
|
+
const resolving = useRef(new Set<string>());
|
|
413
|
+
|
|
414
|
+
const loadDraftsFor = useCallback(async (list: ReviewRequest[]) => {
|
|
415
|
+
const ids = list.filter((request) => request.state === "open" && isDraftReview(request)).flatMap(draftIdsOf);
|
|
416
|
+
if (!ids.length) {
|
|
417
|
+
setDraftsState("ready");
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (demo) {
|
|
421
|
+
setDrafts(Object.fromEntries(FIXTURE_DRAFTS.map((row) => [row.id, draftView(row, true)])));
|
|
422
|
+
setMissingDrafts(new Set(ids.filter((id) => !FIXTURE_DRAFTS.some((row) => row.id === id))));
|
|
423
|
+
setDraftsState("ready");
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
const loaded = await api.loadDrafts(ids);
|
|
428
|
+
setDrafts(loaded.drafts);
|
|
429
|
+
setMissingDrafts(new Set(loaded.missing));
|
|
430
|
+
setDraftsState(loaded.failed.length ? "failed" : "ready");
|
|
431
|
+
} catch {
|
|
432
|
+
setDraftsState("failed");
|
|
433
|
+
}
|
|
434
|
+
}, [api, demo]);
|
|
211
435
|
|
|
212
436
|
const refresh = useCallback(async () => {
|
|
213
437
|
if (demo) {
|
|
214
438
|
setRequests(FIXTURE_REVIEW_REQUESTS);
|
|
215
439
|
setReadIds(new Set(["rr-flag-1"]));
|
|
216
440
|
setLoadState("ready");
|
|
441
|
+
void loadDraftsFor(FIXTURE_REVIEW_REQUESTS);
|
|
217
442
|
return;
|
|
218
443
|
}
|
|
219
444
|
try {
|
|
220
|
-
const
|
|
221
|
-
const loaded = await loadReviews();
|
|
445
|
+
const loaded = await api.loadReviews();
|
|
222
446
|
if (loaded.state !== "ready") {
|
|
223
447
|
setLoadState(loaded.state);
|
|
224
448
|
return;
|
|
@@ -227,14 +451,14 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
|
|
|
227
451
|
setReadIds(new Set(loaded.readIds));
|
|
228
452
|
setUserKey(loaded.userKey);
|
|
229
453
|
setLoadState("ready");
|
|
230
|
-
|
|
231
|
-
const stories = await loadStoryTitles(loaded.requests.filter((request) => request.subject.type === "story").map((request) => request.subject.id)).catch(() => ({ titles: {}, deleted: [] as string[] }));
|
|
454
|
+
void loadDraftsFor(loaded.requests);
|
|
455
|
+
const stories = await api.loadStoryTitles(loaded.requests.filter((request) => request.subject.type === "story").map((request) => request.subject.id)).catch(() => ({ titles: {}, deleted: [] as string[] }));
|
|
232
456
|
setStoryTitles(stories.titles);
|
|
233
457
|
setDeletedStories(new Set(stories.deleted));
|
|
234
458
|
} catch {
|
|
235
459
|
setLoadState("unavailable");
|
|
236
460
|
}
|
|
237
|
-
}, [demo]);
|
|
461
|
+
}, [api, demo, loadDraftsFor]);
|
|
238
462
|
|
|
239
463
|
useEffect(() => {
|
|
240
464
|
void refresh();
|
|
@@ -248,17 +472,15 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
|
|
|
248
472
|
const markRead = useCallback(async (requestId: string) => {
|
|
249
473
|
setReadIds((current) => new Set(current).add(requestId));
|
|
250
474
|
if (demo || !userKey) return;
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}, [demo, userKey]);
|
|
475
|
+
await api.markReviewRead(userKey, requestId).catch(() => undefined);
|
|
476
|
+
}, [api, demo, userKey]);
|
|
254
477
|
|
|
255
478
|
const answer = useCallback(async (request: ReviewRequest, input: AnswerInput) => {
|
|
256
479
|
setError(null);
|
|
257
480
|
setBusyId(request.id);
|
|
258
481
|
try {
|
|
259
482
|
if (demo) throw new Error("This is a demo: answers are not saved.");
|
|
260
|
-
const
|
|
261
|
-
const saved = await answerReviewRequest(request, input, actorLabel);
|
|
483
|
+
const saved = await api.answerReviewRequest(request, input, actorLabel);
|
|
262
484
|
setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
|
|
263
485
|
await markRead(request.id);
|
|
264
486
|
} catch (failure) {
|
|
@@ -266,7 +488,65 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
|
|
|
266
488
|
} finally {
|
|
267
489
|
setBusyId(null);
|
|
268
490
|
}
|
|
269
|
-
}, [actorLabel, demo, markRead]);
|
|
491
|
+
}, [actorLabel, api, demo, markRead]);
|
|
492
|
+
|
|
493
|
+
/** Accept or Reject one draft: the Proposal moves to approved or declined, the decision is recorded on the request, and the request closes once every draft is decided. */
|
|
494
|
+
const decideDraft = useCallback(async (request: ReviewRequest, draftId: string, choice: DraftChoice, extras: DraftExtras) => {
|
|
495
|
+
const key = `${request.id}#${draftId}`;
|
|
496
|
+
setDraftErrors(({ [key]: _gone, ...rest }) => rest);
|
|
497
|
+
setDraftBusyId(key);
|
|
498
|
+
resolving.current.add(request.id);
|
|
499
|
+
try {
|
|
500
|
+
if (demo) throw new Error("This is a demo: decisions are not saved.");
|
|
501
|
+
const view = drafts[draftId];
|
|
502
|
+
if (!view) throw new Error("This draft has not loaded yet.");
|
|
503
|
+
const path = choice === "accept" ? acceptPath(view.state) : rejectPath(view.state);
|
|
504
|
+
if (!path) throw new Error(`This draft is ${stateLabel(view.state, false).toLowerCase()} and cannot be ${choice === "accept" ? "accepted" : "rejected"} from here. Open it in Proposals.`);
|
|
505
|
+
const receipt = await api.transitionProposalPath(draftId, path);
|
|
506
|
+
if (receipt.status !== "ok") throw new Error(proposalReceiptMessage(receipt));
|
|
507
|
+
const decided: DraftView = { ...view, state: choice === "accept" ? "approved" : "declined", decided: choice };
|
|
508
|
+
const nextDrafts = { ...drafts, [draftId]: decided };
|
|
509
|
+
setDrafts(nextDrafts);
|
|
510
|
+
const decision: DraftDecision = { choice, title: view.headline, why: extras.why || null, notThisKind: choice === "reject" && extras.notThisKind, by: actorLabel, at: new Date().toISOString() };
|
|
511
|
+
const withDecisionRequest = { ...request, links: withDecision(request, draftId, decision) };
|
|
512
|
+
const closing = batchAnswer(withDecisionRequest, nextDrafts, { by: actorLabel, at: decision.at });
|
|
513
|
+
try {
|
|
514
|
+
const saved = closing ? await api.answerReviewRequest(request, closing, actorLabel) : await api.saveDraftDecision(request, draftId, decision);
|
|
515
|
+
setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
|
|
516
|
+
if (closing) await markRead(request.id);
|
|
517
|
+
} catch (failure) {
|
|
518
|
+
throw new Error(`The draft was ${choice === "accept" ? "accepted" : "rejected"} in Proposals, but this request could not record it. ${failure instanceof Error ? failure.message : ""}`.trim());
|
|
519
|
+
}
|
|
520
|
+
} catch (failure) {
|
|
521
|
+
setDraftErrors((current) => ({ ...current, [key]: failure instanceof Error ? failure.message : "Could not save the decision." }));
|
|
522
|
+
if (!demo) void loadDraftsFor([request]);
|
|
523
|
+
} finally {
|
|
524
|
+
resolving.current.delete(request.id);
|
|
525
|
+
setDraftBusyId(null);
|
|
526
|
+
}
|
|
527
|
+
}, [actorLabel, api, demo, drafts, loadDraftsFor, markRead]);
|
|
528
|
+
|
|
529
|
+
// Drafts decided in Proposals (not here) still close their request.
|
|
530
|
+
useEffect(() => {
|
|
531
|
+
if (demo || draftsState !== "ready") return;
|
|
532
|
+
for (const request of requests) {
|
|
533
|
+
if (request.state !== "open" || !isDraftReview(request) || resolving.current.has(request.id)) continue;
|
|
534
|
+
if (undecidedDraftIds(request, drafts).length) continue;
|
|
535
|
+
const closing = batchAnswer(request, drafts, { by: "an editor in Proposals", at: new Date().toISOString() });
|
|
536
|
+
if (!closing) continue;
|
|
537
|
+
resolving.current.add(request.id);
|
|
538
|
+
void (async () => {
|
|
539
|
+
try {
|
|
540
|
+
const saved = await api.answerReviewRequest(request, closing, "an editor in Proposals");
|
|
541
|
+
setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
|
|
542
|
+
} catch {
|
|
543
|
+
// The next refresh shows what became of it.
|
|
544
|
+
} finally {
|
|
545
|
+
resolving.current.delete(request.id);
|
|
546
|
+
}
|
|
547
|
+
})();
|
|
548
|
+
}
|
|
549
|
+
}, [api, demo, drafts, draftsState, requests]);
|
|
270
550
|
|
|
271
551
|
const markAllRead = useCallback(() => {
|
|
272
552
|
for (const request of requests) if (isUnread(request, readIds)) void markRead(request.id);
|
|
@@ -274,8 +554,10 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
|
|
|
274
554
|
|
|
275
555
|
return (
|
|
276
556
|
<NotificationCenterView
|
|
277
|
-
autoDecided={autoDecided} busyId={busyId} storyTitles={storyTitles} deletedStories={deletedStories} demo={demo}
|
|
278
|
-
|
|
557
|
+
autoDecided={autoDecided} busyId={busyId} storyTitles={storyTitles} deletedStories={deletedStories} demo={demo} draftBusyId={draftBusyId} draftErrors={draftErrors}
|
|
558
|
+
drafts={drafts} draftsState={draftsState} error={error} held={held} loadState={loadState} missingDrafts={missingDrafts}
|
|
559
|
+
onAnswer={answer} onDecideDraft={(request, draftId, choice, extras) => void decideDraft(request, draftId, choice, extras)}
|
|
560
|
+
onMarkAllRead={markAllRead} onMarkRead={markRead} onRetryDrafts={() => void loadDraftsFor(requests)} readIds={readIds} requests={requests}
|
|
279
561
|
/>
|
|
280
562
|
);
|
|
281
563
|
}
|
|
@@ -6,6 +6,7 @@ import dynamic from "next/dynamic";
|
|
|
6
6
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
7
7
|
import { CheckIcon, UserIcon } from "lucide-react";
|
|
8
8
|
import { Md5 } from "@smithy/md5-js";
|
|
9
|
+
import { hasEditorRights } from "../lib/staff-groups";
|
|
9
10
|
import {
|
|
10
11
|
createConsoleMessage,
|
|
11
12
|
createConsoleThread,
|
|
@@ -154,7 +155,7 @@ export function PapyrusConsoleShell({ children, privateChatEnabled = false }: Pa
|
|
|
154
155
|
const [openingReferenceChat, setOpeningReferenceChat] = useState(false);
|
|
155
156
|
const internalPathname = browserPathToInternalNewsroomPath(pathname);
|
|
156
157
|
const isNewsroomPath = internalPathname === "/newsroom" || internalPathname.startsWith("/newsroom/");
|
|
157
|
-
const canUseConsole = Boolean(session?.hasSession && session.groups
|
|
158
|
+
const canUseConsole = Boolean(session?.hasSession && hasEditorRights(session.groups));
|
|
158
159
|
const shouldOfferConsole = isNewsroomPath && canUseConsole;
|
|
159
160
|
const { open, openConsole, closeConsole, toggleOpen } = useConsoleVisibility({ available: shouldOfferConsole });
|
|
160
161
|
|