@anthusai/papyrus 5.57.0 → 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 (36) 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-thread.tsx +27 -12
  8. package/components/conversations-memory-source.ts +46 -0
  9. package/components/conversations-network-source.ts +40 -0
  10. package/components/conversations-page.tsx +14 -0
  11. package/components/conversations-source.ts +24 -0
  12. package/components/conversations-view.tsx +319 -0
  13. package/components/decision-control.tsx +307 -0
  14. package/components/needs-me-bodies.tsx +4 -3
  15. package/components/needs-me-feed.tsx +8 -14
  16. package/components/news-desk-taxonomy-client.ts +83 -14
  17. package/components/newsroom-organizer.css +24 -0
  18. package/components/notification-cards.tsx +5 -4
  19. package/components/reference-feed.tsx +60 -42
  20. package/components/reference-review-card.tsx +8 -134
  21. package/components/story-reporting-trail-client.ts +4 -3
  22. package/components/story-reporting-trail.tsx +8 -0
  23. package/components/topic-steering-workspace.tsx +40 -782
  24. package/components/use-reference-ratings.ts +11 -7
  25. package/lib/category-repository.ts +7 -0
  26. package/lib/conversation-feed.ts +96 -0
  27. package/lib/cyclotron-suggestions.ts +18 -36
  28. package/lib/local-preview-seed.ts +16 -27
  29. package/lib/reference-feed-keys.ts +69 -0
  30. package/lib/reference-policy.ts +83 -11
  31. package/lib/reference-review-details.ts +21 -57
  32. package/lib/relevance-decisions.ts +6 -2
  33. package/lib/story-reporting-trail.ts +28 -2
  34. package/package.json +9 -2
  35. package/routes.manifest.json +1 -1
  36. package/components/review-rating-grid.tsx +0 -190
@@ -9,7 +9,7 @@ import { cannotMoveReason, type DraftChoice } from "../lib/review-drafts";
9
9
  import { answerWords, formatWhen, modeLabel, nextText, steeringText, subjectHref, subjectLabel, type ReviewRequest } from "../lib/review-requests";
10
10
  import type { ReferencePreview } from "../lib/reference-preview";
11
11
  import { FEED_SURFACE_PROPS, FeedImage, PILL, SourceMark } from "./reference-feed";
12
- import { RatingGrid } from "./review-rating-grid";
12
+ import { DecisionControl } from "./decision-control";
13
13
 
14
14
  /** What the editor typed with an answer. */
15
15
  export type DecisionExtras = { why: string; notThisKind: boolean };
@@ -109,9 +109,9 @@ function DecisionFooter({ busy, decision, disabledReason, error, kind, lockedNot
109
109
  {locked ? (
110
110
  <>
111
111
  <DecidedLine choice={decision.choice} notThisKind={decision.notThisKind} why={decision.why} />
112
- <RatingGrid
112
+ <DecisionControl
113
113
  answer={decision.choice} disabled disabledReason={null} feedback={{ phase: "saved" }} onChoose={() => undefined}
114
- prediction={{ state: "none" }} showPrediction={false} surface="footer"
114
+ prediction={{ state: "none" }} showPrediction={false} showReason={false} surface="footer"
115
115
  />
116
116
  <p className="text-xs text-muted-foreground" data-review-disabled-reason>{lockedNote}</p>
117
117
  </>
@@ -132,7 +132,7 @@ function DecisionFooter({ busy, decision, disabledReason, error, kind, lockedNot
132
132
  </button>
133
133
  <span className="text-xs text-muted-foreground">Optional. Counts if you reject.</span>
134
134
  </div>
135
- <RatingGrid
135
+ <DecisionControl
136
136
  answer={busy ? pending : null}
137
137
  disabled={Boolean(disabledReason)}
138
138
  disabledReason={disabledReason}
@@ -140,6 +140,7 @@ function DecisionFooter({ busy, decision, disabledReason, error, kind, lockedNot
140
140
  onChoose={choose}
141
141
  prediction={{ state: "none" }}
142
142
  showPrediction={false}
143
+ showReason={false}
143
144
  surface="footer"
144
145
  />
145
146
  </>
@@ -20,7 +20,7 @@ import { referenceDisplayTitle } from "../lib/newsroom-references";
20
20
  import type { RelevanceDecisionView } from "../lib/relevance-decisions";
21
21
  import { cn } from "../lib/utils";
22
22
  import { predictionFromDecision, type ReviewChoice } from "./reference-review-card";
23
- import { RatingGrid } from "./review-rating-grid";
23
+ import { DecisionControl } from "./decision-control";
24
24
  import { CaptureBadge } from "./reference-capture-panel";
25
25
  import { AutoAcceptedBadge } from "./auto-accepted-badge";
26
26
  import { useRatingStore, type RatingController } from "./use-reference-ratings";
@@ -202,6 +202,58 @@ export function FeedImage({ host, lead, name, src }: { host: string | null; lead
202
202
  );
203
203
  }
204
204
 
205
+ /**
206
+ * The standard Reference header: its picture, source, headline, dek and age. The feed card wraps it in the button that opens the
207
+ * Reference, and the Messages page shows it under a conversation as the thing the conversation is about. No decision controls.
208
+ */
209
+ export function ReferenceCardHeader({ capture, lead = false, messageCount = 0, now, preview, reference, titleId }: {
210
+ capture?: CaptureStatus | undefined;
211
+ lead?: boolean;
212
+ messageCount?: number;
213
+ now: number;
214
+ preview: ReferencePreview | undefined;
215
+ reference: ReferenceRecord;
216
+ titleId?: string;
217
+ }) {
218
+ const host = referenceSourceHost(reference);
219
+ const site = preview?.siteName ?? host ?? "Unknown source";
220
+ const title = referenceDisplayTitle(reference);
221
+ const dek = referenceDek(reference, preview);
222
+ const showDek = dekAddsInformation(title, dek, preview?.imageUrl ?? null);
223
+ const time = referenceDisplayTime(reference);
224
+ const age = formatFeedAge(time, now);
225
+ return (
226
+ <>
227
+ <FeedImage host={host} lead={lead} name={site} src={preview?.imageUrl ?? null} />
228
+ <span className={cn("flex flex-1 flex-col gap-2.5", lead ? "p-5 @[36rem]:p-7" : "p-5")}>
229
+ <span className="flex items-center gap-2 text-xs font-medium text-muted-foreground" data-feed-source>
230
+ <SourceMark host={host} />
231
+ <span className="truncate">{site}</span>
232
+ </span>
233
+ <span className={cn("font-bold leading-[1.15] tracking-tight text-foreground", lead ? "line-clamp-3 text-2xl @[36rem]:text-3xl @[60rem]:text-4xl" : "line-clamp-3 text-xl")} data-feed-title id={titleId}>
234
+ {title}
235
+ </span>
236
+ {showDek ? <span className={cn("text-muted-foreground", lead ? "line-clamp-3 text-base" : "line-clamp-2 text-sm")} data-feed-dek>{dek}</span> : null}
237
+ <span className="mt-auto flex flex-wrap items-center gap-x-2 gap-y-1 pt-1 text-xs text-muted-foreground">
238
+ <span>
239
+ {age ? <time dateTime={new Date(time ?? now).toISOString()} title={new Date(time ?? now).toLocaleString()}>{age}</time> : null}
240
+ {age && host ? <span aria-hidden="true"> · </span> : null}
241
+ {host}
242
+ </span>
243
+ {capture ? <CaptureBadge status={capture} /> : null}
244
+ {messageCount > 0 ? (
245
+ <span aria-label={messageCountLabel(messageCount)} className="inline-flex items-center gap-1 tabular-nums" data-feed-message-count={messageCount} role="img">
246
+ <MessageSquareIcon aria-hidden="true" className="size-3.5" />
247
+ {messageCount}
248
+ </span>
249
+ ) : null}
250
+ {isAutoAccepted(reference) ? <AutoAcceptedBadge actor={reference.curationStatusUpdatedBy} reason={reference.curationStatusReason} /> : null}
251
+ </span>
252
+ </span>
253
+ </>
254
+ );
255
+ }
256
+
205
257
  export function FeedCard({
206
258
  capture,
207
259
  messageCount = 0,
@@ -283,60 +335,26 @@ export function FeedCard({
283
335
  onClick={onOpen}
284
336
  type="button"
285
337
  >
286
- <FeedImage host={host} lead={lead} name={site} src={preview?.imageUrl ?? null} />
287
- <span className={cn("flex flex-1 flex-col gap-2.5", lead ? "p-5 @[36rem]:p-7" : "p-5")}>
288
- <span className="flex items-center gap-2 text-xs font-medium text-muted-foreground" data-feed-source>
289
- <SourceMark host={host} />
290
- <span className="truncate">{site}</span>
291
- </span>
292
- <span className={cn("font-bold leading-[1.15] tracking-tight text-foreground", lead ? "line-clamp-3 text-2xl @[36rem]:text-3xl @[60rem]:text-4xl" : "line-clamp-3 text-xl")} data-feed-title id={titleId}>
293
- {title}
294
- </span>
295
- {showDek ? <span className={cn("text-muted-foreground", lead ? "line-clamp-3 text-base" : "line-clamp-2 text-sm")} data-feed-dek>{dek}</span> : null}
296
- <span className="mt-auto flex flex-wrap items-center gap-x-2 gap-y-1 pt-1 text-xs text-muted-foreground">
297
- <span>
298
- {age ? <time dateTime={new Date(time ?? now).toISOString()} title={new Date(time ?? now).toLocaleString()}>{age}</time> : null}
299
- {age && host ? <span aria-hidden="true"> · </span> : null}
300
- {host}
301
- </span>
302
- {capture ? <CaptureBadge status={capture} /> : null}
303
- {messageCount > 0 ? (
304
- <span aria-label={messageCountLabel(messageCount)} className="inline-flex items-center gap-1 tabular-nums" data-feed-message-count={messageCount} role="img">
305
- <MessageSquareIcon aria-hidden="true" className="size-3.5" />
306
- {messageCount}
307
- </span>
308
- ) : null}
309
- {isAutoAccepted(reference) ? <AutoAcceptedBadge actor={reference.curationStatusUpdatedBy} reason={reference.curationStatusReason} /> : null}
310
- </span>
311
- </span>
338
+ <ReferenceCardHeader capture={capture} lead={lead} messageCount={messageCount} now={now} preview={preview} reference={reference} titleId={titleId} />
312
339
  </button>
313
340
 
314
341
  <div className="relative bg-[var(--feed-footer)] px-4 pb-4 pt-3" data-feed-actions>
315
- <RatingGrid
342
+ <DecisionControl
316
343
  answer={chosen}
317
344
  decided={answer}
318
345
  disabled={Boolean(disabledReason)}
319
346
  disabledReason={disabledReason}
320
347
  feedback={feedback}
348
+ needsReason={rating.askReason}
321
349
  note={reasonLabel ? `Reason: ${reasonLabel}` : null}
322
350
  onChoose={(choice) => { if (!disabledReason) store.choose(item.lineageId, choice); }}
351
+ onReasonChange={(text) => store.setNote(item.lineageId, text)}
352
+ onReasonCommit={() => { if (!disabledReason) store.commitNote(item.lineageId); }}
323
353
  prediction={decision ? predictionFromDecision(decision) : predictionLoading ? { state: "loading" } : { state: "none" }}
354
+ reason={rating.note ?? ""}
355
+ reasonRequiredFor="reject"
324
356
  statusOverlay
325
357
  />
326
- {rating.askReason ? (
327
- // Covers the footer rather than adding to it, so asking for a reason never pushes the cards below it down.
328
- <div className="absolute inset-0 z-10 flex flex-col justify-center space-y-2 bg-[var(--feed-footer)] p-3" data-review-reasons>
329
- <p className="text-xs font-medium">Reason for rejecting. Choose one to save your Reject.</p>
330
- <div className="flex flex-wrap gap-1.5">
331
- {REFERENCE_REJECTION_REASON_CODES.map((code) => (
332
- <button className={cn(PILL, "min-h-8 bg-[var(--feed-control)] px-3 text-xs")} data-review-reason={code} key={code} onClick={() => store.setReason(item.lineageId, code)} type="button">
333
- {REASON_LABELS[code]}
334
- </button>
335
- ))}
336
- <button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() => store.cancelReason(item.lineageId)} type="button">Cancel</button>
337
- </div>
338
- </div>
339
- ) : null}
340
358
  </div>
341
359
  </article>
342
360
  );
@@ -2,20 +2,16 @@
2
2
 
3
3
  import {
4
4
  ArchiveIcon,
5
- CheckIcon,
6
5
  ChevronLeftIcon,
7
6
  ChevronRightIcon,
8
- CircleCheckIcon,
9
- CircleXIcon,
10
7
  ExternalLinkIcon,
11
8
  } from "lucide-react";
12
9
  import { useEffect, useRef, useState, type ReactNode, type PointerEvent as ReactPointerEvent } from "react";
13
10
  import { Button } from "./ui/button";
14
- import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
15
11
  import { cn } from "../lib/utils";
16
- import { RatingGrid } from "./review-rating-grid";
12
+ import { DecisionControl } from "./decision-control";
17
13
  import { useRatingStore, type RatingController } from "./use-reference-ratings";
18
- import { REFERENCE_REJECTION_REASON_CODES, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
14
+ import type { ReferenceRejectionReasonCode } from "../lib/reference-policy";
19
15
 
20
16
  export type ReviewChoice = "accept" | "reject";
21
17
 
@@ -31,96 +27,6 @@ export type ReviewCardDecision = {
31
27
  reasonCode: ReferenceRejectionReasonCode | null;
32
28
  };
33
29
 
34
- const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
35
- out_of_scope: "Out of scope",
36
- policy_exclusion: "Policy exclusion",
37
- duplicate: "Duplicate",
38
- low_quality: "Low quality",
39
- unavailable: "Unavailable",
40
- provenance: "Provenance",
41
- other: "Other",
42
- };
43
-
44
- const CHOICE_LABEL: Record<ReviewChoice, string> = { accept: "Accept", reject: "Reject" };
45
-
46
- function percent(confidence: number | null): string | null {
47
- return confidence === null ? null : `${Math.round(confidence * 100)}%`;
48
- }
49
-
50
- /**
51
- * The Cyclotron's read-only row: its Accept or Reject highlighted with its confidence, and a check or cross once a person has answered.
52
- * Shared by the review card and by every list that shows a suggestion before a decision.
53
- */
54
- export function CyclotronRow({ decided = null, prediction }: { decided?: ReviewChoice | null; prediction: CardPrediction }) {
55
- const matched = decided && prediction.state === "ready" ? prediction.choice === decided : null;
56
- return (
57
- <div className="space-y-2" data-review-row="cyclotron">
58
- <div className="flex items-center gap-3">
59
- <span className="w-20 shrink-0 text-sm font-semibold">Cyclotron</span>
60
- {prediction.state === "ready" ? (
61
- <div className="flex flex-1 gap-2">
62
- {(["accept", "reject"] as const).map((choice) => {
63
- const predicted = prediction.state === "ready" && prediction.choice === choice;
64
- const verdict = predicted && matched !== null ? (matched ? "match" : "miss") : null;
65
- return (
66
- <span
67
- className={cn(
68
- "inline-flex min-h-10 flex-1 items-center justify-center gap-1.5 rounded-md border px-3 text-sm",
69
- predicted ? "border-foreground bg-muted font-semibold" : "border-dashed text-muted-foreground",
70
- verdict === "match" && "border-green-700 bg-green-100 text-green-950",
71
- verdict === "miss" && "border-red-700 bg-red-100 text-red-950",
72
- )}
73
- data-cyclotron-choice={choice}
74
- data-predicted={predicted || undefined}
75
- data-verdict={verdict ?? undefined}
76
- key={choice}
77
- >
78
- {verdict === "match" ? <CircleCheckIcon aria-hidden="true" className="size-4" /> : null}
79
- {verdict === "miss" ? <CircleXIcon aria-hidden="true" className="size-4" /> : null}
80
- {CHOICE_LABEL[choice]}
81
- {predicted && percent(prediction.confidence) ? <small className="text-xs font-normal">{percent(prediction.confidence)}</small> : null}
82
- {verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
83
- </span>
84
- );
85
- })}
86
- </div>
87
- ) : (
88
- <p className="text-sm text-muted-foreground" data-cyclotron-state={prediction.state}>
89
- {prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
90
- </p>
91
- )}
92
- </div>
93
- </div>
94
- );
95
- }
96
-
97
- /** The two answers, large enough to tap: Accept and Reject, the chosen one filled and checked (never colour alone). */
98
- export function ChoiceButtons({ chosen, disabled = false, onChoose }: { chosen: ReviewChoice | null; disabled?: boolean; onChoose: (choice: ReviewChoice) => void }) {
99
- return (
100
- <div className="flex flex-1 gap-2">
101
- {(["accept", "reject"] as const).map((choice) => {
102
- const on = chosen === choice;
103
- return (
104
- <Button
105
- aria-disabled={disabled ? true : undefined}
106
- aria-pressed={on}
107
- className="min-h-14 flex-1 text-base"
108
- data-review-choice={choice}
109
- key={choice}
110
- onClick={() => onChoose(choice)}
111
- type="button"
112
- variant={on ? (choice === "accept" ? "default" : "destructive") : "outline"}
113
- >
114
- {on ? <CheckIcon aria-hidden="true" className="size-5" /> : null}
115
- {CHOICE_LABEL[choice]}
116
- </Button>
117
- );
118
- })}
119
- </div>
120
- );
121
- }
122
-
123
- /** A reference's relevance decision in the card's own vocabulary. */
124
30
  export function predictionFromDecision(decision: { label: string; positiveLabel: string | null; confidence: number | null }): CardPrediction {
125
31
  return { state: "ready", choice: decision.label === (decision.positiveLabel ?? "include") ? "accept" : "reject", confidence: decision.confidence };
126
32
  }
@@ -181,10 +87,8 @@ export function ReviewCard({
181
87
  const store = ratings ?? local;
182
88
  const item = store.get(itemId);
183
89
  const note = item.note ?? initialNote;
184
- const reason = item.reasonCode !== undefined ? item.reasonCode : initialReason;
185
90
  const chosen = item.pending ?? decided;
186
91
  const choose = (choice: ReviewChoice) => { if (!disabledReason) store.choose(itemId, choice); };
187
- const chooseReason = (code: ReferenceRejectionReasonCode) => { if (!disabledReason) store.setReason(itemId, code); };
188
92
  const commitNote = () => { if (!disabledReason) store.commitNote(itemId); };
189
93
 
190
94
  // Desktop shortcuts: A accepts, R rejects. Never while typing.
@@ -222,53 +126,23 @@ export function ReviewCard({
222
126
  ) : null}
223
127
  </header>
224
128
 
225
- <RatingGrid
129
+ <DecisionControl
226
130
  answer={chosen}
227
131
  decided={decided}
228
132
  disabled={Boolean(disabledReason)}
229
133
  disabledReason={disabledReason}
230
134
  feedback={item.feedback}
135
+ needsReason={item.askReason}
231
136
  onChoose={choose}
137
+ onReasonChange={(text) => store.setNote(itemId, text)}
138
+ onReasonCommit={commitNote}
232
139
  prediction={prediction}
140
+ reason={note}
141
+ reasonRequiredFor="reject"
233
142
  size="large"
234
143
  surface="card"
235
144
  />
236
145
 
237
- <label className="block text-sm font-medium">
238
- Why? (optional)
239
- <AutoGrowTextarea
240
- aria-label="Why? (optional)"
241
- className="mt-1 block min-h-20 w-full rounded-[var(--feed-radius-lg,10px)] bg-[var(--feed-control)] p-3 text-base font-normal outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
242
- onBlur={commitNote}
243
- onChange={(event) => store.setNote(itemId, event.target.value)}
244
- placeholder="Say what made this an easy yes or a clear no. Your note teaches the Cyclotron."
245
- value={note}
246
- />
247
- </label>
248
-
249
- {chosen === "reject" ? (
250
- <div className="space-y-2" data-review-reasons>
251
- <p className="text-sm font-medium">Reason for rejecting</p>
252
- <div className="flex flex-wrap gap-2">
253
- {REFERENCE_REJECTION_REASON_CODES.map((code) => (
254
- <button
255
- aria-disabled={disabledReason ? true : undefined}
256
- aria-pressed={reason === code}
257
- className={cn("inline-flex min-h-10 items-center gap-1.5 rounded-[var(--feed-radius-pill,8px)] px-3 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", reason === code ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)]")}
258
- data-review-reason={code}
259
- key={code}
260
- onClick={() => chooseReason(code)}
261
- type="button"
262
- >
263
- {reason === code ? <CheckIcon aria-hidden="true" className="size-4" /> : null}
264
- {REASON_LABELS[code]}
265
- </button>
266
- ))}
267
- </div>
268
- {!reason && !note.trim() ? <p className="text-sm text-muted-foreground">Choose a reason, or say why above, to save your Reject.</p> : null}
269
- </div>
270
- ) : null}
271
-
272
146
  {children}
273
147
 
274
148
  {onArchive ? (
@@ -4,7 +4,7 @@ import { generateClient } from "aws-amplify/data";
4
4
  import type { Schema } from "../amplify/data/resource";
5
5
  import { isLocalPreviewEnabled } from "../lib/local-preview";
6
6
  import { createLocalPreviewReportingTrail, createLocalPreviewStoriesForReference } from "../lib/local-preview-seed";
7
- import { EMPTY_STORY_REPORTING_TRAIL, referencesByRole, siteNameOf, type ReferenceStory, type StoryReportingTrail, type TrailAssignment, type TrailAttachment, type TrailReference, type TrailThread, type TrailVersion } from "../lib/story-reporting-trail";
7
+ import { EMPTY_STORY_REPORTING_TRAIL, referencesByRole, siteNameOf, stageMovesOf, type ReferenceStory, type StoryReportingTrail, type TrailAssignment, type TrailAttachment, type TrailReference, type TrailThread, type TrailVersion } from "../lib/story-reporting-trail";
8
8
  import { configureAmplifyClient } from "./amplify-client-provider";
9
9
 
10
10
  type GraphqlResponse = { data?: Record<string, { items?: Record<string, unknown>[] | null; nextToken?: string | null } | null> | null; errors?: { message?: string }[] | null };
@@ -89,7 +89,7 @@ export async function loadStoryReportingTrail(lineageId: string, itemId: string,
89
89
  configureAmplifyClient();
90
90
  const client = generateClient<Schema>() as unknown as GraphqlClient;
91
91
  const [versionRows, assignmentRows, attachmentRows, relationRows] = await Promise.all([
92
- listAll(client, "listItemsByLineageAndVersion", "id versionNumber versionState status stage headline title changeReason versionCreatedBy versionCreatedAt updatedAt", ", $lineageId: ID!", ", lineageId: $lineageId", { lineageId }).catch(() => []),
92
+ listAll(client, "listItemsByLineageAndVersion", "id versionNumber versionState status stage headline title changeReason versionCreatedBy versionCreatedAt updatedAt metadata", ", $lineageId: ID!", ", lineageId: $lineageId", { lineageId }).catch(() => []),
93
93
  listAll(client, "listAssignmentsByItemLineageAndCreatedAt", "id title assignmentTypeKey status assigneeKey summary createdAt", ", $itemLineageId: ID!", ", itemLineageId: $itemLineageId", { itemLineageId: lineageId }).catch(() => []),
94
94
  listAll(client, "listModelAttachmentsByOwnerRoleAndSortKey", "id role filename mediaType byteSize", ", $ownerId: ID!", ", ownerId: $ownerId", { ownerId: itemId }).catch(() => []),
95
95
  listAll(client, "listSemanticRelations", "id predicate subjectKind subjectLineageId objectKind objectId objectLineageId metadata", ", $filter: ModelSemanticRelationFilterInput", ", filter: $filter", { filter: { or: [{ subjectLineageId: { eq: lineageId } }, { objectLineageId: { eq: lineageId } }] } }).catch(() => []),
@@ -124,5 +124,6 @@ export async function loadStoryReportingTrail(lineageId: string, itemId: string,
124
124
  };
125
125
  })));
126
126
  const attachments: TrailAttachment[] = attachmentRows.map(row => ({ id: String(row.id), role: text(row.role), filename: text(row.filename), mediaType: text(row.mediaType), byteSize: typeof row.byteSize === "number" ? row.byteSize : null }));
127
- return { ...EMPTY_STORY_REPORTING_TRAIL, versions, assignments, threads, references, attachments };
127
+ const latestRow = [...versionRows].sort((left, right) => Number(right.versionNumber ?? 0) - Number(left.versionNumber ?? 0))[0];
128
+ return { ...EMPTY_STORY_REPORTING_TRAIL, versions, assignments, threads, references, attachments, stageMoves: stageMovesOf(latestRow?.metadata) };
128
129
  }
@@ -91,6 +91,14 @@ export function StoryReportingTrailView({ citedKeys = [], demo = false, focusedR
91
91
  </li>)}
92
92
  </ol>
93
93
  </TrailSection>
94
+ {trail.stageMoves.length > 0 && <TrailSection count={trail.stageMoves.length} title="Stage moves">
95
+ <ol className="story-trail-list" data-story-trail-stage-moves>
96
+ {[...trail.stageMoves].reverse().map(move => <li key={`${move.at}-${move.to}`}>
97
+ <div className="story-trail-row"><strong>{move.from ?? "start"} → {move.to}</strong>{move.by && <span className="story-trail-chip">{move.by}</span>}<span className="story-trail-meta">{formatTimestamp(move.at)}</span></div>
98
+ {move.reason && <p className="story-trail-meta">{move.reason}</p>}
99
+ </li>)}
100
+ </ol>
101
+ </TrailSection>}
94
102
  <TrailSection count={trail.versions.length} title="Draft versions">
95
103
  <ol className="story-trail-list">
96
104
  {trail.versions.map((version, versionIndex) => <li key={version.id}>