@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
@@ -3,6 +3,7 @@
3
3
  import { generateClient } from "aws-amplify/data";
4
4
  import type { Schema } from "../amplify/data/resource";
5
5
  import { configureAmplifyClient } from "../components/amplify-client-provider";
6
+ import { postMessageArguments, postMessageMutation } from "./post-message";
6
7
  import { loadModelPayloadsForOwner, uploadModelPayloadForOwner } from "../components/news-desk-taxonomy-client";
7
8
 
8
9
  const USER_POOL_AUTH_MODE = "userPool";
@@ -190,14 +191,6 @@ const UPDATE_CONSOLE_THREAD = `
190
191
  }
191
192
  `;
192
193
 
193
- const CREATE_CONSOLE_MESSAGE_LEGACY = `
194
- mutation CreateMessage($input: CreateMessageInput!) {
195
- createMessage(input: $input) {
196
- ${CONSOLE_MESSAGE_FIELDS_LEGACY}
197
- }
198
- }
199
- `;
200
-
201
194
  export type ConsoleChatThread = {
202
195
  id: string;
203
196
  threadKind: string;
@@ -383,79 +376,6 @@ function chooseMessageSelectionSet(schemaFields: Set<string> | null): string {
383
376
  return selected.join("\n ");
384
377
  }
385
378
 
386
- function fieldValuesForCreateMessageInput(input: CreateConsoleMessageInput): Record<string, unknown> {
387
- return {
388
- id: input.id,
389
- messageKind: input.messageKind,
390
- messageDomain: input.messageDomain,
391
- status: input.status,
392
- summary: input.summary ?? truncateSummary(input.content ?? null),
393
- source: input.source,
394
- importRunId: null,
395
- authorSub: null,
396
- authorUserProfileId: input.authorUserProfileId ?? null,
397
- authorLabel: input.authorLabel,
398
- threadId: input.threadId ?? null,
399
- parentMessageId: input.parentMessageId ?? null,
400
- sequenceNumber: input.sequenceNumber ?? null,
401
- role: input.role ?? null,
402
- messageType: input.messageType ?? null,
403
- semanticLayer: input.semanticLayer,
404
- searchVisibility: input.searchVisibility,
405
- responseTarget: input.responseTarget,
406
- responseStatus: input.responseStatus ?? null,
407
- responseOwner: input.responseOwner ?? null,
408
- responseStartedAt: input.responseStartedAt ?? null,
409
- responseCompletedAt: input.responseCompletedAt ?? null,
410
- responseError: input.responseError ?? null,
411
- metadata: input.metadata,
412
- createdAt: input.createdAt,
413
- updatedAt: input.updatedAt,
414
- newsroomFeedKey: input.newsroomFeedKey,
415
- };
416
- }
417
-
418
- function buildCreateMessageInputCandidates(
419
- input: CreateConsoleMessageInput,
420
- availableFields: Set<string>,
421
- ): Record<string, unknown>[] {
422
- const values = fieldValuesForCreateMessageInput(input);
423
- const candidates: Record<string, unknown>[] = [];
424
- const seen = new Set<string>();
425
-
426
- const pushCandidate = (candidate: Record<string, unknown>) => {
427
- const cleaned: Record<string, unknown> = {};
428
- for (const [key, value] of Object.entries(candidate)) {
429
- if (value !== undefined) cleaned[key] = value;
430
- }
431
- const signature = JSON.stringify(Object.keys(cleaned).sort().map((key) => [key, cleaned[key]]));
432
- if (!seen.has(signature)) {
433
- seen.add(signature);
434
- candidates.push(cleaned);
435
- }
436
- };
437
-
438
- if (availableFields.size) {
439
- pushCandidate(sanitizeCreateMessageInput(input, availableFields));
440
- } else {
441
- pushCandidate(values);
442
- pushCandidate({
443
- id: values.id,
444
- messageKind: values.messageKind,
445
- messageDomain: values.messageDomain,
446
- status: values.status,
447
- summary: values.summary,
448
- source: values.source,
449
- authorLabel: values.authorLabel,
450
- createdAt: values.createdAt,
451
- updatedAt: values.updatedAt,
452
- newsroomFeedKey: values.newsroomFeedKey,
453
- });
454
- }
455
-
456
- return candidates;
457
- }
458
-
459
379
  async function loadConsoleSchemaSnapshot(): Promise<ConsoleSchemaSnapshot> {
460
380
  if (consoleSchemaSnapshot) return consoleSchemaSnapshot;
461
381
  if (consoleSchemaSnapshotPromise) return consoleSchemaSnapshotPromise;
@@ -540,70 +460,6 @@ function buildListMessagesQuery(
540
460
  `;
541
461
  }
542
462
 
543
- function sanitizeCreateMessageInput(
544
- input: CreateConsoleMessageInput,
545
- availableFields: Set<string>,
546
- ): Record<string, unknown> {
547
- const fieldValues: Record<string, unknown> = {
548
- id: input.id,
549
- messageKind: input.messageKind,
550
- messageDomain: input.messageDomain,
551
- status: input.status,
552
- summary: input.summary ?? truncateSummary(input.content ?? null),
553
- source: input.source,
554
- importRunId: null,
555
- authorSub: null,
556
- authorUserProfileId: input.authorUserProfileId ?? null,
557
- authorLabel: input.authorLabel,
558
- threadId: input.threadId ?? null,
559
- parentMessageId: input.parentMessageId ?? null,
560
- sequenceNumber: input.sequenceNumber ?? null,
561
- role: input.role ?? null,
562
- messageType: input.messageType ?? null,
563
- semanticLayer: input.semanticLayer,
564
- searchVisibility: input.searchVisibility,
565
- responseTarget: input.responseTarget,
566
- responseStatus: input.responseStatus ?? null,
567
- responseOwner: input.responseOwner ?? null,
568
- responseStartedAt: input.responseStartedAt ?? null,
569
- responseCompletedAt: input.responseCompletedAt ?? null,
570
- responseError: input.responseError ?? null,
571
- metadata: input.metadata,
572
- createdAt: input.createdAt,
573
- updatedAt: input.updatedAt,
574
- newsroomFeedKey: input.newsroomFeedKey,
575
- };
576
-
577
- if (!availableFields.size) {
578
- const conservativeFields = [
579
- "id",
580
- "messageKind",
581
- "messageDomain",
582
- "status",
583
- "summary",
584
- "source",
585
- "authorLabel",
586
- "createdAt",
587
- "updatedAt",
588
- "newsroomFeedKey",
589
- ] as const;
590
- const conservative: Record<string, unknown> = {};
591
- for (const key of conservativeFields) {
592
- const value = fieldValues[key];
593
- if (value !== undefined) conservative[key] = value;
594
- }
595
- return conservative;
596
- }
597
-
598
- const sanitized: Record<string, unknown> = {};
599
- for (const [key, value] of Object.entries(fieldValues)) {
600
- if (!availableFields.has(key)) continue;
601
- if (value === undefined) continue;
602
- sanitized[key] = value;
603
- }
604
- return sanitized;
605
- }
606
-
607
463
  function truncateSummary(value: string | null): string | null {
608
464
  if (!value) return null;
609
465
  const normalized = value.replace(/\s+/g, " ").trim();
@@ -733,53 +589,27 @@ export async function createConsoleMessage(input: CreateConsoleMessageInput): Pr
733
589
  const selectionSets = snapshot.messageFields.size
734
590
  ? [chooseMessageSelectionSet(snapshot.messageFields)]
735
591
  : [CONSOLE_MESSAGE_FIELDS, CONSOLE_MESSAGE_FIELDS_LEGACY];
736
- const inputCandidates = buildCreateMessageInputCandidates(input, snapshot.createMessageInputFields);
592
+ // The backend stamps the author from the signed-in identity (postMessage): the browser names none and sets no timestamps.
593
+ const args = postMessageArguments({ ...input, summary: input.summary ?? truncateSummary(input.content ?? null) } as unknown as Record<string, unknown>);
737
594
  let lastError: unknown = null;
738
595
 
739
596
  for (const selectionSet of selectionSets) {
740
- const createMutation = `
741
- mutation CreateMessage($input: CreateMessageInput!) {
742
- createMessage(input: $input) {
743
- ${selectionSet}
744
- }
745
- }
746
- `;
747
- for (const candidateInput of inputCandidates) {
748
- try {
749
- const response = await getGraphQLClient().graphql({
750
- query: createMutation,
751
- variables: { input: candidateInput },
752
- authMode: USER_POOL_AUTH_MODE,
753
- });
754
- const created = normalizeConsoleMessageRecord(assertGraphQL<Record<string, unknown>>(response, "createMessage"));
755
- await maybeUploadConsoleMessageBody(created.id, input.content);
756
- return input.content?.trim() ? { ...created, content: input.content } : created;
757
- } catch (error) {
758
- if (!isUnavailableQueryError(error)) throw normalizeUnknownError(error, "createMessage");
759
- lastError = error;
760
- }
761
- }
762
- }
763
-
764
- if (lastError) {
765
597
  try {
766
598
  const response = await getGraphQLClient().graphql({
767
- query: CREATE_CONSOLE_MESSAGE_LEGACY,
768
- variables: {
769
- input: inputCandidates[inputCandidates.length - 1] ?? {},
770
- },
599
+ query: postMessageMutation(args, selectionSet),
600
+ variables: args,
771
601
  authMode: USER_POOL_AUTH_MODE,
772
602
  });
773
- const created = normalizeConsoleMessageRecord(assertGraphQL<Record<string, unknown>>(response, "createMessage"));
603
+ const created = normalizeConsoleMessageRecord(assertGraphQL<Record<string, unknown>>(response, "postMessage"));
774
604
  await maybeUploadConsoleMessageBody(created.id, input.content);
775
605
  return input.content?.trim() ? { ...created, content: input.content } : created;
776
606
  } catch (error) {
777
- if (!isUnavailableQueryError(error)) throw normalizeUnknownError(error, "createMessage");
607
+ if (!isUnavailableQueryError(error)) throw normalizeUnknownError(error, "postMessage");
778
608
  lastError = error;
779
609
  }
780
610
  }
781
611
 
782
- throw normalizeUnknownError(lastError, "createMessage");
612
+ throw normalizeUnknownError(lastError, "postMessage");
783
613
  }
784
614
 
785
615
  export async function updateConsoleMessage(
@@ -21,10 +21,53 @@ export type ConversationMessage = {
21
21
  content: string;
22
22
  createdAt: string;
23
23
  sequenceNumber: number;
24
+ /** The sub of the person who wrote it: only they can edit it. */
25
+ authorSub?: string | null;
26
+ /** Their profile, where their display name lives. */
27
+ authorUserProfileId?: string | null;
28
+ /** When the author last changed the text. */
29
+ editedAt?: string | null;
30
+ /** A deleted Message keeps its place (replies stay under it) but has no text. */
31
+ deleted?: boolean;
32
+ deletedAt?: string | null;
24
33
  /** True while an optimistic Message waits for the save. */
25
34
  pending?: boolean;
26
35
  };
27
36
 
37
+ /** Who is looking: what they may change is decided from this (an author edits their own; an admin removes anyone's). */
38
+ export type ConversationViewer = { sub: string | null; isAdmin: boolean };
39
+
40
+ export const NO_VIEWER: ConversationViewer = { sub: null, isAdmin: false };
41
+
42
+ /** Only the author edits a Message, and not one that is deleted or still being saved. */
43
+ export function canEditMessage(viewer: ConversationViewer | undefined, message: ConversationMessage): boolean {
44
+ return Boolean(viewer?.sub && message.authorSub && message.authorSub === viewer.sub && !message.deleted && !message.pending);
45
+ }
46
+
47
+ /** The author deletes their own; an admin deletes anyone's. */
48
+ export function canDeleteMessage(viewer: ConversationViewer | undefined, message: ConversationMessage): boolean {
49
+ if (!viewer || message.deleted || message.pending) return false;
50
+ return viewer.isAdmin || Boolean(viewer.sub && message.authorSub && message.authorSub === viewer.sub);
51
+ }
52
+
53
+ /** Only the person who started a conversation, or an admin, renames it. */
54
+ export function canRenameThread(viewer: ConversationViewer | undefined, thread: { createdBySub?: string | null }): boolean {
55
+ if (!viewer) return false;
56
+ return viewer.isAdmin || Boolean(viewer.sub && thread.createdBySub && thread.createdBySub === viewer.sub);
57
+ }
58
+
59
+ /**
60
+ * The name to show. The label saved with the Message is the author's display name (it travels with the Message, because other
61
+ * editors cannot read profiles); an older Message saved an email there, so a profile name, when it can be read, comes before an
62
+ * email's first part.
63
+ */
64
+ export function messageAuthorName(message: Pick<ConversationMessage, "authorLabel" | "authorUserProfileId">, names?: ReadonlyMap<string, string>): string {
65
+ const label = String(message.authorLabel ?? "").trim();
66
+ if (label && !label.includes("@")) return label;
67
+ const profileName = message.authorUserProfileId ? names?.get(message.authorUserProfileId)?.trim() : "";
68
+ return profileName || conversationAuthorName(label);
69
+ }
70
+
28
71
  export type ConversationReply = { message: ConversationMessage; answers: string | null };
29
72
  export type ConversationRoot = { message: ConversationMessage; replies: ConversationReply[] };
30
73
 
@@ -75,7 +118,7 @@ export function conversationAuthorName(label: string | null | undefined): string
75
118
  * Top-level Messages in order, each with its replies. A reply to a reply stays flat under the same root and says who it answers.
76
119
  * A reply whose parent is missing is shown as a top-level Message rather than lost.
77
120
  */
78
- export function buildConversationTree(messages: ReadonlyArray<ConversationMessage>): ConversationRoot[] {
121
+ export function buildConversationTree(messages: ReadonlyArray<ConversationMessage>, nameOf: (message: ConversationMessage) => string = (message) => conversationAuthorName(message.authorLabel)): ConversationRoot[] {
79
122
  const sorted = [...messages].sort((a, b) => a.sequenceNumber - b.sequenceNumber || a.createdAt.localeCompare(b.createdAt));
80
123
  const byId = new Map(sorted.map((message) => [message.id, message]));
81
124
  const rootOf = (message: ConversationMessage): string => {
@@ -95,7 +138,7 @@ export function buildConversationTree(messages: ReadonlyArray<ConversationMessag
95
138
  const root = roots.get(rootOf(message));
96
139
  if (!root || root.message.id === message.id) continue;
97
140
  const parent = message.parentMessageId ? byId.get(message.parentMessageId) : undefined;
98
- root.replies.push({ message, answers: parent && parent.id !== root.message.id ? conversationAuthorName(parent.authorLabel) : null });
141
+ root.replies.push({ message, answers: parent && parent.id !== root.message.id ? nameOf(parent) : null });
99
142
  }
100
143
  return [...roots.values()];
101
144
  }
@@ -112,6 +155,34 @@ export function conversationAge(createdAt: string, now: number): string {
112
155
  return `${Math.round(hours / 24)}d`;
113
156
  }
114
157
 
158
+ /** The first line of a message, trimmed to fit a card: what a thread shows as its latest message. */
159
+ export function conversationPreviewLine(content: string | null | undefined, max = 140): string {
160
+ const line = String(content ?? "").split("\n").map((part) => part.trim()).find(Boolean) ?? "";
161
+ return line.length > max ? `${line.slice(0, max - 1).trimEnd()}…` : line;
162
+ }
163
+
164
+ function threadMetadataObject(metadata: unknown): Record<string, unknown> {
165
+ if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) return metadata as Record<string, unknown>;
166
+ if (typeof metadata !== "string" || !metadata.trim()) return {};
167
+ try {
168
+ const parsed = JSON.parse(metadata);
169
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
170
+ } catch {
171
+ return {};
172
+ }
173
+ }
174
+
175
+ /** The thread's metadata (a JSON string) with the author of its latest message recorded, so a list can show who spoke last without reading messages. */
176
+ export function threadMetadataWithLastAuthor(metadata: unknown, authorLabel: string): string {
177
+ return JSON.stringify({ ...threadMetadataObject(metadata), lastAuthorLabel: authorLabel });
178
+ }
179
+
180
+ /** Who wrote the latest message, as recorded on the thread; null for a thread that predates the record. */
181
+ export function threadLastAuthorLabel(metadata: unknown): string | null {
182
+ const label = threadMetadataObject(metadata).lastAuthorLabel;
183
+ return typeof label === "string" && label.trim() ? label : null;
184
+ }
185
+
115
186
  export function messageCountLabel(count: number): string {
116
187
  return `${count} ${count === 1 ? "message" : "messages"}`;
117
188
  }
@@ -127,9 +127,9 @@ export function draftItem(item: DraftItem, bySource: ReadonlyMap<string, Referen
127
127
  type: "draft",
128
128
  id: item.key,
129
129
  title: view?.headline ?? link?.label ?? "Draft",
130
- dek: view ? view.dek : "Loading the draft…",
130
+ dek: view ? view.dek : "Loading the Proposed Story…",
131
131
  image: picture.imageUrl,
132
- source: picture.siteName ?? picture.host ?? (view ? `AI ${view.kindLabel.toLowerCase()}` : "AI draft"),
132
+ source: picture.siteName ?? picture.host ?? (view ? `AI ${view.kindLabel.toLowerCase()}` : "Proposed Story"),
133
133
  host: picture.host,
134
134
  createdAt: timeOf(request.raisedAt),
135
135
  importance: 1,
@@ -273,10 +273,10 @@ function plural(count: number, one: string, many = `${one}s`): string {
273
273
  return `${count} ${count === 1 ? one : many}`;
274
274
  }
275
275
 
276
- /** "3 drafts and 12 references waiting for you"; "All caught up" when nothing waits. */
276
+ /** "3 Proposed Stories and 12 references waiting for you"; "All caught up" when nothing waits. */
277
277
  export function waitingTextFor(counts: NeedsMeCounts): string {
278
278
  const parts = [
279
- counts.drafts ? plural(counts.drafts, "draft") : null,
279
+ counts.drafts ? plural(counts.drafts, "Proposed Story", "Proposed Stories") : null,
280
280
  counts.references ? plural(counts.references, "reference") : null,
281
281
  counts.stories ? plural(counts.stories, "story review") : null,
282
282
  counts.email ? plural(counts.email, "held email", "held emails") : null,
@@ -5,12 +5,10 @@
5
5
  * What they share is one question: where would a person's answer change the most? In plain words, as the page says them:
6
6
  * 1. Hard gates first. Nothing continues until a person decides one, so it leads, oldest first.
7
7
  * 2. Then the interim "value of your input" score:
8
- * value = uncertainty x importance x freshness
8
+ * value = uncertainty x importance
9
9
  * - uncertainty is 1 - |2P - 1|, where P is the Cyclotron's probability that you would Accept. A coin toss (P = 0.5) is 1, a
10
10
  * certainty is 0. An item with no prediction is 0.5: we do not know how unsure we are.
11
11
  * - importance is the item's own priority (a relevance score, or a story or section priority). It is 1 until a source gives one.
12
- * - freshness is exp(-age / tau), the weight the References feed already uses; tau is learned from how old the References you
13
- * accepted were when you accepted them (reference-feed-ranking.ts). With too few examples there is no decay.
14
12
  * 3. Types are interleaved, never more than two of one type in a row while another type is waiting, so a long run of References
15
13
  * does not bury the drafts and emails behind it.
16
14
  * 4. Confident items do not ask for you. An item whose uncertainty is below the threshold goes to "Decided automatically", where it
@@ -25,7 +23,7 @@ import type { ReferenceRecord } from "./category-repository";
25
23
  import { referenceLineageId } from "./newsroom-references";
26
24
  import { normalizeReferenceCurationStatus } from "./reference-policy";
27
25
  import type { RelevanceDecisionView } from "./relevance-decisions";
28
- import { acceptProbability, MINIMUM_FRESHNESS_EXAMPLES, type FeedFreshness } from "./reference-feed-ranking";
26
+ import { acceptProbability } from "./reference-feed-ranking";
29
27
 
30
28
  export type NeedsMeType = "reference" | "draft" | "stage_review" | "held_email" | "incomplete_capture" | "concept_proposal" | "flag";
31
29
 
@@ -35,7 +33,7 @@ export const NEEDS_ME_TYPES: readonly NeedsMeType[] = ["reference", "draft", "st
35
33
  export type NeedsMeFilter = "all" | "references" | "drafts" | "stories" | "email" | "captures" | "other";
36
34
  export const NEEDS_ME_FILTERS: readonly NeedsMeFilter[] = ["all", "references", "drafts", "stories", "email", "captures", "other"];
37
35
  export const NEEDS_ME_FILTER_LABELS: Record<NeedsMeFilter, string> = {
38
- all: "All", references: "References", drafts: "Drafts", stories: "Stories", email: "Email", captures: "Captures", other: "Other",
36
+ all: "All", references: "References", drafts: "Proposed Stories", stories: "Stories", email: "Email", captures: "Captures", other: "Other",
39
37
  };
40
38
 
41
39
  const FILTER_OF: Record<NeedsMeType, Exclude<NeedsMeFilter, "all">> = {
@@ -52,7 +50,7 @@ export function matchesFilter(item: Pick<NeedsMeItem, "type">, filter: NeedsMeFi
52
50
 
53
51
  /** The words for a type, used on the card and in the detail pane. */
54
52
  export const NEEDS_ME_TYPE_LABELS: Record<NeedsMeType, string> = {
55
- reference: "Reference", draft: "AI draft", stage_review: "Story review", held_email: "Held email", incomplete_capture: "Capture check", concept_proposal: "Concept proposal", flag: "For your information",
53
+ reference: "Reference", draft: "Proposed Story", stage_review: "Story review", held_email: "Held email", incomplete_capture: "Capture check", concept_proposal: "Concept proposal", flag: "For your information",
56
54
  };
57
55
 
58
56
  export type NeedsMePrediction = {
@@ -109,33 +107,23 @@ export function predictionOf(decision: RelevanceDecisionView | undefined): Needs
109
107
  return { label: decision.label === (decision.positiveLabel ?? "include") ? "accept" : "reject", pAccept, confidence: decision.confidence };
110
108
  }
111
109
 
112
- const DAY_MILLISECONDS = 86_400_000;
113
-
114
- /** exp(-age / tau) at `now`; 1 without a learned tau or an unknown arrival. The References feed's own weight. */
115
- export function freshnessAt(createdAt: number | null, freshness: FeedFreshness, now: number): number {
116
- if (freshness.tauDays === null || createdAt === null) return 1;
117
- return Math.exp(-Math.max(now - createdAt, 0) / DAY_MILLISECONDS / freshness.tauDays);
118
- }
119
-
120
- export type AttentionContext = { now: number; freshness: FeedFreshness };
121
-
122
110
  /**
123
111
  * The value of this editor's input on one item. Higher is earlier. The interim model below is a formula; a Cyclotron "attention"
124
112
  * decision implements the same interface and is passed to `rankNeedsMe` in its place.
125
113
  */
126
114
  export interface AttentionModel {
127
115
  readonly id: string;
128
- value(item: NeedsMeItem, context: AttentionContext): number;
116
+ value(item: NeedsMeItem): number;
129
117
  }
130
118
 
131
119
  export const interimAttention: AttentionModel = {
132
120
  id: "interim-value-of-input",
133
- value: (item, { now, freshness }) => item.uncertainty * item.importance * freshnessAt(item.createdAt, freshness, now),
121
+ value: (item) => item.uncertainty * item.importance,
134
122
  };
135
123
 
136
124
  // --- confident items ----------------------------------------------------------------------------------------------------
137
125
 
138
- export const MINIMUM_CONFIRMED_EXAMPLES = MINIMUM_FRESHNESS_EXAMPLES;
126
+ export const MINIMUM_CONFIRMED_EXAMPLES = 10;
139
127
 
140
128
  export type ConfidenceThreshold = {
141
129
  /** Null with fewer than MINIMUM_CONFIRMED_EXAMPLES confirmed predictions: nothing is guessed, nothing is skipped. */
@@ -217,11 +205,10 @@ function newestFirst(left: NeedsMeItem, right: NeedsMeItem): number {
217
205
 
218
206
  export function rankNeedsMe<P>(
219
207
  items: readonly NeedsMeItem<P>[],
220
- options: { now: number; freshness: FeedFreshness; threshold: ConfidenceThreshold; attention?: AttentionModel },
208
+ options: { threshold: ConfidenceThreshold; attention?: AttentionModel },
221
209
  ): NeedsMeRanking<P> {
222
210
  const attention = options.attention ?? interimAttention;
223
- const context: AttentionContext = { now: options.now, freshness: options.freshness };
224
- const scores = new Map(items.map((item) => [item.id, attention.value(item, context)]));
211
+ const scores = new Map(items.map((item) => [item.id, attention.value(item)]));
225
212
  const score = (item: NeedsMeItem) => scores.get(item.id) ?? 0;
226
213
  const gates = items.filter((item) => item.hardGate).sort((left, right) => (left.createdAt ?? 0) - (right.createdAt ?? 0) || left.id.localeCompare(right.id));
227
214
  const rest = items.filter((item) => !item.hardGate);
@@ -1,4 +1,4 @@
1
- import type { KanbanConfig, KanbanIssue } from "@kanbus/ui";
1
+ import type { KanbanConfig, KanbanIssue, KanbanSortFieldRule } from "@kanbus/ui";
2
2
  import type { AssignmentRecord } from "./category-repository";
3
3
  import {
4
4
  ASSIGNMENT_PHASE_STAGE_KEYS,
@@ -118,11 +118,19 @@ export function assignmentColumnOrder(statusesPresentOnBoard: readonly string[])
118
118
  return [...ASSIGNMENT_STATUSES_BEFORE_OTHERS, ...otherStatuses, ...ASSIGNMENT_STATUSES_AFTER_OTHERS];
119
119
  }
120
120
 
121
+ /**
122
+ * Kanbus's default column order is oldest-created first, and our content items carry no `created_at`, so without this every
123
+ * column listed its cards by id. Newest first by `updated_at`: it is the order the stage pages load in, and the only time a
124
+ * paged content item has (each save is a new version, so `createdAt` is the last edit, not the story's start).
125
+ */
126
+ export const NEWEST_FIRST: KanbanSortFieldRule[] = [{ field: "updated_at", direction: "desc" }];
127
+
121
128
  function boardConfigForType(type: string, columns: readonly { key: string; name: string; collapsed?: boolean }[]): KanbanConfig {
122
129
  const statuses = columns.map(column => ({ ...column, category: "native" }));
123
130
  return {
124
131
  hierarchy: [], types: [type], statuses,
125
132
  categories: [{ name: "native" }], priorities: {}, type_colors: {},
133
+ sort_order: Object.fromEntries(statuses.map(status => [status.key, [...NEWEST_FIRST]])),
126
134
  workflows: { [type]: Object.fromEntries(statuses.map(status => [status.key, []])) },
127
135
  };
128
136
  }
@@ -165,6 +173,7 @@ export function deskBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConf
165
173
  statuses: [...storiesConfig.statuses, ...assignmentsConfig.statuses],
166
174
  categories: storiesConfig.categories,
167
175
  workflows: { ...storiesConfig.workflows, ...assignmentsConfig.workflows },
176
+ sort_order: { ...assignmentsConfig.sort_order, ...storiesConfig.sort_order },
168
177
  };
169
178
  }
170
179
 
@@ -161,13 +161,6 @@ export function applyPilobolusDemoDashboardContent(
161
161
  }));
162
162
 
163
163
  localized.messages = localized.messages.map((message) => {
164
- if (message.messageKind === "ingestion_rationale") {
165
- return {
166
- ...message,
167
- body: "Imported as a useful holdout against generic platform demo intake.",
168
- summary: "Pilobolus intake rationale",
169
- };
170
- }
171
164
  if (message.messageKind === "reporting_context_packet") {
172
165
  const metadata = message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata)
173
166
  ? message.metadata as Record<string, unknown>
@@ -8,10 +8,10 @@ export type OrganizerDestination = {
8
8
  };
9
9
  export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
10
10
  { title: "Editorial work", items: [
11
- { key: "overview", label: "Needs me", description: "See what needs you: drafts, references and reviews.", path: "/newsroom", primary: true },
11
+ { key: "overview", label: "Needs me", description: "See what needs you: Proposed Stories, references and reviews.", path: "/newsroom", primary: true },
12
12
  { key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: `/newsroom/${contentItemBoardPathSegment()}`, alsoActiveOnPaths: ["/newsroom/articles"], primary: true },
13
13
  { key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
14
- { key: "drafts", label: "Drafts", description: "Read the story drafts that AI proposes, then accept or reject them.", path: "/newsroom/administration/proposals" },
14
+ { key: "drafts", label: "Proposed Stories", description: "Read the Proposed Stories that AI suggests, then accept or reject them.", path: "/newsroom/administration/proposals" },
15
15
  { key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
16
16
  ] },
17
17
  { title: "Research & knowledge", items: [
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Messages are created through the `postMessage` mutation, never through `createMessage`: the backend stamps the author from the
3
+ * signed-in identity, so a browser cannot name one. This is the one place that turns a Message record into that mutation and its
4
+ * arguments, shared by the conversation, forum and console chat writers. Authorship and timestamps are left out on purpose.
5
+ */
6
+
7
+ const ARGUMENT_TYPES = {
8
+ id: "ID!",
9
+ threadId: "ID!",
10
+ content: "String",
11
+ messageKind: "String!",
12
+ messageDomain: "String!",
13
+ summary: "String",
14
+ parentMessageId: "ID",
15
+ sequenceNumber: "Int",
16
+ messageType: "String",
17
+ role: "String",
18
+ source: "String",
19
+ authorLabel: "String",
20
+ semanticLayer: "String",
21
+ searchVisibility: "String",
22
+ responseTarget: "String",
23
+ responseStatus: "String",
24
+ responseOwner: "String",
25
+ responseStartedAt: "AWSDateTime",
26
+ responseCompletedAt: "AWSDateTime",
27
+ metadata: "AWSJSON",
28
+ newsroomFeedKey: "String",
29
+ } as const;
30
+
31
+ export type PostMessageArguments = Partial<Record<keyof typeof ARGUMENT_TYPES, string | number | null>>;
32
+
33
+ type MessageLike = Record<string, unknown>;
34
+
35
+ /** The arguments of `postMessage` from a Message record. Anything else on the record (authorSub, authorUserProfileId, createdAt, updatedAt) is dropped. */
36
+ export function postMessageArguments(record: MessageLike): PostMessageArguments {
37
+ const args: PostMessageArguments = {};
38
+ for (const key of Object.keys(ARGUMENT_TYPES) as Array<keyof typeof ARGUMENT_TYPES>) {
39
+ let value = record[key === "content" ? "content" : key];
40
+ if (key === "content") value = record.content ?? record.body;
41
+ if (value === undefined || value === null) continue;
42
+ if (key === "metadata") value = typeof value === "string" ? value : JSON.stringify(value);
43
+ args[key] = value as string | number;
44
+ }
45
+ return args;
46
+ }
47
+
48
+ /** `mutation PostMessage($id: ID!, …) { postMessage(id: $id, …) { <selection> } }` declaring exactly the arguments given. */
49
+ export function postMessageMutation(args: PostMessageArguments, selection: string): string {
50
+ const keys = Object.keys(args) as Array<keyof typeof ARGUMENT_TYPES>;
51
+ const declared = keys.map((key) => `$${key}: ${ARGUMENT_TYPES[key]}`).join(", ");
52
+ const passed = keys.map((key) => `${key}: $${key}`).join(", ");
53
+ return `mutation PostMessage(${declared}) { postMessage(${passed}) { ${selection} } }`;
54
+ }
@@ -22,7 +22,7 @@ export type ProposalFilter = "open" | "draft" | "in_steering" | "editor_review"
22
22
  export type ProposalMove = { label: string; path?: string[]; edit?: "pin" | "unpin" | "hold" | "release"; state?: string };
23
23
 
24
24
  const STATE_LABELS: Record<string, string> = {
25
- draft: "Draft",
25
+ draft: "Proposed",
26
26
  in_steering: "In steering",
27
27
  editor_review: "Editor review",
28
28
  approved: "Approved",
@@ -31,7 +31,7 @@ const STATE_LABELS: Record<string, string> = {
31
31
  };
32
32
 
33
33
  const KIND_LABELS: Record<string, string> = {
34
- story_draft: "Story draft",
34
+ story_draft: "Proposed Story",
35
35
  new_concept: "New concept",
36
36
  rename: "Rename",
37
37
  merge: "Merge",