@anthusai/papyrus 5.57.0 → 5.59.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.
Files changed (68) hide show
  1. package/amplify/data/message-actions.js +68 -0
  2. package/amplify/data/message-actions.ts +70 -0
  3. package/amplify/data/resolvers/delete-own-message.js +30 -0
  4. package/amplify/data/resolvers/edit-message.js +33 -0
  5. package/amplify/data/resolvers/moderate-message.js +32 -0
  6. package/amplify/data/resolvers/post-message.js +68 -0
  7. package/amplify/data/resolvers/rename-thread.js +28 -0
  8. package/amplify/data/resource.js +28 -3
  9. package/amplify/data/resource.ts +28 -3
  10. package/amplify/data/schema.js +34 -5
  11. package/amplify/data/schema.ts +34 -5
  12. package/amplify/functions/category-action/handler.ts +51 -125
  13. package/amplify/functions/shared/reference-curation-metadata.ts +21 -29
  14. package/components/conversation-context.tsx +29 -2
  15. package/components/conversation-provider.tsx +5 -1
  16. package/components/conversation-thread.tsx +154 -25
  17. package/components/conversations-memory-source.ts +83 -0
  18. package/components/conversations-network-source.ts +52 -0
  19. package/components/conversations-page.tsx +14 -0
  20. package/components/conversations-source.ts +36 -0
  21. package/components/conversations-view.tsx +680 -0
  22. package/components/decision-control.tsx +307 -0
  23. package/components/list-detail-layout.tsx +44 -0
  24. package/components/needs-me-bodies.tsx +18 -17
  25. package/components/needs-me-feed.tsx +16 -32
  26. package/components/news-desk-taxonomy-client.ts +216 -62
  27. package/components/newsroom-article-editor.tsx +10 -4
  28. package/components/newsroom-organizer.css +34 -1
  29. package/components/newsroom-references-view.tsx +41 -100
  30. package/components/notification-cards.tsx +9 -8
  31. package/components/notification-center.tsx +5 -7
  32. package/components/proposals-view.tsx +4 -4
  33. package/components/reference-conversation.tsx +5 -0
  34. package/components/reference-feed.tsx +129 -127
  35. package/components/reference-review-card.tsx +9 -135
  36. package/components/references-page-loading.tsx +12 -6
  37. package/components/story-reporting-trail-client.ts +4 -3
  38. package/components/story-reporting-trail.tsx +8 -0
  39. package/components/topic-steering-workspace.tsx +44 -786
  40. package/components/use-conversation.ts +36 -2
  41. package/components/use-feed-preferences.ts +33 -0
  42. package/components/use-reference-ratings.ts +11 -7
  43. package/lib/category-repository.ts +14 -38
  44. package/lib/console-chat-client.ts +8 -178
  45. package/lib/conversation-feed.ts +96 -0
  46. package/lib/conversation.ts +73 -2
  47. package/lib/cyclotron-suggestions.ts +18 -36
  48. package/lib/local-preview-seed.ts +16 -27
  49. package/lib/needs-me-items.ts +4 -4
  50. package/lib/needs-me-ranking.ts +9 -22
  51. package/lib/newsroom-board.ts +10 -1
  52. package/lib/newsroom-demo-dashboard.ts +0 -7
  53. package/lib/newsroom-organizer-nav.ts +2 -2
  54. package/lib/post-message.ts +54 -0
  55. package/lib/proposals-admin.ts +2 -2
  56. package/lib/reference-feed-keys.ts +69 -0
  57. package/lib/reference-feed-ranking.ts +77 -160
  58. package/lib/reference-policy.ts +83 -11
  59. package/lib/reference-review-details.ts +21 -57
  60. package/lib/relevance-decisions.ts +6 -2
  61. package/lib/review-drafts.ts +2 -2
  62. package/lib/review-fixture.ts +1 -1
  63. package/lib/review-requests.ts +4 -4
  64. package/lib/semantic-graph.ts +1 -1
  65. package/lib/story-reporting-trail.ts +28 -2
  66. package/package.json +12 -2
  67. package/routes.manifest.json +1 -1
  68. package/components/review-rating-grid.tsx +0 -190
@@ -0,0 +1,307 @@
1
+ "use client";
2
+
3
+ import { useEffect, useId, useState } from "react";
4
+ import { CheckIcon, Loader2Icon, XIcon } from "lucide-react";
5
+ import { UNRECORDED_CONFIDENCE } from "../lib/reference-feed-ranking";
6
+ import { cn } from "../lib/utils";
7
+ import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
8
+ import type { CardPrediction, ReviewChoice } from "./reference-review-card";
9
+
10
+ /**
11
+ * One classification, in two stages: a model predicts it, a person decides it.
12
+ *
13
+ * PREDICTED ACCEPT
14
+ * [=========== 96% ==========| ] the prediction's own confidence, in its own bar
15
+ * [ ✓ Accept PREDICTED ] [ ✕ Reject ] one pair of segments; the predicted one is pre-filled, in its own tone
16
+ * REASON · OPTIONAL
17
+ * [ Why? In your own words… ] written, always in view, full width
18
+ * ✓ Recorded · confirms the prediction one status line for the whole block
19
+ *
20
+ * The prediction and the answer share one row of segments rather than a row each: they are not two
21
+ * voters, they are one decision before and after a person touched it. Once decided, a small marker
22
+ * stays on the segment the model picked — on the losing one if it was overturned — so the comparison
23
+ * survives the answer without a second row. The bar belongs to the model, so it never moves when the
24
+ * person answers; only its tone changes.
25
+ *
26
+ * The verdict never tints the person's own choice: a decided segment is always `--primary`, and
27
+ * `--feed-verdict-*` colours the marker and the bar. An overturned Reject painted red reads as "your
28
+ * answer is wrong" rather than "the model's was".
29
+ *
30
+ * No engine or product name appears in the copy: the component knows about predictions, not about
31
+ * what made them (AGENTS.md: a deployment publishes about any subject with any classifier behind it).
32
+ *
33
+ * Every tone, corner and opacity is a feed token from newsroom-organizer.css; the values after the
34
+ * comma are the last resort for a surface that declares none.
35
+ */
36
+
37
+ export type RatingFeedback = { phase: "idle" | "saving" | "saved" | "error"; message?: string };
38
+
39
+ /** P(accept) from a prediction: its confidence is in the side it chose; an unrecorded confidence is a mild lean. */
40
+ export function predictionAcceptProbability(prediction: Extract<CardPrediction, { state: "ready" }>): number {
41
+ const confidence = prediction.confidence ?? UNRECORDED_CONFIDENCE;
42
+ return prediction.choice === "accept" ? confidence : 1 - confidence;
43
+ }
44
+
45
+ /** How far, in percent of the whole bar, the fill reaches from the centre: |P(accept) - 0.5| * 2, over the two halves. */
46
+ export function confidenceFillPercent(probability: number): number {
47
+ return Math.min(100, Math.abs(probability - 0.5) * 100 * 2);
48
+ }
49
+
50
+ const WORD: Record<ReviewChoice, string> = { accept: "Accept", reject: "Reject" };
51
+ const LABEL = "font-[family-name:var(--font-sans)] text-[10px] font-extrabold tracking-[0.07em] uppercase";
52
+ /** A fill shorter than this cannot hold its own percentage, so the number sits beside it. */
53
+ const NARROW_FILL = 14;
54
+
55
+ export function DecisionControl({
56
+ answer,
57
+ decided = null,
58
+ disabled = false,
59
+ disabledReason = null,
60
+ feedback = { phase: "idle" },
61
+ needsReason = false,
62
+ note = null,
63
+ onChoose,
64
+ onReasonChange,
65
+ onReasonCommit,
66
+ prediction,
67
+ reason = "",
68
+ reasonRequiredFor = null,
69
+ showPrediction = true,
70
+ showReason = true,
71
+ size = "compact",
72
+ statusOverlay = false,
73
+ surface = "footer",
74
+ }: {
75
+ /** The side shown as chosen. It may be an answer that is still saving. */
76
+ answer: ReviewChoice | null;
77
+ /** The saved answer, which the Confirmed / Overturned verdict compares against. */
78
+ decided?: ReviewChoice | null;
79
+ disabled?: boolean;
80
+ disabledReason?: string | null;
81
+ feedback?: RatingFeedback;
82
+ /** The answer is staged and waiting for a written reason before it can be recorded. */
83
+ needsReason?: boolean;
84
+ /** A short line shown in the status row beside the saved tick. It never adds a row. */
85
+ note?: string | null;
86
+ onChoose: (choice: ReviewChoice) => void;
87
+ onReasonChange?: (text: string) => void;
88
+ /** Save the reason on its own (on blur, or from the status row). */
89
+ onReasonCommit?: () => void;
90
+ prediction: CardPrediction;
91
+ /** The written reason, owned by the caller. */
92
+ reason?: string;
93
+ /** The side that cannot be recorded without a written reason. */
94
+ reasonRequiredFor?: ReviewChoice | null;
95
+ /** False for an item with nothing to predict: the bar and the caption are left out. */
96
+ showPrediction?: boolean;
97
+ /** False where the surrounding card owns the reason field. */
98
+ showReason?: boolean;
99
+ size?: "compact" | "large";
100
+ /** Lay the status line over the bottom padding of the nearest positioned ancestor, so it adds no height. */
101
+ statusOverlay?: boolean;
102
+ /** The tone the control sits on, so the bar's centre notch matches it. */
103
+ surface?: "footer" | "card";
104
+ }) {
105
+ const fieldId = useId();
106
+ const ready = prediction.state === "ready" ? prediction : null;
107
+ const verdict = ready && decided ? (ready.choice === decided ? "match" : "miss") : null;
108
+ const saving = feedback.phase === "saving";
109
+ const [reasonDirty, setReasonDirty] = useState(false);
110
+ useEffect(() => { if (feedback.phase === "saved") setReasonDirty(false); }, [feedback.phase]);
111
+
112
+ const probability = ready ? predictionAcceptProbability(ready) : 0.5;
113
+ const half = confidenceFillPercent(probability) / 2; // percent of the full bar, one side of the centre
114
+ const leansAccept = probability >= 0.5;
115
+ const confidenceText = ready ? (ready.confidence !== null ? `${Math.round(ready.confidence * 100)}%` : "unrecorded") : null;
116
+ const narrow = half < NARROW_FILL;
117
+ const cell = size === "large" ? "min-h-14 text-base" : "min-h-12 text-sm";
118
+ const field = size === "large" ? "min-h-16" : "min-h-9";
119
+
120
+ const caption = ready
121
+ ? `Predicted ${WORD[ready.choice]}`
122
+ : prediction.state === "loading" ? "Reading the prediction…"
123
+ : prediction.state === "error" ? "Prediction unavailable" : "No prediction";
124
+
125
+ /* What the chosen segment says about itself while the answer is in flight or held back. A segment
126
+ never claims to be unrecorded while it is being recorded. */
127
+ const stagedWord = saving ? (decided ? "Updating…" : "Sending…") : needsReason ? "Needs a reason" : null;
128
+
129
+ return (
130
+ <div className="@container/decision grid gap-y-2" data-decision-control data-rating-grid>
131
+ {showPrediction ? (
132
+ <div className="grid gap-y-0.5" data-prediction>
133
+ <span className={cn(LABEL, "text-muted-foreground")} data-prediction-caption>{caption}</span>
134
+ <div
135
+ aria-label={ready ? `Prediction: ${WORD[ready.choice]}, ${confidenceText} confident` : caption}
136
+ className="relative h-4 w-full overflow-hidden rounded-full bg-[var(--feed-control)]"
137
+ data-confidence-bar
138
+ data-confidence-direction={ready ? (leansAccept ? "accept" : "reject") : "none"}
139
+ data-prediction-state={ready ? undefined : prediction.state}
140
+ role="img"
141
+ >
142
+ {ready ? (
143
+ <span
144
+ className={cn(
145
+ "absolute inset-y-0 flex items-center rounded-full",
146
+ leansAccept ? "justify-end" : "justify-start",
147
+ verdict === "match" ? "bg-[var(--feed-verdict-match-fill,#059669)]"
148
+ : verdict === "miss" ? "bg-[var(--feed-verdict-miss-fill,#dc2626)]"
149
+ : "bg-[var(--foreground-muted)]",
150
+ )}
151
+ data-confidence-fill
152
+ data-fill-percent={Math.round(half * 2)}
153
+ style={{ left: leansAccept ? `${50 - half}%` : "50%", width: `${half}%` }}
154
+ >
155
+ {/* The confidence is printed in the bar and nowhere else, so no button has to carry a number. */}
156
+ {narrow ? null : <small className={cn(LABEL, "px-1.5 text-[var(--feed-card)] whitespace-nowrap")} data-confidence-value>{confidenceText}</small>}
157
+ </span>
158
+ ) : null}
159
+ {ready && narrow ? (
160
+ <small
161
+ className={cn(LABEL, "absolute top-0.5 text-muted-foreground whitespace-nowrap")}
162
+ data-confidence-value
163
+ style={leansAccept ? { left: `${50 - half}%`, transform: "translateX(-100%)" } : { left: `${50 + half}%` }}
164
+ >
165
+ {confidenceText}
166
+ </small>
167
+ ) : null}
168
+ <span aria-hidden="true" className={cn("absolute inset-y-0 left-1/2 w-1 -translate-x-1/2", surface === "footer" ? "bg-[var(--feed-footer)]" : "bg-[var(--feed-card)]")} />
169
+ </div>
170
+ </div>
171
+ ) : null}
172
+
173
+ <div aria-label="Classification" className="grid grid-cols-2 gap-x-1.5" data-review-row="you" role="group">
174
+ {(["accept", "reject"] as const).map((choice) => {
175
+ const on = answer === choice;
176
+ const predicted = ready?.choice === choice;
177
+ const proposed = predicted && !answer;
178
+ const Icon = choice === "accept" ? CheckIcon : XIcon;
179
+ const sub = proposed ? "Predicted"
180
+ : on ? (stagedWord ?? (verdict === "miss" ? "Overturned" : verdict === "match" ? "Confirmed" : decided === choice ? "Recorded" : null))
181
+ : null;
182
+ return (
183
+ <button
184
+ aria-disabled={disabled ? true : undefined}
185
+ aria-pressed={on}
186
+ className={cn(
187
+ "relative inline-flex min-w-0 flex-col items-center justify-center gap-px rounded-[var(--feed-radius-md,16px)] px-2 font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring @max-[17rem]/decision:px-1",
188
+ cell,
189
+ // Three degrees of finish, in tone alone: the feed register has no borders to spend
190
+ // (newsroom-organizer.css strips them), so a chosen answer is the solid fill, the
191
+ // predicted side is the control tone, and an untouched side is quieter still.
192
+ on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]"
193
+ : proposed ? "bg-[var(--feed-control)] text-foreground hover:bg-[var(--feed-control)]/70"
194
+ : "bg-[var(--feed-control)]/45 text-muted-foreground hover:bg-[var(--feed-control)]/70",
195
+ disabled && !on && "opacity-[var(--feed-quiet,0.6)]",
196
+ )}
197
+ data-predicted={predicted || undefined}
198
+ data-review-choice={choice}
199
+ data-state={on ? (saving || needsReason ? "staged" : "decided") : proposed ? "predicted" : "idle"}
200
+ key={choice}
201
+ onClick={() => { if (!disabled) onChoose(choice); }}
202
+ type="button"
203
+ >
204
+ {/* The model's opinion survives the answer, on the segment it picked. */}
205
+ {predicted && answer ? (
206
+ <span
207
+ className={cn(
208
+ "absolute top-1.5 right-2 opacity-55",
209
+ verdict === "match" && "text-[var(--feed-verdict-match-fill,#059669)] opacity-100",
210
+ verdict === "miss" && "text-[var(--feed-verdict-miss-fill,#dc2626)] opacity-100",
211
+ )}
212
+ data-prediction-mark
213
+ data-verdict={verdict ?? undefined}
214
+ title="this is what was predicted"
215
+ >
216
+ <svg aria-hidden="true" className="size-3" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
217
+ <circle cx="12" cy="12" r="8" />
218
+ <circle cx="12" cy="12" fill="currentColor" r="2.6" stroke="none" />
219
+ </svg>
220
+ </span>
221
+ ) : null}
222
+ <span className="flex items-center gap-1.5">
223
+ <Icon aria-hidden="true" className={cn("size-4", !on && "opacity-[var(--feed-quiet,0.6)]")} />
224
+ {WORD[choice]}
225
+ </span>
226
+ {sub ? <span className={cn(LABEL, "opacity-75")} data-choice-state>{sub}</span> : null}
227
+ {verdict && predicted ? <span className="sr-only">{verdict === "match" ? ", this is what was predicted" : ", the prediction was the other way"}</span> : null}
228
+ </button>
229
+ );
230
+ })}
231
+ </div>
232
+
233
+ {showReason ? (
234
+ <div className="grid gap-y-1.5" data-review-reason>
235
+ <label className={cn(LABEL, "text-muted-foreground")} htmlFor={fieldId}>
236
+ Reason{reasonRequiredFor ? ` · required to ${WORD[reasonRequiredFor].toLowerCase()}` : " · optional"}
237
+ </label>
238
+ {/* Written reasons only. A row of canned answers collects a label the model mostly has already;
239
+ what trains the next prediction is a sentence. */}
240
+ <AutoGrowTextarea
241
+ className={cn(
242
+ "w-full rounded-[var(--feed-radius-md,16px)] border-0 bg-[var(--feed-control)] px-3 py-2 text-sm leading-5 text-foreground outline-none transition-[min-height] placeholder:text-muted-foreground focus-visible:ring-[3px] focus-visible:ring-ring",
243
+ field,
244
+ )}
245
+ disabled={disabled}
246
+ id={fieldId}
247
+ onBlur={() => { if (!disabled) onReasonCommit?.(); }}
248
+ onChange={(event) => { setReasonDirty(true); onReasonChange?.(event.target.value); }}
249
+ placeholder="Why? In your own words…"
250
+ value={reason}
251
+ />
252
+ </div>
253
+ ) : null}
254
+
255
+ <div
256
+ className={cn(
257
+ "flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground",
258
+ // The overlay strip is 16px tall: a question with a button, or an error, needs a row of its own.
259
+ statusOverlay && !needsReason && feedback.phase !== "error" ? "absolute inset-x-4 bottom-0 h-4 overflow-hidden leading-4" : "min-h-5",
260
+ )}
261
+ data-feed-saved
262
+ role="status"
263
+ >
264
+ {note ? <span data-feed-reason>{note}</span> : null}
265
+ {needsReason ? (
266
+ <>
267
+ <span className="font-semibold text-foreground">A reason is needed to record {WORD[answer ?? "reject"]}</span>
268
+ <button
269
+ className="rounded-[var(--feed-radius-md,16px)] bg-[var(--primary)] px-2.5 py-0.5 font-semibold text-[var(--primary-foreground)] outline-none focus-visible:ring-[3px] focus-visible:ring-ring disabled:opacity-[var(--feed-quiet,0.6)]"
270
+ disabled={!reason.trim()}
271
+ data-review-record
272
+ onClick={() => { if (answer) onChoose(answer); }}
273
+ type="button"
274
+ >
275
+ Record
276
+ </button>
277
+ </>
278
+ ) : null}
279
+ {saving ? <span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Recording…</span> : null}
280
+ {feedback.phase === "saved" && !needsReason ? (
281
+ <span className="inline-flex items-center gap-1 text-foreground" data-review-saved>
282
+ <CheckIcon aria-hidden="true" className="size-3.5" />
283
+ Recorded
284
+ {verdict ? <span className="text-muted-foreground">· {verdict === "match" ? "confirms" : "overturns"} the prediction</span> : null}
285
+ {showReason ? (
286
+ reasonDirty ? <span className="text-muted-foreground">· reason not saved yet</span>
287
+ : reason.trim() ? <span className="text-muted-foreground">· reason saved</span>
288
+ : <span className="text-muted-foreground">· no reason given</span>
289
+ ) : null}
290
+ </span>
291
+ ) : null}
292
+ {feedback.phase === "saved" && reasonDirty && showReason ? (
293
+ <button
294
+ className="rounded-[var(--feed-radius-md,16px)] bg-[var(--primary)] px-2.5 py-0.5 font-semibold text-[var(--primary-foreground)] outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
295
+ data-review-save-reason
296
+ onClick={() => onReasonCommit?.()}
297
+ type="button"
298
+ >
299
+ Save reason
300
+ </button>
301
+ ) : null}
302
+ </div>
303
+ {disabledReason ? <p className="text-xs text-muted-foreground" data-review-disabled-reason>{disabledReason}</p> : null}
304
+ {feedback.phase === "error" ? <p className="text-xs text-destructive" role="alert">{feedback.message}</p> : null}
305
+ </div>
306
+ );
307
+ }
@@ -0,0 +1,44 @@
1
+ "use client";
2
+
3
+ import { ArrowLeftIcon } from "lucide-react";
4
+ import type { ReactNode } from "react";
5
+ import { cn } from "../lib/utils";
6
+
7
+ /**
8
+ * The newsroom's list-and-detail layout, one rule for every page that has it (References, Messages). Below `lg` (1024px) the open
9
+ * item's detail replaces the list at the full width with a Back button; from `lg` the detail is a pane beside the list, 24rem wide and
10
+ * 28rem from `xl`. It is chosen by the viewport, never by the selection.
11
+ */
12
+ export const DETAIL_PANE_WIDTH = "lg:w-[min(100%,24rem)] xl:w-[min(100%,28rem)]";
13
+ export const LIST_HIDDEN_WHEN_DETAIL_OPEN = "max-lg:hidden";
14
+
15
+ export function ListDetailLayout({ backLabel, detail, list, onBack, open }: {
16
+ backLabel: string;
17
+ detail: ReactNode;
18
+ list: ReactNode;
19
+ onBack: () => void;
20
+ open: boolean;
21
+ }) {
22
+ return (
23
+ <div className="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row lg:items-start" data-list-detail-layout={open ? "detail" : "list"}>
24
+ <div className={cn("min-w-0 flex-1", open && LIST_HIDDEN_WHEN_DETAIL_OPEN)} data-list-detail-list>{list}</div>
25
+ {open ? (
26
+ <div
27
+ className={cn("flex w-full shrink-0 flex-col gap-3 rounded-[20px] bg-[var(--feed-card)] p-4 lg:sticky lg:top-4 lg:max-h-[calc(100dvh-2rem)] lg:overflow-y-auto", DETAIL_PANE_WIDTH)}
28
+ data-list-detail-pane
29
+ >
30
+ <button
31
+ className="-ml-2 inline-flex min-h-9 items-center gap-1.5 self-start rounded-[var(--feed-radius-md,16px)] px-2 text-sm font-medium text-muted-foreground outline-none transition-colors hover:bg-[var(--feed-control)]/70 hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring lg:hidden"
32
+ data-detail-back
33
+ onClick={onBack}
34
+ type="button"
35
+ >
36
+ <ArrowLeftIcon aria-hidden="true" className="size-4" />
37
+ {backLabel}
38
+ </button>
39
+ {detail}
40
+ </div>
41
+ ) : null}
42
+ </div>
43
+ );
44
+ }
@@ -13,7 +13,7 @@ import type { NeedsMeEntry } from "../lib/needs-me-items";
13
13
  import { answerWords, formatWhen, nextText, steeringText, subjectHref, subjectLabel } from "../lib/review-requests";
14
14
  import { DecidedLine } from "./notification-cards";
15
15
  import { SourceMark } from "./reference-feed";
16
- import { RatingGrid } from "./review-rating-grid";
16
+ import { DecisionControl } from "./decision-control";
17
17
  import type { RatingController } from "./use-reference-ratings";
18
18
  import { FlagFooter, decidedChoiceOf, recordedAnswer } from "./needs-me-shared";
19
19
 
@@ -96,22 +96,12 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
96
96
 
97
97
  return (
98
98
  <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": "" })}>
99
- <AutoGrowTextarea
100
- aria-label="Why? (optional)"
101
- className="block min-h-20 w-full 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
- onBlur={() => { if (!disabledReason) ratings.commitNote(item.id); }}
104
- onChange={(event) => ratings.setNote(item.id, event.target.value)}
105
- placeholder={flag ? "Why? Optional. Tell the step what to do differently." : "Why? Optional. Your note teaches the AI."}
106
- value={note}
107
- />
108
- {locked ? <ReasonStatus feedback={rating.feedback} hasReason={Boolean(recorded.why)} unsaved={unsaved} /> : null}
109
99
  {locked ? (
110
100
  <>
111
101
  {item.decisionState === "acknowledged"
112
102
  ? <p className="flex items-center gap-1.5 text-sm font-medium" data-flag-acknowledged><CircleCheckIcon aria-hidden="true" className="size-4" />Acknowledged</p>
113
103
  : <DecidedLine choice={decided ?? "accept"} notThisKind={recorded.notThisKind} />}
114
- {flag ? null : <RatingGrid answer={decided} disabled feedback={{ phase: "idle" }} onChoose={() => undefined} prediction={{ state: "none" }} showPrediction={false} size="large" surface="footer" />}
104
+ {flag ? null : <DecisionControl answer={decided} disabled feedback={{ phase: "idle" }} onChoose={() => undefined} prediction={{ state: "none" }} showPrediction={false} showReason={false} size="large" surface="footer" />}
115
105
  {lockedNote ? <p className="text-xs text-muted-foreground" data-review-disabled-reason>{lockedNote}</p> : null}
116
106
  </>
117
107
  ) : (
@@ -134,7 +124,7 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
134
124
  {flag ? (
135
125
  <FlagFooter busy={busy} disabledReason={disabledReason} done={false} error={rating.feedback.phase === "error" ? rating.feedback.message ?? null : null} onAcknowledge={() => ratings.choose(item.id, "accept")} />
136
126
  ) : (
137
- <RatingGrid
127
+ <DecisionControl
138
128
  answer={rating.pending}
139
129
  disabled={Boolean(disabledReason)}
140
130
  disabledReason={disabledReason}
@@ -142,12 +132,23 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
142
132
  onChoose={(choice) => { if (!disabledReason) ratings.choose(item.id, choice); }}
143
133
  prediction={{ state: "none" }}
144
134
  showPrediction={false}
135
+ showReason={false}
145
136
  size="large"
146
137
  surface="footer"
147
138
  />
148
139
  )}
149
140
  </>
150
141
  )}
142
+ <AutoGrowTextarea
143
+ aria-label="Why? (optional)"
144
+ className="block min-h-20 w-full rounded-[16px] bg-[var(--feed-control)] p-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
145
+ data-needs-me-why
146
+ onBlur={() => { if (!disabledReason) ratings.commitNote(item.id); }}
147
+ onChange={(event) => ratings.setNote(item.id, event.target.value)}
148
+ placeholder={flag ? "Why? Optional. Tell the step what to do differently." : "Why? Optional. Your note teaches the AI."}
149
+ value={note}
150
+ />
151
+ {locked ? <ReasonStatus feedback={rating.feedback} hasReason={Boolean(recorded.why)} unsaved={unsaved} /> : null}
151
152
  </section>
152
153
  );
153
154
  }
@@ -163,7 +164,7 @@ export function DraftBody(props: DetailProps & { payload: Extract<NeedsMeEntry["
163
164
  const { item: draft } = payload;
164
165
  const view = draft.view;
165
166
  if (!view) {
166
- return <p className="inline-flex items-center gap-1.5 text-sm text-muted-foreground" role="status"><Loader2Icon aria-hidden="true" className="size-4 animate-spin" />Loading the draft…</p>;
167
+ return <p className="inline-flex items-center gap-1.5 text-sm text-muted-foreground" role="status"><Loader2Icon aria-hidden="true" className="size-4 animate-spin" />Loading the Proposed Story…</p>;
167
168
  }
168
169
  return (
169
170
  <div className="space-y-4" data-draft-detail={draft.draftId}>
@@ -195,16 +196,16 @@ export function DraftBody(props: DetailProps & { payload: Extract<NeedsMeEntry["
195
196
  </li>
196
197
  );
197
198
  })}
198
- {view.moreSources ? <li className="self-center text-xs text-muted-foreground">and {view.moreSources} more in the full draft</li> : null}
199
+ {view.moreSources ? <li className="self-center text-xs text-muted-foreground">and {view.moreSources} more in the full Proposed Story</li> : null}
199
200
  </ul>
200
201
  </section>
201
202
  ) : null}
202
203
  <p className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-muted-foreground">
203
204
  <span data-draft-caption>{requestCaption(draft.request)}</span>
204
- <Link className="inline-flex items-center gap-1 font-medium text-primary underline-offset-4 hover:underline" data-draft-open href={view.href}>Open full draft<ExternalLinkIcon aria-hidden="true" className="size-3.5 shrink-0" /></Link>
205
+ <Link className="inline-flex items-center gap-1 font-medium text-primary underline-offset-4 hover:underline" data-draft-open href={view.href}>Open full Proposed Story<ExternalLinkIcon aria-hidden="true" className="size-3.5 shrink-0" /></Link>
205
206
  </p>
206
207
  <DefaultOutcome item={item} />
207
- <DecisionPanel {...props} lockedNote={<>To change it, <Link className="text-primary underline underline-offset-4" href={view.href}>open the draft in Proposals</Link>.</>} />
208
+ <DecisionPanel {...props} lockedNote={<>To change it, <Link className="text-primary underline underline-offset-4" href={view.href}>open the Proposed Story in Proposals</Link>.</>} />
208
209
  </div>
209
210
  );
210
211
  }
@@ -23,7 +23,6 @@ import { referenceReasonCode, REFERENCE_REJECTION_REASON_CODES, normalizeReferen
23
23
  import { formatFeedAge } from "../lib/reference-preview";
24
24
  import { buildNeedsMeItemPath, parseNeedsMeItemIdFromNewsroomPathname } from "../lib/newsroom-index-filters";
25
25
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
26
- import type { FeedFreshness } from "../lib/reference-feed-ranking";
27
26
  import type { RecentDecision } from "../lib/notification-feed";
28
27
  import { getKanbusTheme, SITE_BRAND } from "../lib/site-brand";
29
28
  import { NeedsMeDetail, type DecisionExtras } from "./needs-me-detail";
@@ -32,7 +31,7 @@ import { NotificationSkeleton, RecentDecisions } from "./notification-cards";
32
31
  import { FEED_GRID, FEED_SURFACE_PROPS, FeedImage, PILL, REASON_LABELS, SELECTED_RING, SourceMark } from "./reference-feed";
33
32
  import { predictionFromDecision, ReviewQueueBar, useQueueGestures, type CardPrediction, type ReviewChoice } from "./reference-review-card";
34
33
  import type { CorrectCaptureAction } from "./reference-capture-client";
35
- import { RatingGrid } from "./review-rating-grid";
34
+ import { DecisionControl } from "./decision-control";
36
35
  import { useMediaQuery } from "./use-media-query";
37
36
  import { useRatingStore, type RatingController } from "./use-reference-ratings";
38
37
  import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
@@ -169,30 +168,24 @@ export function NeedsMeCard({ item, now, onOpen, predictionLoading, ratings, sel
169
168
  {flag ? (
170
169
  <FlagFooter busy={rating.feedback.phase === "saving"} disabledReason={disabledReason} done={item.decisionState === "acknowledged"} error={rating.feedback.phase === "error" ? rating.feedback.message ?? null : null} onAcknowledge={() => ratings.choose(item.id, "accept")} />
171
170
  ) : (
172
- <RatingGrid
171
+ <DecisionControl
173
172
  answer={chosen}
174
173
  decided={decided}
175
174
  disabled={Boolean(disabledReason) || locked}
176
175
  disabledReason={disabledReason}
177
176
  feedback={feedback}
177
+ needsReason={refLike && rating.askReason}
178
178
  note={reasonLabel ? `Reason: ${reasonLabel}` : locked ? whyLine ?? "Decided here." : null}
179
179
  onChoose={(choice) => { if (!disabledReason && !locked) ratings.choose(item.id, choice); }}
180
+ onReasonChange={(text) => ratings.setNote(item.id, text)}
181
+ onReasonCommit={() => { if (!disabledReason && !locked) ratings.commitNote(item.id); }}
180
182
  prediction={cardPrediction(item, predictionLoading)}
183
+ reason={rating.note ?? ""}
184
+ reasonRequiredFor={refLike ? "reject" : null}
181
185
  showPrediction={refLike}
186
+ showReason={refLike}
182
187
  />
183
188
  )}
184
- {refLike && rating.askReason ? (
185
- // Covers the footer rather than adding to it, so asking for a reason never pushes the cards below it down.
186
- <div className="absolute inset-0 z-10 flex flex-col justify-center space-y-2 bg-[var(--feed-footer)] p-3" data-review-reasons>
187
- <p className="text-xs font-medium">Reason for rejecting. Choose one to save your Reject.</p>
188
- <div className="flex flex-wrap gap-1.5">
189
- {REFERENCE_REJECTION_REASON_CODES.map((code) => (
190
- <button className={cn(PILL, "min-h-8 bg-[var(--feed-control)] px-3 text-xs")} data-review-reason={code} key={code} onClick={() => ratings.setReason(item.id, code)} type="button">{REASON_LABELS[code]}</button>
191
- ))}
192
- <button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() => ratings.cancelReason(item.id)} type="button">Cancel</button>
193
- </div>
194
- </div>
195
- ) : null}
196
189
  </div>
197
190
  </article>
198
191
  );
@@ -224,22 +217,14 @@ export function ShapingStrip({ stats }: { stats: ShapingStats }) {
224
217
  );
225
218
  }
226
219
 
227
- function plainDays(days: number): string {
228
- if (days < 1) return `${Math.max(1, Math.round(days * 24))} hours`;
229
- return `${days < 10 ? days.toFixed(1).replace(/\.0$/, "") : Math.round(days)} days`;
230
- }
231
-
232
220
  /** The plain-words recipe, with this site's learned numbers in it. */
233
- export function NeedsMeRecipe({ freshness, threshold }: { freshness: FeedFreshness; threshold: ConfidenceThreshold }) {
221
+ export function NeedsMeRecipe({ threshold }: { threshold: ConfidenceThreshold }) {
234
222
  return (
235
223
  <div className="space-y-3 rounded-[20px] bg-[var(--feed-card)] p-5 text-sm" data-needs-me-recipe>
236
224
  <p className="text-base font-bold">A feed run by algorithms you control</p>
237
225
  <ol className="list-decimal space-y-2.5 pl-5">
238
226
  <li><strong>Hard gates first.</strong> Nothing continues until you decide one.</li>
239
- <li><strong>Then where your answer matters most.</strong> Each item is scored by how unsure the Cyclotron is, times how much it matters, times how fresh it is. A coin toss scores highest; a sure thing scores lowest; no prediction counts as halfway.</li>
240
- <li><strong>Age fades the score.</strong> {freshness.tauDays !== null && freshness.medianAgeDays !== null
241
- ? <>It loses half its pull every {plainDays(freshness.medianAgeDays)}, the typical age of the {freshness.examples} References you accepted.</>
242
- : <>Age does not change the order yet. The pace is learned from the References you accept, and so far there are {freshness.examples} of the 10 needed.</>}</li>
227
+ <li><strong>Then where your answer matters most.</strong> Each item is scored by how unsure the Cyclotron is, times how much it matters. Age does not change it. A coin toss scores highest; a sure thing scores lowest; no prediction counts as halfway.</li>
243
228
  <li><strong>A mix, not a run.</strong> Never more than two of one kind in a row while something else is waiting.</li>
244
229
  <li><strong>Confident items do not ask for you.</strong> {threshold.threshold !== null
245
230
  ? <>Items the Cyclotron is surer about than {Math.round((1 - threshold.threshold) * 100)}% (the median of the {threshold.examples} predictions you confirmed) wait under Decided automatically. You can overrule any of them.</>
@@ -259,7 +244,6 @@ const NO_ATTACHMENTS: ReferenceAttachmentRecord[] = [];
259
244
  export type NeedsMeViewProps = {
260
245
  loadState: NeedsMeLoadState;
261
246
  entries: NeedsMeEntry[];
262
- freshness: FeedFreshness;
263
247
  threshold: ConfidenceThreshold;
264
248
  now: number;
265
249
  shaping: ShapingStats;
@@ -297,7 +281,7 @@ function Note({ children, ...rest }: { children: ReactNode } & Record<string, un
297
281
 
298
282
  /** The CMS console home: one feed that mixes everything that may need a person, with the References feed's cards and detail pane. */
299
283
  export function NeedsMeView({
300
- loadState, entries, freshness, threshold, now, shaping, recent, legacyAuto = [], settled, demo = false, stale = false, error = null, referencesLoading = false, predictionLoading = false,
284
+ loadState, entries, threshold, now, shaping, recent, legacyAuto = [], settled, demo = false, stale = false, error = null, referencesLoading = false, predictionLoading = false,
301
285
  referenceDisabledReason = null, draftsFailed = false, missingDraftCount = 0, initialFilter = "all", referenceAttachments = NO_ATTACHMENTS, captureTeaserLine, onCorrectCapture, onDecide, onEditReason, onRetryDrafts, onOpened, loadEmail, below,
302
286
  }: NeedsMeViewProps) {
303
287
  const themeMode = useResolvedPapyrusTheme();
@@ -318,7 +302,7 @@ export function NeedsMeView({
318
302
 
319
303
  // The order is taken ONCE per visit, when every source has been read, and then left alone: selecting, deciding, a refresh or a cache
320
304
  // revalidation changes what the cards say, never where they are. Items that arrive later wait behind a "N new items: show" pill.
321
- const live = useMemo(() => rankNeedsMe(entries, { now, freshness, threshold }), [entries, now, freshness, threshold]);
305
+ const live = useMemo(() => rankNeedsMe(entries, { threshold }), [entries, threshold]);
322
306
  const snapshot = useRef<NeedsMeSnapshot | null>(null);
323
307
  if (!snapshot.current && settled && entries.length > 0) snapshot.current = takeNeedsMeSnapshot(live);
324
308
  const placed = snapshot.current ? applyNeedsMeSnapshot(snapshot.current, live) : { feed: live.feed, auto: live.auto, newCount: 0 };
@@ -465,7 +449,7 @@ export function NeedsMeView({
465
449
  <InfoIcon aria-hidden="true" className="size-4" />How this is ordered
466
450
  </button>
467
451
  </div>
468
- {recipeOpen ? <NeedsMeRecipe freshness={freshness} threshold={threshold} /> : null}
452
+ {recipeOpen ? <NeedsMeRecipe threshold={threshold} /> : null}
469
453
  <p className="max-w-3xl text-sm text-muted-foreground" data-notification-ignore>
470
454
  Decide what you like and leave the rest. Each item says what happens if you do nothing, and only a hard gate waits for you.
471
455
  </p>
@@ -492,17 +476,17 @@ export function NeedsMeView({
492
476
  <div className="flex flex-col items-center gap-2 rounded-[20px] bg-[var(--feed-card)] px-6 py-12 text-center" data-notification-caught-up>
493
477
  <CircleCheckBigIcon aria-hidden="true" className="size-10 text-primary" />
494
478
  <h2 className="text-2xl font-bold tracking-tight">All caught up</h2>
495
- <p className="max-w-md text-sm text-muted-foreground">Nothing needs you right now. New drafts, references, emails and reviews will appear here as they arrive.</p>
479
+ <p className="max-w-md text-sm text-muted-foreground">Nothing needs you right now. New Proposed Stories, references, emails and reviews will appear here as they arrive.</p>
496
480
  </div>
497
481
  ) : null}
498
482
 
499
483
  {draftsFailed ? (
500
484
  <Note role="alert">
501
- <p>The drafts could not be loaded right now.</p>
485
+ <p>The Proposed Stories could not be loaded right now.</p>
502
486
  <button className={cn(PILL, "mt-3 bg-[var(--feed-control)] text-foreground")} onClick={onRetryDrafts} type="button">Try again</button>
503
487
  </Note>
504
488
  ) : null}
505
- {missingDraftCount ? <p className="text-sm text-muted-foreground" data-needs-me-missing-drafts>{missingDraftCount} of these drafts {missingDraftCount === 1 ? "is" : "are"} not in Proposals on this site, so they cannot be opened here.</p> : null}
489
+ {missingDraftCount ? <p className="text-sm text-muted-foreground" data-needs-me-missing-drafts>{missingDraftCount} of these Proposed Stories {missingDraftCount === 1 ? "is" : "are"} not in Proposals on this site, so they cannot be opened here.</p> : null}
506
490
 
507
491
  {referencesLoading && !placed.feed.length ? (
508
492
  <NotificationSkeleton count={3} bare />