@anthusai/papyrus 5.56.1 → 5.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/amplify/data/resource.js +25 -3
  2. package/amplify/data/resource.ts +25 -3
  3. package/amplify/data/schema.js +25 -3
  4. package/amplify/data/schema.ts +25 -3
  5. package/amplify/functions/category-action/handler.ts +51 -125
  6. package/amplify/functions/shared/reference-curation-metadata.ts +21 -29
  7. package/components/conversation-context.tsx +52 -0
  8. package/components/conversation-provider.tsx +20 -0
  9. package/components/conversation-thread.tsx +188 -0
  10. package/components/conversations-memory-source.ts +46 -0
  11. package/components/conversations-network-source.ts +40 -0
  12. package/components/conversations-page.tsx +14 -0
  13. package/components/conversations-source.ts +24 -0
  14. package/components/conversations-view.tsx +319 -0
  15. package/components/decision-control.tsx +307 -0
  16. package/components/needs-me-bodies.tsx +4 -3
  17. package/components/needs-me-feed.tsx +8 -14
  18. package/components/needs-me-reference-detail.tsx +2 -0
  19. package/components/news-desk-taxonomy-client.ts +322 -37
  20. package/components/newsroom-organizer.css +24 -0
  21. package/components/newsroom-references-view.tsx +9 -0
  22. package/components/notification-cards.tsx +5 -4
  23. package/components/reference-conversation.tsx +43 -0
  24. package/components/reference-feed.tsx +69 -37
  25. package/components/reference-review-card.tsx +8 -134
  26. package/components/story-reporting-trail-client.ts +4 -3
  27. package/components/story-reporting-trail.tsx +8 -0
  28. package/components/topic-steering-workspace.tsx +43 -784
  29. package/components/use-conversation.ts +84 -0
  30. package/components/use-reference-ratings.ts +11 -7
  31. package/lib/category-repository.ts +7 -0
  32. package/lib/conversation-feed.ts +96 -0
  33. package/lib/conversation.ts +154 -0
  34. package/lib/cyclotron-suggestions.ts +18 -36
  35. package/lib/local-preview-seed.ts +16 -27
  36. package/lib/reference-feed-keys.ts +69 -0
  37. package/lib/reference-policy.ts +83 -11
  38. package/lib/reference-review-details.ts +21 -57
  39. package/lib/relevance-decisions.ts +6 -2
  40. package/lib/story-reporting-trail.ts +28 -2
  41. package/package.json +15 -2
  42. package/routes.manifest.json +1 -1
  43. package/components/review-rating-grid.tsx +0 -190
@@ -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
  </>
@@ -0,0 +1,43 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { ConversationThread } from "./conversation-thread";
5
+ import { useConversation } from "./use-conversation";
6
+
7
+ /**
8
+ * The Messages about one Reference, under its review card. Shows nothing where no conversation store is provided.
9
+ * `onCount` tells the feed how many Messages there are, so its card count follows what is written here.
10
+ */
11
+ export function ReferenceConversation({ disabledReason = null, lineageId, onCount, referenceId, title }: {
12
+ disabledReason?: string | null;
13
+ lineageId: string;
14
+ onCount?: (lineageId: string, count: number) => void;
15
+ /** The Reference version's id, when it differs from the lineage id. */
16
+ referenceId?: string;
17
+ title?: string;
18
+ }) {
19
+ const conversation = useConversation({ kind: "reference", lineageId, title, ...(referenceId ? { id: referenceId } : {}) });
20
+ const [now, setNow] = useState(() => Date.now());
21
+ useEffect(() => {
22
+ const timer = window.setInterval(() => setNow(Date.now()), 60000);
23
+ return () => window.clearInterval(timer);
24
+ }, []);
25
+ const settled = conversation.messages.filter((message) => !message.pending).length;
26
+ useEffect(() => {
27
+ if (conversation.state === "ready") onCount?.(lineageId, settled);
28
+ }, [conversation.state, lineageId, onCount, settled]);
29
+ if (!conversation.available) return null;
30
+ return (
31
+ <div data-reference-conversation={lineageId}>
32
+ <ConversationThread
33
+ disabledReason={disabledReason}
34
+ error={conversation.error}
35
+ messages={conversation.messages}
36
+ now={now}
37
+ onPost={conversation.post}
38
+ onRetry={conversation.retry}
39
+ state={conversation.state}
40
+ />
41
+ </div>
42
+ );
43
+ }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { InfoIcon, Loader2Icon } from "lucide-react";
3
+ import { InfoIcon, Loader2Icon, MessageSquareIcon } from "lucide-react";
4
4
  import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
5
5
  import type { ReferenceRecord } from "../lib/category-repository";
6
6
  import { REFERENCE_REJECTION_REASON_CODES, isAutoAccepted, normalizeReferenceCurationStatus, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
@@ -20,11 +20,12 @@ 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";
27
27
  import type { CaptureStatus } from "../lib/reference-capture";
28
+ import { messageCountLabel } from "../lib/conversation";
28
29
 
29
30
  /**
30
31
  * Flat surfaces, no borders: the page tone, a card tone one step away from it, a footer tone, and a control tone for chips and
@@ -56,6 +57,8 @@ export type ReferenceFeedProps = {
56
57
  disabledReason?: string | null;
57
58
  /** True while the first read is in flight: cards are skeletons. */
58
59
  loading?: boolean;
60
+ /** How many Messages each Reference has, by lineage id. A card with none shows no count. */
61
+ messageCounts?: ReadonlyMap<string, number>;
59
62
  now: number;
60
63
  onOpen: (lineageId: string) => void;
61
64
  onRate: (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) => Promise<void> | void;
@@ -199,8 +202,61 @@ export function FeedImage({ host, lead, name, src }: { host: string | null; lead
199
202
  );
200
203
  }
201
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
+
202
257
  export function FeedCard({
203
258
  capture,
259
+ messageCount = 0,
204
260
  decision,
205
261
  disabledReason,
206
262
  item,
@@ -216,6 +272,8 @@ export function FeedCard({
216
272
  }: {
217
273
  /** Optional: the capture badge. */
218
274
  capture?: CaptureStatus | undefined;
275
+ /** Optional: how many Messages this Reference has. */
276
+ messageCount?: number;
219
277
  decision: RelevanceDecisionView | undefined;
220
278
  disabledReason: string | null;
221
279
  item: FeedItem;
@@ -277,54 +335,26 @@ export function FeedCard({
277
335
  onClick={onOpen}
278
336
  type="button"
279
337
  >
280
- <FeedImage host={host} lead={lead} name={site} src={preview?.imageUrl ?? null} />
281
- <span className={cn("flex flex-1 flex-col gap-2.5", lead ? "p-5 @[36rem]:p-7" : "p-5")}>
282
- <span className="flex items-center gap-2 text-xs font-medium text-muted-foreground" data-feed-source>
283
- <SourceMark host={host} />
284
- <span className="truncate">{site}</span>
285
- </span>
286
- <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}>
287
- {title}
288
- </span>
289
- {showDek ? <span className={cn("text-muted-foreground", lead ? "line-clamp-3 text-base" : "line-clamp-2 text-sm")} data-feed-dek>{dek}</span> : null}
290
- <span className="mt-auto flex flex-wrap items-center gap-x-2 gap-y-1 pt-1 text-xs text-muted-foreground">
291
- <span>
292
- {age ? <time dateTime={new Date(time ?? now).toISOString()} title={new Date(time ?? now).toLocaleString()}>{age}</time> : null}
293
- {age && host ? <span aria-hidden="true"> · </span> : null}
294
- {host}
295
- </span>
296
- {capture ? <CaptureBadge status={capture} /> : null}
297
- {isAutoAccepted(reference) ? <AutoAcceptedBadge actor={reference.curationStatusUpdatedBy} reason={reference.curationStatusReason} /> : null}
298
- </span>
299
- </span>
338
+ <ReferenceCardHeader capture={capture} lead={lead} messageCount={messageCount} now={now} preview={preview} reference={reference} titleId={titleId} />
300
339
  </button>
301
340
 
302
341
  <div className="relative bg-[var(--feed-footer)] px-4 pb-4 pt-3" data-feed-actions>
303
- <RatingGrid
342
+ <DecisionControl
304
343
  answer={chosen}
305
344
  decided={answer}
306
345
  disabled={Boolean(disabledReason)}
307
346
  disabledReason={disabledReason}
308
347
  feedback={feedback}
348
+ needsReason={rating.askReason}
309
349
  note={reasonLabel ? `Reason: ${reasonLabel}` : null}
310
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); }}
311
353
  prediction={decision ? predictionFromDecision(decision) : predictionLoading ? { state: "loading" } : { state: "none" }}
354
+ reason={rating.note ?? ""}
355
+ reasonRequiredFor="reject"
312
356
  statusOverlay
313
357
  />
314
- {rating.askReason ? (
315
- // Covers the footer rather than adding to it, so asking for a reason never pushes the cards below it down.
316
- <div className="absolute inset-0 z-10 flex flex-col justify-center space-y-2 bg-[var(--feed-footer)] p-3" data-review-reasons>
317
- <p className="text-xs font-medium">Reason for rejecting. Choose one to save your Reject.</p>
318
- <div className="flex flex-wrap gap-1.5">
319
- {REFERENCE_REJECTION_REASON_CODES.map((code) => (
320
- <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">
321
- {REASON_LABELS[code]}
322
- </button>
323
- ))}
324
- <button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() => store.cancelReason(item.lineageId)} type="button">Cancel</button>
325
- </div>
326
- </div>
327
- ) : null}
328
358
  </div>
329
359
  </article>
330
360
  );
@@ -398,6 +428,7 @@ export function SectionHeading({ children, id }: { children: ReactNode; id: stri
398
428
  export function ReferenceFeed({
399
429
  disabledReason = null,
400
430
  loading = false,
431
+ messageCounts,
401
432
  now,
402
433
  onOpen,
403
434
  onRate,
@@ -426,6 +457,7 @@ export function ReferenceFeed({
426
457
  item={item}
427
458
  key={item.lineageId}
428
459
  lead={lead}
460
+ messageCount={messageCounts?.get(item.lineageId)}
429
461
  now={now}
430
462
  onOpen={() => onOpen(item.lineageId)}
431
463
  onRate={async (reference, choice, reasonCode) => { onVisitRated(item.lineageId); await onRate(reference, choice, reasonCode); }}
@@ -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}>