@anthusai/papyrus 5.56.1 → 5.58.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 (43) hide show
  1. package/amplify/data/resource.js +25 -3
  2. package/amplify/data/resource.ts +25 -3
  3. package/amplify/data/schema.js +25 -3
  4. package/amplify/data/schema.ts +25 -3
  5. package/amplify/functions/category-action/handler.ts +51 -125
  6. package/amplify/functions/shared/reference-curation-metadata.ts +21 -29
  7. package/components/conversation-context.tsx +52 -0
  8. package/components/conversation-provider.tsx +20 -0
  9. package/components/conversation-thread.tsx +188 -0
  10. package/components/conversations-memory-source.ts +46 -0
  11. package/components/conversations-network-source.ts +40 -0
  12. package/components/conversations-page.tsx +14 -0
  13. package/components/conversations-source.ts +24 -0
  14. package/components/conversations-view.tsx +319 -0
  15. package/components/decision-control.tsx +307 -0
  16. package/components/needs-me-bodies.tsx +4 -3
  17. package/components/needs-me-feed.tsx +8 -14
  18. package/components/needs-me-reference-detail.tsx +2 -0
  19. package/components/news-desk-taxonomy-client.ts +322 -37
  20. package/components/newsroom-organizer.css +24 -0
  21. package/components/newsroom-references-view.tsx +9 -0
  22. package/components/notification-cards.tsx +5 -4
  23. package/components/reference-conversation.tsx +43 -0
  24. package/components/reference-feed.tsx +69 -37
  25. package/components/reference-review-card.tsx +8 -134
  26. package/components/story-reporting-trail-client.ts +4 -3
  27. package/components/story-reporting-trail.tsx +8 -0
  28. package/components/topic-steering-workspace.tsx +43 -784
  29. package/components/use-conversation.ts +84 -0
  30. package/components/use-reference-ratings.ts +11 -7
  31. package/lib/category-repository.ts +7 -0
  32. package/lib/conversation-feed.ts +96 -0
  33. package/lib/conversation.ts +154 -0
  34. package/lib/cyclotron-suggestions.ts +18 -36
  35. package/lib/local-preview-seed.ts +16 -27
  36. package/lib/reference-feed-keys.ts +69 -0
  37. package/lib/reference-policy.ts +83 -11
  38. package/lib/reference-review-details.ts +21 -57
  39. package/lib/relevance-decisions.ts +6 -2
  40. package/lib/story-reporting-trail.ts +28 -2
  41. package/package.json +15 -2
  42. package/routes.manifest.json +1 -1
  43. package/components/review-rating-grid.tsx +0 -190
@@ -0,0 +1,319 @@
1
+ "use client";
2
+
3
+ import { BookmarkIcon, ExternalLinkIcon, InboxIcon, Loader2Icon, MessageCircleIcon, MessageSquareTextIcon, MessagesSquareIcon, NewspaperIcon, TriangleAlertIcon } from "lucide-react";
4
+ import { useCallback, useEffect, useRef, useState } from "react";
5
+ import type { MessageThreadRecord } from "../lib/category-repository";
6
+ import { conversationAge, messageCountLabel, type ConversationMessage } from "../lib/conversation";
7
+ import { CONVERSATION_SECTIONS, SECTION_EMPTY_COPY, createMergedThreadStream, sectionOfThreadKind, threadKindsForSection, type ConversationSection } from "../lib/conversation-feed";
8
+ import { getNewsroomNavHref } from "../lib/newsroom-nav";
9
+ import { cn } from "../lib/utils";
10
+ import { Composer, ConversationThread } from "./conversation-thread";
11
+ import type { ConversationReferenceView, ConversationStoryView, ConversationsSource } from "./conversations-source";
12
+ import { FEED_GRID, FEED_SURFACE_PROPS, PILL, ReferenceCardHeader } from "./reference-feed";
13
+
14
+ const PAGE_SIZE = 12;
15
+ /** A card shows this many of the newest messages; the rest are one button away. */
16
+ const LATEST_MESSAGES = 3;
17
+ const STALE_AFTER_MS = 60_000;
18
+ const CONSOLE_SELECT_THREAD_EVENT = "papyrus:console-select-thread";
19
+ const LABEL = "font-[family-name:var(--font-sans)] text-[10px] font-extrabold tracking-[0.07em] uppercase";
20
+
21
+ const SECTION_ICONS = { all: InboxIcon, references: BookmarkIcon, stories: NewspaperIcon, chats: MessageCircleIcon, other: MessagesSquareIcon } as const;
22
+
23
+ type KindLabel = { icon: typeof BookmarkIcon; text: string };
24
+ function kindLabel(kind: string): KindLabel {
25
+ switch (sectionOfThreadKind(kind)) {
26
+ case "references": return { icon: BookmarkIcon, text: "Reference" };
27
+ case "stories": return { icon: NewspaperIcon, text: "Story" };
28
+ case "chats": return { icon: MessageCircleIcon, text: "Chat" };
29
+ default: return kind === "edition_forum" || kind === "section_forum"
30
+ ? { icon: MessagesSquareIcon, text: kind === "edition_forum" ? "Edition forum" : "Section forum" }
31
+ : { icon: MessageSquareTextIcon, text: "Message" };
32
+ }
33
+ }
34
+
35
+ /** One conversation's Messages: loaded once, shown at once when posted, taken back when the save fails. */
36
+ function useThreadMessages(source: ConversationsSource, thread: MessageThreadRecord, seed: ConversationMessage[] | undefined, reloadKey: number) {
37
+ const [messages, setMessages] = useState<ConversationMessage[]>(seed ?? []);
38
+ const [state, setState] = useState<"loading" | "ready" | "error">(seed ? "ready" : "loading");
39
+ const [error, setError] = useState<string | null>(null);
40
+ const [attempt, setAttempt] = useState(0);
41
+ const counter = useRef(0);
42
+ useEffect(() => {
43
+ let active = true;
44
+ source.loadMessages(thread.id).then((loaded) => {
45
+ if (!active) return;
46
+ setMessages((current) => [...loaded, ...current.filter((message) => message.pending)]);
47
+ setState("ready");
48
+ setError(null);
49
+ }).catch((failure) => {
50
+ if (!active) return;
51
+ setError(failure instanceof Error ? failure.message : "The conversation could not be loaded.");
52
+ setState("error");
53
+ });
54
+ return () => { active = false; };
55
+ }, [source, thread.id, reloadKey, attempt]);
56
+ const post = useCallback(async (body: string, parentMessageId: string | null) => {
57
+ counter.current += 1;
58
+ const pendingId = `pending-${counter.current}`;
59
+ const pending: ConversationMessage = { id: pendingId, parentMessageId, authorLabel: source.authorLabel, content: body.trim(), createdAt: new Date().toISOString(), sequenceNumber: Number.MAX_SAFE_INTEGER - 1000 + counter.current, pending: true };
60
+ setMessages((current) => [...current, pending]);
61
+ try {
62
+ const saved = await source.post({ thread, body, parentMessageId });
63
+ setMessages((current) => current.map((message) => (message.id === pendingId ? saved : message)));
64
+ } catch (failure) {
65
+ setMessages((current) => current.filter((message) => message.id !== pendingId));
66
+ throw failure instanceof Error ? failure : new Error("Your message could not be saved.");
67
+ }
68
+ }, [source, thread]);
69
+ return { messages, state, error, post, retry: () => setAttempt((current) => current + 1) };
70
+ }
71
+
72
+ function CardCaption({ kind, now, updatedAt }: { kind: string; now: number; updatedAt: string }) {
73
+ const { icon: Icon, text } = kindLabel(kind);
74
+ const age = conversationAge(updatedAt, now);
75
+ return (
76
+ <p className={cn(LABEL, "flex items-center justify-between gap-2 text-muted-foreground")} data-conversation-caption>
77
+ <span className="inline-flex items-center gap-1.5"><Icon aria-hidden="true" className="size-3.5" />{text}</span>
78
+ {age ? <span className="font-medium normal-case tracking-normal" data-conversation-activity>Active {age === "now" ? "now" : `${age} ago`}</span> : null}
79
+ </p>
80
+ );
81
+ }
82
+
83
+ /** The Reference a conversation is about, as an attachment under its messages: the standard header, no decision controls. */
84
+ function ReferenceAttachment({ href, now, onNavigate, thread, view }: { href: string; now: number; onNavigate?: (href: string) => void; thread: MessageThreadRecord; view: ConversationReferenceView | undefined }) {
85
+ const open = (event: React.MouseEvent<HTMLAnchorElement>) => {
86
+ if (!onNavigate || event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
87
+ event.preventDefault();
88
+ onNavigate(href);
89
+ };
90
+ return (
91
+ <a
92
+ aria-label={`Open the Reference: ${view?.reference.title ?? thread.title}`}
93
+ className="flex flex-col overflow-hidden rounded-[var(--feed-radius-lg,10px)] bg-[var(--feed-footer)] text-left outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
94
+ data-conversation-attachment="reference"
95
+ href={href}
96
+ onClick={open}
97
+ >
98
+ {view ? <ReferenceCardHeader now={now} preview={view.preview} reference={view.reference} /> : (
99
+ <span className="flex items-center gap-2 p-4 text-sm text-muted-foreground"><BookmarkIcon aria-hidden="true" className="size-4" />{thread.title}<ExternalLinkIcon aria-hidden="true" className="size-4" /></span>
100
+ )}
101
+ </a>
102
+ );
103
+ }
104
+
105
+ function ConversationCard({ demo, now, onNavigate, reloadKey, references, seed, source, stories, thread }: {
106
+ demo: boolean;
107
+ now: number;
108
+ onNavigate?: (href: string) => void;
109
+ reloadKey: number;
110
+ references: ReadonlyMap<string, ConversationReferenceView>;
111
+ seed: ConversationMessage[] | undefined;
112
+ source: ConversationsSource;
113
+ stories: ReadonlyMap<string, ConversationStoryView>;
114
+ thread: MessageThreadRecord;
115
+ }) {
116
+ const section = sectionOfThreadKind(thread.threadKind);
117
+ const lineageId = thread.primaryAnchorLineageId ?? "";
118
+ // Forum threads and chats keep their own detail views; every other conversation is read and answered right here.
119
+ const inline = section === "references" || section === "stories" || thread.threadKind === "conversation";
120
+ const messages = useThreadMessages(source, thread, seed, reloadKey);
121
+ const story = stories.get(lineageId);
122
+ const frame = "flex flex-col gap-3 rounded-[20px] bg-[var(--feed-card)] p-4";
123
+
124
+ if (!inline) {
125
+ const isChat = section === "chats";
126
+ const href = getNewsroomNavHref(`/newsroom/forum/${encodeURIComponent(thread.id)}`, demo);
127
+ return (
128
+ <article className={frame} data-conversation-card={thread.id} data-conversation-kind={thread.threadKind}>
129
+ <CardCaption kind={thread.threadKind} now={now} updatedAt={thread.updatedAt} />
130
+ <h3 className="text-base font-semibold leading-snug" data-conversation-title>{thread.title}</h3>
131
+ <p className="text-sm text-muted-foreground">{messageCountLabel(thread.messageCount ?? 0)}</p>
132
+ {isChat ? (
133
+ <button className={cn(PILL, "self-start bg-[var(--feed-control)] text-foreground")} data-conversation-open onClick={() => window.dispatchEvent(new CustomEvent(CONSOLE_SELECT_THREAD_EVENT, { detail: { threadId: thread.id } }))} type="button">
134
+ <MessageCircleIcon aria-hidden="true" className="size-4" />Open chat
135
+ </button>
136
+ ) : (
137
+ <a className={cn(PILL, "self-start bg-[var(--feed-control)] text-foreground")} data-conversation-open href={href} onClick={(event) => { if (onNavigate && !event.metaKey && !event.ctrlKey && event.button === 0) { event.preventDefault(); onNavigate(href); } }}>
138
+ <MessagesSquareIcon aria-hidden="true" className="size-4" />Open thread
139
+ </a>
140
+ )}
141
+ </article>
142
+ );
143
+ }
144
+
145
+ return (
146
+ <article className={frame} data-conversation-card={thread.id} data-conversation-kind={thread.threadKind}>
147
+ <CardCaption kind={thread.threadKind} now={now} updatedAt={thread.updatedAt} />
148
+ {section === "stories" ? (
149
+ <div className="space-y-1 rounded-[var(--feed-radius-lg,10px)] bg-[var(--feed-footer)] p-3" data-conversation-story>
150
+ <p className="text-base font-semibold leading-snug">{story?.title ?? thread.title}</p>
151
+ <p className="flex flex-wrap items-center gap-x-3 text-sm text-muted-foreground">
152
+ {story?.stage ? <span data-conversation-stage>Stage: {story.stage}</span> : null}
153
+ <a className="inline-flex items-center gap-1 underline-offset-2 hover:underline" href={getNewsroomNavHref(`/newsroom/articles/${encodeURIComponent(lineageId)}`, demo)}>
154
+ <ExternalLinkIcon aria-hidden="true" className="size-3.5" />Open the Story
155
+ </a>
156
+ </p>
157
+ </div>
158
+ ) : null}
159
+ <ConversationThread
160
+ error={messages.error}
161
+ heading={`Messages in ${thread.title}`}
162
+ hideHeading
163
+ latest={LATEST_MESSAGES}
164
+ messages={messages.messages}
165
+ now={now}
166
+ onPost={messages.post}
167
+ onRetry={messages.retry}
168
+ state={messages.state}
169
+ />
170
+ {section === "references" && lineageId ? (
171
+ <ReferenceAttachment href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo)} now={now} onNavigate={onNavigate} thread={thread} view={references.get(lineageId)} />
172
+ ) : null}
173
+ </article>
174
+ );
175
+ }
176
+
177
+ /**
178
+ * Every conversation, newest activity first, as cards in a grid. Messages lead each card; the Reference or Story it is about is an
179
+ * attachment under them. Sections are buttons. Reads page by page from the thread indexes: nothing is loaded that is not shown.
180
+ */
181
+ export function ConversationsView({ demo = false, onNavigate, source }: {
182
+ demo?: boolean;
183
+ /** Open a link inside the app without a page load. Without it links are ordinary links. */
184
+ onNavigate?: (href: string) => void;
185
+ source: ConversationsSource;
186
+ }) {
187
+ const [section, setSection] = useState<ConversationSection>("all");
188
+ const [threads, setThreads] = useState<MessageThreadRecord[]>([]);
189
+ const [seeds, setSeeds] = useState<Record<string, ConversationMessage[]>>({});
190
+ const [loading, setLoading] = useState(true);
191
+ const [loadingMore, setLoadingMore] = useState(false);
192
+ const [error, setError] = useState<string | null>(null);
193
+ const [hasMore, setHasMore] = useState(false);
194
+ const [references, setReferences] = useState<ReadonlyMap<string, ConversationReferenceView>>(() => new Map());
195
+ const [stories, setStories] = useState<ReadonlyMap<string, ConversationStoryView>>(() => new Map());
196
+ const [reloadKey, setReloadKey] = useState(0);
197
+ const [now, setNow] = useState(() => Date.now());
198
+ const loadedAt = useRef(Date.now());
199
+ const stream = useRef<ReturnType<typeof createMergedThreadStream<MessageThreadRecord>> | null>(null);
200
+ const generation = useRef(0);
201
+
202
+ const enrich = useCallback((page: MessageThreadRecord[]) => {
203
+ const referenceIds = page.filter((thread) => thread.threadKind === "reference_conversation" && thread.primaryAnchorLineageId).map((thread) => thread.primaryAnchorLineageId!);
204
+ const storyIds = page.filter((thread) => thread.threadKind === "story_conversation" && thread.primaryAnchorLineageId).map((thread) => thread.primaryAnchorLineageId!);
205
+ if (referenceIds.length) void source.loadReferences(referenceIds).then((loaded) => setReferences((current) => new Map([...current, ...loaded]))).catch(() => undefined);
206
+ if (storyIds.length) void source.loadStories(storyIds).then((loaded) => setStories((current) => new Map([...current, ...loaded]))).catch(() => undefined);
207
+ }, [source]);
208
+
209
+ useEffect(() => {
210
+ const mine = ++generation.current;
211
+ stream.current = createMergedThreadStream(threadKindsForSection(section), source.loadThreads);
212
+ setLoading(true);
213
+ setError(null);
214
+ stream.current.next(PAGE_SIZE).then((page) => {
215
+ if (generation.current !== mine) return;
216
+ setThreads(page.items);
217
+ setHasMore(page.hasMore);
218
+ setLoading(false);
219
+ loadedAt.current = Date.now();
220
+ enrich(page.items);
221
+ }).catch((failure) => {
222
+ if (generation.current !== mine) return;
223
+ setError(failure instanceof Error ? failure.message : "Conversations could not be loaded.");
224
+ setLoading(false);
225
+ });
226
+ }, [enrich, section, source, reloadKey]);
227
+
228
+ useEffect(() => {
229
+ const timer = window.setInterval(() => setNow(Date.now()), 60_000);
230
+ // Nothing subscribes: the page catches up when it is looked at again.
231
+ const refocus = () => { if (document.visibilityState !== "hidden" && Date.now() - loadedAt.current > STALE_AFTER_MS) setReloadKey((current) => current + 1); };
232
+ window.addEventListener("focus", refocus);
233
+ document.addEventListener("visibilitychange", refocus);
234
+ return () => { window.clearInterval(timer); window.removeEventListener("focus", refocus); document.removeEventListener("visibilitychange", refocus); };
235
+ }, []);
236
+
237
+ const loadMore = async () => {
238
+ if (!stream.current || loadingMore) return;
239
+ const mine = generation.current;
240
+ setLoadingMore(true);
241
+ try {
242
+ const page = await stream.current.next(PAGE_SIZE);
243
+ if (generation.current !== mine) return;
244
+ setThreads((current) => [...current, ...page.items.filter((thread) => !current.some((entry) => entry.id === thread.id))]);
245
+ setHasMore(page.hasMore);
246
+ enrich(page.items);
247
+ } catch (failure) {
248
+ setError(failure instanceof Error ? failure.message : "More conversations could not be loaded.");
249
+ } finally {
250
+ setLoadingMore(false);
251
+ }
252
+ };
253
+
254
+ const startConversation = async (body: string) => {
255
+ const { thread, message } = await source.start(body);
256
+ setSeeds((current) => ({ ...current, [thread.id]: [message] }));
257
+ setThreads((current) => [thread, ...current.filter((entry) => entry.id !== thread.id)]);
258
+ };
259
+
260
+ const canStart = section === "all" || section === "other";
261
+ return (
262
+ <div className="@container space-y-5" data-conversations-view {...FEED_SURFACE_PROPS}>
263
+ <div aria-label="Conversations" className="flex flex-wrap gap-1.5" data-conversation-sections role="group">
264
+ {CONVERSATION_SECTIONS.map(({ key, label }) => {
265
+ const Icon = SECTION_ICONS[key];
266
+ const on = section === key;
267
+ return (
268
+ <button
269
+ aria-pressed={on}
270
+ className={cn(PILL, on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)]/45 text-muted-foreground hover:bg-[var(--feed-control)]/70 hover:text-foreground")}
271
+ data-conversation-section={key}
272
+ key={key}
273
+ onClick={() => setSection(key)}
274
+ type="button"
275
+ >
276
+ <Icon aria-hidden="true" className="size-4" />
277
+ {label}
278
+ </button>
279
+ );
280
+ })}
281
+ </div>
282
+
283
+ {canStart ? (
284
+ <section aria-label="New message" className="max-w-2xl space-y-2 rounded-[20px] bg-[var(--feed-card)] p-4" data-conversation-new>
285
+ <h2 className={cn(LABEL, "text-muted-foreground")}>New message</h2>
286
+ <Composer buttonLabel="Post" icon="post" label="New message" onSubmit={startConversation} placeholder="New message… (a thought that is not about any one Reference or Story)" />
287
+ </section>
288
+ ) : null}
289
+
290
+ {loading ? <p className="flex items-center gap-1.5 text-sm text-muted-foreground" data-conversations-loading role="status"><Loader2Icon aria-hidden="true" className="size-4 animate-spin" />Loading conversations</p> : null}
291
+ {error ? (
292
+ <p className="flex items-start gap-1.5 text-sm text-destructive" data-conversations-error role="alert">
293
+ <TriangleAlertIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
294
+ <span>Conversations could not be loaded. {error}</span>
295
+ </p>
296
+ ) : null}
297
+ {!loading && !error && !threads.length ? (
298
+ <p className="flex items-center gap-2 text-sm text-muted-foreground" data-conversations-empty>
299
+ <InboxIcon aria-hidden="true" className="size-4" />
300
+ {SECTION_EMPTY_COPY[section]}
301
+ </p>
302
+ ) : null}
303
+
304
+ {threads.length ? (
305
+ <div className={FEED_GRID} data-conversation-grid>
306
+ {threads.map((thread) => (
307
+ <ConversationCard demo={demo} key={thread.id} now={now} onNavigate={onNavigate} reloadKey={reloadKey} references={references} seed={seeds[thread.id]} source={source} stories={stories} thread={thread} />
308
+ ))}
309
+ </div>
310
+ ) : null}
311
+ {hasMore ? (
312
+ <button className={cn(PILL, "bg-[var(--feed-control)] text-foreground")} data-conversations-more disabled={loadingMore} onClick={() => void loadMore()} type="button">
313
+ {loadingMore ? <Loader2Icon aria-hidden="true" className="size-4 animate-spin" /> : null}
314
+ Show more conversations
315
+ </button>
316
+ ) : null}
317
+ </div>
318
+ );
319
+ }
@@ -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
+ }
@@ -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
 
@@ -111,7 +111,7 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
111
111
  {item.decisionState === "acknowledged"
112
112
  ? <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
113
  : <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" />}
114
+ {flag ? null : <DecisionControl answer={decided} disabled feedback={{ phase: "idle" }} onChoose={() => undefined} prediction={{ state: "none" }} showPrediction={false} showReason={false} size="large" surface="footer" />}
115
115
  {lockedNote ? <p className="text-xs text-muted-foreground" data-review-disabled-reason>{lockedNote}</p> : null}
116
116
  </>
117
117
  ) : (
@@ -134,7 +134,7 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
134
134
  {flag ? (
135
135
  <FlagFooter busy={busy} disabledReason={disabledReason} done={false} error={rating.feedback.phase === "error" ? rating.feedback.message ?? null : null} onAcknowledge={() => ratings.choose(item.id, "accept")} />
136
136
  ) : (
137
- <RatingGrid
137
+ <DecisionControl
138
138
  answer={rating.pending}
139
139
  disabled={Boolean(disabledReason)}
140
140
  disabledReason={disabledReason}
@@ -142,6 +142,7 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
142
142
  onChoose={(choice) => { if (!disabledReason) ratings.choose(item.id, choice); }}
143
143
  prediction={{ state: "none" }}
144
144
  showPrediction={false}
145
+ showReason={false}
145
146
  size="large"
146
147
  surface="footer"
147
148
  />