@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
@@ -32,7 +32,7 @@ import { NotificationSkeleton, RecentDecisions } from "./notification-cards";
32
32
  import { FEED_GRID, FEED_SURFACE_PROPS, FeedImage, PILL, REASON_LABELS, SELECTED_RING, SourceMark } from "./reference-feed";
33
33
  import { predictionFromDecision, ReviewQueueBar, useQueueGestures, type CardPrediction, type ReviewChoice } from "./reference-review-card";
34
34
  import type { CorrectCaptureAction } from "./reference-capture-client";
35
- import { RatingGrid } from "./review-rating-grid";
35
+ import { DecisionControl } from "./decision-control";
36
36
  import { useMediaQuery } from "./use-media-query";
37
37
  import { useRatingStore, type RatingController } from "./use-reference-ratings";
38
38
  import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
@@ -169,30 +169,24 @@ export function NeedsMeCard({ item, now, onOpen, predictionLoading, ratings, sel
169
169
  {flag ? (
170
170
  <FlagFooter busy={rating.feedback.phase === "saving"} disabledReason={disabledReason} done={item.decisionState === "acknowledged"} error={rating.feedback.phase === "error" ? rating.feedback.message ?? null : null} onAcknowledge={() => ratings.choose(item.id, "accept")} />
171
171
  ) : (
172
- <RatingGrid
172
+ <DecisionControl
173
173
  answer={chosen}
174
174
  decided={decided}
175
175
  disabled={Boolean(disabledReason) || locked}
176
176
  disabledReason={disabledReason}
177
177
  feedback={feedback}
178
+ needsReason={refLike && rating.askReason}
178
179
  note={reasonLabel ? `Reason: ${reasonLabel}` : locked ? whyLine ?? "Decided here." : null}
179
180
  onChoose={(choice) => { if (!disabledReason && !locked) ratings.choose(item.id, choice); }}
181
+ onReasonChange={(text) => ratings.setNote(item.id, text)}
182
+ onReasonCommit={() => { if (!disabledReason && !locked) ratings.commitNote(item.id); }}
180
183
  prediction={cardPrediction(item, predictionLoading)}
184
+ reason={rating.note ?? ""}
185
+ reasonRequiredFor={refLike ? "reject" : null}
181
186
  showPrediction={refLike}
187
+ showReason={refLike}
182
188
  />
183
189
  )}
184
- {refLike && rating.askReason ? (
185
- // Covers the footer rather than adding to it, so asking for a reason never pushes the cards below it down.
186
- <div className="absolute inset-0 z-10 flex flex-col justify-center space-y-2 bg-[var(--feed-footer)] p-3" data-review-reasons>
187
- <p className="text-xs font-medium">Reason for rejecting. Choose one to save your Reject.</p>
188
- <div className="flex flex-wrap gap-1.5">
189
- {REFERENCE_REJECTION_REASON_CODES.map((code) => (
190
- <button className={cn(PILL, "min-h-8 bg-[var(--feed-control)] px-3 text-xs")} data-review-reason={code} key={code} onClick={() => ratings.setReason(item.id, code)} type="button">{REASON_LABELS[code]}</button>
191
- ))}
192
- <button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() => ratings.cancelReason(item.id)} type="button">Cancel</button>
193
- </div>
194
- </div>
195
- ) : null}
196
190
  </div>
197
191
  </article>
198
192
  );
@@ -11,6 +11,7 @@ import type { NeedsMeEntry } from "../lib/needs-me-items";
11
11
  import { DefaultOutcome, Heading, type DetailProps } from "./needs-me-bodies";
12
12
  import { decidedChoiceOf } from "./needs-me-shared";
13
13
  import { predictionFromDecision, ReviewCard, type CardPrediction } from "./reference-review-card";
14
+ import { ReferenceConversation } from "./reference-conversation";
14
15
  import { ReferenceCapturePanel } from "./reference-capture-panel";
15
16
  import { useReferenceCapture } from "./reference-capture-client";
16
17
  import { ReferenceSourcePreview } from "./reference-source-preview";
@@ -57,6 +58,7 @@ export function ReferenceBody(props: DetailProps & { payload: Extract<NeedsMeEnt
57
58
  titleHref={url}
58
59
  >
59
60
  {payload.decision ? <div data-reference-relevance-decision={lineageId}><ReferenceSuggestionProvenance decision={payload.decision} /></div> : null}
61
+ <ReferenceConversation disabledReason={disabledReason ? "Editor access is required to write messages." : null} lineageId={lineageId} referenceId={reference.id} title={referenceDisplayTitle(reference)} />
60
62
  </ReviewCard>
61
63
  <DefaultOutcome item={item} />
62
64
  {incomplete ? <p className="text-sm text-muted-foreground" data-needs-me-capture-note>The captured text looks cut off or missing. Read it below. If it is incomplete, choose Paste or correct the text and say whether the capture was complete, or rate the Reference as it is.</p> : null}
@@ -10,6 +10,7 @@ import { createLocalPreviewRealPublishedVersions, createLocalPreviewAssignments,
10
10
  import { generateClient } from "aws-amplify/data";
11
11
  import { testEditorOverrideAllowed } from "../lib/test-editor-override";
12
12
  import { downloadData, getUrl } from "aws-amplify/storage";
13
+ import { fetchAuthSession } from "aws-amplify/auth";
13
14
  import type { Schema } from "../amplify/data/resource";
14
15
  import type { NewsDeskAppendix, NewsDeskCategoryTreeNode } from "../lib/content-types";
15
16
  import { createEmptyCategorySteeringDashboard } from "../lib/category-dashboard";
@@ -45,7 +46,20 @@ import type {
45
46
  SemanticRelationRecord,
46
47
  UserDirectoryEntry,
47
48
  } from "../lib/category-repository";
48
- import { relationTypeKey } from "../lib/semantic-graph";
49
+ import { relationTypeKey, semanticStateKey, semanticVersionKey } from "../lib/semantic-graph";
50
+ import {
51
+ CONVERSATION_FEED_KEY,
52
+ GENERAL_CONVERSATION_KIND,
53
+ REFERENCE_CONVERSATION_KIND,
54
+ conversationAnchorKey,
55
+ conversationMessageId,
56
+ conversationThreadId,
57
+ conversationThreadKind,
58
+ createWithSequenceRetry,
59
+ findOrCreateThread,
60
+ type ConversationAnchor,
61
+ type ConversationMessage,
62
+ } from "../lib/conversation";
49
63
  import { legacyStageKeysToLoad, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
50
64
  import { getContentItemStageDefinitions } from "../lib/site-brand";
51
65
  import { DOCTRINE_DEFINITIONS, getCategoryDoctrineDefinitions, type DoctrineCategory } from "../lib/doctrine";
@@ -103,6 +117,23 @@ const LIST_MESSAGE_THREADS_BY_KIND_AND_UPDATED_AT_QUERY = `
103
117
  }
104
118
  `;
105
119
 
120
+ const LIST_MESSAGE_THREADS_BY_ANCHOR_AND_UPDATED_AT_QUERY = `
121
+ query ListMessageThreadsByAnchorAndUpdatedAt($primaryAnchorKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
122
+ listMessageThreadsByAnchorAndUpdatedAt(primaryAnchorKey: $primaryAnchorKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
123
+ items {
124
+ id threadKind status title summary primaryAnchorKind primaryAnchorId primaryAnchorLineageId primaryAnchorKey createdBySub createdByUserProfileId createdByLabel messageCount lastMessageId lastMessageAt contextDigest activeResponseMessageId responseLockOwner responseLockExpiresAt metadata createdAt updatedAt newsroomFeedKey
125
+ }
126
+ nextToken
127
+ }
128
+ }
129
+ `;
130
+
131
+ const CREATE_SEMANTIC_RELATION_MUTATION = `
132
+ mutation CreateSemanticRelation($input: CreateSemanticRelationInput!) {
133
+ createSemanticRelation(input: $input) { id }
134
+ }
135
+ `;
136
+
106
137
  const LIST_MESSAGES_BY_THREAD_AND_SEQUENCE_QUERY = `
107
138
  query ListMessagesByThreadAndSequence($threadId: ID!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
108
139
  listMessagesByThreadAndSequence(threadId: $threadId, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
@@ -271,7 +302,7 @@ const NEWSROOM_ASSIGNMENT_FEED_QUERY = `
271
302
  const NEWSROOM_REFERENCE_FEED_QUERY = `
272
303
  query ListReferencesByNewsroomFeedAndCreatedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
273
304
  listReferencesByNewsroomFeedAndCreatedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
274
- items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt }
305
+ items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey updatedAt }
275
306
  nextToken
276
307
  }
277
308
  }
@@ -280,7 +311,7 @@ const NEWSROOM_REFERENCE_FEED_QUERY = `
280
311
  const NEWSROOM_REFERENCE_IMPORTED_FEED_QUERY = `
281
312
  query ListReferencesByNewsroomFeedAndImportedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
282
313
  listReferencesByNewsroomFeedAndImportedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
283
- items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt }
314
+ items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey updatedAt }
284
315
  nextToken
285
316
  }
286
317
  }
@@ -289,7 +320,7 @@ const NEWSROOM_REFERENCE_IMPORTED_FEED_QUERY = `
289
320
  const LIST_REFERENCES_BY_CURATION_STATUS_AND_UPDATED_AT_QUERY = `
290
321
  query ListReferencesByCurationStatusAndUpdatedAt($curationStatus: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
291
322
  listReferencesByCurationStatusAndUpdatedAt(curationStatus: $curationStatus, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
292
- items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt }
323
+ items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey updatedAt }
293
324
  nextToken
294
325
  }
295
326
  }
@@ -298,7 +329,7 @@ const LIST_REFERENCES_BY_CURATION_STATUS_AND_UPDATED_AT_QUERY = `
298
329
  const LIST_REFERENCES_BY_REVIEWED_FEED_AND_UPDATED_AT_QUERY = `
299
330
  query ListReferencesByReviewedFeedAndUpdatedAt($reviewedFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
300
331
  listReferencesByReviewedFeedAndUpdatedAt(reviewedFeedKey: $reviewedFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
301
- items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt }
332
+ items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey updatedAt }
302
333
  nextToken
303
334
  }
304
335
  }
@@ -307,7 +338,7 @@ const LIST_REFERENCES_BY_REVIEWED_FEED_AND_UPDATED_AT_QUERY = `
307
338
  const LIST_REFERENCES_FALLBACK_QUERY = `
308
339
  query ListReferencesFallback($limit: Int, $nextToken: String) {
309
340
  listReferences(limit: $limit, nextToken: $nextToken) {
310
- items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt }
341
+ items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey updatedAt }
311
342
  nextToken
312
343
  }
313
344
  }
@@ -440,7 +471,7 @@ const MODEL_ATTACHMENTS_BY_OWNER_QUERY = `
440
471
 
441
472
  /** Matches Reference model fields in amplify/data/resource.ts (no metadata on Reference). */
442
473
  const REFERENCE_RECORD_GRAPHQL_FIELDS =
443
- "id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt";
474
+ "id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey updatedAt";
444
475
 
445
476
  const LIST_REFERENCES_BY_LINEAGE_AND_VERSION_QUERY = `
446
477
  query ListReferencesByLineageAndVersion($lineageId: ID!, $limit: Int, $nextToken: String) {
@@ -1388,6 +1419,43 @@ export async function createSectionForumThreadRecord(input: {
1388
1419
  return { created: true, thread: normalizeThreadRecord(response.createMessageThread ?? thread) };
1389
1420
  }
1390
1421
 
1422
+ /**
1423
+ * Appends one Message to a thread: the next sequence number, the Message, then the thread's count and last Message. The Message id
1424
+ * derives from the sequence number, so a create that loses a race for the number collides and retries with the next one.
1425
+ */
1426
+ async function appendMessageToThread(thread: MessageThreadRecord, build: (sequenceNumber: number, now: string) => MessageRecord): Promise<{ thread: MessageThreadRecord; message: MessageRecord }> {
1427
+ const threadId = thread.id;
1428
+ let now = new Date().toISOString();
1429
+ let built: MessageRecord | null = null;
1430
+ const { created } = await createWithSequenceRetry({
1431
+ list: () => listMessagesByThreadId(threadId, 1000),
1432
+ create: (sequenceNumber) => {
1433
+ now = new Date().toISOString();
1434
+ // `createdAt` is part of the composite sort key of messagesByResponseTargetStatusCreatedAt, so AppSync refuses a Message that
1435
+ // sets it without `responseStatus`. A person's message waits on no responder: COMPLETED, as the Python message contract writes.
1436
+ built = { responseStatus: "COMPLETED", ...build(sequenceNumber, now) };
1437
+ return runGraphql<{ createMessage?: MessageRecord | null }>(CREATE_MESSAGE_MUTATION, { input: built });
1438
+ },
1439
+ });
1440
+ const createdMessage = normalizeMessageRecord(created.createMessage ?? built!);
1441
+ const updated = await runGraphql<{ updateMessageThread?: MessageThreadRecord | null }>(
1442
+ UPDATE_MESSAGE_THREAD_MUTATION,
1443
+ {
1444
+ input: {
1445
+ id: threadId,
1446
+ messageCount: (thread.messageCount ?? 0) + 1,
1447
+ lastMessageId: createdMessage.id,
1448
+ lastMessageAt: createdMessage.createdAt,
1449
+ updatedAt: now,
1450
+ },
1451
+ },
1452
+ );
1453
+ return {
1454
+ thread: normalizeThreadRecord(updated.updateMessageThread ?? { ...thread, updatedAt: now }),
1455
+ message: createdMessage,
1456
+ };
1457
+ }
1458
+
1391
1459
  export async function appendForumThreadMessageRecord(input: {
1392
1460
  threadId: string;
1393
1461
  summary: string;
@@ -1404,11 +1472,8 @@ export async function appendForumThreadMessageRecord(input: {
1404
1472
  if (!summary) throw new Error("summary is required");
1405
1473
  if (!content) throw new Error("content is required");
1406
1474
  const thread = await getMessageThreadRecord(threadId);
1407
- const existing = await listMessagesByThreadId(threadId, 1000);
1408
- const nextSequence = Math.max(0, ...existing.map((message) => Number(message.sequenceNumber || 0))) + 1;
1409
- const now = new Date().toISOString();
1410
- const message: MessageRecord = {
1411
- id: `message-forum-${safeForumId(threadId)}-${String(nextSequence).padStart(4, "0")}`,
1475
+ return appendMessageToThread(thread, (sequenceNumber, now) => ({
1476
+ id: `message-forum-${safeForumId(threadId)}-${String(sequenceNumber).padStart(4, "0")}`,
1412
1477
  messageKind: "forum_post",
1413
1478
  messageDomain: "edition_coordination",
1414
1479
  status: "active",
@@ -1417,7 +1482,7 @@ export async function appendForumThreadMessageRecord(input: {
1417
1482
  authorLabel: input.authorLabel?.trim() || "editor",
1418
1483
  threadId,
1419
1484
  parentMessageId: input.parentMessageId?.trim() || null,
1420
- sequenceNumber: nextSequence,
1485
+ sequenceNumber,
1421
1486
  role: input.role || "human",
1422
1487
  messageType: "forum_message",
1423
1488
  content,
@@ -1425,28 +1490,243 @@ export async function appendForumThreadMessageRecord(input: {
1425
1490
  createdAt: now,
1426
1491
  updatedAt: now,
1427
1492
  newsroomFeedKey: "messages",
1428
- };
1429
- const created = await runGraphql<{ createMessage?: MessageRecord | null }>(
1430
- CREATE_MESSAGE_MUTATION,
1431
- { input: message },
1432
- );
1433
- const createdMessage = normalizeMessageRecord(created.createMessage ?? message);
1434
- const updated = await runGraphql<{ updateMessageThread?: MessageThreadRecord | null }>(
1435
- UPDATE_MESSAGE_THREAD_MUTATION,
1436
- {
1437
- input: {
1438
- id: threadId,
1439
- messageCount: (thread.messageCount ?? 0) + 1,
1440
- lastMessageId: createdMessage.id,
1441
- lastMessageAt: createdMessage.createdAt,
1493
+ }));
1494
+ }
1495
+
1496
+ type ConversationAuthor = { sub: string | null; label: string };
1497
+
1498
+ async function currentConversationAuthor(): Promise<ConversationAuthor> {
1499
+ const snapshot = await loadReaderSessionSnapshot();
1500
+ let sub: string | null = null;
1501
+ try {
1502
+ const session = await fetchAuthSession();
1503
+ const claim = session.tokens?.idToken?.payload?.sub ?? session.tokens?.accessToken?.payload?.sub;
1504
+ sub = typeof claim === "string" && claim ? claim : null;
1505
+ } catch {
1506
+ sub = null;
1507
+ }
1508
+ return { sub, label: snapshot.auth.status === "signedIn" ? snapshot.auth.label : "editor" };
1509
+ }
1510
+
1511
+ async function findConversationThread(anchorKey: string): Promise<MessageThreadRecord | null> {
1512
+ type Response = { listMessageThreadsByAnchorAndUpdatedAt?: { items?: Array<MessageThreadRecord | null> | null } | null };
1513
+ const response = await runGraphql<Response>(LIST_MESSAGE_THREADS_BY_ANCHOR_AND_UPDATED_AT_QUERY, {
1514
+ primaryAnchorKey: anchorKey,
1515
+ sortDirection: "DESC",
1516
+ limit: 10,
1517
+ });
1518
+ const found = (response.listMessageThreadsByAnchorAndUpdatedAt?.items ?? []).filter(Boolean) as MessageThreadRecord[];
1519
+ const thread = found.find((entry) => entry.status !== "deleted") ?? null;
1520
+ return thread ? normalizeThreadRecord(thread) : null;
1521
+ }
1522
+
1523
+ /** The one conversation for an anchor: found by its anchor key, created when there is none. Concurrent first posts share the thread. */
1524
+ export async function ensureConversationThread(anchor: ConversationAnchor, author?: ConversationAuthor): Promise<MessageThreadRecord> {
1525
+ const anchorKey = conversationAnchorKey(anchor);
1526
+ if (!anchorKey) throw new Error("An unattached conversation starts its own thread.");
1527
+ return findOrCreateThread({
1528
+ find: () => findConversationThread(anchorKey),
1529
+ create: async () => {
1530
+ const now = new Date().toISOString();
1531
+ const lineageId = anchor.lineageId!.trim();
1532
+ const thread: MessageThreadRecord = {
1533
+ id: conversationThreadId(anchor),
1534
+ threadKind: conversationThreadKind(anchor),
1535
+ status: "active",
1536
+ title: anchor.title?.trim() || lineageId,
1537
+ primaryAnchorKind: anchor.kind,
1538
+ primaryAnchorId: lineageId,
1539
+ primaryAnchorLineageId: lineageId,
1540
+ primaryAnchorKey: anchorKey,
1541
+ createdBySub: author?.sub ?? null,
1542
+ createdByLabel: author?.label ?? "editor",
1543
+ messageCount: 0,
1544
+ lastMessageId: null,
1545
+ lastMessageAt: null,
1546
+ metadata: JSON.stringify({ anchorKind: anchor.kind, anchorLineageId: lineageId }),
1547
+ createdAt: now,
1442
1548
  updatedAt: now,
1443
- },
1549
+ newsroomFeedKey: CONVERSATION_FEED_KEY,
1550
+ };
1551
+ const response = await runGraphql<{ createMessageThread?: MessageThreadRecord | null }>(CREATE_MESSAGE_THREAD_MUTATION, { input: thread });
1552
+ return normalizeThreadRecord(response.createMessageThread ?? thread);
1444
1553
  },
1445
- );
1554
+ });
1555
+ }
1556
+
1557
+ function toConversationMessage(record: MessageRecord): ConversationMessage {
1446
1558
  return {
1447
- thread: normalizeThreadRecord(updated.updateMessageThread ?? { ...thread, updatedAt: now }),
1448
- message: createdMessage,
1559
+ id: record.id,
1560
+ parentMessageId: record.parentMessageId ?? null,
1561
+ authorLabel: record.authorLabel ?? "",
1562
+ content: record.content ?? record.body ?? "",
1563
+ createdAt: record.createdAt,
1564
+ sequenceNumber: Number(record.sequenceNumber ?? 0),
1565
+ };
1566
+ }
1567
+
1568
+ /** Every Message in the anchor's conversation, oldest first. An anchor with no conversation yet has none. */
1569
+ export async function loadConversationMessages(anchor: ConversationAnchor): Promise<ConversationMessage[]> {
1570
+ const anchorKey = conversationAnchorKey(anchor);
1571
+ if (!anchorKey) return [];
1572
+ const thread = await findConversationThread(anchorKey);
1573
+ if (!thread) return [];
1574
+ const records = await listMessagesByThreadId(thread.id, 500);
1575
+ return records.filter((record) => record.status !== "deleted").map(toConversationMessage);
1576
+ }
1577
+
1578
+ /** How many Messages each Reference has, from the Reference conversation threads alone (no query per card). Keyed by lineage id. */
1579
+ export async function loadReferenceConversationCounts(): Promise<Map<string, number>> {
1580
+ const threads = await listMessageThreadsByKind(REFERENCE_CONVERSATION_KIND, 500);
1581
+ const counts = new Map<string, number>();
1582
+ for (const thread of threads) {
1583
+ const lineageId = thread.primaryAnchorLineageId;
1584
+ if (lineageId && thread.status !== "deleted" && (thread.messageCount ?? 0) > 0) counts.set(lineageId, thread.messageCount ?? 0);
1585
+ }
1586
+ return counts;
1587
+ }
1588
+
1589
+ /**
1590
+ * Writes a Message into the anchor's conversation (finding or creating the thread), and a `comment` relation from the Message to
1591
+ * the Reference or Item so the thing's own detail can find it. The relation is secondary: if it fails the Message is still saved.
1592
+ */
1593
+ export async function appendConversationMessage(input: {
1594
+ anchor: ConversationAnchor;
1595
+ body: string;
1596
+ parentMessageId?: string | null;
1597
+ /** The conversation to write into, when the caller already holds it (the Messages page). Without it the anchor finds or creates it. */
1598
+ thread?: MessageThreadRecord;
1599
+ }): Promise<{ message: ConversationMessage; thread: MessageThreadRecord; relationError: string | null }> {
1600
+ const content = String(input.body || "").trim();
1601
+ if (!content) throw new Error("Write something to post.");
1602
+ const author = await currentConversationAuthor();
1603
+ const thread = input.thread ?? await ensureConversationThread(input.anchor, author);
1604
+ const result = await appendMessageToThread(thread, (sequenceNumber, now) => ({
1605
+ id: conversationMessageId(thread.id, sequenceNumber),
1606
+ messageKind: "comment",
1607
+ messageDomain: "commentary",
1608
+ status: "active",
1609
+ summary: content.slice(0, 120),
1610
+ source: "newsroom.conversation",
1611
+ authorSub: author.sub,
1612
+ authorLabel: author.label,
1613
+ threadId: thread.id,
1614
+ parentMessageId: input.parentMessageId?.trim() || null,
1615
+ sequenceNumber,
1616
+ role: "human",
1617
+ messageType: "comment",
1618
+ content,
1619
+ metadata: JSON.stringify({ messageKind: "comment", anchorKey: conversationAnchorKey(input.anchor) }),
1620
+ createdAt: now,
1621
+ updatedAt: now,
1622
+ newsroomFeedKey: CONVERSATION_FEED_KEY,
1623
+ }));
1624
+ let relationError: string | null = null;
1625
+ if (input.anchor.kind !== "none") {
1626
+ try {
1627
+ await createCommentRelation(result.message, input.anchor);
1628
+ } catch (error) {
1629
+ relationError = error instanceof Error ? error.message : "The link to the Reference could not be saved.";
1630
+ }
1631
+ }
1632
+ return { message: toConversationMessage(result.message), thread: result.thread, relationError };
1633
+ }
1634
+
1635
+ /** One page of conversation threads of a kind, newest activity first. */
1636
+ export async function loadConversationThreadPage(threadKind: string, nextToken: string | null, limit = 20): Promise<{ items: MessageThreadRecord[]; nextToken: string | null }> {
1637
+ type ThreadConnectionResponse = { listMessageThreadsByKindAndUpdatedAt?: { items?: Array<MessageThreadRecord | null> | null; nextToken?: string | null } | null };
1638
+ const response = await runGraphql<ThreadConnectionResponse>(LIST_MESSAGE_THREADS_BY_KIND_AND_UPDATED_AT_QUERY, { threadKind, sortDirection: "DESC", limit, nextToken });
1639
+ const connection = response.listMessageThreadsByKindAndUpdatedAt;
1640
+ const items = ((connection?.items ?? []).filter(Boolean) as MessageThreadRecord[]).map(normalizeThreadRecord).filter((thread) => thread.status !== "deleted");
1641
+ return { items, nextToken: connection?.nextToken ?? null };
1642
+ }
1643
+
1644
+ /** Every Message in a conversation thread, oldest first. */
1645
+ export async function loadConversationThreadMessages(threadId: string): Promise<ConversationMessage[]> {
1646
+ const records = await listMessagesByThreadId(threadId, 500);
1647
+ return records.filter((record) => record.status !== "deleted").map(toConversationMessage);
1648
+ }
1649
+
1650
+ /** Starts a conversation about nothing but itself: its own thread, and its first Message. */
1651
+ export async function startUnattachedConversation(body: string): Promise<{ message: ConversationMessage; thread: MessageThreadRecord }> {
1652
+ const content = String(body || "").trim();
1653
+ if (!content) throw new Error("Write something to post.");
1654
+ const author = await currentConversationAuthor();
1655
+ const now = new Date().toISOString();
1656
+ const id = `message-thread-conversation-new-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1657
+ const thread: MessageThreadRecord = {
1658
+ id,
1659
+ threadKind: GENERAL_CONVERSATION_KIND,
1660
+ status: "active",
1661
+ title: content.split("\n")[0].slice(0, 80),
1662
+ createdBySub: author.sub,
1663
+ createdByLabel: author.label,
1664
+ messageCount: 0,
1665
+ lastMessageId: null,
1666
+ lastMessageAt: null,
1667
+ metadata: JSON.stringify({ anchorKind: "none" }),
1668
+ createdAt: now,
1669
+ updatedAt: now,
1670
+ newsroomFeedKey: CONVERSATION_FEED_KEY,
1449
1671
  };
1672
+ const created = await runGraphql<{ createMessageThread?: MessageThreadRecord | null }>(CREATE_MESSAGE_THREAD_MUTATION, { input: thread });
1673
+ const saved = normalizeThreadRecord(created.createMessageThread ?? thread);
1674
+ const result = await appendConversationMessage({ anchor: { kind: "none" }, body: content, thread: saved });
1675
+ return { message: result.message, thread: result.thread };
1676
+ }
1677
+
1678
+ /** The title and editorial stage of each Story, by lineage id. A Story that cannot be read has no entry. */
1679
+ export async function loadStoryHeaders(lineageIds: string[]): Promise<Map<string, { title: string; stage: string | null }>> {
1680
+ const headers = new Map<string, { title: string; stage: string | null }>();
1681
+ await Promise.all([...new Set(lineageIds)].map(async (lineageId) => {
1682
+ try {
1683
+ type Response = { listItemsByLineageAndVersion?: { items?: Array<{ versionNumber?: number | null; headline?: string | null; title?: string | null; stage?: string | null } | null> | null } | null };
1684
+ const response = await runGraphql<Response>(`query StoryHeader($lineageId: ID!) { listItemsByLineageAndVersion(lineageId: $lineageId, limit: 50) { items { versionNumber headline title stage } } }`, { lineageId });
1685
+ const items = (response.listItemsByLineageAndVersion?.items ?? []).filter(Boolean) as Array<{ versionNumber?: number | null; headline?: string | null; title?: string | null; stage?: string | null }>;
1686
+ const newest = items.sort((left, right) => (right.versionNumber ?? 0) - (left.versionNumber ?? 0))[0];
1687
+ const title = newest?.headline || newest?.title;
1688
+ if (title) headers.set(lineageId, { title, stage: newest?.stage ?? null });
1689
+ } catch { /* a Story that cannot be read keeps the title the thread was started with */ }
1690
+ }));
1691
+ return headers;
1692
+ }
1693
+
1694
+ async function createCommentRelation(message: MessageRecord, anchor: ConversationAnchor): Promise<void> {
1695
+ if (anchor.kind === "none") return;
1696
+ const lineageId = anchor.lineageId.trim();
1697
+ const objectId = anchor.id?.trim() || lineageId;
1698
+ const now = message.createdAt;
1699
+ await runGraphql(CREATE_SEMANTIC_RELATION_MUTATION, {
1700
+ input: {
1701
+ id: `semantic-relation-${message.id}-comment`,
1702
+ relationState: "current",
1703
+ predicate: "comment",
1704
+ relationTypeId: "semantic-relation-type-comment",
1705
+ relationTypeKey: "comment",
1706
+ relationDomain: "commentary",
1707
+ subjectKind: "message",
1708
+ subjectId: message.id,
1709
+ subjectLineageId: message.id,
1710
+ subjectVersionNumber: 1,
1711
+ objectKind: anchor.kind,
1712
+ objectId,
1713
+ objectLineageId: lineageId,
1714
+ subjectStateKey: semanticStateKey("message", message.id),
1715
+ objectStateKey: semanticStateKey(anchor.kind, lineageId),
1716
+ objectSubjectStateKey: `${semanticStateKey(anchor.kind, lineageId)}#message`,
1717
+ predicateObjectStateKey: `comment#${semanticStateKey(anchor.kind, lineageId)}`,
1718
+ subjectVersionKey: semanticVersionKey("message", message.id),
1719
+ objectVersionKey: semanticVersionKey(anchor.kind, objectId),
1720
+ score: 1,
1721
+ rank: 1,
1722
+ reviewRecommended: false,
1723
+ importedAt: now,
1724
+ createdAt: now,
1725
+ updatedAt: now,
1726
+ newsroomFeedKey: "semanticRelations",
1727
+ metadata: JSON.stringify({ kind: "conversation.comment", messageKind: "comment" }),
1728
+ },
1729
+ });
1450
1730
  }
1451
1731
 
1452
1732
  export async function deleteForumThreadMessageRecord(input: {
@@ -1627,10 +1907,17 @@ async function runGraphql<T extends Record<string, unknown>>(
1627
1907
  const client = generateClient<Schema>() as unknown as {
1628
1908
  graphql: (options: Record<string, unknown>) => Promise<{ data?: T | null; errors?: unknown[] | null }>;
1629
1909
  };
1910
+ // The Amplify client rejects with the raw `{ data, errors }` response when the API returns errors; turn that into an
1911
+ // Error carrying the API's own words, so a refusal is said in words instead of as an unknown failure.
1630
1912
  const response = await client.graphql({
1631
1913
  query,
1632
1914
  variables,
1633
1915
  authMode: USER_POOL_AUTH_MODE,
1916
+ }).catch((failure: unknown) => {
1917
+ if (failure && typeof failure === "object" && Array.isArray((failure as { errors?: unknown }).errors)) {
1918
+ assertNoGraphQLErrors((failure as { errors: unknown[] }).errors);
1919
+ }
1920
+ throw failure;
1634
1921
  });
1635
1922
  assertNoGraphQLErrors(response.errors);
1636
1923
  return (response.data ?? {}) as T;
@@ -1866,17 +2153,15 @@ export async function loadEditorReferenceReviewDetails(lineageId: string) {
1866
2153
  configureAmplifyClient();
1867
2154
  const testMock = getTestEditorNewsroomMock();
1868
2155
  return loadReferenceReviewDetails(lineageId, {
1869
- relations: async (direction) => {
2156
+ relations: async () => {
1870
2157
  if (testMock?.semanticRelations) return testMock.semanticRelations;
1871
2158
  return loadSemanticRelationsByState({
1872
- field: direction === "outgoing" ? "listSemanticRelationsBySubjectState" : "listSemanticRelationsByObjectState",
1873
- keyName: direction === "outgoing" ? "subjectStateKey" : "objectStateKey",
2159
+ field: "listSemanticRelationsBySubjectState",
2160
+ keyName: "subjectStateKey",
1874
2161
  keyValue: `reference#${lineageId}#current`,
1875
- query: direction === "outgoing" ? LIST_SEMANTIC_RELATIONS_BY_SUBJECT_STATE_QUERY : LIST_SEMANTIC_RELATIONS_BY_OBJECT_STATE_QUERY,
2162
+ query: LIST_SEMANTIC_RELATIONS_BY_SUBJECT_STATE_QUERY,
1876
2163
  });
1877
2164
  },
1878
- message: async (id) => testMock?.messages ? testMock.messages.find((message) => message.id === id) ?? null : getMessageRecordById(id),
1879
- payloads: (id) => loadModelPayloadsForOwner("message", id, ["metadata"]),
1880
2165
  });
1881
2166
  }
1882
2167
 
@@ -401,11 +401,29 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
401
401
  --feed-radius-lg: 10px;
402
402
  --feed-radius-md: 8px;
403
403
  --feed-radius-pill: 8px;
404
+ /* Verdict tones for the rating grid. Hue is never the only signal: the cell also carries a CircleCheck or
405
+ CircleX and the word Match or Miss. Named here, in the feed layer, because the component must not hold
406
+ literal palette values and because Tailwind's dark: variant (.dark *) never fires in this app. */
407
+ --feed-verdict-match-tint: #05966933;
408
+ --feed-verdict-match-ink: #064e3b;
409
+ --feed-verdict-match-fill: #059669;
410
+ --feed-verdict-miss-tint: #dc262633;
411
+ --feed-verdict-miss-ink: #7f1d1d;
412
+ --feed-verdict-miss-fill: #dc2626;
413
+ /* The quiet step for an unpredicted cell and a switched-off button. */
414
+ --feed-quiet: 0.6;
404
415
  }
405
416
  [data-kanbus-theme][data-color-mode="dark"] [data-feed-surface], [data-kanbus-theme][data-color-mode="dark"][data-feed-surface] {
406
417
  --feed-card: color-mix(in oklab, var(--kb-theme-paper) 94%, var(--kb-theme-ink));
407
418
  --feed-footer: color-mix(in oklab, var(--kb-theme-paper) 90%, var(--kb-theme-ink));
408
419
  --feed-control: color-mix(in oklab, var(--kb-theme-paper) 80%, var(--kb-theme-ink));
420
+ /* Dark verdicts lift toward the tint rather than down into it, so the ink reads on a dark card. */
421
+ --feed-verdict-match-tint: #34d39933;
422
+ --feed-verdict-match-ink: #d1fae5;
423
+ --feed-verdict-match-fill: #34d399;
424
+ --feed-verdict-miss-tint: #f8717133;
425
+ --feed-verdict-miss-ink: #fee2e2;
426
+ --feed-verdict-miss-fill: #f87171;
409
427
  }
410
428
 
411
429
  /* Borderless detail pane, Add panel and mobile sheet ([data-feed-flat]): no border, rule or box anywhere inside. Regions are told apart
@@ -426,6 +444,12 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
426
444
  }
427
445
  [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-cyclotron-choice] { border-radius: var(--feed-radius-pill) !important; }
428
446
  [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-review-queue-bar] { background: var(--feed-footer) !important; }
447
+ /* The decision control keeps one corner across its segments and its reason field, and its predicted segment
448
+ keeps its tones apart: the flat rules above give every unpressed button the same control fill, which would
449
+ leave the predicted side and an untouched side looking identical. */
450
+ [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-decision-control] :is(button, textarea) { border-radius: var(--feed-radius-md) !important; }
451
+ [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-decision-control] [data-review-choice][data-state="idle"] { background: color-mix(in oklab, var(--feed-control) 45%, transparent) !important; }
452
+
429
453
  /* The rating grid keeps its own rounded rectangles in the borderless panes (not the pill shape the flat rules give other buttons). */
430
454
  [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-rating-grid] :is(button, [data-cyclotron-choice]) { border-radius: var(--feed-radius-md) !important; }
431
455
 
@@ -50,6 +50,8 @@ import { FEED_SURFACE_PROPS, FEED_SURFACE_STYLE, ReferenceFeed } from "./referen
50
50
  import { ReferenceCapturePanel } from "./reference-capture-panel";
51
51
  import { useReferenceCapture, type CorrectCaptureAction } from "./reference-capture-client";
52
52
  import { useRatingStore, type RatingController } from "./use-reference-ratings";
53
+ import { ReferenceConversation } from "./reference-conversation";
54
+ import { useReferenceMessageCounts } from "./use-conversation";
53
55
  import { captureStatusesByLineage } from "../lib/reference-capture";
54
56
  import { applyFeedOrderSnapshot, partitionReferenceFeed, takeFeedOrderSnapshot, type FeedOrderSnapshot, type ReferenceFeedTabKey } from "../lib/reference-feed-ranking";
55
57
  import { referenceDek, referencePreviewsByLineage, type ReferencePreview } from "../lib/reference-preview";
@@ -200,6 +202,7 @@ function ReferenceDetailPanel({
200
202
  decision,
201
203
  demo = false,
202
204
  disabled,
205
+ onMessageCount,
203
206
  onReview,
204
207
  predictionState = "ready",
205
208
  reviewReason,
@@ -223,6 +226,7 @@ function ReferenceDetailPanel({
223
226
  decision?: RelevanceDecisionView;
224
227
  demo?: boolean;
225
228
  disabled?: boolean;
229
+ onMessageCount?: (lineageId: string, count: number) => void;
226
230
  onRelevanceReview?: (curation: RelevanceCuration) => Promise<void> | void;
227
231
  onReview: (action: ReferenceCurationAction, options?: ReferenceReviewOptions) => Promise<void> | void;
228
232
  predictionState?: PredictionState;
@@ -308,6 +312,7 @@ function ReferenceDetailPanel({
308
312
  </details>
309
313
  ) : null}
310
314
  {delegated ? <DelegatedCurationNote delegated={delegated} referenceLineageId={lineageId} /> : countsAsTrainingLabel ? <CountsAsTrainingLabelNote referenceLineageId={lineageId} /> : null}
315
+ <ReferenceConversation disabledReason={disabled ? "Editor access is required to write messages." : null} lineageId={lineageId} onCount={onMessageCount} referenceId={reference.id} title={referenceDisplayTitle(reference)} />
311
316
  </ReviewCard>
312
317
 
313
318
  <Separator />
@@ -445,6 +450,7 @@ export function NewsroomReferencesView({
445
450
  const [view, setView] = useState<ReferenceView>("feed");
446
451
  const [addOpen, setAddOpen] = useState(false);
447
452
  const [feedTab, setFeedTab] = useState<ReferenceFeedTabKey>("feed");
453
+ const { counts: messageCounts, setCount: setMessageCount } = useReferenceMessageCounts(!loading);
448
454
  const [visitRated, setVisitRated] = useState<ReadonlySet<string>>(() => new Set());
449
455
  const [mountedAt] = useState(() => Date.now());
450
456
  const markVisitRated = useCallback((lineageId: string) => {
@@ -774,6 +780,7 @@ export function NewsroomReferencesView({
774
780
  <ReferenceFeed
775
781
  disabledReason={feedDisabledReason}
776
782
  loading={previewSkeleton || (loading && !references.length)}
783
+ messageCounts={messageCounts}
777
784
  now={mountedAt}
778
785
  onOpen={selectReference}
779
786
  onRate={rateFromFeed}
@@ -909,6 +916,7 @@ export function NewsroomReferencesView({
909
916
  decision={decisions?.get(referenceLineageId(selectedReference))}
910
917
  demo={demo}
911
918
  disabled={disabled}
919
+ onMessageCount={setMessageCount}
912
920
  onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
913
921
  onReview={runReview}
914
922
  sectionAgreement={sectionAgreement}
@@ -952,6 +960,7 @@ export function NewsroomReferencesView({
952
960
  demo={demo}
953
961
  disabled={disabled}
954
962
  onClose={closeDetail}
963
+ onMessageCount={setMessageCount}
955
964
  onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
956
965
  onReview={runReview}
957
966
  sectionAgreement={sectionAgreement}