@anthusai/papyrus 5.58.0 → 5.60.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 (53) hide show
  1. package/amplify/data/message-actions.js +72 -0
  2. package/amplify/data/message-actions.ts +74 -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 +8 -24
  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/reference-review-response.ts +19 -0
  48. package/lib/review-drafts.ts +2 -2
  49. package/lib/review-fixture.ts +1 -1
  50. package/lib/review-requests.ts +4 -4
  51. package/lib/semantic-graph.ts +1 -1
  52. package/package.json +5 -1
  53. package/routes.manifest.json +1 -1
@@ -0,0 +1,44 @@
1
+ "use client";
2
+
3
+ import { ArrowLeftIcon } from "lucide-react";
4
+ import type { ReactNode } from "react";
5
+ import { cn } from "../lib/utils";
6
+
7
+ /**
8
+ * The newsroom's list-and-detail layout, one rule for every page that has it (References, Messages). Below `lg` (1024px) the open
9
+ * item's detail replaces the list at the full width with a Back button; from `lg` the detail is a pane beside the list, 24rem wide and
10
+ * 28rem from `xl`. It is chosen by the viewport, never by the selection.
11
+ */
12
+ export const DETAIL_PANE_WIDTH = "lg:w-[min(100%,24rem)] xl:w-[min(100%,28rem)]";
13
+ export const LIST_HIDDEN_WHEN_DETAIL_OPEN = "max-lg:hidden";
14
+
15
+ export function ListDetailLayout({ backLabel, detail, list, onBack, open }: {
16
+ backLabel: string;
17
+ detail: ReactNode;
18
+ list: ReactNode;
19
+ onBack: () => void;
20
+ open: boolean;
21
+ }) {
22
+ return (
23
+ <div className="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row lg:items-start" data-list-detail-layout={open ? "detail" : "list"}>
24
+ <div className={cn("min-w-0 flex-1", open && LIST_HIDDEN_WHEN_DETAIL_OPEN)} data-list-detail-list>{list}</div>
25
+ {open ? (
26
+ <div
27
+ className={cn("flex w-full shrink-0 flex-col gap-3 rounded-[20px] bg-[var(--feed-card)] p-4 lg:sticky lg:top-4 lg:max-h-[calc(100dvh-2rem)] lg:overflow-y-auto", DETAIL_PANE_WIDTH)}
28
+ data-list-detail-pane
29
+ >
30
+ <button
31
+ className="-ml-2 inline-flex min-h-9 items-center gap-1.5 self-start rounded-[var(--feed-radius-md,16px)] px-2 text-sm font-medium text-muted-foreground outline-none transition-colors hover:bg-[var(--feed-control)]/70 hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring lg:hidden"
32
+ data-detail-back
33
+ onClick={onBack}
34
+ type="button"
35
+ >
36
+ <ArrowLeftIcon aria-hidden="true" className="size-4" />
37
+ {backLabel}
38
+ </button>
39
+ {detail}
40
+ </div>
41
+ ) : null}
42
+ </div>
43
+ );
44
+ }
@@ -96,16 +96,6 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
96
96
 
97
97
  return (
98
98
  <section aria-label="Your decision" className="space-y-3 rounded-[20px] bg-[var(--feed-footer)] p-3" data-needs-me-decision {...(locked ? {} : { "data-review-controls": "" })}>
99
- <AutoGrowTextarea
100
- aria-label="Why? (optional)"
101
- className="block min-h-20 w-full rounded-[16px] bg-[var(--feed-control)] p-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
102
- data-needs-me-why
103
- onBlur={() => { if (!disabledReason) ratings.commitNote(item.id); }}
104
- onChange={(event) => ratings.setNote(item.id, event.target.value)}
105
- placeholder={flag ? "Why? Optional. Tell the step what to do differently." : "Why? Optional. Your note teaches the AI."}
106
- value={note}
107
- />
108
- {locked ? <ReasonStatus feedback={rating.feedback} hasReason={Boolean(recorded.why)} unsaved={unsaved} /> : null}
109
99
  {locked ? (
110
100
  <>
111
101
  {item.decisionState === "acknowledged"
@@ -149,6 +139,16 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
149
139
  )}
150
140
  </>
151
141
  )}
142
+ <AutoGrowTextarea
143
+ aria-label="Why? (optional)"
144
+ className="block min-h-20 w-full rounded-[16px] bg-[var(--feed-control)] p-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
145
+ data-needs-me-why
146
+ onBlur={() => { if (!disabledReason) ratings.commitNote(item.id); }}
147
+ onChange={(event) => ratings.setNote(item.id, event.target.value)}
148
+ placeholder={flag ? "Why? Optional. Tell the step what to do differently." : "Why? Optional. Your note teaches the AI."}
149
+ value={note}
150
+ />
151
+ {locked ? <ReasonStatus feedback={rating.feedback} hasReason={Boolean(recorded.why)} unsaved={unsaved} /> : null}
152
152
  </section>
153
153
  );
154
154
  }
@@ -164,7 +164,7 @@ export function DraftBody(props: DetailProps & { payload: Extract<NeedsMeEntry["
164
164
  const { item: draft } = payload;
165
165
  const view = draft.view;
166
166
  if (!view) {
167
- return <p className="inline-flex items-center gap-1.5 text-sm text-muted-foreground" role="status"><Loader2Icon aria-hidden="true" className="size-4 animate-spin" />Loading the draft…</p>;
167
+ return <p className="inline-flex items-center gap-1.5 text-sm text-muted-foreground" role="status"><Loader2Icon aria-hidden="true" className="size-4 animate-spin" />Loading the Proposed Story…</p>;
168
168
  }
169
169
  return (
170
170
  <div className="space-y-4" data-draft-detail={draft.draftId}>
@@ -196,16 +196,16 @@ export function DraftBody(props: DetailProps & { payload: Extract<NeedsMeEntry["
196
196
  </li>
197
197
  );
198
198
  })}
199
- {view.moreSources ? <li className="self-center text-xs text-muted-foreground">and {view.moreSources} more in the full draft</li> : null}
199
+ {view.moreSources ? <li className="self-center text-xs text-muted-foreground">and {view.moreSources} more in the full Proposed Story</li> : null}
200
200
  </ul>
201
201
  </section>
202
202
  ) : null}
203
203
  <p className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-muted-foreground">
204
204
  <span data-draft-caption>{requestCaption(draft.request)}</span>
205
- <Link className="inline-flex items-center gap-1 font-medium text-primary underline-offset-4 hover:underline" data-draft-open href={view.href}>Open full draft<ExternalLinkIcon aria-hidden="true" className="size-3.5 shrink-0" /></Link>
205
+ <Link className="inline-flex items-center gap-1 font-medium text-primary underline-offset-4 hover:underline" data-draft-open href={view.href}>Open full Proposed Story<ExternalLinkIcon aria-hidden="true" className="size-3.5 shrink-0" /></Link>
206
206
  </p>
207
207
  <DefaultOutcome item={item} />
208
- <DecisionPanel {...props} lockedNote={<>To change it, <Link className="text-primary underline underline-offset-4" href={view.href}>open the draft in Proposals</Link>.</>} />
208
+ <DecisionPanel {...props} lockedNote={<>To change it, <Link className="text-primary underline underline-offset-4" href={view.href}>open the Proposed Story in Proposals</Link>.</>} />
209
209
  </div>
210
210
  );
211
211
  }
@@ -23,7 +23,6 @@ import { referenceReasonCode, REFERENCE_REJECTION_REASON_CODES, normalizeReferen
23
23
  import { formatFeedAge } from "../lib/reference-preview";
24
24
  import { buildNeedsMeItemPath, parseNeedsMeItemIdFromNewsroomPathname } from "../lib/newsroom-index-filters";
25
25
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
26
- import type { FeedFreshness } from "../lib/reference-feed-ranking";
27
26
  import type { RecentDecision } from "../lib/notification-feed";
28
27
  import { getKanbusTheme, SITE_BRAND } from "../lib/site-brand";
29
28
  import { NeedsMeDetail, type DecisionExtras } from "./needs-me-detail";
@@ -218,22 +217,14 @@ export function ShapingStrip({ stats }: { stats: ShapingStats }) {
218
217
  );
219
218
  }
220
219
 
221
- function plainDays(days: number): string {
222
- if (days < 1) return `${Math.max(1, Math.round(days * 24))} hours`;
223
- return `${days < 10 ? days.toFixed(1).replace(/\.0$/, "") : Math.round(days)} days`;
224
- }
225
-
226
220
  /** The plain-words recipe, with this site's learned numbers in it. */
227
- export function NeedsMeRecipe({ freshness, threshold }: { freshness: FeedFreshness; threshold: ConfidenceThreshold }) {
221
+ export function NeedsMeRecipe({ threshold }: { threshold: ConfidenceThreshold }) {
228
222
  return (
229
223
  <div className="space-y-3 rounded-[20px] bg-[var(--feed-card)] p-5 text-sm" data-needs-me-recipe>
230
224
  <p className="text-base font-bold">A feed run by algorithms you control</p>
231
225
  <ol className="list-decimal space-y-2.5 pl-5">
232
226
  <li><strong>Hard gates first.</strong> Nothing continues until you decide one.</li>
233
- <li><strong>Then where your answer matters most.</strong> Each item is scored by how unsure the Cyclotron is, times how much it matters, times how fresh it is. A coin toss scores highest; a sure thing scores lowest; no prediction counts as halfway.</li>
234
- <li><strong>Age fades the score.</strong> {freshness.tauDays !== null && freshness.medianAgeDays !== null
235
- ? <>It loses half its pull every {plainDays(freshness.medianAgeDays)}, the typical age of the {freshness.examples} References you accepted.</>
236
- : <>Age does not change the order yet. The pace is learned from the References you accept, and so far there are {freshness.examples} of the 10 needed.</>}</li>
227
+ <li><strong>Then where your answer matters most.</strong> Each item is scored by how unsure the Cyclotron is, times how much it matters. Age does not change it. A coin toss scores highest; a sure thing scores lowest; no prediction counts as halfway.</li>
237
228
  <li><strong>A mix, not a run.</strong> Never more than two of one kind in a row while something else is waiting.</li>
238
229
  <li><strong>Confident items do not ask for you.</strong> {threshold.threshold !== null
239
230
  ? <>Items the Cyclotron is surer about than {Math.round((1 - threshold.threshold) * 100)}% (the median of the {threshold.examples} predictions you confirmed) wait under Decided automatically. You can overrule any of them.</>
@@ -253,7 +244,6 @@ const NO_ATTACHMENTS: ReferenceAttachmentRecord[] = [];
253
244
  export type NeedsMeViewProps = {
254
245
  loadState: NeedsMeLoadState;
255
246
  entries: NeedsMeEntry[];
256
- freshness: FeedFreshness;
257
247
  threshold: ConfidenceThreshold;
258
248
  now: number;
259
249
  shaping: ShapingStats;
@@ -291,7 +281,7 @@ function Note({ children, ...rest }: { children: ReactNode } & Record<string, un
291
281
 
292
282
  /** The CMS console home: one feed that mixes everything that may need a person, with the References feed's cards and detail pane. */
293
283
  export function NeedsMeView({
294
- loadState, entries, freshness, threshold, now, shaping, recent, legacyAuto = [], settled, demo = false, stale = false, error = null, referencesLoading = false, predictionLoading = false,
284
+ loadState, entries, threshold, now, shaping, recent, legacyAuto = [], settled, demo = false, stale = false, error = null, referencesLoading = false, predictionLoading = false,
295
285
  referenceDisabledReason = null, draftsFailed = false, missingDraftCount = 0, initialFilter = "all", referenceAttachments = NO_ATTACHMENTS, captureTeaserLine, onCorrectCapture, onDecide, onEditReason, onRetryDrafts, onOpened, loadEmail, below,
296
286
  }: NeedsMeViewProps) {
297
287
  const themeMode = useResolvedPapyrusTheme();
@@ -312,7 +302,7 @@ export function NeedsMeView({
312
302
 
313
303
  // The order is taken ONCE per visit, when every source has been read, and then left alone: selecting, deciding, a refresh or a cache
314
304
  // revalidation changes what the cards say, never where they are. Items that arrive later wait behind a "N new items: show" pill.
315
- const live = useMemo(() => rankNeedsMe(entries, { now, freshness, threshold }), [entries, now, freshness, threshold]);
305
+ const live = useMemo(() => rankNeedsMe(entries, { threshold }), [entries, threshold]);
316
306
  const snapshot = useRef<NeedsMeSnapshot | null>(null);
317
307
  if (!snapshot.current && settled && entries.length > 0) snapshot.current = takeNeedsMeSnapshot(live);
318
308
  const placed = snapshot.current ? applyNeedsMeSnapshot(snapshot.current, live) : { feed: live.feed, auto: live.auto, newCount: 0 };
@@ -459,7 +449,7 @@ export function NeedsMeView({
459
449
  <InfoIcon aria-hidden="true" className="size-4" />How this is ordered
460
450
  </button>
461
451
  </div>
462
- {recipeOpen ? <NeedsMeRecipe freshness={freshness} threshold={threshold} /> : null}
452
+ {recipeOpen ? <NeedsMeRecipe threshold={threshold} /> : null}
463
453
  <p className="max-w-3xl text-sm text-muted-foreground" data-notification-ignore>
464
454
  Decide what you like and leave the rest. Each item says what happens if you do nothing, and only a hard gate waits for you.
465
455
  </p>
@@ -486,17 +476,17 @@ export function NeedsMeView({
486
476
  <div className="flex flex-col items-center gap-2 rounded-[20px] bg-[var(--feed-card)] px-6 py-12 text-center" data-notification-caught-up>
487
477
  <CircleCheckBigIcon aria-hidden="true" className="size-10 text-primary" />
488
478
  <h2 className="text-2xl font-bold tracking-tight">All caught up</h2>
489
- <p className="max-w-md text-sm text-muted-foreground">Nothing needs you right now. New drafts, references, emails and reviews will appear here as they arrive.</p>
479
+ <p className="max-w-md text-sm text-muted-foreground">Nothing needs you right now. New Proposed Stories, references, emails and reviews will appear here as they arrive.</p>
490
480
  </div>
491
481
  ) : null}
492
482
 
493
483
  {draftsFailed ? (
494
484
  <Note role="alert">
495
- <p>The drafts could not be loaded right now.</p>
485
+ <p>The Proposed Stories could not be loaded right now.</p>
496
486
  <button className={cn(PILL, "mt-3 bg-[var(--feed-control)] text-foreground")} onClick={onRetryDrafts} type="button">Try again</button>
497
487
  </Note>
498
488
  ) : null}
499
- {missingDraftCount ? <p className="text-sm text-muted-foreground" data-needs-me-missing-drafts>{missingDraftCount} of these drafts {missingDraftCount === 1 ? "is" : "are"} not in Proposals on this site, so they cannot be opened here.</p> : null}
489
+ {missingDraftCount ? <p className="text-sm text-muted-foreground" data-needs-me-missing-drafts>{missingDraftCount} of these Proposed Stories {missingDraftCount === 1 ? "is" : "are"} not in Proposals on this site, so they cannot be opened here.</p> : null}
500
490
 
501
491
  {referencesLoading && !placed.feed.length ? (
502
492
  <NotificationSkeleton count={3} bare />
@@ -52,6 +52,8 @@ import {
52
52
  GENERAL_CONVERSATION_KIND,
53
53
  REFERENCE_CONVERSATION_KIND,
54
54
  conversationAnchorKey,
55
+ conversationPreviewLine,
56
+ threadMetadataWithLastAuthor,
55
57
  conversationMessageId,
56
58
  conversationThreadId,
57
59
  conversationThreadKind,
@@ -59,12 +61,15 @@ import {
59
61
  findOrCreateThread,
60
62
  type ConversationAnchor,
61
63
  type ConversationMessage,
64
+ type ConversationViewer,
62
65
  } from "../lib/conversation";
66
+ import { postMessageArguments, postMessageMutation } from "../lib/post-message";
63
67
  import { legacyStageKeysToLoad, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
64
68
  import { getContentItemStageDefinitions } from "../lib/site-brand";
65
69
  import { DOCTRINE_DEFINITIONS, getCategoryDoctrineDefinitions, type DoctrineCategory } from "../lib/doctrine";
66
70
  import { configureAmplifyClient } from "./amplify-client-provider";
67
71
  import { isUnauthenticatedError, loadReaderSessionSnapshot, type ReaderAuthSnapshot } from "./reader-auth-state";
72
+ import { resolveReaderSettings } from "./reader-settings";
68
73
 
69
74
  const USER_POOL_AUTH_MODE = "userPool";
70
75
  const USER_POOL_LIST_LIMIT = 500;
@@ -78,7 +83,7 @@ const REFERENCE_STATUS_KEYS = ["pending", "accepted", "rejected", "archived"] as
78
83
  const NEWSROOM_MESSAGE_FEED_QUERY = `
79
84
  query ListMessagesByNewsroomFeedAndCreatedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelMessageFilterInput) {
80
85
  listMessagesByNewsroomFeedAndCreatedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
81
- items { id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey }
86
+ items { id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel editedAt deletedAt deletedBySub threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey }
82
87
  nextToken
83
88
  }
84
89
  }
@@ -137,7 +142,7 @@ const CREATE_SEMANTIC_RELATION_MUTATION = `
137
142
  const LIST_MESSAGES_BY_THREAD_AND_SEQUENCE_QUERY = `
138
143
  query ListMessagesByThreadAndSequence($threadId: ID!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
139
144
  listMessagesByThreadAndSequence(threadId: $threadId, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
140
- items { id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey }
145
+ items { id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel editedAt deletedAt deletedBySub threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey }
141
146
  nextToken
142
147
  }
143
148
  }
@@ -146,7 +151,7 @@ const LIST_MESSAGES_BY_THREAD_AND_SEQUENCE_QUERY = `
146
151
  const LIST_MESSAGES_BY_KIND_AND_CREATED_AT_QUERY = `
147
152
  query ListMessagesByKindAndCreatedAt($messageKind: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
148
153
  listMessagesByKindAndCreatedAt(messageKind: $messageKind, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
149
- items { id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey }
154
+ items { id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel editedAt deletedAt deletedBySub threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey }
150
155
  nextToken
151
156
  }
152
157
  }
@@ -155,7 +160,7 @@ const LIST_MESSAGES_BY_KIND_AND_CREATED_AT_QUERY = `
155
160
  const GET_MESSAGE_QUERY = `
156
161
  query GetMessage($id: ID!) {
157
162
  getMessage(id: $id) {
158
- id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey
163
+ id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel editedAt deletedAt deletedBySub threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey
159
164
  }
160
165
  }
161
166
  `;
@@ -220,40 +225,7 @@ const UPDATE_MESSAGE_THREAD_MUTATION = `
220
225
  }
221
226
  `;
222
227
 
223
- const CREATE_MESSAGE_MUTATION = `
224
- mutation CreateMessage($input: CreateMessageInput!) {
225
- createMessage(input: $input) {
226
- id
227
- messageKind
228
- messageDomain
229
- status
230
- summary
231
- source
232
- importRunId
233
- authorSub
234
- authorUserProfileId
235
- authorLabel
236
- threadId
237
- parentMessageId
238
- sequenceNumber
239
- role
240
- messageType
241
- content
242
- semanticLayer
243
- searchVisibility
244
- responseTarget
245
- responseStatus
246
- responseOwner
247
- responseStartedAt
248
- responseCompletedAt
249
- responseError
250
- metadata
251
- createdAt
252
- updatedAt
253
- newsroomFeedKey
254
- }
255
- }
256
- `;
228
+ const POST_MESSAGE_SELECTION = "id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel editedAt deletedAt deletedBySub threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey";
257
229
 
258
230
  const UPDATE_MESSAGE_MUTATION = `
259
231
  mutation UpdateMessage($input: UpdateMessageInput!) {
@@ -268,6 +240,9 @@ const UPDATE_MESSAGE_MUTATION = `
268
240
  authorSub
269
241
  authorUserProfileId
270
242
  authorLabel
243
+ editedAt
244
+ deletedAt
245
+ deletedBySub
271
246
  threadId
272
247
  parentMessageId
273
248
  sequenceNumber
@@ -302,7 +277,7 @@ const NEWSROOM_ASSIGNMENT_FEED_QUERY = `
302
277
  const NEWSROOM_REFERENCE_FEED_QUERY = `
303
278
  query ListReferencesByNewsroomFeedAndCreatedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
304
279
  listReferencesByNewsroomFeedAndCreatedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
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 }
280
+ 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 discoveredByMonitorId updatedAt }
306
281
  nextToken
307
282
  }
308
283
  }
@@ -311,7 +286,7 @@ const NEWSROOM_REFERENCE_FEED_QUERY = `
311
286
  const NEWSROOM_REFERENCE_IMPORTED_FEED_QUERY = `
312
287
  query ListReferencesByNewsroomFeedAndImportedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
313
288
  listReferencesByNewsroomFeedAndImportedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
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 }
289
+ 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 discoveredByMonitorId updatedAt }
315
290
  nextToken
316
291
  }
317
292
  }
@@ -320,7 +295,7 @@ const NEWSROOM_REFERENCE_IMPORTED_FEED_QUERY = `
320
295
  const LIST_REFERENCES_BY_CURATION_STATUS_AND_UPDATED_AT_QUERY = `
321
296
  query ListReferencesByCurationStatusAndUpdatedAt($curationStatus: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
322
297
  listReferencesByCurationStatusAndUpdatedAt(curationStatus: $curationStatus, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
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 }
298
+ 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 discoveredByMonitorId updatedAt }
324
299
  nextToken
325
300
  }
326
301
  }
@@ -329,7 +304,7 @@ const LIST_REFERENCES_BY_CURATION_STATUS_AND_UPDATED_AT_QUERY = `
329
304
  const LIST_REFERENCES_BY_REVIEWED_FEED_AND_UPDATED_AT_QUERY = `
330
305
  query ListReferencesByReviewedFeedAndUpdatedAt($reviewedFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
331
306
  listReferencesByReviewedFeedAndUpdatedAt(reviewedFeedKey: $reviewedFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
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 }
307
+ 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 discoveredByMonitorId updatedAt }
333
308
  nextToken
334
309
  }
335
310
  }
@@ -338,7 +313,7 @@ const LIST_REFERENCES_BY_REVIEWED_FEED_AND_UPDATED_AT_QUERY = `
338
313
  const LIST_REFERENCES_FALLBACK_QUERY = `
339
314
  query ListReferencesFallback($limit: Int, $nextToken: String) {
340
315
  listReferences(limit: $limit, nextToken: $nextToken) {
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 }
316
+ 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 discoveredByMonitorId updatedAt }
342
317
  nextToken
343
318
  }
344
319
  }
@@ -471,7 +446,7 @@ const MODEL_ATTACHMENTS_BY_OWNER_QUERY = `
471
446
 
472
447
  /** Matches Reference model fields in amplify/data/resource.ts (no metadata on Reference). */
473
448
  const REFERENCE_RECORD_GRAPHQL_FIELDS =
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";
449
+ "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 discoveredByMonitorId updatedAt";
475
450
 
476
451
  const LIST_REFERENCES_BY_LINEAGE_AND_VERSION_QUERY = `
477
452
  query ListReferencesByLineageAndVersion($lineageId: ID!, $limit: Int, $nextToken: String) {
@@ -1423,7 +1398,7 @@ export async function createSectionForumThreadRecord(input: {
1423
1398
  * Appends one Message to a thread: the next sequence number, the Message, then the thread's count and last Message. The Message id
1424
1399
  * derives from the sequence number, so a create that loses a race for the number collides and retries with the next one.
1425
1400
  */
1426
- async function appendMessageToThread(thread: MessageThreadRecord, build: (sequenceNumber: number, now: string) => MessageRecord): Promise<{ thread: MessageThreadRecord; message: MessageRecord }> {
1401
+ async function appendMessageToThread(thread: MessageThreadRecord, build: (sequenceNumber: number, now: string) => MessageRecord, threadFields: { summary?: string; metadata?: string } = {}): Promise<{ thread: MessageThreadRecord; message: MessageRecord }> {
1427
1402
  const threadId = thread.id;
1428
1403
  let now = new Date().toISOString();
1429
1404
  let built: MessageRecord | null = null;
@@ -1434,10 +1409,12 @@ async function appendMessageToThread(thread: MessageThreadRecord, build: (sequen
1434
1409
  // `createdAt` is part of the composite sort key of messagesByResponseTargetStatusCreatedAt, so AppSync refuses a Message that
1435
1410
  // sets it without `responseStatus`. A person's message waits on no responder: COMPLETED, as the Python message contract writes.
1436
1411
  built = { responseStatus: "COMPLETED", ...build(sequenceNumber, now) };
1437
- return runGraphql<{ createMessage?: MessageRecord | null }>(CREATE_MESSAGE_MUTATION, { input: built });
1412
+ // The backend stamps the author from the signed-in identity (postMessage); nothing here names one.
1413
+ const args = postMessageArguments(built as unknown as Record<string, unknown>);
1414
+ return runGraphql<{ postMessage?: MessageRecord | null }>(postMessageMutation(args, POST_MESSAGE_SELECTION), args);
1438
1415
  },
1439
1416
  });
1440
- const createdMessage = normalizeMessageRecord(created.createMessage ?? built!);
1417
+ const createdMessage = normalizeMessageRecord(created.postMessage ?? built!);
1441
1418
  const updated = await runGraphql<{ updateMessageThread?: MessageThreadRecord | null }>(
1442
1419
  UPDATE_MESSAGE_THREAD_MUTATION,
1443
1420
  {
@@ -1447,11 +1424,12 @@ async function appendMessageToThread(thread: MessageThreadRecord, build: (sequen
1447
1424
  lastMessageId: createdMessage.id,
1448
1425
  lastMessageAt: createdMessage.createdAt,
1449
1426
  updatedAt: now,
1427
+ ...threadFields,
1450
1428
  },
1451
1429
  },
1452
1430
  );
1453
1431
  return {
1454
- thread: normalizeThreadRecord(updated.updateMessageThread ?? { ...thread, updatedAt: now }),
1432
+ thread: normalizeThreadRecord(updated.updateMessageThread ?? { ...thread, ...threadFields, updatedAt: now }),
1455
1433
  message: createdMessage,
1456
1434
  };
1457
1435
  }
@@ -1493,7 +1471,7 @@ export async function appendForumThreadMessageRecord(input: {
1493
1471
  }));
1494
1472
  }
1495
1473
 
1496
- type ConversationAuthor = { sub: string | null; label: string };
1474
+ type ConversationAuthor = { sub: string | null; label: string; userProfileId: string | null; isAdmin: boolean };
1497
1475
 
1498
1476
  async function currentConversationAuthor(): Promise<ConversationAuthor> {
1499
1477
  const snapshot = await loadReaderSessionSnapshot();
@@ -1505,7 +1483,50 @@ async function currentConversationAuthor(): Promise<ConversationAuthor> {
1505
1483
  } catch {
1506
1484
  sub = null;
1507
1485
  }
1508
- return { sub, label: snapshot.auth.status === "signedIn" ? snapshot.auth.label : "editor" };
1486
+ const profile = await currentUserProfile();
1487
+ // The author's name travels on the message (other editors cannot read profiles): their display name, else the session's label.
1488
+ const sessionLabel = snapshot.auth.status === "signedIn" ? snapshot.auth.label : "editor";
1489
+ return { sub, label: profile.displayName || sessionLabel, userProfileId: profile.id, isAdmin: snapshot.groups.some((group) => group.trim().toLowerCase() === "admin") };
1490
+ }
1491
+
1492
+ let userProfileLookup: Promise<{ id: string | null; displayName: string | null }> | null = null;
1493
+ /** The signed-in person's profile id and display name (their own profile is readable by them), read once per page load. A failed read is not kept, and does not stop a post. */
1494
+ function currentUserProfile(): Promise<{ id: string | null; displayName: string | null }> {
1495
+ userProfileLookup ??= (async () => {
1496
+ const resolution = await resolveReaderSettings();
1497
+ const id = resolution.userProfileId ?? null;
1498
+ if (!id) return { id: null, displayName: null };
1499
+ const names = await loadUserProfileNames([id]);
1500
+ return { id, displayName: names.get(id) ?? null };
1501
+ })().catch(() => { userProfileLookup = null; return { id: null, displayName: null }; });
1502
+ return userProfileLookup;
1503
+ }
1504
+
1505
+ /** Who is looking at a conversation: their sub, and whether they are an admin. Decided from the session, never from a Message. */
1506
+ export async function loadConversationViewer(): Promise<ConversationViewer> {
1507
+ const author = await currentConversationAuthor();
1508
+ return { sub: author.sub, isAdmin: author.isAdmin };
1509
+ }
1510
+
1511
+ /** Display names by profile id, in one request. A profile this person may not read is left out and its Messages keep the saved label. */
1512
+ export async function loadUserProfileNames(profileIds: string[]): Promise<Map<string, string>> {
1513
+ const ids = [...new Set(profileIds.filter(Boolean))];
1514
+ const names = new Map<string, string>();
1515
+ if (!ids.length) return names;
1516
+ const query = `query UserProfileNames { ${ids.map((id, index) => `p${index}: getUserProfile(id: ${JSON.stringify(id)}) { id displayName }`).join(" ")} }`;
1517
+ configureAmplifyClient();
1518
+ const client = generateClient<Schema>() as unknown as { graphql: (options: Record<string, unknown>) => Promise<{ data?: Record<string, { id?: string; displayName?: string | null } | null> | null }> };
1519
+ let data: Record<string, { id?: string; displayName?: string | null } | null> | null | undefined;
1520
+ try {
1521
+ data = (await client.graphql({ query, authMode: USER_POOL_AUTH_MODE })).data;
1522
+ } catch (failure) {
1523
+ // Some of the profiles are refused: the rest of the answer is still good.
1524
+ data = (failure as { data?: typeof data } | null)?.data;
1525
+ }
1526
+ for (const profile of Object.values(data ?? {})) {
1527
+ if (profile?.id && profile.displayName?.trim()) names.set(profile.id, profile.displayName.trim());
1528
+ }
1529
+ return names;
1509
1530
  }
1510
1531
 
1511
1532
  async function findConversationThread(anchorKey: string): Promise<MessageThreadRecord | null> {
@@ -1539,6 +1560,7 @@ export async function ensureConversationThread(anchor: ConversationAnchor, autho
1539
1560
  primaryAnchorLineageId: lineageId,
1540
1561
  primaryAnchorKey: anchorKey,
1541
1562
  createdBySub: author?.sub ?? null,
1563
+ createdByUserProfileId: author?.userProfileId ?? null,
1542
1564
  createdByLabel: author?.label ?? "editor",
1543
1565
  messageCount: 0,
1544
1566
  lastMessageId: null,
@@ -1559,9 +1581,14 @@ function toConversationMessage(record: MessageRecord): ConversationMessage {
1559
1581
  id: record.id,
1560
1582
  parentMessageId: record.parentMessageId ?? null,
1561
1583
  authorLabel: record.authorLabel ?? "",
1562
- content: record.content ?? record.body ?? "",
1584
+ authorSub: record.authorSub ?? null,
1585
+ authorUserProfileId: record.authorUserProfileId ?? null,
1586
+ content: record.status === "deleted" ? "" : record.content ?? record.body ?? "",
1563
1587
  createdAt: record.createdAt,
1564
1588
  sequenceNumber: Number(record.sequenceNumber ?? 0),
1589
+ editedAt: record.editedAt ?? null,
1590
+ deleted: record.status === "deleted",
1591
+ deletedAt: record.deletedAt ?? null,
1565
1592
  };
1566
1593
  }
1567
1594
 
@@ -1572,7 +1599,7 @@ export async function loadConversationMessages(anchor: ConversationAnchor): Prom
1572
1599
  const thread = await findConversationThread(anchorKey);
1573
1600
  if (!thread) return [];
1574
1601
  const records = await listMessagesByThreadId(thread.id, 500);
1575
- return records.filter((record) => record.status !== "deleted").map(toConversationMessage);
1602
+ return records.map(toConversationMessage);
1576
1603
  }
1577
1604
 
1578
1605
  /** How many Messages each Reference has, from the Reference conversation threads alone (no query per card). Keyed by lineage id. */
@@ -1608,7 +1635,6 @@ export async function appendConversationMessage(input: {
1608
1635
  status: "active",
1609
1636
  summary: content.slice(0, 120),
1610
1637
  source: "newsroom.conversation",
1611
- authorSub: author.sub,
1612
1638
  authorLabel: author.label,
1613
1639
  threadId: thread.id,
1614
1640
  parentMessageId: input.parentMessageId?.trim() || null,
@@ -1620,7 +1646,7 @@ export async function appendConversationMessage(input: {
1620
1646
  createdAt: now,
1621
1647
  updatedAt: now,
1622
1648
  newsroomFeedKey: CONVERSATION_FEED_KEY,
1623
- }));
1649
+ }), { summary: conversationPreviewLine(content), metadata: threadMetadataWithLastAuthor(thread.metadata, author.label) });
1624
1650
  let relationError: string | null = null;
1625
1651
  if (input.anchor.kind !== "none") {
1626
1652
  try {
@@ -1644,12 +1670,14 @@ export async function loadConversationThreadPage(threadKind: string, nextToken:
1644
1670
  /** Every Message in a conversation thread, oldest first. */
1645
1671
  export async function loadConversationThreadMessages(threadId: string): Promise<ConversationMessage[]> {
1646
1672
  const records = await listMessagesByThreadId(threadId, 500);
1647
- return records.filter((record) => record.status !== "deleted").map(toConversationMessage);
1673
+ return records.map(toConversationMessage);
1648
1674
  }
1649
1675
 
1650
1676
  /** 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 }> {
1677
+ export async function startUnattachedConversation(title: string, body: string): Promise<{ message: ConversationMessage; thread: MessageThreadRecord }> {
1652
1678
  const content = String(body || "").trim();
1679
+ const heading = String(title || "").trim();
1680
+ if (!heading) throw new Error("Give the conversation a title.");
1653
1681
  if (!content) throw new Error("Write something to post.");
1654
1682
  const author = await currentConversationAuthor();
1655
1683
  const now = new Date().toISOString();
@@ -1658,8 +1686,9 @@ export async function startUnattachedConversation(body: string): Promise<{ messa
1658
1686
  id,
1659
1687
  threadKind: GENERAL_CONVERSATION_KIND,
1660
1688
  status: "active",
1661
- title: content.split("\n")[0].slice(0, 80),
1689
+ title: heading.slice(0, 120),
1662
1690
  createdBySub: author.sub,
1691
+ createdByUserProfileId: author.userProfileId,
1663
1692
  createdByLabel: author.label,
1664
1693
  messageCount: 0,
1665
1694
  lastMessageId: null,
@@ -1675,6 +1704,62 @@ export async function startUnattachedConversation(body: string): Promise<{ messa
1675
1704
  return { message: result.message, thread: result.thread };
1676
1705
  }
1677
1706
 
1707
+ const GET_MESSAGE_THREAD_QUERY = `
1708
+ query GetMessageThread($id: ID!) {
1709
+ getMessageThread(id: $id) {
1710
+ id threadKind status title summary primaryAnchorKind primaryAnchorId primaryAnchorLineageId primaryAnchorKey createdBySub createdByUserProfileId createdByLabel messageCount lastMessageId lastMessageAt contextDigest activeResponseMessageId responseLockOwner responseLockExpiresAt metadata createdAt updatedAt newsroomFeedKey
1711
+ }
1712
+ }
1713
+ `;
1714
+
1715
+ const MESSAGE_ACTION_SELECTION = "id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel editedAt deletedAt deletedBySub threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey";
1716
+
1717
+ /** The author changes the text of their own Message. The backend (editMessage) checks the stored author against the signed-in user and records when. */
1718
+ export async function editConversationMessage(messageId: string, body: string): Promise<ConversationMessage> {
1719
+ const content = String(body || "").trim();
1720
+ if (!content) throw new Error("A message cannot be empty.");
1721
+ const updated = await runGraphql<{ editMessage?: MessageRecord | null }>(
1722
+ `mutation EditMessage($id: ID!, $content: String!, $summary: String) { editMessage(id: $id, content: $content, summary: $summary) { ${MESSAGE_ACTION_SELECTION} } }`,
1723
+ { id: messageId, content, summary: content.slice(0, 120) },
1724
+ );
1725
+ if (!updated.editMessage) throw new Error("The message was not saved.");
1726
+ return toConversationMessage(normalizeMessageRecord(updated.editMessage));
1727
+ }
1728
+
1729
+ /**
1730
+ * Deletes a Message softly: it stays (so its replies keep their place) marked deleted, with when and by whom. Nothing is removed.
1731
+ * An author deletes their own (deleteOwnMessage); an admin deleting someone else's moderates it (moderateMessage). The backend decides.
1732
+ */
1733
+ export async function deleteConversationMessage(messageId: string, moderate = false): Promise<ConversationMessage> {
1734
+ const field = moderate ? "moderateMessage" : "deleteOwnMessage";
1735
+ const updated = await runGraphql<Record<string, MessageRecord | null | undefined>>(
1736
+ `mutation DeleteMessage($id: ID!) { ${field}(id: $id) { ${MESSAGE_ACTION_SELECTION} } }`,
1737
+ { id: messageId },
1738
+ );
1739
+ const record = updated[field];
1740
+ if (!record) throw new Error("The message was not deleted.");
1741
+ return toConversationMessage(normalizeMessageRecord(record));
1742
+ }
1743
+
1744
+ /** One conversation thread by id (a linked address), or null when there is none or it was removed. */
1745
+ export async function loadConversationThread(threadId: string): Promise<MessageThreadRecord | null> {
1746
+ const response = await runGraphql<{ getMessageThread?: MessageThreadRecord | null }>(GET_MESSAGE_THREAD_QUERY, { id: threadId });
1747
+ const thread = response.getMessageThread ? normalizeThreadRecord(response.getMessageThread) : null;
1748
+ return thread && thread.status !== "deleted" ? thread : null;
1749
+ }
1750
+
1751
+ /** Gives a conversation a new title. Nothing else about the thread changes. */
1752
+ export async function renameConversationThread(thread: MessageThreadRecord, title: string): Promise<MessageThreadRecord> {
1753
+ const next = String(title || "").trim();
1754
+ if (!next) throw new Error("A title cannot be empty.");
1755
+ // renameThread (the backend): only the person who started the conversation, or an admin.
1756
+ const updated = await runGraphql<{ renameThread?: MessageThreadRecord | null }>(
1757
+ `mutation RenameThread($id: ID!, $title: String!) { renameThread(id: $id, title: $title) { id threadKind status title summary primaryAnchorKind primaryAnchorId primaryAnchorLineageId primaryAnchorKey createdBySub createdByUserProfileId createdByLabel messageCount lastMessageId lastMessageAt contextDigest activeResponseMessageId responseLockOwner responseLockExpiresAt metadata createdAt updatedAt newsroomFeedKey } }`,
1758
+ { id: thread.id, title: next.slice(0, 120) },
1759
+ );
1760
+ return normalizeThreadRecord(updated.renameThread ?? { ...thread, title: next.slice(0, 120) });
1761
+ }
1762
+
1678
1763
  /** The title and editorial stage of each Story, by lineage id. A Story that cannot be read has no entry. */
1679
1764
  export async function loadStoryHeaders(lineageIds: string[]): Promise<Map<string, { title: string; stage: string | null }>> {
1680
1765
  const headers = new Map<string, { title: string; stage: string | null }>();
@@ -513,10 +513,15 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
513
513
  }
514
514
  if (!editor) {
515
515
  return (
516
- <div aria-label="Loading article" className="space-y-5" role="status">
517
- <div className="h-8 w-2/3 rounded bg-muted" />
518
- <div className="grid gap-4 sm:grid-cols-2"><div className="h-12 rounded bg-muted" /><div className="h-12 rounded bg-muted" /></div>
519
- <div className="h-64 rounded-xl border bg-card p-4"><div className="h-4 w-5/6 rounded bg-muted" /><div className="mt-3 h-4 w-3/4 rounded bg-muted" /></div>
516
+ // The same blocks, in the same order and heights, as the loaded editor (toolbar, then fields, tabs and the preview card),
517
+ // so nothing moves when the article arrives. The heights live in newsroom-organizer.css (data-skeleton-*).
518
+ <div aria-label="Loading article" aria-busy="true" className="space-y-4" data-newsroom-article-skeleton role="status">
519
+ <div className="rounded-md bg-muted" data-skeleton-toolbar />
520
+ <div className="grid gap-6">
521
+ <div className="rounded-md bg-muted" data-skeleton-fields />
522
+ <div className="h-10 w-72 max-w-full rounded-md bg-muted" data-skeleton-tabs />
523
+ <div className="rounded-xl bg-muted" data-skeleton-preview />
524
+ </div>
520
525
  </div>
521
526
  );
522
527
  }
@@ -568,6 +573,7 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
568
573
  <Badge data-newsroom-article-version variant="outline">{`Version ${editor.versionNumber}`}</Badge>
569
574
  ) : null}
570
575
  <Badge
576
+ className="min-w-[8.5rem] justify-center"
571
577
  data-newsroom-article-validity={validation.status}
572
578
  variant={validation.status === "valid" ? "secondary" : invalid ? "outline" : "muted"}
573
579
  >