@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.
@@ -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 typed before deciding an item that is not a Reference: the optional why, and the one steering chip. */
21
- export type DecisionExtras = { why: string; notThisKind: boolean };
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
- /** Accept / Reject for everything that is not a Reference: an optional why (read when you tap), one steering chip on Reject, then the shared grid. */
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{recorded.why ? <span className="font-normal text-muted-foreground">: {recorded.why}</span> : null}</p>
93
- : <DecidedLine choice={decided ?? "accept"} notThisKind={recorded.notThisKind} why={recorded.why} />}
94
- {flag ? null : <RatingGrid answer={decided} disabled feedback={{ phase: "saved" }} onChoose={() => undefined} prediction={{ state: "none" }} showPrediction={false} size="large" surface="footer" />}
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 = { why: "", notThisKind: false };
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
- const referenceLike = isReferenceLike(item);
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
- saved: (id) => { const item = latest.current.byId.get(id); return item ? decidedChoiceOf(item) : null; },
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 || item.payload.kind !== "reference") return { note: "", reasonCode: null };
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="@container flex flex-col gap-5 md:h-full md:flex-row md:items-stretch" data-detail-reserved="true" data-notification-center {...FEED_SURFACE_PROPS}>
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-[24px] bg-[var(--feed-card)] p-0" data-feed-flat="" data-feed-surface="" initialFocus={false} side="bottom" {...themeAttributes}>
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 { newsroomInboxRowClassName } from "../lib/newsroom-list-selection";
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
- return (
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={newsroomInboxRowClassName(active)}
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
- <Badge className="mt-0.5 shrink-0" variant="secondary">{assignment.status}</Badge>
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:border-primary/30">
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:border-primary/30">
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
- "mb-4 rounded-xl border px-3 py-2 text-sm",
214
- tone === "error" && "border-destructive/30 bg-destructive/10 text-destructive",
215
- tone === "ok" && "border-primary/20 bg-primary/10 text-foreground",
216
- tone === "info" && "border-border bg-muted/40 text-foreground",
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;border-bottom:1px solid var(--org-line)}
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: 9999px !important; }
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: 20px !important; }
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: 16px !important; }
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: 16px !important; }
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: 24px !important; }
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: 9999px !important; color: var(--foreground, inherit);
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: 9999px !important;
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: 9999px !important; }
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: 9999px !important; }
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: 16px !important; }
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-[24px] bg-[var(--feed-card)] p-0" data-feed-flat="" data-feed-surface="" side="bottom" {...themeAttributes}>
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
- const result = await api.decideEmail?.(payload.summary.receiptId, decision.choice, decision.why);
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, api, decideDraft, onRateReference]);
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 border-t border-border bg-card px-3 py-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
- useEffect(() => () => { for (const timer of Object.values(timers.current)) clearTimeout(timer); }, []);
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
  }
@@ -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
  /**
@@ -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: lines.join(" ") || null,
272
+ feedback: draftFeedback(decisions),
248
273
  steer: rejected.some((decision) => decision.notThisKind) ? "not_this_kind" : null,
249
274
  drafts: decisions,
250
275
  links,
@@ -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
@@ -495,7 +495,7 @@
495
495
  "optional": true
496
496
  }
497
497
  },
498
- "version": "5.48.0",
498
+ "version": "5.49.0",
499
499
  "dependencies": {
500
500
  "@aws-amplify/adapter-nextjs": "^1.7.3",
501
501
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.48.0",
2
+ "version": "5.49.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",