@anthusai/papyrus 5.48.0 → 5.49.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/components/needs-me-bodies.tsx +35 -25
- package/components/needs-me-feed.tsx +30 -13
- package/components/needs-me-shared.tsx +9 -0
- package/components/newsroom-ops-overview.tsx +36 -30
- package/components/newsroom-ops-shell.tsx +4 -4
- package/components/newsroom-organizer.css +23 -11
- package/components/newsroom-references-view.tsx +3 -3
- package/components/notification-center.tsx +63 -11
- package/components/reference-review-card.tsx +5 -1
- package/components/use-reference-ratings.ts +54 -5
- package/lib/review-client.ts +16 -0
- package/lib/review-drafts.ts +33 -8
- package/lib/review-requests.ts +25 -0
- package/package.json +1 -1
- package/routes.manifest.json +1 -1
|
@@ -14,11 +14,11 @@ import { DecidedLine } from "./notification-cards";
|
|
|
14
14
|
import { SourceMark } from "./reference-feed";
|
|
15
15
|
import { RatingGrid } from "./review-rating-grid";
|
|
16
16
|
import type { RatingController } from "./use-reference-ratings";
|
|
17
|
-
import { FlagFooter, decidedChoiceOf } from "./needs-me-shared";
|
|
17
|
+
import { FlagFooter, decidedChoiceOf, recordedAnswer } from "./needs-me-shared";
|
|
18
18
|
|
|
19
19
|
import type { CorrectCaptureAction } from "./reference-capture-client";
|
|
20
|
-
/** What the editor
|
|
21
|
-
export type DecisionExtras = {
|
|
20
|
+
/** What the editor chose before deciding an item that is not a Reference: the one steering chip. (The Why lives in the rating store, so it can be typed before or after.) */
|
|
21
|
+
export type DecisionExtras = { notThisKind: boolean };
|
|
22
22
|
|
|
23
23
|
const CHIP = "inline-flex min-h-8 max-w-full items-center gap-1.5 rounded-full bg-[var(--feed-control)] px-2.5 text-xs font-medium text-foreground outline-none transition-colors hover:bg-[var(--feed-control)]/70 focus-visible:ring-2 focus-visible:ring-ring";
|
|
24
24
|
const PILL_TOGGLE = "inline-flex min-h-8 items-center justify-center gap-1.5 rounded-full px-3 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring";
|
|
@@ -47,20 +47,27 @@ export function Heading({ item }: { item: NeedsMeEntry }) {
|
|
|
47
47
|
);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
/** What was recorded when the item was decided, for the line under the buttons. */
|
|
51
|
-
function recordedAnswer(item: NeedsMeEntry): { why: string | null; notThisKind: boolean } {
|
|
52
|
-
const { payload } = item;
|
|
53
|
-
if (payload.kind === "draft") return { why: payload.item.decision?.why ?? null, notThisKind: Boolean(payload.item.decision?.notThisKind) };
|
|
54
|
-
if (payload.kind === "request") return { why: payload.request.answer?.feedback ?? null, notThisKind: payload.request.answer?.steer === "not_this_kind" };
|
|
55
|
-
if (payload.kind === "email") return { why: payload.summary.quarantine?.decision?.reason || null, notThisKind: false };
|
|
56
|
-
return { why: null, notThisKind: false };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
50
|
function chipLabel(item: NeedsMeEntry): string | null {
|
|
60
51
|
return item.type === "draft" ? "Not this kind of story" : item.type === "stage_review" ? "Not this kind of thing" : null;
|
|
61
52
|
}
|
|
62
53
|
|
|
63
|
-
/**
|
|
54
|
+
/** The save state of a Why edited after the decision, in words and a tick: never colour alone. */
|
|
55
|
+
function ReasonStatus({ feedback, unsaved, hasReason }: { feedback: { phase: string; message?: string }; unsaved: boolean; hasReason: boolean }) {
|
|
56
|
+
return (
|
|
57
|
+
<div aria-live="polite" className="min-h-5 text-xs text-muted-foreground" data-reason-status={feedback.phase}>
|
|
58
|
+
{feedback.phase === "saving" ? <span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Saving…</span> : null}
|
|
59
|
+
{(feedback.phase === "saved" || (feedback.phase === "idle" && hasReason)) && !unsaved ? <span className="inline-flex items-center gap-1" data-reason-saved><CheckIcon aria-hidden="true" className="size-3.5" />Saved</span> : null}
|
|
60
|
+
{feedback.phase === "idle" && unsaved ? <span data-reason-unsaved>Not saved yet. It saves when you pause or tap away.</span> : null}
|
|
61
|
+
{feedback.phase === "error" ? <span className="text-destructive" role="alert">{feedback.message}</span> : null}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Accept / Reject for everything that is not a Reference. The Why box is always there, and the label and the explanation can come in either
|
|
68
|
+
* order: a Why typed first goes with the vote; a Why typed after the vote is saved onto that decision (when you pause or tap away), and
|
|
69
|
+
* it can be changed again whenever the item is reopened. A decided item keeps its Accept or Reject; only its Why stays editable.
|
|
70
|
+
*/
|
|
64
71
|
export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason, lockedNote }: Pick<DetailProps, "item" | "ratings" | "extras" | "onExtras" | "disabledReason"> & { lockedNote?: ReactNode }) {
|
|
65
72
|
const rating = ratings.get(item.id);
|
|
66
73
|
const decided = decidedChoiceOf(item);
|
|
@@ -69,6 +76,8 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
|
|
|
69
76
|
const flag = item.type === "flag";
|
|
70
77
|
const chip = chipLabel(item);
|
|
71
78
|
const recorded = recordedAnswer(item);
|
|
79
|
+
const note = rating.note ?? "";
|
|
80
|
+
const unsaved = note.trim() !== (recorded.why ?? "").trim();
|
|
72
81
|
|
|
73
82
|
// Desktop shortcuts: A accepts, R rejects. Never while typing, and never once decided.
|
|
74
83
|
useEffect(() => {
|
|
@@ -86,25 +95,26 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
|
|
|
86
95
|
|
|
87
96
|
return (
|
|
88
97
|
<section aria-label="Your decision" className="space-y-3 rounded-[20px] bg-[var(--feed-footer)] p-3" data-needs-me-decision {...(locked ? {} : { "data-review-controls": "" })}>
|
|
98
|
+
<textarea
|
|
99
|
+
aria-label="Why? (optional)"
|
|
100
|
+
className="block min-h-20 w-full resize-y rounded-[16px] bg-[var(--feed-control)] p-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
101
|
+
data-needs-me-why
|
|
102
|
+
onBlur={() => { if (!disabledReason) ratings.commitNote(item.id); }}
|
|
103
|
+
onChange={(event) => ratings.setNote(item.id, event.target.value)}
|
|
104
|
+
placeholder={flag ? "Why? Optional. Tell the step what to do differently." : "Why? Optional. Your note teaches the AI."}
|
|
105
|
+
value={note}
|
|
106
|
+
/>
|
|
107
|
+
{locked ? <ReasonStatus feedback={rating.feedback} hasReason={Boolean(recorded.why)} unsaved={unsaved} /> : null}
|
|
89
108
|
{locked ? (
|
|
90
109
|
<>
|
|
91
110
|
{item.decisionState === "acknowledged"
|
|
92
|
-
? <p className="flex items-center gap-1.5 text-sm font-medium" data-flag-acknowledged><CircleCheckIcon aria-hidden="true" className="size-4" />Acknowledged
|
|
93
|
-
: <DecidedLine choice={decided ?? "accept"} notThisKind={recorded.notThisKind}
|
|
94
|
-
{flag ? null : <RatingGrid answer={decided} disabled feedback={{ phase: "
|
|
111
|
+
? <p className="flex items-center gap-1.5 text-sm font-medium" data-flag-acknowledged><CircleCheckIcon aria-hidden="true" className="size-4" />Acknowledged</p>
|
|
112
|
+
: <DecidedLine choice={decided ?? "accept"} notThisKind={recorded.notThisKind} />}
|
|
113
|
+
{flag ? null : <RatingGrid answer={decided} disabled feedback={{ phase: "idle" }} onChoose={() => undefined} prediction={{ state: "none" }} showPrediction={false} size="large" surface="footer" />}
|
|
95
114
|
{lockedNote ? <p className="text-xs text-muted-foreground" data-review-disabled-reason>{lockedNote}</p> : null}
|
|
96
115
|
</>
|
|
97
116
|
) : (
|
|
98
117
|
<>
|
|
99
|
-
<textarea
|
|
100
|
-
aria-label="Why? (optional)"
|
|
101
|
-
className="block min-h-20 w-full resize-y rounded-[16px] bg-[var(--feed-control)] p-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
102
|
-
data-needs-me-why
|
|
103
|
-
onChange={(event) => onExtras({ ...extras, why: event.target.value })}
|
|
104
|
-
placeholder={flag ? "Why? Optional. Tell the step what to do differently." : "Why? Optional. Your note teaches the AI."}
|
|
105
|
-
readOnly={busy}
|
|
106
|
-
value={extras.why}
|
|
107
|
-
/>
|
|
108
118
|
{chip && !flag ? (
|
|
109
119
|
<div className="flex flex-wrap items-center gap-2" data-review-reasons>
|
|
110
120
|
<button
|
|
@@ -24,7 +24,7 @@ import type { FeedFreshness } from "../lib/reference-feed-ranking";
|
|
|
24
24
|
import type { RecentDecision } from "../lib/notification-feed";
|
|
25
25
|
import { getKanbusTheme, SITE_BRAND } from "../lib/site-brand";
|
|
26
26
|
import { NeedsMeDetail, type DecisionExtras } from "./needs-me-detail";
|
|
27
|
-
import { FlagFooter, decidedChoiceOf, isReferenceLike } from "./needs-me-shared";
|
|
27
|
+
import { FlagFooter, decidedChoiceOf, isReferenceLike, recordedAnswer } from "./needs-me-shared";
|
|
28
28
|
import { NotificationSkeleton, RecentDecisions } from "./notification-cards";
|
|
29
29
|
import { FEED_GRID, FEED_SURFACE_PROPS, FeedImage, PILL, REASON_LABELS, SourceMark } from "./reference-feed";
|
|
30
30
|
import { predictionFromDecision, ReviewQueueBar, useQueueGestures, type CardPrediction, type ReviewChoice } from "./reference-review-card";
|
|
@@ -102,6 +102,9 @@ export function NeedsMeCard({ item, now, onOpen, predictionLoading, ratings, sel
|
|
|
102
102
|
const feedback = rating.feedback.phase === "idle" && decided ? { phase: "saved" as const } : rating.feedback;
|
|
103
103
|
const reason = item.payload.kind === "reference" ? rating.reasonCode : null;
|
|
104
104
|
const reasonLabel = decided === "reject" && reason ? REASON_LABELS[reason] : null;
|
|
105
|
+
// A decided item shows the Why it was given (clipped; the whole text is edited in the detail pane).
|
|
106
|
+
const why = !refLike && locked ? rating.note?.trim() ?? "" : "";
|
|
107
|
+
const whyLine = why ? `Why: ${why.length > 90 ? `${why.slice(0, 89).trimEnd()}\u2026` : why}` : null;
|
|
105
108
|
const time = item.payload.kind === "reference" ? item.payload.displayTime ?? item.createdAt : item.createdAt;
|
|
106
109
|
const age = formatFeedAge(time, now);
|
|
107
110
|
const picture = item.image || item.host ? <FeedImage host={item.host} lead={false} name={item.source} src={item.image} /> : <TypeTile type={item.type} />;
|
|
@@ -169,7 +172,7 @@ export function NeedsMeCard({ item, now, onOpen, predictionLoading, ratings, sel
|
|
|
169
172
|
disabled={Boolean(disabledReason) || locked}
|
|
170
173
|
disabledReason={disabledReason}
|
|
171
174
|
feedback={feedback}
|
|
172
|
-
note={reasonLabel ? `Reason: ${reasonLabel}` : locked ? "Decided here." : null}
|
|
175
|
+
note={reasonLabel ? `Reason: ${reasonLabel}` : locked ? whyLine ?? "Decided here." : null}
|
|
173
176
|
onChoose={(choice) => { if (!disabledReason && !locked) ratings.choose(item.id, choice); }}
|
|
174
177
|
prediction={cardPrediction(item, predictionLoading)}
|
|
175
178
|
showPrediction={refLike}
|
|
@@ -247,7 +250,7 @@ export function NeedsMeRecipe({ freshness, threshold }: { freshness: FeedFreshne
|
|
|
247
250
|
// --- the view -----------------------------------------------------------------------------------------------------------
|
|
248
251
|
|
|
249
252
|
export type NeedsMeLoadState = "loading" | "ready" | "forbidden" | "unavailable";
|
|
250
|
-
const NO_EXTRAS: DecisionExtras = {
|
|
253
|
+
const NO_EXTRAS: DecisionExtras = { notThisKind: false };
|
|
251
254
|
const NO_ATTACHMENTS: ReferenceAttachmentRecord[] = [];
|
|
252
255
|
|
|
253
256
|
export type NeedsMeViewProps = {
|
|
@@ -276,9 +279,13 @@ export type NeedsMeViewProps = {
|
|
|
276
279
|
captureTeaserLine?: number;
|
|
277
280
|
onCorrectCapture?: CorrectCaptureAction;
|
|
278
281
|
onDecide: (item: NeedsMeEntry, decision: NeedsMeDecision) => Promise<void>;
|
|
282
|
+
/** Changes only the Why of an item that is already decided (an AI draft, a review request, a held email). Throws a sentence when it cannot. */
|
|
283
|
+
onEditReason?: (item: NeedsMeEntry, why: string) => Promise<void>;
|
|
279
284
|
onRetryDrafts?: () => void;
|
|
280
285
|
onOpened?: (item: NeedsMeEntry) => void;
|
|
281
286
|
loadEmail?: (receiptId: string) => Promise<MonitoringEmailDetails>;
|
|
287
|
+
/** Other sections of the page (open assignments, sections). They scroll inside the feed, below the cards, so the feed and the detail keep the viewport's full height. */
|
|
288
|
+
below?: ReactNode;
|
|
282
289
|
};
|
|
283
290
|
|
|
284
291
|
function Note({ children, ...rest }: { children: ReactNode } & Record<string, unknown>) {
|
|
@@ -288,7 +295,7 @@ function Note({ children, ...rest }: { children: ReactNode } & Record<string, un
|
|
|
288
295
|
/** The CMS console home: one feed that mixes everything that may need a person, with the References feed's cards and detail pane. */
|
|
289
296
|
export function NeedsMeView({
|
|
290
297
|
loadState, entries, freshness, threshold, now, shaping, recent, legacyAuto = [], settled, demo = false, stale = false, error = null, referencesLoading = false, predictionLoading = false,
|
|
291
|
-
referenceDisabledReason = null, draftsFailed = false, missingDraftCount = 0, initialFilter = "all", referenceAttachments = NO_ATTACHMENTS, captureTeaserLine, onCorrectCapture, onDecide, onRetryDrafts, onOpened, loadEmail,
|
|
298
|
+
referenceDisabledReason = null, draftsFailed = false, missingDraftCount = 0, initialFilter = "all", referenceAttachments = NO_ATTACHMENTS, captureTeaserLine, onCorrectCapture, onDecide, onEditReason, onRetryDrafts, onOpened, loadEmail, below,
|
|
292
299
|
}: NeedsMeViewProps) {
|
|
293
300
|
const themeMode = useResolvedPapyrusTheme();
|
|
294
301
|
const themeAttributes = { "data-kanbus-theme": getKanbusTheme(SITE_BRAND), "data-color-mode": themeMode };
|
|
@@ -319,13 +326,22 @@ export function NeedsMeView({
|
|
|
319
326
|
const item = latest.current.byId.get(id);
|
|
320
327
|
if (!item) throw new Error("This item is no longer in the list.");
|
|
321
328
|
const typed = latest.current.extras[id] ?? NO_EXTRAS;
|
|
322
|
-
|
|
323
|
-
await onDecide(item, { choice, reasonCode: detail.reasonCode, why: referenceLike ? detail.note : typed.why.trim(), notThisKind: choice === "reject" && typed.notThisKind });
|
|
329
|
+
await onDecide(item, { choice, reasonCode: detail.reasonCode, why: detail.note, notThisKind: choice === "reject" && typed.notThisKind });
|
|
324
330
|
},
|
|
325
|
-
|
|
331
|
+
// The Why of an AI draft, a review request or a held email is typed in either order and edited after the decision, onto that decision.
|
|
332
|
+
sticky: (id) => { const item = latest.current.byId.get(id); return item ? !isReferenceLike(item) : false; },
|
|
333
|
+
saveNote: async (id, note) => {
|
|
334
|
+
const item = latest.current.byId.get(id);
|
|
335
|
+
if (!item) throw new Error("This item is no longer in the list.");
|
|
336
|
+
if (!onEditReason) throw new Error("A reason cannot be changed here.");
|
|
337
|
+
await onEditReason(item, note);
|
|
338
|
+
},
|
|
339
|
+
// An acknowledged flag counts as an Accept: it is decided, and only its note can change.
|
|
340
|
+
saved: (id) => { const item = latest.current.byId.get(id); return item ? decidedChoiceOf(item) ?? (item.decisionState === "acknowledged" ? "accept" : null) : null; },
|
|
326
341
|
seed: (id) => {
|
|
327
342
|
const item = latest.current.byId.get(id);
|
|
328
|
-
if (!item
|
|
343
|
+
if (!item) return { note: "", reasonCode: null };
|
|
344
|
+
if (item.payload.kind !== "reference") return { note: recordedAnswer(item).why ?? "", reasonCode: null };
|
|
329
345
|
const { reference } = item.payload;
|
|
330
346
|
const pending = normalizeReferenceCurationStatus(reference.curationStatus) === "pending";
|
|
331
347
|
return { note: pending ? "" : reference.curationStatusReason ?? "", reasonCode: referenceReasonCode(reference) ?? null };
|
|
@@ -384,12 +400,12 @@ export function NeedsMeView({
|
|
|
384
400
|
</div>
|
|
385
401
|
);
|
|
386
402
|
const queueBar = queue.length || caughtUp ? (
|
|
387
|
-
<ReviewQueueBar caughtUp={caughtUp} onNext={goNext} onPrevious={goPrevious} position={selectedIndex >= 0 ? selectedIndex + 1 : null} total={queue.length} />
|
|
403
|
+
<ReviewQueueBar anchored caughtUp={caughtUp} onNext={goNext} onPrevious={goPrevious} position={selectedIndex >= 0 ? selectedIndex + 1 : null} total={queue.length} />
|
|
388
404
|
) : null;
|
|
389
405
|
|
|
390
406
|
return (
|
|
391
|
-
<div className="
|
|
392
|
-
<div className="min-w-0 flex-1 space-y-6 pb-6 md:min-h-0 md:overflow-y-auto md:pr-1" data-needs-me-main data-needs-me-scroll>
|
|
407
|
+
<div className="flex flex-col gap-5 md:h-full md:flex-row md:items-stretch" data-detail-reserved="true" data-notification-center {...FEED_SURFACE_PROPS}>
|
|
408
|
+
<div className="@container min-w-0 flex-1 space-y-6 pb-6 md:min-h-0 md:overflow-y-auto md:pr-1" data-needs-me-main data-needs-me-scroll>
|
|
393
409
|
<header className="space-y-4">
|
|
394
410
|
<div className="space-y-1.5">
|
|
395
411
|
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl" data-notification-title>What needs me?</h1>
|
|
@@ -523,11 +539,12 @@ export function NeedsMeView({
|
|
|
523
539
|
<RecentDecisions entries={recent} />
|
|
524
540
|
</section>
|
|
525
541
|
) : null}
|
|
542
|
+
{below}
|
|
526
543
|
</div>
|
|
527
544
|
|
|
528
545
|
<aside
|
|
529
546
|
aria-label="Detail"
|
|
530
|
-
className="hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-[20px] bg-[var(--feed-card)] md:flex md:h-full md:w-[min(100%,24rem)] lg:w-[min(100%,28rem)]"
|
|
547
|
+
className="hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-[20px] bg-[var(--feed-card)] md:flex md:h-full md:w-[min(100%,24rem)] md:pb-16 lg:w-[min(100%,28rem)]"
|
|
531
548
|
data-feed-flat=""
|
|
532
549
|
data-needs-me-detail-shell
|
|
533
550
|
data-detail-open={selected ? "true" : "false"}
|
|
@@ -545,7 +562,7 @@ export function NeedsMeView({
|
|
|
545
562
|
|
|
546
563
|
{isMobile ? (
|
|
547
564
|
<Sheet onOpenChange={(open) => { if (!open) closeDetail(); else setSheetOpen(true); }} open={(sheetOpen && Boolean(selected)) || caughtUp}>
|
|
548
|
-
<SheetContent className="gap-0 rounded-t-[
|
|
565
|
+
<SheetContent className="gap-0 rounded-t-[length:var(--feed-radius-xl)] bg-[var(--feed-card)] p-0" data-feed-flat="" data-feed-surface="" initialFocus={false} side="bottom" {...themeAttributes}>
|
|
549
566
|
<SheetHeader className="px-4 py-3"><SheetTitle className="text-left text-base">{selected ? NEEDS_ME_TYPE_LABELS[selected.type] : "Detail"}</SheetTitle></SheetHeader>
|
|
550
567
|
{selected ? (
|
|
551
568
|
<div className="min-w-0" data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}>
|
|
@@ -14,6 +14,15 @@ export function decidedChoiceOf(item: Pick<NeedsMeEntry, "decisionState">): Revi
|
|
|
14
14
|
return item.decisionState === "accepted" ? "accept" : item.decisionState === "rejected" ? "reject" : null;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** What was recorded when a non-Reference item was decided: its Why, and whether it was rejected as "not this kind". Shown again whenever the item is reopened. */
|
|
18
|
+
export function recordedAnswer(item: NeedsMeEntry): { why: string | null; notThisKind: boolean } {
|
|
19
|
+
const { payload } = item;
|
|
20
|
+
if (payload.kind === "draft") return { why: payload.item.decision?.why ?? null, notThisKind: Boolean(payload.item.decision?.notThisKind) };
|
|
21
|
+
if (payload.kind === "request") return { why: payload.request.answer?.feedback ?? null, notThisKind: payload.request.answer?.steer === "not_this_kind" };
|
|
22
|
+
if (payload.kind === "email") return { why: payload.summary.quarantine?.decision?.reason || null, notThisKind: false };
|
|
23
|
+
return { why: null, notThisKind: false };
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
/** A flag is information, not a question: one Acknowledge button in the footer's own tone. */
|
|
18
27
|
export function FlagFooter({ busy, disabledReason, done, error, onAcknowledge }: { busy: boolean; disabledReason: string | null; done: boolean; error: string | null; onAcknowledge: () => void }) {
|
|
19
28
|
return (
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { usePathname } from "next/navigation";
|
|
5
5
|
import { useCallback, useMemo } from "react";
|
|
6
|
-
import { Badge } from "./ui/badge";
|
|
7
6
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
|
|
8
7
|
import type { AssignmentRecord, NewsroomSectionRecord, ReferenceAttachmentRecord, ReferenceRecord } from "../lib/category-repository";
|
|
9
8
|
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
@@ -15,7 +14,8 @@ import {
|
|
|
15
14
|
newsroomDeskKind,
|
|
16
15
|
sortNewsroomSections,
|
|
17
16
|
} from "../lib/newsroom-sections";
|
|
18
|
-
import {
|
|
17
|
+
import { cn } from "../lib/utils";
|
|
18
|
+
import { FEED_SURFACE_PROPS } from "./reference-feed";
|
|
19
19
|
import type { RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
20
20
|
import type { ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
21
21
|
import { referencePreviewsByLineage, type ReferencePreview } from "../lib/reference-preview";
|
|
@@ -100,30 +100,8 @@ export function NewsroomOpsOverview({
|
|
|
100
100
|
const rotatingDesks = useMemo(() => desks.filter(desk => newsroomDeskKind(desk) === "rotating"), [desks]);
|
|
101
101
|
const rateDisabled = disabledReason ?? (showingSavedCopy ? "Confirming your sign-in. You can answer in a moment." : null);
|
|
102
102
|
|
|
103
|
-
|
|
104
|
-
<div
|
|
105
|
-
className="space-y-12 font-sans"
|
|
106
|
-
data-news-desk-section="overview"
|
|
107
|
-
data-newsroom-ops-home="true"
|
|
108
|
-
>
|
|
109
|
-
<div className="space-y-12" data-newsroom-overview-feeds>
|
|
110
|
-
<div data-newsroom-overview-notifications>
|
|
111
|
-
<NotificationCenter
|
|
112
|
-
actorLabel={actorLabel}
|
|
113
|
-
decisions={shownDecisions}
|
|
114
|
-
demo={demo}
|
|
115
|
-
fixture={notificationFixture}
|
|
116
|
-
humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
|
|
117
|
-
onCorrectCapture={onCorrectCapture}
|
|
118
|
-
onRateReference={rateReference}
|
|
119
|
-
referenceAttachments={referenceAttachments}
|
|
120
|
-
predictionLoading={predictionState === "loading"}
|
|
121
|
-
previews={previews}
|
|
122
|
-
referenceDisabledReason={rateDisabled}
|
|
123
|
-
references={canonicalReferences}
|
|
124
|
-
referencesLoading={loading && !canonicalReferences.length}
|
|
125
|
-
/>
|
|
126
|
-
</div>
|
|
103
|
+
const below = (
|
|
104
|
+
<div className="space-y-12 pt-6" data-newsroom-overview-below {...FEED_SURFACE_PROPS}>
|
|
127
105
|
{attentionAssignments.length ? (
|
|
128
106
|
<section aria-label="Open assignments" className="space-y-3">
|
|
129
107
|
<h2 className="text-sm font-medium text-muted-foreground">Open assignments</h2>
|
|
@@ -134,7 +112,7 @@ export function NewsroomOpsOverview({
|
|
|
134
112
|
return (
|
|
135
113
|
<Link
|
|
136
114
|
aria-current={active ? "true" : undefined}
|
|
137
|
-
className={
|
|
115
|
+
className={cn("flex items-start gap-3 rounded-[16px] bg-[var(--feed-card)] p-3 text-foreground outline-none transition-colors hover:bg-[var(--feed-control)] focus-visible:ring-2 focus-visible:ring-ring", active && "bg-[var(--feed-control)]")}
|
|
138
116
|
data-newsroom-inbox-item="assignment"
|
|
139
117
|
data-newsroom-list-row
|
|
140
118
|
data-selected={active || undefined}
|
|
@@ -145,7 +123,7 @@ export function NewsroomOpsOverview({
|
|
|
145
123
|
<p className="truncate text-sm font-medium text-foreground">{assignment.title}</p>
|
|
146
124
|
<p className="text-xs text-muted-foreground">Assignment · {assignment.assignmentTypeKey}</p>
|
|
147
125
|
</div>
|
|
148
|
-
<
|
|
126
|
+
<span className="mt-0.5 shrink-0 rounded-full bg-[var(--feed-control)] px-2 py-0.5 text-xs font-medium">{assignment.status}</span>
|
|
149
127
|
</Link>
|
|
150
128
|
);
|
|
151
129
|
})}
|
|
@@ -165,7 +143,7 @@ export function NewsroomOpsOverview({
|
|
|
165
143
|
href={buildNewsroomSectionHref(section.id, demo)}
|
|
166
144
|
key={section.id}
|
|
167
145
|
>
|
|
168
|
-
<Card className="h-full transition-colors hover:
|
|
146
|
+
<Card className="h-full rounded-[20px] border-0 bg-[var(--feed-card)] shadow-none ring-0 transition-colors hover:bg-[var(--feed-control)]">
|
|
169
147
|
<CardHeader className="p-4">
|
|
170
148
|
<CardTitle className="text-base font-semibold">{section.title}</CardTitle>
|
|
171
149
|
<CardDescription className="uppercase tracking-wide text-[0.65rem]">
|
|
@@ -185,7 +163,7 @@ export function NewsroomOpsOverview({
|
|
|
185
163
|
href={buildNewsroomSectionHref(section.id, demo)}
|
|
186
164
|
key={section.id}
|
|
187
165
|
>
|
|
188
|
-
<Card className="h-full transition-colors hover:
|
|
166
|
+
<Card className="h-full rounded-[20px] border-0 bg-[var(--feed-card)] shadow-none ring-0 transition-colors hover:bg-[var(--feed-control)]">
|
|
189
167
|
<CardHeader className="p-4">
|
|
190
168
|
<CardTitle className="text-base font-semibold">{section.title}</CardTitle>
|
|
191
169
|
<CardDescription className="uppercase tracking-wide text-[0.65rem]">
|
|
@@ -198,6 +176,34 @@ export function NewsroomOpsOverview({
|
|
|
198
176
|
</div> : null}
|
|
199
177
|
</section>
|
|
200
178
|
) : null}
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<div
|
|
184
|
+
className="h-full font-sans"
|
|
185
|
+
data-news-desk-section="overview"
|
|
186
|
+
data-newsroom-ops-home="true"
|
|
187
|
+
>
|
|
188
|
+
<div className="h-full" data-newsroom-overview-feeds>
|
|
189
|
+
<div className="h-full" data-newsroom-overview-notifications>
|
|
190
|
+
<NotificationCenter
|
|
191
|
+
below={below}
|
|
192
|
+
actorLabel={actorLabel}
|
|
193
|
+
decisions={shownDecisions}
|
|
194
|
+
demo={demo}
|
|
195
|
+
fixture={notificationFixture}
|
|
196
|
+
humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
|
|
197
|
+
onCorrectCapture={onCorrectCapture}
|
|
198
|
+
onRateReference={rateReference}
|
|
199
|
+
referenceAttachments={referenceAttachments}
|
|
200
|
+
predictionLoading={predictionState === "loading"}
|
|
201
|
+
previews={previews}
|
|
202
|
+
referenceDisabledReason={rateDisabled}
|
|
203
|
+
references={canonicalReferences}
|
|
204
|
+
referencesLoading={loading && !canonicalReferences.length}
|
|
205
|
+
/>
|
|
206
|
+
</div>
|
|
201
207
|
</div>
|
|
202
208
|
</div>
|
|
203
209
|
);
|
|
@@ -210,10 +210,10 @@ export function NewsroomOpsStatusBanner({
|
|
|
210
210
|
return (
|
|
211
211
|
<div
|
|
212
212
|
className={cn(
|
|
213
|
-
|
|
214
|
-
tone === "
|
|
215
|
-
tone === "
|
|
216
|
-
tone === "info" && "
|
|
213
|
+
// A success is announced to screen readers and never drawn: it would push the page down. Errors and notes are flat fills, no outlines.
|
|
214
|
+
tone === "ok" ? "sr-only" : "mb-4 rounded-lg px-3 py-2 text-sm",
|
|
215
|
+
tone === "error" && "bg-destructive/10 text-destructive",
|
|
216
|
+
tone === "info" && "bg-muted/60 text-foreground",
|
|
217
217
|
)}
|
|
218
218
|
role="status"
|
|
219
219
|
>
|
|
@@ -16,7 +16,7 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
16
16
|
.organizer-destinations a[aria-current=page]{color:var(--org-ink);box-shadow:inset 3px 0 0 var(--org-accent)}
|
|
17
17
|
.newsroom-organizer :is(a,button):focus-visible,.organizer-menu :is(a,button):focus-visible{outline:2px solid currentColor;outline-offset:-3px}
|
|
18
18
|
.organizer-workspace{display:flex;flex-direction:column;min-height:0;min-width:0;overflow:hidden;padding:0 16px}
|
|
19
|
-
.organizer-topbar{height:48px;min-height:48px;display:flex;align-items:center;gap:16px
|
|
19
|
+
.organizer-topbar{height:48px;min-height:48px;display:flex;align-items:center;gap:16px}
|
|
20
20
|
.organizer-masthead{font:900 28px var(--font-wordmark),Georgia,serif;white-space:nowrap;max-width:30vw;overflow:hidden;text-overflow:ellipsis}
|
|
21
21
|
.organizer-masthead-logo{display:block;height:24px;width:auto;max-width:36vw;overflow:visible}
|
|
22
22
|
.organizer-topbar h1{font:400 12px var(--kb-theme-ui-font);flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:0}
|
|
@@ -362,15 +362,21 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
362
362
|
The newsroom chrome squares every "rounded" class above; inside a [data-feed-surface] the rounded classes mean what they say.
|
|
363
363
|
The repeated attribute raises specificity over the squaring rules without !important escalation. */
|
|
364
364
|
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-full"],
|
|
365
|
-
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-full"] { border-radius:
|
|
365
|
+
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-full"] { border-radius: var(--feed-radius-pill) !important; }
|
|
366
366
|
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-[20px]"],
|
|
367
|
-
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-[20px]"] { border-radius:
|
|
367
|
+
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-[20px]"] { border-radius: var(--feed-radius-lg) !important; }
|
|
368
368
|
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-2xl"],
|
|
369
|
-
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-2xl"] { border-radius:
|
|
369
|
+
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-2xl"] { border-radius: var(--feed-radius-md) !important; }
|
|
370
370
|
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-[16px]"],
|
|
371
|
-
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-[16px]"] { border-radius:
|
|
371
|
+
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-[16px]"] { border-radius: var(--feed-radius-md) !important; }
|
|
372
372
|
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-3xl"],
|
|
373
|
-
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-3xl"] { border-radius:
|
|
373
|
+
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-3xl"] { border-radius: var(--feed-radius-xl) !important; }
|
|
374
|
+
|
|
375
|
+
/* The mobile sheet is portaled outside .newsroom-organizer; it carries the theme attributes itself, so the same radii apply inside it. */
|
|
376
|
+
[data-kanbus-theme][data-feed-surface] [class*="rounded-full"] { border-radius: var(--feed-radius-pill) !important; }
|
|
377
|
+
[data-kanbus-theme][data-feed-surface] [class*="rounded-[20px]"] { border-radius: var(--feed-radius-lg) !important; }
|
|
378
|
+
[data-kanbus-theme][data-feed-surface] [class*="rounded-2xl"], [data-kanbus-theme][data-feed-surface] [class*="rounded-[16px]"] { border-radius: var(--feed-radius-md) !important; }
|
|
379
|
+
[data-kanbus-theme][data-feed-surface] [class*="rounded-3xl"] { border-radius: var(--feed-radius-xl) !important; }
|
|
374
380
|
|
|
375
381
|
/* Feed tones. Light: cards a touch lighter than the page, footers between, controls a touch darker. Dark: each step lifts toward the ink.
|
|
376
382
|
Also set on the portaled mobile sheet, which carries the theme attributes itself. */
|
|
@@ -378,6 +384,12 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
378
384
|
--feed-card: color-mix(in oklab, var(--kb-theme-paper) 35%, white);
|
|
379
385
|
--feed-footer: color-mix(in oklab, var(--kb-theme-paper) 70%, white);
|
|
380
386
|
--feed-control: color-mix(in oklab, var(--kb-theme-paper) 90%, var(--kb-theme-ink));
|
|
387
|
+
/* Corner radii of the whole feed layer, in one place (Ryan, 2026-10-27: half of the first design). lg: cards and the detail pane;
|
|
388
|
+
md: rating cells, buttons, textareas; xl: sheets; pill: chips, filters and toggles (a rounded rectangle, not a capsule). */
|
|
389
|
+
--feed-radius-xl: 12px;
|
|
390
|
+
--feed-radius-lg: 10px;
|
|
391
|
+
--feed-radius-md: 8px;
|
|
392
|
+
--feed-radius-pill: 8px;
|
|
381
393
|
}
|
|
382
394
|
[data-kanbus-theme][data-color-mode="dark"] [data-feed-surface], [data-kanbus-theme][data-color-mode="dark"][data-feed-surface] {
|
|
383
395
|
--feed-card: color-mix(in oklab, var(--kb-theme-paper) 94%, var(--kb-theme-ink));
|
|
@@ -392,16 +404,16 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
392
404
|
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] *:not(:focus-visible) { box-shadow: none; }
|
|
393
405
|
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is([data-slot=separator], hr) { display: none !important; }
|
|
394
406
|
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is(button, [role=button], [data-slot=badge]):not([aria-pressed=true]):not([data-slot=sheet-close]):not([class*="hover:bg-muted"]) {
|
|
395
|
-
background: var(--feed-control) !important; border-radius:
|
|
407
|
+
background: var(--feed-control) !important; border-radius: var(--feed-radius-pill) !important; color: var(--foreground, inherit);
|
|
396
408
|
}
|
|
397
409
|
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is(button, [role=button])[aria-pressed=true]:not([class*="destructive"]) {
|
|
398
|
-
background: var(--primary) !important; color: var(--primary-foreground) !important; border-radius:
|
|
410
|
+
background: var(--primary) !important; color: var(--primary-foreground) !important; border-radius: var(--feed-radius-pill) !important;
|
|
399
411
|
}
|
|
400
|
-
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is(button, [role=button])[aria-pressed=true][class*="destructive"] { border-radius:
|
|
412
|
+
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is(button, [role=button])[aria-pressed=true][class*="destructive"] { border-radius: var(--feed-radius-pill) !important; }
|
|
401
413
|
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is(textarea, input:not([type=radio]):not([type=checkbox]), select, details, [data-cyclotron-choice]:not([data-predicted])) {
|
|
402
414
|
background: var(--feed-control) !important; border-radius: 16px !important;
|
|
403
415
|
}
|
|
404
|
-
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-cyclotron-choice] { border-radius:
|
|
416
|
+
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-cyclotron-choice] { border-radius: var(--feed-radius-pill) !important; }
|
|
405
417
|
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-review-queue-bar] { background: var(--feed-footer) !important; }
|
|
406
418
|
/* The rating grid keeps its own rounded rectangles in the borderless panes (not the pill shape the flat rules give other buttons). */
|
|
407
|
-
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-rating-grid] :is(button, [data-cyclotron-choice]) { border-radius:
|
|
419
|
+
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-rating-grid] :is(button, [data-cyclotron-choice]) { border-radius: var(--feed-radius-md) !important; }
|
|
@@ -649,7 +649,7 @@ export function NewsroomReferencesView({
|
|
|
649
649
|
</div>
|
|
650
650
|
);
|
|
651
651
|
const queueBar = queue.length || caughtUp ? (
|
|
652
|
-
<ReviewQueueBar caughtUp={caughtUp} onNext={goNext} onPrevious={goPrevious} position={queuePosition} total={queue.length} />
|
|
652
|
+
<ReviewQueueBar anchored caughtUp={caughtUp} onNext={goNext} onPrevious={goPrevious} position={queuePosition} total={queue.length} />
|
|
653
653
|
) : null;
|
|
654
654
|
|
|
655
655
|
const emptyLabel = canonicalReferences.length
|
|
@@ -873,7 +873,7 @@ export function NewsroomReferencesView({
|
|
|
873
873
|
|
|
874
874
|
<div
|
|
875
875
|
className={cn(
|
|
876
|
-
"hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-[20px] bg-[var(--feed-card)] md:flex md:w-[min(100%,24rem)] lg:w-[min(100%,28rem)]",
|
|
876
|
+
"hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-[20px] bg-[var(--feed-card)] md:flex md:w-[min(100%,24rem)] md:pb-16 lg:w-[min(100%,28rem)]",
|
|
877
877
|
)}
|
|
878
878
|
data-newsroom-list-detail-shell
|
|
879
879
|
data-news-desk-section="references"
|
|
@@ -927,7 +927,7 @@ export function NewsroomReferencesView({
|
|
|
927
927
|
}}
|
|
928
928
|
open={(mobileDetailOpen && Boolean(selectedReference)) || caughtUp}
|
|
929
929
|
>
|
|
930
|
-
<SheetContent className="gap-0 rounded-t-[
|
|
930
|
+
<SheetContent className="gap-0 rounded-t-[length:var(--feed-radius-xl)] bg-[var(--feed-card)] p-0" data-feed-flat="" data-feed-surface="" side="bottom" {...themeAttributes}>
|
|
931
931
|
<SheetHeader className="border-b border-border px-4 py-3">
|
|
932
932
|
<SheetTitle className="text-left text-base">Reference</SheetTitle>
|
|
933
933
|
</SheetHeader>
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
4
4
|
import type { ReferenceAttachmentRecord, ReferenceRecord } from "../lib/category-repository";
|
|
5
5
|
import type { EmailDecisionResult, MonitoringEmailDetails, MonitoringEmailSummary } from "../lib/monitoring-overview";
|
|
6
6
|
import { proposalReceiptMessage, stateLabel, type ProposalReceipt } from "../lib/proposals-admin";
|
|
7
7
|
import type { DraftLoad, ReviewLoad } from "../lib/review-client";
|
|
8
|
-
import { acceptPath, batchAnswer, draftView, rejectPath, undecidedDraftIds, withDecision, draftDecisionUpdate, type DraftChoice, type DraftView } from "../lib/review-drafts";
|
|
8
|
+
import { acceptPath, batchAnswer, draftDecisionOf, draftReasonUpdate, draftView, rejectPath, undecidedDraftIds, withDecision, draftDecisionUpdate, type DraftChoice, type DraftView } from "../lib/review-drafts";
|
|
9
9
|
import { previewsBySourceAddress, recentDecisions } from "../lib/notification-feed";
|
|
10
|
-
import { answerUpdate, cyclotronAutoDecided, draftIdsOf, isDraftReview, sectionReviews, type AnswerInput, type DraftDecision, type ReviewRequest } from "../lib/review-requests";
|
|
10
|
+
import { answerReasonUpdate, answerUpdate, applyReviewUpdate, cyclotronAutoDecided, draftIdsOf, isDraftReview, sectionReviews, type AnswerInput, type DraftDecision, type ReviewRequest } from "../lib/review-requests";
|
|
11
11
|
import type { RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
12
12
|
import { learnFreshness } from "../lib/reference-feed-ranking";
|
|
13
13
|
import { learnConfidenceThreshold, shapingThisWeek } from "../lib/needs-me-ranking";
|
|
@@ -42,6 +42,8 @@ export type NotificationApi = {
|
|
|
42
42
|
loadStoryTitles: (ids: string[]) => Promise<{ titles: Record<string, string>; deleted: string[]; excerpts?: Record<string, string> }>;
|
|
43
43
|
answerReviewRequest: (request: ReviewRequest, input: AnswerInput, by: string) => Promise<ReviewRequest>;
|
|
44
44
|
saveDraftDecision: (request: ReviewRequest, draftId: string, decision: DraftDecision) => Promise<ReviewRequest>;
|
|
45
|
+
/** Changes only the Why of a draft or request that is already decided (`draftReasonUpdate`, `answerReasonUpdate`). Returns the saved request. */
|
|
46
|
+
saveReviewReason: (request: ReviewRequest, update: Record<string, unknown>) => Promise<ReviewRequest>;
|
|
45
47
|
markReviewRead: (userKey: string, requestId: string) => Promise<void>;
|
|
46
48
|
transitionProposalPath: (proposalId: string, path: readonly string[]) => Promise<ProposalReceipt>;
|
|
47
49
|
/** Email submissions held for review (Monitoring's own read). Optional: a site without monitoring has none. */
|
|
@@ -56,6 +58,7 @@ const liveApi: NotificationApi = {
|
|
|
56
58
|
loadStoryTitles: async (ids) => (await import("../lib/review-client")).loadStoryTitles(ids),
|
|
57
59
|
answerReviewRequest: async (request, input, by) => (await import("../lib/review-client")).answerReviewRequest(request, input, by),
|
|
58
60
|
saveDraftDecision: async (request, draftId, decision) => (await import("../lib/review-client")).saveDraftDecision(request, draftId, decision),
|
|
61
|
+
saveReviewReason: async (request, update) => (await import("../lib/review-client")).saveReviewReason(request, update),
|
|
59
62
|
markReviewRead: async (userKey, requestId) => (await import("../lib/review-client")).markReviewRead(userKey, requestId),
|
|
60
63
|
transitionProposalPath: async (proposalId, path) => (await import("../lib/proposals-client")).transitionProposalPath(proposalId, path),
|
|
61
64
|
loadHeldEmails: async () => {
|
|
@@ -87,6 +90,7 @@ const demoApi: NotificationApi = {
|
|
|
87
90
|
const update = draftDecisionUpdate(request, draftId, decision);
|
|
88
91
|
return { ...request, links: JSON.parse(String(update.links)), history: JSON.parse(String(update.history)), updatedAt: String(update.updatedAt) };
|
|
89
92
|
},
|
|
93
|
+
saveReviewReason: async (request, update) => applyReviewUpdate(request, update),
|
|
90
94
|
markReviewRead: async () => undefined,
|
|
91
95
|
transitionProposalPath: async () => ({ status: "ok" }),
|
|
92
96
|
loadHeldEmails: async () => FIXTURE_HELD_EMAILS,
|
|
@@ -112,6 +116,8 @@ type ContainerProps = {
|
|
|
112
116
|
/** A real-data preview (see scripts/export-notification-center-fixture.py); the invented sample otherwise. */
|
|
113
117
|
fixture?: NotificationFixture | null;
|
|
114
118
|
api?: NotificationApi;
|
|
119
|
+
/** Other page sections, shown at the end of the feed's own scroll. */
|
|
120
|
+
below?: ReactNode;
|
|
115
121
|
};
|
|
116
122
|
|
|
117
123
|
const REFRESH_MILLISECONDS = 60_000;
|
|
@@ -126,6 +132,7 @@ function previewState(): "loading" | "caught-up" | "sample" | "mixed" | null {
|
|
|
126
132
|
const EMAIL_REFUSALS: Record<string, string> = {
|
|
127
133
|
forbidden: "Your session cannot decide held email.",
|
|
128
134
|
bad_request: "That email could not be decided from here.",
|
|
135
|
+
already_applied: "This email was already queued, so its reason can no longer be changed here.",
|
|
129
136
|
already_decided: "Someone already decided this email. Refresh to see what they chose.",
|
|
130
137
|
not_quarantined: "This email is no longer held.",
|
|
131
138
|
expired: "This email expired and can no longer be accepted.",
|
|
@@ -133,7 +140,7 @@ const EMAIL_REFUSALS: Record<string, string> = {
|
|
|
133
140
|
|
|
134
141
|
export function NotificationCenter({
|
|
135
142
|
references, decisions, humanCuratedReferenceLineageIds = NO_VISIT, previews = NO_PREVIEWS, referencesLoading = false, predictionLoading = false, referenceDisabledReason = null,
|
|
136
|
-
onRateReference, referenceAttachments = NO_ATTACHMENTS, onCorrectCapture, actorLabel, demo, fixture = null, api: liveOverride = liveApi,
|
|
143
|
+
onRateReference, referenceAttachments = NO_ATTACHMENTS, onCorrectCapture, actorLabel, demo, fixture = null, api: liveOverride = liveApi, below,
|
|
137
144
|
}: ContainerProps) {
|
|
138
145
|
const api = demo ? demoApi : liveOverride;
|
|
139
146
|
const [loadState, setLoadState] = useState<NotificationLoadState>("loading");
|
|
@@ -332,6 +339,54 @@ export function NotificationCenter({
|
|
|
332
339
|
}
|
|
333
340
|
}, [api, demo, drafts, draftsState, requests, stale]);
|
|
334
341
|
|
|
342
|
+
/** Accept or Reject a held email, or (the same choice again) correct the reason it was given. The reason shown is what the server recorded. */
|
|
343
|
+
const sendEmailDecision = useCallback(async (summary: MonitoringEmailSummary, choice: "accept" | "reject", why: string, editing = false) => {
|
|
344
|
+
const result = await api.decideEmail?.(summary.receiptId, choice, why);
|
|
345
|
+
if (!result) throw new Error("Held email cannot be decided from here.");
|
|
346
|
+
if (!("ok" in result)) throw new Error("error" in result ? EMAIL_REFUSALS[result.error] ?? "The decision could not be saved." : "The decision could not be saved right now. Try again in a minute.");
|
|
347
|
+
const plain = (text: string) => text.replace(/\s+/g, " ").trim().slice(0, 500);
|
|
348
|
+
const recorded = result.quarantine?.decision;
|
|
349
|
+
// The server keeps the first reason once it has queued the email; say so in words rather than showing a reason that was not kept.
|
|
350
|
+
if (editing && recorded && plain(recorded.reason) !== plain(why)) throw new Error(EMAIL_REFUSALS.already_applied);
|
|
351
|
+
const kept = recorded?.reason ?? plain(why);
|
|
352
|
+
setEmailsDecided((current) => new Map(current).set(summary.receiptId, choice));
|
|
353
|
+
setEmails((current) => current.map((entry) => (entry.receiptId === summary.receiptId
|
|
354
|
+
? { ...entry, quarantine: { state: entry.quarantine?.state ?? "quarantined", failure: entry.quarantine?.failure ?? null, decision: { action: choice, by: recorded?.by || actorLabel, at: recorded?.at ?? new Date().toISOString(), reason: kept, viaGroup: recorded?.viaGroup } } }
|
|
355
|
+
: entry)));
|
|
356
|
+
}, [actorLabel, api]);
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Changes only the Why of an item that is already decided, onto the decision that exists:
|
|
360
|
+
* AI draft the request's link for the draft (and, once the request has closed, its answer's drafts and feedback)
|
|
361
|
+
* review request the request's answer note (a hard gate, a stage review, an acknowledged flag)
|
|
362
|
+
* held email the same decision again with the new reason, so the Monitoring view updates the recorded reason
|
|
363
|
+
* A Proposal's own transitions carry only public reasons, so a private note is never written there.
|
|
364
|
+
*/
|
|
365
|
+
const latest = useRef({ requests, drafts, emails, emailsDecided });
|
|
366
|
+
latest.current = { requests, drafts, emails, emailsDecided };
|
|
367
|
+
const editReason = useCallback(async (item: NeedsMeEntry, why: string) => {
|
|
368
|
+
const { payload } = item;
|
|
369
|
+
const now = new Date();
|
|
370
|
+
const store = (saved: ReviewRequest) => {
|
|
371
|
+
latest.current.requests = latest.current.requests.map((entry) => (entry.id === saved.id ? saved : entry));
|
|
372
|
+
setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
|
|
373
|
+
};
|
|
374
|
+
if (payload.kind === "draft") {
|
|
375
|
+
const request = latest.current.requests.find((entry) => entry.id === payload.item.request.id) ?? payload.item.request;
|
|
376
|
+
const view = latest.current.drafts[payload.item.draftId];
|
|
377
|
+
const decision = draftDecisionOf(request.links.find((link) => link.id === payload.item.draftId), view, { by: actorLabel, at: now.toISOString() });
|
|
378
|
+
if (!decision) throw new Error("This draft has not been decided yet.");
|
|
379
|
+
store(await api.saveReviewReason(request, draftReasonUpdate(request, payload.item.draftId, { ...decision, title: decision.title ?? view?.headline, why }, actorLabel, now)));
|
|
380
|
+
} else if (payload.kind === "request") {
|
|
381
|
+
const request = latest.current.requests.find((entry) => entry.id === payload.request.id) ?? payload.request;
|
|
382
|
+
store(await api.saveReviewReason(request, answerReasonUpdate(request, why, actorLabel, now)));
|
|
383
|
+
} else if (payload.kind === "email") {
|
|
384
|
+
const choice = latest.current.emailsDecided.get(payload.summary.receiptId) ?? payload.summary.quarantine?.decision?.action;
|
|
385
|
+
if (!choice) throw new Error("This email has not been decided yet.");
|
|
386
|
+
await sendEmailDecision(payload.summary, choice, why, true);
|
|
387
|
+
}
|
|
388
|
+
}, [actorLabel, api, sendEmailDecision]);
|
|
389
|
+
|
|
335
390
|
/** One decision, whatever the type: it goes to the action that already owns that kind of thing. */
|
|
336
391
|
const decide = useCallback(async (item: NeedsMeEntry, decision: NeedsMeDecision) => {
|
|
337
392
|
const { payload } = item;
|
|
@@ -346,12 +401,9 @@ export function NotificationCenter({
|
|
|
346
401
|
else if (decision.choice === "accept") await answer(request, { decision: "approve", feedback: decision.why || null });
|
|
347
402
|
else await answer(request, { decision: "reject", feedback: decision.why || null, steer: decision.notThisKind ? "not_this_kind" : null });
|
|
348
403
|
} else if (payload.kind === "email") {
|
|
349
|
-
|
|
350
|
-
if (!result) throw new Error("Held email cannot be decided from here.");
|
|
351
|
-
if (!("ok" in result)) throw new Error("error" in result ? EMAIL_REFUSALS[result.error] ?? "The decision could not be saved." : "The decision could not be saved right now. Try again in a minute.");
|
|
352
|
-
setEmailsDecided((current) => new Map(current).set(payload.summary.receiptId, decision.choice));
|
|
404
|
+
await sendEmailDecision(payload.summary, decision.choice, decision.why);
|
|
353
405
|
}
|
|
354
|
-
}, [answer,
|
|
406
|
+
}, [answer, decideDraft, onRateReference, sendEmailDecision]);
|
|
355
407
|
|
|
356
408
|
const shownReferences = preview === "caught-up" ? references.filter((reference) => normalizeReferenceCurationStatus(reference.curationStatus) !== "pending") : references;
|
|
357
409
|
const bySource = useMemo(() => previewsBySourceAddress(shownReferences, previews), [shownReferences, previews]);
|
|
@@ -383,8 +435,8 @@ export function NotificationCenter({
|
|
|
383
435
|
|
|
384
436
|
return (
|
|
385
437
|
<NeedsMeView
|
|
386
|
-
captureTeaserLine={teaserLine} demo={demo} draftsFailed={draftsState === "failed"} entries={entries} error={error} freshness={freshness} legacyAuto={legacyAuto}
|
|
387
|
-
loadEmail={api.loadEmail} loadState={preview === "loading" ? "loading" : loadState} missingDraftCount={missingCount} now={now} onCorrectCapture={onCorrectCapture} onDecide={decide}
|
|
438
|
+
below={below} captureTeaserLine={teaserLine} demo={demo} draftsFailed={draftsState === "failed"} entries={entries} error={error} freshness={freshness} legacyAuto={legacyAuto}
|
|
439
|
+
loadEmail={api.loadEmail} loadState={preview === "loading" ? "loading" : loadState} missingDraftCount={missingCount} now={now} onCorrectCapture={onCorrectCapture} onDecide={decide} onEditReason={editReason}
|
|
388
440
|
onRetryDrafts={() => void loadDraftsFor(requests)} predictionLoading={predictionLoading} recent={recent} referenceAttachments={referenceAttachments}
|
|
389
441
|
referenceDisabledReason={referenceDisabledReason} referencesLoading={referencesLoading} settled={settled} shaping={shaping} stale={stale} threshold={threshold}
|
|
390
442
|
/>
|
|
@@ -270,7 +270,10 @@ export function ReviewQueueBar({
|
|
|
270
270
|
onPrevious,
|
|
271
271
|
position,
|
|
272
272
|
total,
|
|
273
|
+
anchored = false,
|
|
273
274
|
}: {
|
|
275
|
+
/** On a wide screen, pin the bar to the bottom-right corner of the viewport (the detail column's width), whatever scrolls. A sheet's own bar is not anchored. */
|
|
276
|
+
anchored?: boolean;
|
|
274
277
|
caughtUp?: boolean;
|
|
275
278
|
onNext: () => void;
|
|
276
279
|
onPrevious: () => void;
|
|
@@ -282,7 +285,8 @@ export function ReviewQueueBar({
|
|
|
282
285
|
return (
|
|
283
286
|
<nav
|
|
284
287
|
aria-label="Review queue"
|
|
285
|
-
className="sticky bottom-0 z-10 flex shrink-0 items-center gap-2
|
|
288
|
+
className={cn("sticky bottom-0 z-10 flex shrink-0 items-center gap-2 bg-[var(--feed-card)] px-3 py-2", anchored && "md:fixed md:bottom-0 md:right-0 md:w-[calc(24rem+1rem)] md:pr-7 lg:w-[calc(28rem+1rem)]")}
|
|
289
|
+
data-review-queue-anchored={anchored || undefined}
|
|
286
290
|
data-review-queue-bar
|
|
287
291
|
>
|
|
288
292
|
<Button
|
|
@@ -41,20 +41,29 @@ export type RatingController = {
|
|
|
41
41
|
* A Reject needs a reason code (the backend refuses one without), so it waits as `pending` until one is chosen; a typed reason is
|
|
42
42
|
* kept in the store until it is saved and is never dropped by a failed save.
|
|
43
43
|
*/
|
|
44
|
-
export function useRatingStore({ save, saved, seed, needsReason }: {
|
|
44
|
+
export function useRatingStore({ save, saved, seed, needsReason, sticky, saveNote }: {
|
|
45
45
|
save: RatingSave;
|
|
46
46
|
saved: (id: string) => ReviewChoice | null;
|
|
47
47
|
seed: (id: string) => { note: string; reasonCode: ReferenceRejectionReasonCode | null };
|
|
48
48
|
/** Whether a Reject on this item must wait for a reason code. Default yes (a Reference); an AI draft or an email asks nothing. */
|
|
49
49
|
needsReason?: (id: string) => boolean;
|
|
50
|
+
/**
|
|
51
|
+
* True for an item whose decision, once made, cannot be sent again (an AI draft moves its Proposal, a review request closes, a held email is
|
|
52
|
+
* queued). A reason typed after such a decision goes to `saveNote` instead, onto the decision that exists.
|
|
53
|
+
*/
|
|
54
|
+
sticky?: (id: string) => boolean;
|
|
55
|
+
/** Saves the reason of an item that is already decided and sticky. Throws a sentence when it cannot. */
|
|
56
|
+
saveNote?: (id: string, note: string) => Promise<void>;
|
|
50
57
|
}): RatingController {
|
|
51
58
|
const [items, setItems] = useState<Record<string, ItemRating>>({});
|
|
52
59
|
const itemsRef = useRef(items);
|
|
53
|
-
const latest = useRef({ save, saved, seed, needsReason });
|
|
54
|
-
latest.current = { save, saved, seed, needsReason };
|
|
60
|
+
const latest = useRef({ save, saved, seed, needsReason, sticky, saveNote });
|
|
61
|
+
latest.current = { save, saved, seed, needsReason, sticky, saveNote };
|
|
55
62
|
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
|
56
63
|
const inFlight = useRef<Set<string>>(new Set());
|
|
57
64
|
const dirty = useRef<Set<string>>(new Set());
|
|
65
|
+
const decidedHere = useRef<Set<string>>(new Set());
|
|
66
|
+
const noteInFlight = useRef<Set<string>>(new Set());
|
|
58
67
|
|
|
59
68
|
const patch = useCallback((id: string, change: Partial<ItemRating>) => {
|
|
60
69
|
const next = { ...itemsRef.current, [id]: { ...(itemsRef.current[id] ?? EMPTY), ...change } };
|
|
@@ -75,7 +84,32 @@ export function useRatingStore({ save, saved, seed, needsReason }: {
|
|
|
75
84
|
return { note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
|
|
76
85
|
};
|
|
77
86
|
|
|
87
|
+
/** True once a sticky item has its decision: from then on only its reason can change. */
|
|
88
|
+
const finalDecision = (id: string) => Boolean(latest.current.sticky?.(id) && latest.current.saveNote && (decidedHere.current.has(id) || latest.current.saved(id)));
|
|
89
|
+
|
|
90
|
+
/** A reason typed after the decision: saved onto it. The text stays in the store until it is saved, and a failed save never drops it. */
|
|
91
|
+
const saveNoteOnly = useCallback(async (id: string): Promise<void> => {
|
|
92
|
+
if (noteInFlight.current.has(id)) { dirty.current.add(`note:${id}`); return; }
|
|
93
|
+
noteInFlight.current.add(id);
|
|
94
|
+
dirty.current.delete(`note:${id}`);
|
|
95
|
+
const started = itemsRef.current[id]?.note;
|
|
96
|
+
const { note } = values(id);
|
|
97
|
+
patch(id, { feedback: { phase: "saving" } });
|
|
98
|
+
try {
|
|
99
|
+
await latest.current.saveNote!(id, note.trim());
|
|
100
|
+
const now = itemsRef.current[id];
|
|
101
|
+
patch(id, { feedback: { phase: "saved" }, ...(now?.note === started ? { note: undefined } : {}) });
|
|
102
|
+
} catch (failure) {
|
|
103
|
+
dirty.current.add(`note:${id}`); // still unsaved: tapping away, or the next pause, tries again
|
|
104
|
+
patch(id, { feedback: { phase: "error", message: failure instanceof Error ? failure.message : "Your note could not be saved. It is kept here; keep typing or tap away to try again." } });
|
|
105
|
+
} finally {
|
|
106
|
+
noteInFlight.current.delete(id);
|
|
107
|
+
}
|
|
108
|
+
if (itemsRef.current[id]?.feedback.phase !== "error" && dirty.current.delete(`note:${id}`)) void saveNoteOnly(id);
|
|
109
|
+
}, [patch]);
|
|
110
|
+
|
|
78
111
|
const commit = useCallback(async (id: string, choice: ReviewChoice) => {
|
|
112
|
+
if (finalDecision(id)) { void saveNoteOnly(id); return; }
|
|
79
113
|
const { note, reasonCode } = values(id);
|
|
80
114
|
if (choice === "reject" && !reasonCode && (latest.current.needsReason?.(id) ?? true)) { patch(id, { pending: "reject", askReason: true }); return; }
|
|
81
115
|
if (inFlight.current.has(id)) { dirty.current.add(id); return; }
|
|
@@ -85,6 +119,7 @@ export function useRatingStore({ save, saved, seed, needsReason }: {
|
|
|
85
119
|
patch(id, { pending: choice, askReason: false, feedback: { phase: "saving" } });
|
|
86
120
|
try {
|
|
87
121
|
await latest.current.save(id, choice, { note: note.trim(), reasonCode: choice === "reject" ? reasonCode : null });
|
|
122
|
+
if (latest.current.sticky?.(id)) decidedHere.current.add(id);
|
|
88
123
|
// What was saved now follows the record again (another editor's update shows); anything typed meanwhile is kept.
|
|
89
124
|
const now = itemsRef.current[id] ?? EMPTY;
|
|
90
125
|
patch(id, { pending: null, feedback: { phase: "saved" }, ...(now.note === started.note ? { note: undefined } : {}), ...(now.reasonCode === started.reasonCode ? { reasonCode: undefined } : {}) });
|
|
@@ -95,11 +130,12 @@ export function useRatingStore({ save, saved, seed, needsReason }: {
|
|
|
95
130
|
inFlight.current.delete(id);
|
|
96
131
|
}
|
|
97
132
|
if (dirty.current.has(id)) { dirty.current.delete(id); const again = itemsRef.current[id]?.pending ?? latest.current.saved(id); if (again) void commit(id, again); }
|
|
98
|
-
}, [patch]);
|
|
133
|
+
}, [patch, saveNoteOnly]);
|
|
99
134
|
|
|
100
135
|
const effective = (id: string): ReviewChoice | null => itemsRef.current[id]?.pending ?? latest.current.saved(id);
|
|
101
136
|
|
|
102
137
|
const choose = useCallback((id: string, choice: ReviewChoice) => {
|
|
138
|
+
if (finalDecision(id)) return; // a decision that cannot be sent again is only ever given a reason, never a second vote
|
|
103
139
|
if (effective(id) === choice && !itemsRef.current[id]?.askReason) return; // the chosen answer never unselects itself
|
|
104
140
|
void commit(id, choice);
|
|
105
141
|
}, [commit]);
|
|
@@ -128,7 +164,20 @@ export function useRatingStore({ save, saved, seed, needsReason }: {
|
|
|
128
164
|
|
|
129
165
|
const cancelReason = useCallback((id: string) => patch(id, { pending: null, askReason: false }), [patch]);
|
|
130
166
|
|
|
131
|
-
|
|
167
|
+
// Leaving the page (or closing the view) saves what was typed and not yet saved, rather than dropping it.
|
|
168
|
+
const leaving = useRef<() => void>(() => undefined);
|
|
169
|
+
leaving.current = () => {
|
|
170
|
+
for (const key of [...dirty.current]) {
|
|
171
|
+
if (!key.startsWith("note:")) continue;
|
|
172
|
+
const id = key.slice(5);
|
|
173
|
+
const choice = effective(id);
|
|
174
|
+
if (choice && dirty.current.delete(key)) void commit(id, choice);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
useEffect(() => () => {
|
|
178
|
+
for (const timer of Object.values(timers.current)) clearTimeout(timer);
|
|
179
|
+
leaving.current();
|
|
180
|
+
}, []);
|
|
132
181
|
|
|
133
182
|
return { get, choose, setReason, setNote, commitNote, cancelReason };
|
|
134
183
|
}
|
package/lib/review-client.ts
CHANGED
|
@@ -149,6 +149,22 @@ export async function saveDraftDecision(request: ReviewRequest, draftId: string,
|
|
|
149
149
|
return normalizeReviewRow(result.data.updateReviewRequest);
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Changes only the Why of an already decided draft or answered request (see `draftReasonUpdate` and `answerReasonUpdate`). The condition is that the
|
|
154
|
+
* request is still in the state it was read in, so a change cannot overwrite a request that closed or timed out meanwhile.
|
|
155
|
+
*/
|
|
156
|
+
export async function saveReviewReason(request: ReviewRequest, update: Record<string, unknown>): Promise<ReviewRequest> {
|
|
157
|
+
const result = await run(
|
|
158
|
+
`mutation Reason($input: UpdateReviewRequestInput!, $condition: ModelReviewRequestConditionInput) { updateReviewRequest(input: $input, condition: $condition) { ${FIELDS} } }`,
|
|
159
|
+
{ input: update, condition: { state: { eq: request.state } } },
|
|
160
|
+
);
|
|
161
|
+
if (result.errors?.some((entry) => /conditional/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`))) {
|
|
162
|
+
throw new Error("This request changed while you were writing. Your note is kept here; refresh, then save it again.");
|
|
163
|
+
}
|
|
164
|
+
if (result.errors?.length || !result.data?.updateReviewRequest) throw new Error("Your note could not be saved. It is kept here; keep typing or tap away to try again.");
|
|
165
|
+
return normalizeReviewRow(result.data.updateReviewRequest);
|
|
166
|
+
}
|
|
167
|
+
|
|
152
168
|
export type DraftLoad = { drafts: Record<string, DraftView>; missing: string[]; failed: string[] };
|
|
153
169
|
|
|
154
170
|
/**
|
package/lib/review-drafts.ts
CHANGED
|
@@ -215,6 +215,38 @@ function quote(text: string): string {
|
|
|
215
215
|
return `"${text.replace(/"/g, "'")}"`;
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
/** The words of an answer that covers several drafts: each rejection and each Why in a sentence, the plain Accepts of a mixed batch in one more. */
|
|
219
|
+
export function draftFeedback(decisions: readonly (DraftDecision & { id: string })[]): string | null {
|
|
220
|
+
const rejected = decisions.filter((decision) => decision.choice === "reject");
|
|
221
|
+
const lines = decisions
|
|
222
|
+
.filter((decision) => decision.choice === "reject" || decision.why)
|
|
223
|
+
.map((decision) => `${decision.choice === "accept" ? "Accepted" : "Rejected"} ${quote(decision.title ?? decision.id)}${decision.notThisKind ? " as not this kind of story" : ""}${decision.why ? `: ${decision.why}` : ""}.`);
|
|
224
|
+
if (rejected.length > 0 && rejected.length < decisions.length) {
|
|
225
|
+
const kept = decisions.filter((decision) => decision.choice === "accept" && !decision.why).map((decision) => quote(decision.title ?? decision.id));
|
|
226
|
+
if (kept.length) lines.push(`Accepted ${kept.join(", ")}.`);
|
|
227
|
+
}
|
|
228
|
+
return lines.join(" ") || null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The update that changes only the Why of a draft that is already decided. The Why is stored where the decision is: on the request's link
|
|
233
|
+
* for the draft and, once the request has closed, in its answer (`drafts` and the `feedback` sentence the idea pipeline reads back).
|
|
234
|
+
* The choice, the steering chip, who decided and when stay as they were. Applied with the condition that the state is unchanged.
|
|
235
|
+
*/
|
|
236
|
+
export function draftReasonUpdate(request: ReviewRequest, draftId: string, decision: DraftDecision, editor: string, now: Date): Record<string, unknown> {
|
|
237
|
+
const at = now.toISOString();
|
|
238
|
+
const why = decision.why?.trim() || null;
|
|
239
|
+
const next: DraftDecision = { ...decision, why };
|
|
240
|
+
const history = [...request.history, { at, event: "reason_edited", by: editor, note: `${decision.choice} ${draftId}` }].slice(-50);
|
|
241
|
+
const update: Record<string, unknown> = { id: request.id, links: JSON.stringify(withDecision(request, draftId, next)), updatedAt: at, history: JSON.stringify(history) };
|
|
242
|
+
if (request.answer?.drafts?.length && request.state === "answered") {
|
|
243
|
+
const known = request.answer.drafts;
|
|
244
|
+
const drafts = known.some((entry) => entry.id === draftId) ? known.map((entry) => (entry.id === draftId ? { ...entry, why } : entry)) : [...known, { ...next, id: draftId }];
|
|
245
|
+
update.answer = JSON.stringify({ ...request.answer, drafts, feedback: draftFeedback(drafts) });
|
|
246
|
+
}
|
|
247
|
+
return update;
|
|
248
|
+
}
|
|
249
|
+
|
|
218
250
|
/**
|
|
219
251
|
* The answer that closes a request once every draft that can be decided has been: Accept on all is an approval, Reject on all a rejection,
|
|
220
252
|
* a mix is an answer with a note naming each draft. A draft rejected as the wrong kind of story makes the answer a "not this kind" steer, which
|
|
@@ -230,21 +262,14 @@ export function batchAnswer(request: ReviewRequest, drafts: DraftLookup, fallbac
|
|
|
230
262
|
decisions.push({ ...decision, title: decision.title ?? drafts[id]?.headline, id });
|
|
231
263
|
}
|
|
232
264
|
const rejected = decisions.filter((decision) => decision.choice === "reject");
|
|
233
|
-
const lines = decisions
|
|
234
|
-
.filter((decision) => decision.choice === "reject" || decision.why)
|
|
235
|
-
.map((decision) => `${decision.choice === "accept" ? "Accepted" : "Rejected"} ${quote(decision.title ?? decision.id)}${decision.notThisKind ? " as not this kind of story" : ""}${decision.why ? `: ${decision.why}` : ""}.`);
|
|
236
265
|
const mixed = rejected.length > 0 && rejected.length < decisions.length;
|
|
237
|
-
if (mixed) {
|
|
238
|
-
const kept = decisions.filter((decision) => decision.choice === "accept" && !decision.why).map((decision) => quote(decision.title ?? decision.id));
|
|
239
|
-
if (kept.length) lines.push(`Accepted ${kept.join(", ")}.`);
|
|
240
|
-
}
|
|
241
266
|
const links = request.links.some((link) => link.type === "proposal") ? request.links.map((link) => {
|
|
242
267
|
const decision = decisions.find((entry) => entry.id === link.id);
|
|
243
268
|
return decision ? { ...link, decision } : link;
|
|
244
269
|
}) : [...request.links, ...decisions.map((decision) => ({ type: "proposal", id: decision.id, decision }))];
|
|
245
270
|
return {
|
|
246
271
|
decision: !rejected.length ? "approve" : mixed ? "feedback" : "reject",
|
|
247
|
-
feedback:
|
|
272
|
+
feedback: draftFeedback(decisions),
|
|
248
273
|
steer: rejected.some((decision) => decision.notThisKind) ? "not_this_kind" : null,
|
|
249
274
|
drafts: decisions,
|
|
250
275
|
links,
|
package/lib/review-requests.ts
CHANGED
|
@@ -108,6 +108,31 @@ export function answerUpdate(request: ReviewRequest, input: AnswerInput, by: str
|
|
|
108
108
|
};
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* The update that changes only the note of a request that already has an answer (a hard gate, a stage review, an acknowledged flag).
|
|
113
|
+
* The decision, the steering signal, who answered and when stay as they were. Applied with the condition that the state is unchanged,
|
|
114
|
+
* so it cannot overwrite a request that timed out or was withdrawn in the meantime.
|
|
115
|
+
*/
|
|
116
|
+
export function answerReasonUpdate(request: ReviewRequest, why: string, editor: string, now: Date): Record<string, unknown> {
|
|
117
|
+
if (!request.answer) throw new Error("This request has no answer yet; answer it first.");
|
|
118
|
+
const at = now.toISOString();
|
|
119
|
+
const answer: ReviewAnswer = { ...request.answer, feedback: why.trim() || null };
|
|
120
|
+
const history = [...request.history, { at, event: "reason_edited", by: editor, note: request.answer.decision }].slice(-MAX_HISTORY);
|
|
121
|
+
return { id: request.id, answer: JSON.stringify(answer), updatedAt: at, history: JSON.stringify(history) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A request with a stored update applied in memory, for the demo and the fixtures; the live data API returns the saved row instead. */
|
|
125
|
+
export function applyReviewUpdate(request: ReviewRequest, update: Record<string, unknown>): ReviewRequest {
|
|
126
|
+
return {
|
|
127
|
+
...request,
|
|
128
|
+
...(typeof update.state === "string" ? { state: update.state as ReviewState } : {}),
|
|
129
|
+
...(update.answer ? { answer: JSON.parse(String(update.answer)) } : {}),
|
|
130
|
+
...(update.links ? { links: JSON.parse(String(update.links)) } : {}),
|
|
131
|
+
history: update.history ? JSON.parse(String(update.history)) : request.history,
|
|
132
|
+
updatedAt: String(update.updatedAt ?? request.updatedAt),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
111
136
|
// --- words ------------------------------------------------------------------------------------------------
|
|
112
137
|
|
|
113
138
|
export function formatWhen(iso: string | null | undefined): string {
|
package/package.json
CHANGED
package/routes.manifest.json
CHANGED