@anthusai/papyrus 5.58.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 (52) 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 +8 -5
  9. package/amplify/data/resource.ts +8 -5
  10. package/amplify/data/schema.js +14 -7
  11. package/amplify/data/schema.ts +14 -7
  12. package/components/conversation-context.tsx +29 -2
  13. package/components/conversation-provider.tsx +5 -1
  14. package/components/conversation-thread.tsx +133 -19
  15. package/components/conversations-memory-source.ts +43 -6
  16. package/components/conversations-network-source.ts +13 -1
  17. package/components/conversations-source.ts +15 -3
  18. package/components/conversations-view.tsx +437 -76
  19. package/components/list-detail-layout.tsx +44 -0
  20. package/components/needs-me-bodies.tsx +14 -14
  21. package/components/needs-me-feed.tsx +8 -18
  22. package/components/news-desk-taxonomy-client.ts +142 -57
  23. package/components/newsroom-article-editor.tsx +10 -4
  24. package/components/newsroom-organizer.css +10 -1
  25. package/components/newsroom-references-view.tsx +41 -100
  26. package/components/notification-cards.tsx +4 -4
  27. package/components/notification-center.tsx +5 -7
  28. package/components/proposals-view.tsx +4 -4
  29. package/components/reference-conversation.tsx +5 -0
  30. package/components/reference-feed.tsx +69 -85
  31. package/components/reference-review-card.tsx +1 -1
  32. package/components/references-page-loading.tsx +12 -6
  33. package/components/topic-steering-workspace.tsx +4 -4
  34. package/components/use-conversation.ts +36 -2
  35. package/components/use-feed-preferences.ts +33 -0
  36. package/lib/category-repository.ts +7 -38
  37. package/lib/console-chat-client.ts +8 -178
  38. package/lib/conversation.ts +73 -2
  39. package/lib/needs-me-items.ts +4 -4
  40. package/lib/needs-me-ranking.ts +9 -22
  41. package/lib/newsroom-board.ts +10 -1
  42. package/lib/newsroom-demo-dashboard.ts +0 -7
  43. package/lib/newsroom-organizer-nav.ts +2 -2
  44. package/lib/post-message.ts +54 -0
  45. package/lib/proposals-admin.ts +2 -2
  46. package/lib/reference-feed-ranking.ts +77 -160
  47. package/lib/review-drafts.ts +2 -2
  48. package/lib/review-fixture.ts +1 -1
  49. package/lib/review-requests.ts +4 -4
  50. package/lib/semantic-graph.ts +1 -1
  51. package/package.json +4 -1
  52. package/routes.manifest.json +1 -1
@@ -1,19 +1,14 @@
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";
@@ -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
 
@@ -360,13 +364,8 @@ export function FeedCard({
360
364
  );
361
365
  }
362
366
 
363
- function plainDays(days: number): string {
364
- if (days < 1) return `${Math.max(1, Math.round(days * 24))} hours`;
365
- return `${days < 10 ? days.toFixed(1).replace(/\.0$/, "") : Math.round(days)} days`;
366
- }
367
-
368
- /** The plain-words recipe, with this site's learned numbers in it. */
369
- function RankingPopover({ freshness }: { freshness: FeedFreshness }) {
367
+ /** The plain-words recipe. */
368
+ function RankingPopover() {
370
369
  const [open, setOpen] = useState(false);
371
370
  const panelId = useId();
372
371
  const root = useRef<HTMLDivElement | null>(null);
@@ -405,15 +404,10 @@ function RankingPopover({ freshness }: { freshness: FeedFreshness }) {
405
404
  >
406
405
  <p className="text-base font-bold">A feed run by algorithms you control</p>
407
406
  <ol className="list-decimal space-y-2.5 pl-5">
408
- <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>
409
- <li><strong>Confidence counts.</strong> An Accept at 90% ranks above an Accept at 60%.</li>
410
- <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
411
- ? <>A prediction loses half its pull every {plainDays(freshness.medianAgeDays)}, the typical age of the {freshness.examples} items you accepted.</>
412
- : <>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>
413
- <li><strong>Earlier.</strong> {freshness.tauDays !== null
414
- ? <>Unrated items older than {plainDays(freshness.tauDays * EARLIER_AFTER_TAUS)} move to Earlier at the bottom. You can still rate them. Nothing is deleted.</>
415
- : <>Once the pace is learned, unrated items past three times that pace move to Earlier at the bottom. Nothing is deleted.</>}</li>
416
- <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>
417
411
  </ol>
418
412
  </div>
419
413
  ) : null}
@@ -421,10 +415,6 @@ function RankingPopover({ freshness }: { freshness: FeedFreshness }) {
421
415
  );
422
416
  }
423
417
 
424
- export function SectionHeading({ children, id }: { children: ReactNode; id: string }) {
425
- 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>;
426
- }
427
-
428
418
  export function ReferenceFeed({
429
419
  disabledReason = null,
430
420
  loading = false,
@@ -432,22 +422,23 @@ export function ReferenceFeed({
432
422
  now,
433
423
  onOpen,
434
424
  onRate,
435
- onTabChange,
425
+ onShowChange,
426
+ onSortChange,
436
427
  onVisitRated,
437
- partition,
428
+ items,
429
+ ratedCount,
438
430
  predictionLoading = false,
439
431
  previews,
440
432
  selectedLineageId = "",
441
433
  captures,
442
- groups: stableGroups,
443
- leadId: stableLeadId,
434
+ leadId,
444
435
  ratings,
445
436
  scrollTarget = null,
437
+ show,
438
+ sort,
446
439
  stale = false,
447
- tab,
448
440
  visitRated,
449
441
  }: ReferenceFeedProps) {
450
- const counts: Record<ReferenceFeedTabKey, number> = { feed: partition.feed.length, rated: partition.rated.length, earlier: partition.earlier.length };
451
442
 
452
443
  const renderCard = (item: FeedItem, lead: boolean) => (
453
444
  <FeedCard
@@ -479,28 +470,39 @@ export function ReferenceFeed({
479
470
  document.querySelector(`[data-feed-card="${CSS.escape(scrollTarget.id)}"]`)?.scrollIntoView({ behavior: "smooth", block: "nearest" });
480
471
  }, [scrollTarget]);
481
472
 
482
- const groups = stableGroups ?? groupFeedItems(partition.feed);
483
- const headings = groups.length > 1;
484
- const leadId = stableGroups ? stableLeadId : groups[0]?.key !== "least" ? groups[0]?.items[0]?.lineageId : undefined;
485
473
 
486
474
  return (
487
- <div className="@container space-y-5" data-reference-feed data-feed-tab={tab} {...FEED_SURFACE_PROPS}>
488
- <div className="flex items-center justify-between gap-2">
489
- <div aria-label="Feed sections" className="inline-flex rounded-full bg-[var(--feed-card)] p-1" role="tablist">
490
- {(Object.keys(TAB_LABELS) as ReferenceFeedTabKey[]).map((key) => (
491
- <button
492
- aria-selected={tab === key}
493
- 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")}
494
- data-feed-tab-button={key}
495
- key={key}
496
- onClick={() => onTabChange(key)}
497
- role="tab"
498
- type="button"
499
- >
500
- {TAB_LABELS[key]}
501
- <span className="text-xs tabular-nums opacity-70" data-feed-tab-count>{counts[key]}</span>
502
- </button>
503
- ))}
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>
504
506
  </div>
505
507
  <div className="flex items-center gap-2">
506
508
  {stale ? (
@@ -508,33 +510,15 @@ export function ReferenceFeed({
508
510
  <Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Showing your last view while it updates
509
511
  </span>
510
512
  ) : null}
511
- <RankingPopover freshness={partition.freshness} />
513
+ <RankingPopover />
512
514
  </div>
513
515
  </div>
514
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}
515
517
 
516
518
  {loading ? <ReferenceFeedSkeleton /> : (
517
- <div role="tabpanel">
518
- {tab === "feed" ? (
519
- <div>
520
- {groups.length ? groups.map((group) => (
521
- <section aria-labelledby={headings ? `feed-group-${group.key}` : undefined} data-feed-group={group.key} key={group.key}>
522
- {headings ? <SectionHeading id={`feed-group-${group.key}`}>{GROUP_HEADINGS[group.key]}</SectionHeading> : null}
523
- <div className={FEED_GRID} data-feed-grid>{group.items.map((item) => renderCard(item, item.lineageId === leadId))}</div>
524
- </section>
525
- )) : empty(counts.rated || counts.earlier ? "You are all caught up. Check Earlier and Rated." : "No references yet.")}
526
- {partition.earlier.length ? (
527
- <section aria-labelledby="feed-earlier-heading" data-feed-earlier>
528
- <SectionHeading id="feed-earlier-heading">Earlier</SectionHeading>
529
- <p className="-mt-3 mb-5 text-sm text-muted-foreground">Unrated and past their freshness. Still yours to rate.</p>
530
- <div className={FEED_GRID} data-feed-grid>{partition.earlier.map((item) => renderCard(item, false))}</div>
531
- </section>
532
- ) : null}
533
- </div>
534
- ) : null}
535
- {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}
536
- {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}
537
- </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.")
538
522
  )}
539
523
  </div>
540
524
  );
@@ -177,7 +177,7 @@ export function ReviewQueueBar({
177
177
  return (
178
178
  <nav
179
179
  aria-label="Review queue"
180
- 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)]")}
181
181
  data-review-queue-anchored={anchored || undefined}
182
182
  data-review-queue-bar
183
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
  ) : (
@@ -4378,8 +4378,8 @@ function AdministrationDeskView({
4378
4378
  data-active={administrationPanel === "proposals" || undefined}
4379
4379
  onClick={() => onPanelChange("proposals")}
4380
4380
  >
4381
- <strong>Proposals</strong>
4382
- <span>Story drafts and changes that AI proposes</span>
4381
+ <strong>Proposed Stories</strong>
4382
+ <span>Proposed Stories and changes that AI proposes</span>
4383
4383
  </Link>
4384
4384
  {readerPluginEnabled() ? (
4385
4385
  <Link
@@ -15967,8 +15967,8 @@ function CategorySetPanel({
15967
15967
  <dd>{referenceAttachments.length}</dd>
15968
15968
  </div>
15969
15969
  <div>
15970
- <dt>Ingestion Rationales</dt>
15971
- <dd>{messages.filter((message) => message.messageKind === "ingestion_rationale").length}</dd>
15970
+ <dt>Monitor Deliveries</dt>
15971
+ <dd>{references.filter((reference) => Boolean(reference.discoveredByMonitorId)).length}</dd>
15972
15972
  </div>
15973
15973
  <div>
15974
15974
  <dt>Review Links</dt>
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
- import type { ConversationAnchor, ConversationMessage } from "../lib/conversation";
4
+ import { NO_VIEWER, type ConversationAnchor, type ConversationMessage, type ConversationViewer } from "../lib/conversation";
5
5
  import { useConversationStore } from "./conversation-context";
6
6
 
7
7
  export type ConversationState = "loading" | "ready" | "error";
@@ -64,7 +64,41 @@ export function useConversation(anchor: ConversationAnchor) {
64
64
  }
65
65
  }, [store]);
66
66
 
67
- return { available: Boolean(store), messages, state, error, retry, post };
67
+ const [viewer, setViewer] = useState<ConversationViewer>(NO_VIEWER);
68
+ useEffect(() => {
69
+ if (!store) return;
70
+ let active = true;
71
+ store.viewer().then((loaded) => { if (active) setViewer(loaded); }).catch(() => undefined);
72
+ return () => { active = false; };
73
+ }, [store]);
74
+
75
+ // Display names come from the authors' profiles: one request for every author not yet asked about.
76
+ const [names, setNames] = useState<ReadonlyMap<string, string>>(() => new Map());
77
+ const asked = useRef(new Set<string>());
78
+ useEffect(() => {
79
+ if (!store) return;
80
+ const missing = [...new Set(messages.map((message) => message.authorUserProfileId).filter((id): id is string => Boolean(id) && !asked.current.has(id as string)))];
81
+ if (!missing.length) return;
82
+ missing.forEach((id) => asked.current.add(id));
83
+ let active = true;
84
+ store.authors(missing).then((loaded) => { if (active && loaded.size) setNames((current) => new Map([...current, ...loaded])); }).catch(() => undefined);
85
+ return () => { active = false; };
86
+ }, [messages, store]);
87
+
88
+ const edit = useCallback(async (messageId: string, body: string) => {
89
+ if (!store) throw new Error("Messages are not available here.");
90
+ const saved = await store.edit(messageId, body);
91
+ setMessages((current) => current.map((message) => (message.id === messageId ? { ...message, ...saved } : message)));
92
+ }, [store]);
93
+ const remove = useCallback(async (messageId: string) => {
94
+ if (!store) throw new Error("Messages are not available here.");
95
+ const target = messages.find((message) => message.id === messageId);
96
+ const moderate = viewer.isAdmin && target?.authorSub !== viewer.sub;
97
+ const saved = await store.remove(messageId, moderate);
98
+ setMessages((current) => current.map((message) => (message.id === messageId ? { ...message, ...saved } : message)));
99
+ }, [store, messages, viewer]);
100
+
101
+ return { available: Boolean(store), messages, state, error, retry, post, edit, remove, viewer, names };
68
102
  }
69
103
 
70
104
  /** Message counts per Reference lineage, for the feed cards. A failed read just shows no counts. */
@@ -0,0 +1,33 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import { DEFAULT_FEED_SHOW, DEFAULT_FEED_SORT, type FeedShow, type FeedSort } from "../lib/reference-feed-ranking";
5
+
6
+ const SORT_KEY = "papyrus:reference-feed:sort";
7
+ const SHOW_KEY = "papyrus:reference-feed:show";
8
+
9
+ function read<T extends string>(key: string, allowed: readonly T[], fallback: T): T {
10
+ try {
11
+ const stored = window.localStorage.getItem(key);
12
+ return allowed.includes(stored as T) ? (stored as T) : fallback;
13
+ } catch {
14
+ return fallback;
15
+ }
16
+ }
17
+
18
+ function write(key: string, value: string) {
19
+ try { window.localStorage.setItem(key, value); } catch { /* The choice still works for this visit without storage. */ }
20
+ }
21
+
22
+ /** The viewer's Sort and Show choices, remembered in this browser. The page renders with the defaults, then applies what was saved. */
23
+ export function useFeedPreferences() {
24
+ const [sort, setSortState] = useState<FeedSort>(DEFAULT_FEED_SORT);
25
+ const [show, setShowState] = useState<FeedShow>(DEFAULT_FEED_SHOW);
26
+ useEffect(() => {
27
+ setSortState(read<FeedSort>(SORT_KEY, ["best", "newest"], DEFAULT_FEED_SORT));
28
+ setShowState(read<FeedShow>(SHOW_KEY, ["unrated", "all"], DEFAULT_FEED_SHOW));
29
+ }, []);
30
+ const setSort = useCallback((next: FeedSort) => { setSortState(next); write(SORT_KEY, next); }, []);
31
+ const setShow = useCallback((next: FeedShow) => { setShowState(next); write(SHOW_KEY, next); }, []);
32
+ return { sort, setSort, show, setShow };
33
+ }
@@ -289,6 +289,10 @@ export type ReferenceRecord = {
289
289
  curationDecisionRelationId?: string | null;
290
290
  newsroomFeedKey?: string | null;
291
291
  reviewedFeedKey?: string | null;
292
+ /** The monitor that delivered this Reference (set at intake; the Reference is the record, no Message). */
293
+ discoveredByMonitorId?: string | null;
294
+ /** What the monitor's source said about it (a Hub model's structured fields, say). */
295
+ discoveryDetails?: unknown;
292
296
  metadata?: unknown;
293
297
  updatedAt?: string | null;
294
298
  };
@@ -419,6 +423,9 @@ export type MessageRecord = {
419
423
  authorSub?: string | null;
420
424
  authorUserProfileId?: string | null;
421
425
  authorLabel?: string | null;
426
+ editedAt?: string | null;
427
+ deletedAt?: string | null;
428
+ deletedBySub?: string | null;
422
429
  threadId?: string | null;
423
430
  parentMessageId?: string | null;
424
431
  sequenceNumber?: number | null;
@@ -1478,18 +1485,6 @@ export function createDemoCategorySteeringDashboard(
1478
1485
  },
1479
1486
  ],
1480
1487
  messages: [
1481
- {
1482
- id: "message-demo-history-002-rationale",
1483
- messageKind: "ingestion_rationale",
1484
- messageDomain: "commentary",
1485
- status: "active",
1486
- body: "Imported as a useful holdout against modern scaling coverage.",
1487
- summary: "Holdout ingestion rationale",
1488
- source: "biblicus-import",
1489
- importRunId: "knowledge-import-demo-projection",
1490
- createdAt: importedAt,
1491
- updatedAt: importedAt,
1492
- },
1493
1488
  {
1494
1489
  id: reportingPacketMessageId,
1495
1490
  messageKind: "reporting_context_packet",
@@ -1833,32 +1828,6 @@ export function createDemoCategorySteeringDashboard(
1833
1828
  importRunId: "knowledge-import-demo-projection",
1834
1829
  importedAt,
1835
1830
  },
1836
- {
1837
- id: "semantic-relation-demo-message-history-002-rationale",
1838
- relationState: "current",
1839
- predicate: "comment",
1840
- relationTypeId: "semantic-relation-type-comment",
1841
- relationTypeKey: "comment",
1842
- relationDomain: "commentary",
1843
- subjectKind: "message",
1844
- subjectId: "message-demo-history-002-rationale",
1845
- subjectLineageId: "message-demo-history-002-rationale",
1846
- subjectVersionNumber: 1,
1847
- objectKind: "reference",
1848
- objectId: referenceHistoryTwoId,
1849
- objectLineageId: referenceHistoryTwoLineageId,
1850
- objectVersionNumber: 1,
1851
- subjectStateKey: "message#message-demo-history-002-rationale#current",
1852
- objectStateKey: `reference#${referenceHistoryTwoLineageId}#current`,
1853
- objectSubjectStateKey: `reference#${referenceHistoryTwoLineageId}#current#message`,
1854
- predicateObjectStateKey: `comment#reference#${referenceHistoryTwoLineageId}#current`,
1855
- subjectVersionKey: "message#message-demo-history-002-rationale",
1856
- objectVersionKey: `reference#${referenceHistoryTwoId}`,
1857
- rank: 1,
1858
- reviewRecommended: false,
1859
- importRunId: "knowledge-import-demo-projection",
1860
- importedAt,
1861
- },
1862
1831
  ],
1863
1832
  loadError: null,
1864
1833
  };