@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
@@ -1,26 +1,21 @@
1
1
  "use client";
2
2
 
3
- import { InfoIcon, Loader2Icon, MessageSquareIcon } from "lucide-react";
4
- import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
3
+ import { CheckCheckIcon, CircleDashedIcon, ClockIcon, InfoIcon, Loader2Icon, MessageSquareIcon, SparklesIcon } from "lucide-react";
4
+ import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
5
5
  import type { ReferenceRecord } from "../lib/category-repository";
6
6
  import { REFERENCE_REJECTION_REASON_CODES, isAutoAccepted, normalizeReferenceCurationStatus, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
7
7
  import {
8
8
  referenceDisplayTime,
9
- groupFeedItems,
10
- EARLIER_AFTER_TAUS,
11
- MINIMUM_FRESHNESS_EXAMPLES,
12
- type FeedGroupKey,
13
9
  type FeedItem,
14
- type FeedFreshness,
15
- type ReferenceFeedPartition,
16
- type ReferenceFeedTabKey,
10
+ type FeedShow,
11
+ type FeedSort,
17
12
  } from "../lib/reference-feed-ranking";
18
13
  import { dekAddsInformation, isCardStyleImage, referenceDek, referenceSourceHost, formatFeedAge, type ReferencePreview } from "../lib/reference-preview";
19
14
  import { referenceDisplayTitle } from "../lib/newsroom-references";
20
15
  import type { RelevanceDecisionView } from "../lib/relevance-decisions";
21
16
  import { cn } from "../lib/utils";
22
17
  import { predictionFromDecision, type ReviewChoice } from "./reference-review-card";
23
- import { RatingGrid } from "./review-rating-grid";
18
+ import { DecisionControl } from "./decision-control";
24
19
  import { CaptureBadge } from "./reference-capture-panel";
25
20
  import { AutoAcceptedBadge } from "./auto-accepted-badge";
26
21
  import { useRatingStore, type RatingController } from "./use-reference-ratings";
@@ -47,8 +42,14 @@ export const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
47
42
  other: "Other",
48
43
  };
49
44
 
50
- const TAB_LABELS: Record<ReferenceFeedTabKey, string> = { feed: "Feed", rated: "Rated", earlier: "Earlier" };
51
- const GROUP_HEADINGS: Record<FeedGroupKey, string> = { top: "Top predictions", more: "More for you", least: "Least likely" };
45
+ const SORTS: Array<{ key: FeedSort; label: string; Icon: typeof SparklesIcon }> = [
46
+ { key: "best", label: "Best bets", Icon: SparklesIcon },
47
+ { key: "newest", label: "Newest", Icon: ClockIcon },
48
+ ];
49
+ const SHOWS: Array<{ key: FeedShow; label: string; Icon: typeof SparklesIcon }> = [
50
+ { key: "unrated", label: "Unrated only", Icon: CircleDashedIcon },
51
+ { key: "all", label: "Include rated", Icon: CheckCheckIcon },
52
+ ];
52
53
 
53
54
  export const PILL = "inline-flex min-h-9 items-center justify-center gap-1.5 rounded-full px-3.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring";
54
55
 
@@ -63,16 +64,20 @@ export type ReferenceFeedProps = {
63
64
  onOpen: (lineageId: string) => void;
64
65
  onRate: (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) => Promise<void> | void;
65
66
  onVisitRated: (lineageId: string) => void;
66
- onTabChange: (tab: ReferenceFeedTabKey) => void;
67
- /** The ranked tabs, from `partitionReferenceFeed`. */
68
- partition: ReferenceFeedPartition;
67
+ onSortChange: (sort: FeedSort) => void;
68
+ onShowChange: (show: FeedShow) => void;
69
+ /** The visible list, already ordered by the chosen sort and filtered by the chosen Show. */
70
+ items: FeedItem[];
71
+ /** How many References already have an answer, for the empty text. */
72
+ ratedCount: number;
73
+ sort: FeedSort;
74
+ show: FeedShow;
69
75
  predictionLoading?: boolean;
70
76
  previews: ReadonlyMap<string, ReferencePreview>;
71
77
  selectedLineageId?: string;
72
78
  /** Capture status per Reference lineage, for the small badge on each card. Optional. */
73
79
  captures?: ReadonlyMap<string, CaptureStatus>;
74
- /** The sections and lead, when the caller keeps the order stable (a snapshot). Without them the feed groups its own partition. */
75
- groups?: Array<{ key: FeedGroupKey; items: FeedItem[] }>;
80
+ /** The large lead card, if any. */
76
81
  leadId?: string;
77
82
  /** Scrolls the grid to a card, deliberately (Previous / Next). A new nonce is a new request; selecting alone never scrolls. */
78
83
  scrollTarget?: { id: string; nonce: number } | null;
@@ -80,7 +85,6 @@ export type ReferenceFeedProps = {
80
85
  ratings?: RatingController;
81
86
  /** True when the cards are a saved copy that is being refreshed. */
82
87
  stale?: boolean;
83
- tab: ReferenceFeedTabKey;
84
88
  visitRated: ReadonlySet<string>;
85
89
  };
86
90
 
@@ -202,6 +206,58 @@ export function FeedImage({ host, lead, name, src }: { host: string | null; lead
202
206
  );
203
207
  }
204
208
 
209
+ /**
210
+ * The standard Reference header: its picture, source, headline, dek and age. The feed card wraps it in the button that opens the
211
+ * Reference, and the Messages page shows it under a conversation as the thing the conversation is about. No decision controls.
212
+ */
213
+ export function ReferenceCardHeader({ capture, lead = false, messageCount = 0, now, preview, reference, titleId }: {
214
+ capture?: CaptureStatus | undefined;
215
+ lead?: boolean;
216
+ messageCount?: number;
217
+ now: number;
218
+ preview: ReferencePreview | undefined;
219
+ reference: ReferenceRecord;
220
+ titleId?: string;
221
+ }) {
222
+ const host = referenceSourceHost(reference);
223
+ const site = preview?.siteName ?? host ?? "Unknown source";
224
+ const title = referenceDisplayTitle(reference);
225
+ const dek = referenceDek(reference, preview);
226
+ const showDek = dekAddsInformation(title, dek, preview?.imageUrl ?? null);
227
+ const time = referenceDisplayTime(reference);
228
+ const age = formatFeedAge(time, now);
229
+ return (
230
+ <>
231
+ <FeedImage host={host} lead={lead} name={site} src={preview?.imageUrl ?? null} />
232
+ <span className={cn("flex flex-1 flex-col gap-2.5", lead ? "p-5 @[36rem]:p-7" : "p-5")}>
233
+ <span className="flex items-center gap-2 text-xs font-medium text-muted-foreground" data-feed-source>
234
+ <SourceMark host={host} />
235
+ <span className="truncate">{site}</span>
236
+ </span>
237
+ <span className={cn("font-bold leading-[1.15] tracking-tight text-foreground", lead ? "line-clamp-3 text-2xl @[36rem]:text-3xl @[60rem]:text-4xl" : "line-clamp-3 text-xl")} data-feed-title id={titleId}>
238
+ {title}
239
+ </span>
240
+ {showDek ? <span className={cn("text-muted-foreground", lead ? "line-clamp-3 text-base" : "line-clamp-2 text-sm")} data-feed-dek>{dek}</span> : null}
241
+ <span className="mt-auto flex flex-wrap items-center gap-x-2 gap-y-1 pt-1 text-xs text-muted-foreground">
242
+ <span>
243
+ {age ? <time dateTime={new Date(time ?? now).toISOString()} title={new Date(time ?? now).toLocaleString()}>{age}</time> : null}
244
+ {age && host ? <span aria-hidden="true"> · </span> : null}
245
+ {host}
246
+ </span>
247
+ {capture ? <CaptureBadge status={capture} /> : null}
248
+ {messageCount > 0 ? (
249
+ <span aria-label={messageCountLabel(messageCount)} className="inline-flex items-center gap-1 tabular-nums" data-feed-message-count={messageCount} role="img">
250
+ <MessageSquareIcon aria-hidden="true" className="size-3.5" />
251
+ {messageCount}
252
+ </span>
253
+ ) : null}
254
+ {isAutoAccepted(reference) ? <AutoAcceptedBadge actor={reference.curationStatusUpdatedBy} reason={reference.curationStatusReason} /> : null}
255
+ </span>
256
+ </span>
257
+ </>
258
+ );
259
+ }
260
+
205
261
  export function FeedCard({
206
262
  capture,
207
263
  messageCount = 0,
@@ -283,72 +339,33 @@ export function FeedCard({
283
339
  onClick={onOpen}
284
340
  type="button"
285
341
  >
286
- <FeedImage host={host} lead={lead} name={site} src={preview?.imageUrl ?? null} />
287
- <span className={cn("flex flex-1 flex-col gap-2.5", lead ? "p-5 @[36rem]:p-7" : "p-5")}>
288
- <span className="flex items-center gap-2 text-xs font-medium text-muted-foreground" data-feed-source>
289
- <SourceMark host={host} />
290
- <span className="truncate">{site}</span>
291
- </span>
292
- <span className={cn("font-bold leading-[1.15] tracking-tight text-foreground", lead ? "line-clamp-3 text-2xl @[36rem]:text-3xl @[60rem]:text-4xl" : "line-clamp-3 text-xl")} data-feed-title id={titleId}>
293
- {title}
294
- </span>
295
- {showDek ? <span className={cn("text-muted-foreground", lead ? "line-clamp-3 text-base" : "line-clamp-2 text-sm")} data-feed-dek>{dek}</span> : null}
296
- <span className="mt-auto flex flex-wrap items-center gap-x-2 gap-y-1 pt-1 text-xs text-muted-foreground">
297
- <span>
298
- {age ? <time dateTime={new Date(time ?? now).toISOString()} title={new Date(time ?? now).toLocaleString()}>{age}</time> : null}
299
- {age && host ? <span aria-hidden="true"> · </span> : null}
300
- {host}
301
- </span>
302
- {capture ? <CaptureBadge status={capture} /> : null}
303
- {messageCount > 0 ? (
304
- <span aria-label={messageCountLabel(messageCount)} className="inline-flex items-center gap-1 tabular-nums" data-feed-message-count={messageCount} role="img">
305
- <MessageSquareIcon aria-hidden="true" className="size-3.5" />
306
- {messageCount}
307
- </span>
308
- ) : null}
309
- {isAutoAccepted(reference) ? <AutoAcceptedBadge actor={reference.curationStatusUpdatedBy} reason={reference.curationStatusReason} /> : null}
310
- </span>
311
- </span>
342
+ <ReferenceCardHeader capture={capture} lead={lead} messageCount={messageCount} now={now} preview={preview} reference={reference} titleId={titleId} />
312
343
  </button>
313
344
 
314
345
  <div className="relative bg-[var(--feed-footer)] px-4 pb-4 pt-3" data-feed-actions>
315
- <RatingGrid
346
+ <DecisionControl
316
347
  answer={chosen}
317
348
  decided={answer}
318
349
  disabled={Boolean(disabledReason)}
319
350
  disabledReason={disabledReason}
320
351
  feedback={feedback}
352
+ needsReason={rating.askReason}
321
353
  note={reasonLabel ? `Reason: ${reasonLabel}` : null}
322
354
  onChoose={(choice) => { if (!disabledReason) store.choose(item.lineageId, choice); }}
355
+ onReasonChange={(text) => store.setNote(item.lineageId, text)}
356
+ onReasonCommit={() => { if (!disabledReason) store.commitNote(item.lineageId); }}
323
357
  prediction={decision ? predictionFromDecision(decision) : predictionLoading ? { state: "loading" } : { state: "none" }}
358
+ reason={rating.note ?? ""}
359
+ reasonRequiredFor="reject"
324
360
  statusOverlay
325
361
  />
326
- {rating.askReason ? (
327
- // Covers the footer rather than adding to it, so asking for a reason never pushes the cards below it down.
328
- <div className="absolute inset-0 z-10 flex flex-col justify-center space-y-2 bg-[var(--feed-footer)] p-3" data-review-reasons>
329
- <p className="text-xs font-medium">Reason for rejecting. Choose one to save your Reject.</p>
330
- <div className="flex flex-wrap gap-1.5">
331
- {REFERENCE_REJECTION_REASON_CODES.map((code) => (
332
- <button className={cn(PILL, "min-h-8 bg-[var(--feed-control)] px-3 text-xs")} data-review-reason={code} key={code} onClick={() => store.setReason(item.lineageId, code)} type="button">
333
- {REASON_LABELS[code]}
334
- </button>
335
- ))}
336
- <button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() => store.cancelReason(item.lineageId)} type="button">Cancel</button>
337
- </div>
338
- </div>
339
- ) : null}
340
362
  </div>
341
363
  </article>
342
364
  );
343
365
  }
344
366
 
345
- function plainDays(days: number): string {
346
- if (days < 1) return `${Math.max(1, Math.round(days * 24))} hours`;
347
- return `${days < 10 ? days.toFixed(1).replace(/\.0$/, "") : Math.round(days)} days`;
348
- }
349
-
350
- /** The plain-words recipe, with this site's learned numbers in it. */
351
- function RankingPopover({ freshness }: { freshness: FeedFreshness }) {
367
+ /** The plain-words recipe. */
368
+ function RankingPopover() {
352
369
  const [open, setOpen] = useState(false);
353
370
  const panelId = useId();
354
371
  const root = useRef<HTMLDivElement | null>(null);
@@ -387,15 +404,10 @@ function RankingPopover({ freshness }: { freshness: FeedFreshness }) {
387
404
  >
388
405
  <p className="text-base font-bold">A feed run by algorithms you control</p>
389
406
  <ol className="list-decimal space-y-2.5 pl-5">
390
- <li><strong>Best bets first.</strong> The Cyclotron predicts Accept or Reject and how sure it is. The items it expects you to Accept most surely come first. The ones it expects you to Reject most surely come last. Items with no prediction sit in the middle.</li>
391
- <li><strong>Confidence counts.</strong> An Accept at 90% ranks above an Accept at 60%.</li>
392
- <li><strong>Age fades a prediction toward &ldquo;not sure&rdquo;.</strong> Older items drift toward the middle, never to the bottom. {freshness.tauDays !== null && freshness.medianAgeDays !== null
393
- ? <>A prediction loses half its pull every {plainDays(freshness.medianAgeDays)}, the typical age of the {freshness.examples} items you accepted.</>
394
- : <>Age does not change the order yet. The pace is learned from the items you accept, and so far there are {freshness.examples} of the {MINIMUM_FRESHNESS_EXAMPLES} needed.</>}</li>
395
- <li><strong>Earlier.</strong> {freshness.tauDays !== null
396
- ? <>Unrated items older than {plainDays(freshness.tauDays * EARLIER_AFTER_TAUS)} move to Earlier at the bottom. You can still rate them. Nothing is deleted.</>
397
- : <>Once the pace is learned, unrated items past three times that pace move to Earlier at the bottom. Nothing is deleted.</>}</li>
398
- <li><strong>Your answers stay put.</strong> A card you rate stays where it is for this visit. Next time it is under Rated.</li>
407
+ <li><strong>Best bets</strong> puts the items the Cyclotron is most sure you will Accept first. It goes by confidence alone: an Accept at 90% ranks above an Accept at 60%, and the ones it expects you to Reject most surely come last. Items with no prediction sit in the middle. Age does not change this order.</li>
408
+ <li><strong>Newest</strong> puts the items that arrived most recently first.</li>
409
+ <li><strong>Unrated only</strong> shows items nobody has answered yet. <strong>Include rated</strong> adds the ones you have already accepted or rejected to the same list, showing your answer, in the same sort. Nothing is hidden because it is old.</li>
410
+ <li><strong>Your answers stay put.</strong> A card you rate stays where it is for this visit, and new items wait behind a button until you ask for them.</li>
399
411
  </ol>
400
412
  </div>
401
413
  ) : null}
@@ -403,10 +415,6 @@ function RankingPopover({ freshness }: { freshness: FeedFreshness }) {
403
415
  );
404
416
  }
405
417
 
406
- export function SectionHeading({ children, id }: { children: ReactNode; id: string }) {
407
- return <h3 className="mb-5 mt-10 text-3xl font-bold tracking-tight text-primary first:mt-2 sm:mt-12 sm:text-4xl" id={id}>{children}</h3>;
408
- }
409
-
410
418
  export function ReferenceFeed({
411
419
  disabledReason = null,
412
420
  loading = false,
@@ -414,22 +422,23 @@ export function ReferenceFeed({
414
422
  now,
415
423
  onOpen,
416
424
  onRate,
417
- onTabChange,
425
+ onShowChange,
426
+ onSortChange,
418
427
  onVisitRated,
419
- partition,
428
+ items,
429
+ ratedCount,
420
430
  predictionLoading = false,
421
431
  previews,
422
432
  selectedLineageId = "",
423
433
  captures,
424
- groups: stableGroups,
425
- leadId: stableLeadId,
434
+ leadId,
426
435
  ratings,
427
436
  scrollTarget = null,
437
+ show,
438
+ sort,
428
439
  stale = false,
429
- tab,
430
440
  visitRated,
431
441
  }: ReferenceFeedProps) {
432
- const counts: Record<ReferenceFeedTabKey, number> = { feed: partition.feed.length, rated: partition.rated.length, earlier: partition.earlier.length };
433
442
 
434
443
  const renderCard = (item: FeedItem, lead: boolean) => (
435
444
  <FeedCard
@@ -461,28 +470,39 @@ export function ReferenceFeed({
461
470
  document.querySelector(`[data-feed-card="${CSS.escape(scrollTarget.id)}"]`)?.scrollIntoView({ behavior: "smooth", block: "nearest" });
462
471
  }, [scrollTarget]);
463
472
 
464
- const groups = stableGroups ?? groupFeedItems(partition.feed);
465
- const headings = groups.length > 1;
466
- const leadId = stableGroups ? stableLeadId : groups[0]?.key !== "least" ? groups[0]?.items[0]?.lineageId : undefined;
467
473
 
468
474
  return (
469
- <div className="@container space-y-5" data-reference-feed data-feed-tab={tab} {...FEED_SURFACE_PROPS}>
470
- <div className="flex items-center justify-between gap-2">
471
- <div aria-label="Feed sections" className="inline-flex rounded-full bg-[var(--feed-card)] p-1" role="tablist">
472
- {(Object.keys(TAB_LABELS) as ReferenceFeedTabKey[]).map((key) => (
473
- <button
474
- aria-selected={tab === key}
475
- className={cn(PILL, "min-h-9 gap-1.5 px-3.5", tab === key ? "bg-[var(--feed-control)] text-foreground" : "text-muted-foreground hover:text-foreground")}
476
- data-feed-tab-button={key}
477
- key={key}
478
- onClick={() => onTabChange(key)}
479
- role="tab"
480
- type="button"
481
- >
482
- {TAB_LABELS[key]}
483
- <span className="text-xs tabular-nums opacity-70" data-feed-tab-count>{counts[key]}</span>
484
- </button>
485
- ))}
475
+ <div className="@container space-y-5" data-reference-feed data-feed-sort={sort} data-feed-show={show} {...FEED_SURFACE_PROPS}>
476
+ <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
477
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
478
+ <div aria-label="Sort" className="inline-flex rounded-full bg-[var(--feed-card)] p-1" data-feed-control="sort" role="group">
479
+ {SORTS.map(({ key, label, Icon }) => (
480
+ <button
481
+ aria-pressed={sort === key}
482
+ className={cn(PILL, "px-3.5", sort === key ? "bg-[var(--feed-control)] text-foreground" : "text-muted-foreground hover:text-foreground")}
483
+ data-feed-sort-button={key}
484
+ key={key}
485
+ onClick={() => onSortChange(key)}
486
+ type="button"
487
+ >
488
+ <Icon aria-hidden="true" className="size-4" />{label}
489
+ </button>
490
+ ))}
491
+ </div>
492
+ <div aria-label="Show" className="inline-flex rounded-full bg-[var(--feed-card)] p-1" data-feed-control="show" role="group">
493
+ {SHOWS.map(({ key, label, Icon }) => (
494
+ <button
495
+ aria-pressed={show === key}
496
+ className={cn(PILL, "px-3.5", show === key ? "bg-[var(--feed-control)] text-foreground" : "text-muted-foreground hover:text-foreground")}
497
+ data-feed-show-button={key}
498
+ key={key}
499
+ onClick={() => onShowChange(key)}
500
+ type="button"
501
+ >
502
+ <Icon aria-hidden="true" className="size-4" />{label}
503
+ </button>
504
+ ))}
505
+ </div>
486
506
  </div>
487
507
  <div className="flex items-center gap-2">
488
508
  {stale ? (
@@ -490,33 +510,15 @@ export function ReferenceFeed({
490
510
  <Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Showing your last view while it updates
491
511
  </span>
492
512
  ) : null}
493
- <RankingPopover freshness={partition.freshness} />
513
+ <RankingPopover />
494
514
  </div>
495
515
  </div>
496
516
  {stale ? <p className="text-xs text-muted-foreground sm:hidden" role="status"><Loader2Icon aria-hidden="true" className="mr-1 inline size-3.5 animate-spin" />Showing your last view while it updates</p> : null}
497
517
 
498
518
  {loading ? <ReferenceFeedSkeleton /> : (
499
- <div role="tabpanel">
500
- {tab === "feed" ? (
501
- <div>
502
- {groups.length ? groups.map((group) => (
503
- <section aria-labelledby={headings ? `feed-group-${group.key}` : undefined} data-feed-group={group.key} key={group.key}>
504
- {headings ? <SectionHeading id={`feed-group-${group.key}`}>{GROUP_HEADINGS[group.key]}</SectionHeading> : null}
505
- <div className={FEED_GRID} data-feed-grid>{group.items.map((item) => renderCard(item, item.lineageId === leadId))}</div>
506
- </section>
507
- )) : empty(counts.rated || counts.earlier ? "You are all caught up. Check Earlier and Rated." : "No references yet.")}
508
- {partition.earlier.length ? (
509
- <section aria-labelledby="feed-earlier-heading" data-feed-earlier>
510
- <SectionHeading id="feed-earlier-heading">Earlier</SectionHeading>
511
- <p className="-mt-3 mb-5 text-sm text-muted-foreground">Unrated and past their freshness. Still yours to rate.</p>
512
- <div className={FEED_GRID} data-feed-grid>{partition.earlier.map((item) => renderCard(item, false))}</div>
513
- </section>
514
- ) : null}
515
- </div>
516
- ) : null}
517
- {tab === "rated" ? (partition.rated.length ? <div className={FEED_GRID} data-feed-grid>{partition.rated.map((item) => renderCard(item, false))}</div> : empty("Nothing rated yet. Your Accepts and Rejects collect here, newest first.")) : null}
518
- {tab === "earlier" ? (partition.earlier.length ? <div className={FEED_GRID} data-feed-grid>{partition.earlier.map((item) => renderCard(item, false))}</div> : empty("Nothing has aged out. Unrated items that fall past their freshness collect here.")) : null}
519
- </div>
519
+ items.length ? (
520
+ <div className={FEED_GRID} data-feed-grid>{items.map((item) => renderCard(item, item.lineageId === leadId))}</div>
521
+ ) : empty(ratedCount && show === "unrated" ? "You are all caught up. Choose Include rated to see your answers." : "No references yet.")
520
522
  )}
521
523
  </div>
522
524
  );
@@ -2,20 +2,16 @@
2
2
 
3
3
  import {
4
4
  ArchiveIcon,
5
- CheckIcon,
6
5
  ChevronLeftIcon,
7
6
  ChevronRightIcon,
8
- CircleCheckIcon,
9
- CircleXIcon,
10
7
  ExternalLinkIcon,
11
8
  } from "lucide-react";
12
9
  import { useEffect, useRef, useState, type ReactNode, type PointerEvent as ReactPointerEvent } from "react";
13
10
  import { Button } from "./ui/button";
14
- import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
15
11
  import { cn } from "../lib/utils";
16
- import { RatingGrid } from "./review-rating-grid";
12
+ import { DecisionControl } from "./decision-control";
17
13
  import { useRatingStore, type RatingController } from "./use-reference-ratings";
18
- import { REFERENCE_REJECTION_REASON_CODES, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
14
+ import type { ReferenceRejectionReasonCode } from "../lib/reference-policy";
19
15
 
20
16
  export type ReviewChoice = "accept" | "reject";
21
17
 
@@ -31,96 +27,6 @@ export type ReviewCardDecision = {
31
27
  reasonCode: ReferenceRejectionReasonCode | null;
32
28
  };
33
29
 
34
- const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
35
- out_of_scope: "Out of scope",
36
- policy_exclusion: "Policy exclusion",
37
- duplicate: "Duplicate",
38
- low_quality: "Low quality",
39
- unavailable: "Unavailable",
40
- provenance: "Provenance",
41
- other: "Other",
42
- };
43
-
44
- const CHOICE_LABEL: Record<ReviewChoice, string> = { accept: "Accept", reject: "Reject" };
45
-
46
- function percent(confidence: number | null): string | null {
47
- return confidence === null ? null : `${Math.round(confidence * 100)}%`;
48
- }
49
-
50
- /**
51
- * The Cyclotron's read-only row: its Accept or Reject highlighted with its confidence, and a check or cross once a person has answered.
52
- * Shared by the review card and by every list that shows a suggestion before a decision.
53
- */
54
- export function CyclotronRow({ decided = null, prediction }: { decided?: ReviewChoice | null; prediction: CardPrediction }) {
55
- const matched = decided && prediction.state === "ready" ? prediction.choice === decided : null;
56
- return (
57
- <div className="space-y-2" data-review-row="cyclotron">
58
- <div className="flex items-center gap-3">
59
- <span className="w-20 shrink-0 text-sm font-semibold">Cyclotron</span>
60
- {prediction.state === "ready" ? (
61
- <div className="flex flex-1 gap-2">
62
- {(["accept", "reject"] as const).map((choice) => {
63
- const predicted = prediction.state === "ready" && prediction.choice === choice;
64
- const verdict = predicted && matched !== null ? (matched ? "match" : "miss") : null;
65
- return (
66
- <span
67
- className={cn(
68
- "inline-flex min-h-10 flex-1 items-center justify-center gap-1.5 rounded-md border px-3 text-sm",
69
- predicted ? "border-foreground bg-muted font-semibold" : "border-dashed text-muted-foreground",
70
- verdict === "match" && "border-green-700 bg-green-100 text-green-950",
71
- verdict === "miss" && "border-red-700 bg-red-100 text-red-950",
72
- )}
73
- data-cyclotron-choice={choice}
74
- data-predicted={predicted || undefined}
75
- data-verdict={verdict ?? undefined}
76
- key={choice}
77
- >
78
- {verdict === "match" ? <CircleCheckIcon aria-hidden="true" className="size-4" /> : null}
79
- {verdict === "miss" ? <CircleXIcon aria-hidden="true" className="size-4" /> : null}
80
- {CHOICE_LABEL[choice]}
81
- {predicted && percent(prediction.confidence) ? <small className="text-xs font-normal">{percent(prediction.confidence)}</small> : null}
82
- {verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
83
- </span>
84
- );
85
- })}
86
- </div>
87
- ) : (
88
- <p className="text-sm text-muted-foreground" data-cyclotron-state={prediction.state}>
89
- {prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
90
- </p>
91
- )}
92
- </div>
93
- </div>
94
- );
95
- }
96
-
97
- /** The two answers, large enough to tap: Accept and Reject, the chosen one filled and checked (never colour alone). */
98
- export function ChoiceButtons({ chosen, disabled = false, onChoose }: { chosen: ReviewChoice | null; disabled?: boolean; onChoose: (choice: ReviewChoice) => void }) {
99
- return (
100
- <div className="flex flex-1 gap-2">
101
- {(["accept", "reject"] as const).map((choice) => {
102
- const on = chosen === choice;
103
- return (
104
- <Button
105
- aria-disabled={disabled ? true : undefined}
106
- aria-pressed={on}
107
- className="min-h-14 flex-1 text-base"
108
- data-review-choice={choice}
109
- key={choice}
110
- onClick={() => onChoose(choice)}
111
- type="button"
112
- variant={on ? (choice === "accept" ? "default" : "destructive") : "outline"}
113
- >
114
- {on ? <CheckIcon aria-hidden="true" className="size-5" /> : null}
115
- {CHOICE_LABEL[choice]}
116
- </Button>
117
- );
118
- })}
119
- </div>
120
- );
121
- }
122
-
123
- /** A reference's relevance decision in the card's own vocabulary. */
124
30
  export function predictionFromDecision(decision: { label: string; positiveLabel: string | null; confidence: number | null }): CardPrediction {
125
31
  return { state: "ready", choice: decision.label === (decision.positiveLabel ?? "include") ? "accept" : "reject", confidence: decision.confidence };
126
32
  }
@@ -181,10 +87,8 @@ export function ReviewCard({
181
87
  const store = ratings ?? local;
182
88
  const item = store.get(itemId);
183
89
  const note = item.note ?? initialNote;
184
- const reason = item.reasonCode !== undefined ? item.reasonCode : initialReason;
185
90
  const chosen = item.pending ?? decided;
186
91
  const choose = (choice: ReviewChoice) => { if (!disabledReason) store.choose(itemId, choice); };
187
- const chooseReason = (code: ReferenceRejectionReasonCode) => { if (!disabledReason) store.setReason(itemId, code); };
188
92
  const commitNote = () => { if (!disabledReason) store.commitNote(itemId); };
189
93
 
190
94
  // Desktop shortcuts: A accepts, R rejects. Never while typing.
@@ -222,53 +126,23 @@ export function ReviewCard({
222
126
  ) : null}
223
127
  </header>
224
128
 
225
- <RatingGrid
129
+ <DecisionControl
226
130
  answer={chosen}
227
131
  decided={decided}
228
132
  disabled={Boolean(disabledReason)}
229
133
  disabledReason={disabledReason}
230
134
  feedback={item.feedback}
135
+ needsReason={item.askReason}
231
136
  onChoose={choose}
137
+ onReasonChange={(text) => store.setNote(itemId, text)}
138
+ onReasonCommit={commitNote}
232
139
  prediction={prediction}
140
+ reason={note}
141
+ reasonRequiredFor="reject"
233
142
  size="large"
234
143
  surface="card"
235
144
  />
236
145
 
237
- <label className="block text-sm font-medium">
238
- Why? (optional)
239
- <AutoGrowTextarea
240
- aria-label="Why? (optional)"
241
- className="mt-1 block min-h-20 w-full rounded-[var(--feed-radius-lg,10px)] bg-[var(--feed-control)] p-3 text-base font-normal outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
242
- onBlur={commitNote}
243
- onChange={(event) => store.setNote(itemId, event.target.value)}
244
- placeholder="Say what made this an easy yes or a clear no. Your note teaches the Cyclotron."
245
- value={note}
246
- />
247
- </label>
248
-
249
- {chosen === "reject" ? (
250
- <div className="space-y-2" data-review-reasons>
251
- <p className="text-sm font-medium">Reason for rejecting</p>
252
- <div className="flex flex-wrap gap-2">
253
- {REFERENCE_REJECTION_REASON_CODES.map((code) => (
254
- <button
255
- aria-disabled={disabledReason ? true : undefined}
256
- aria-pressed={reason === code}
257
- className={cn("inline-flex min-h-10 items-center gap-1.5 rounded-[var(--feed-radius-pill,8px)] px-3 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", reason === code ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)]")}
258
- data-review-reason={code}
259
- key={code}
260
- onClick={() => chooseReason(code)}
261
- type="button"
262
- >
263
- {reason === code ? <CheckIcon aria-hidden="true" className="size-4" /> : null}
264
- {REASON_LABELS[code]}
265
- </button>
266
- ))}
267
- </div>
268
- {!reason && !note.trim() ? <p className="text-sm text-muted-foreground">Choose a reason, or say why above, to save your Reject.</p> : null}
269
- </div>
270
- ) : null}
271
-
272
146
  {children}
273
147
 
274
148
  {onArchive ? (
@@ -303,7 +177,7 @@ export function ReviewQueueBar({
303
177
  return (
304
178
  <nav
305
179
  aria-label="Review queue"
306
- 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)]")}
180
+ className={cn("sticky bottom-0 z-10 flex shrink-0 items-center gap-2 bg-[var(--feed-card)] px-3 py-2", anchored && "lg:fixed lg:bottom-0 lg:right-0 lg:w-[calc(24rem+1rem)] lg:pr-7 xl:w-[calc(28rem+1rem)]")}
307
181
  data-review-queue-anchored={anchored || undefined}
308
182
  data-review-queue-bar
309
183
  >
@@ -4,9 +4,10 @@ import { useEffect, useMemo, useState } from "react";
4
4
  import { LoaderIcon } from "lucide-react";
5
5
  import { SITE_BRAND } from "../lib/site-brand";
6
6
  import { loadFeedSnapshot, type FeedSnapshot } from "../lib/reference-feed-cache";
7
- import { partitionReferenceFeed, type ReferenceFeedTabKey } from "../lib/reference-feed-ranking";
7
+ import { buildFeedItems, feedLeadId, visibleFeedItems } from "../lib/reference-feed-ranking";
8
8
  import { selectCanonicalReferenceRecords } from "../lib/newsroom-references";
9
9
  import { ReferenceFeed, ReferenceFeedSkeleton } from "./reference-feed";
10
+ import { useFeedPreferences } from "./use-feed-preferences";
10
11
 
11
12
  /**
12
13
  * What the References page shows while the session and the first reads are still resolving: the real page header at once,
@@ -16,7 +17,7 @@ import { ReferenceFeed, ReferenceFeedSkeleton } from "./reference-feed";
16
17
  export function ReferencesPageLoading() {
17
18
  const [snapshot, setSnapshot] = useState<FeedSnapshot | null>(null);
18
19
  const [checked, setChecked] = useState(false);
19
- const [tab, setTab] = useState<ReferenceFeedTabKey>("feed");
20
+ const { sort, setSort, show, setShow } = useFeedPreferences();
20
21
  const [note, setNote] = useState<string | null>(null);
21
22
  const [now] = useState(() => Date.now());
22
23
  useEffect(() => {
@@ -26,8 +27,9 @@ export function ReferencesPageLoading() {
26
27
  const references = useMemo(() => selectCanonicalReferenceRecords(snapshot?.references ?? []), [snapshot]);
27
28
  const decisions = useMemo(() => new Map(snapshot?.decisions ?? []), [snapshot]);
28
29
  const previews = useMemo(() => new Map(snapshot?.previews ?? []), [snapshot]);
29
- const partition = useMemo(() => partitionReferenceFeed(references, decisions, { now }), [references, decisions, now]);
30
30
  const noVisitRated = useMemo(() => new Set<string>(), []);
31
+ const allItems = useMemo(() => buildFeedItems(references, decisions), [references, decisions]);
32
+ const items = useMemo(() => visibleFeedItems(allItems, sort, show, noVisitRated), [allItems, sort, show, noVisitRated]);
31
33
 
32
34
  return (
33
35
  <div className="space-y-4" data-references-page-loading>
@@ -42,12 +44,16 @@ export function ReferencesPageLoading() {
42
44
  now={now}
43
45
  onOpen={() => setNote("Opening a reference is available in a moment, once your sign-in is confirmed.")}
44
46
  onRate={() => undefined}
45
- onTabChange={setTab}
47
+ onShowChange={setShow}
48
+ onSortChange={setSort}
46
49
  onVisitRated={() => undefined}
47
- partition={partition}
50
+ items={items}
51
+ leadId={feedLeadId(items)}
52
+ ratedCount={allItems.filter((item) => item.rated).length}
48
53
  previews={previews}
54
+ show={show}
55
+ sort={sort}
49
56
  stale
50
- tab={tab}
51
57
  visitRated={noVisitRated}
52
58
  />
53
59
  ) : (