@sentry/junior-dashboard 0.275.0 → 0.277.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 (68) hide show
  1. package/dist/app.js +194 -40
  2. package/dist/client.js +120 -92
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +189 -38
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +5 -4
  7. package/src/api/schema.ts +20 -2
  8. package/src/app.ts +88 -15
  9. package/src/auth.ts +5 -1
  10. package/src/client/components/FormNotice.tsx +11 -14
  11. package/src/client/components/ProfileMenu.tsx +5 -6
  12. package/src/client/conversations/AutoArchiveToggle.tsx +35 -0
  13. package/src/client/conversations/ConversationArchiveNotices.tsx +1 -1
  14. package/src/client/conversations/ConversationChildAgents.tsx +2 -1
  15. package/src/client/conversations/ConversationComposer.tsx +42 -26
  16. package/src/client/conversations/ConversationDetails.tsx +66 -35
  17. package/src/client/conversations/ConversationHeader.tsx +19 -2
  18. package/src/client/conversations/ConversationHeaderActions.tsx +23 -0
  19. package/src/client/conversations/ConversationHomeList.tsx +1 -1
  20. package/src/client/conversations/ConversationListStatusIcon.tsx +16 -11
  21. package/src/client/conversations/ConversationMemories.tsx +114 -77
  22. package/src/client/conversations/ConversationMeta.tsx +47 -42
  23. package/src/client/conversations/ConversationPage.tsx +183 -60
  24. package/src/client/conversations/ConversationSidebar.tsx +80 -24
  25. package/src/client/conversations/ConversationTranscript.tsx +37 -8
  26. package/src/client/conversations/ConversationWatches.tsx +42 -26
  27. package/src/client/conversations/ConversationWorkspace.tsx +4 -4
  28. package/src/client/conversations/DetailsList.tsx +8 -14
  29. package/src/client/conversations/MemoryIcons.tsx +23 -0
  30. package/src/client/conversations/PendingMailboxStack.tsx +11 -1
  31. package/src/client/conversations/QuickQuestion.tsx +476 -0
  32. package/src/client/conversations/TelemetryMetrics.tsx +2 -1
  33. package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
  34. package/src/client/conversations/TranscriptEventView.tsx +25 -2
  35. package/src/client/conversations/TranscriptMemoryPopover.tsx +174 -0
  36. package/src/client/conversations/TranscriptMessageView.tsx +46 -16
  37. package/src/client/conversations/TranscriptRows.tsx +9 -2
  38. package/src/client/conversations/TranscriptTurnContextView.tsx +9 -5
  39. package/src/client/conversations/UsageCard.tsx +3 -3
  40. package/src/client/conversations/autoArchiveQueries.ts +53 -0
  41. package/src/client/conversations/conversationArchive.ts +255 -0
  42. package/src/client/conversations/conversationMemory.ts +139 -0
  43. package/src/client/conversations/conversationSections.ts +24 -18
  44. package/src/client/conversations/eventTranscript.ts +8 -0
  45. package/src/client/conversations/queries.ts +48 -238
  46. package/src/client/conversations/usePinConversation.ts +88 -0
  47. package/src/client/display-text/DisplayText.tsx +7 -2
  48. package/src/client/display-text/codeBlocks.ts +92 -0
  49. package/src/client/format.ts +3 -67
  50. package/src/client/pages/code/RepositoryTabs.tsx +2 -2
  51. package/src/client/pages/settings/AutoArchiveSettings.tsx +145 -0
  52. package/src/client/pages/settings/SettingsPage.tsx +2 -0
  53. package/src/client/review-desk/ConversationReviewItems.tsx +7 -9
  54. package/src/client/review-desk/ReviewItemActions.tsx +1 -1
  55. package/src/client/review-desk/ReviewItemChangeRow.tsx +85 -0
  56. package/src/client/review-desk/ReviewItemContext.tsx +41 -23
  57. package/src/client/review-desk/queries.ts +2 -1
  58. package/src/client/review-desk/reviewItemFormat.ts +16 -0
  59. package/src/client/sentry.ts +77 -0
  60. package/src/client/types.ts +7 -0
  61. package/src/client.tsx +3 -0
  62. package/src/conversation-cache.ts +1 -1
  63. package/src/mock-reporting/fixtures.ts +103 -27
  64. package/src/mock-reporting/routes.ts +47 -0
  65. package/src/mock-reporting/watches.ts +2 -0
  66. package/src/shell.ts +27 -2
  67. package/src/tailwind.css +8 -19
  68. package/src/client/components/ActiveIndicator.tsx +0 -14
@@ -0,0 +1,476 @@
1
+ import { GitFork } from "lucide-react";
2
+ import {
3
+ useCallback,
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ type RefObject,
8
+ } from "react";
9
+ import { createPortal } from "react-dom";
10
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
11
+ import { useNavigate } from "react-router";
12
+
13
+ import { getDashboardAgentName } from "../agentName";
14
+ import { useDashboardCoreData } from "../api";
15
+ import { DetailsPanel } from "../components/DetailsPanel";
16
+ import { ShimmerText } from "../components/ShimmerText";
17
+ import { Button } from "../components/ui/button";
18
+ import { DisplayText } from "../display-text/DisplayText";
19
+ import { DashboardApiError, post } from "../http";
20
+ import {
21
+ acceptedConversationMessageSchema,
22
+ forkConversationResponseSchema,
23
+ type ConversationDetailReport,
24
+ } from "@sentry/junior/api/schema";
25
+ import { cn } from "../styles";
26
+ import { ConversationComposer } from "./ConversationComposer";
27
+ import { conversationPath } from "./conversationRoutes";
28
+ import {
29
+ conversationDetailQueryKey,
30
+ conversationDetailQueryOptions,
31
+ } from "./queries";
32
+ import { TranscriptMessageShell } from "./TranscriptMessageShell";
33
+
34
+ const HIGHLIGHT_NAME = "quick-question";
35
+ const STARTER_PROMPTS = ["Explain this", "Is this right?", "What's the risk?"];
36
+
37
+ type QuickQuestionMessage = { role: "user" | "assistant"; text: string };
38
+
39
+ /** The selected text in one Junior reply. */
40
+ type QuickQuestionSelection = {
41
+ messageId: string;
42
+ quote: string;
43
+ range: Range;
44
+ };
45
+
46
+ type QuickQuestionThread = QuickQuestionSelection & {
47
+ conversationId: string;
48
+ /** Hidden fork that the first question starts. */
49
+ forkId?: string;
50
+ id: string;
51
+ /** Questions sent from this page, shown until the fork history has them. */
52
+ asked: string[];
53
+ askedAtMs?: number;
54
+ };
55
+
56
+ /** Show or clear the selection highlight. Browsers without the API skip it. */
57
+ function setHighlight(range: Range | undefined) {
58
+ if (typeof CSS === "undefined" || !("highlights" in CSS)) return;
59
+ if (range) CSS.highlights.set(HIGHLIGHT_NAME, new Highlight(range));
60
+ else CSS.highlights.delete(HIGHLIGHT_NAME);
61
+ }
62
+
63
+ /** Longest quote the API accepts. Longer selections are clipped. */
64
+ const MAX_QUOTE_CHARS = 4_000;
65
+
66
+ /**
67
+ * Read the selection when it lies in the text of one Junior reply that can
68
+ * end a fork. `TranscriptMessageView` marks that text.
69
+ */
70
+ function readSelection(
71
+ root: HTMLElement | null,
72
+ ): QuickQuestionSelection | undefined {
73
+ const selection = window.getSelection();
74
+ if (!root || !selection || selection.isCollapsed || !selection.rangeCount)
75
+ return undefined;
76
+ const quote = selection.toString().trim().slice(0, MAX_QUOTE_CHARS).trim();
77
+ if (!quote) return undefined;
78
+ const range = selection.getRangeAt(0);
79
+ const node = range.commonAncestorContainer;
80
+ const element = node instanceof Element ? node : node.parentElement;
81
+ const message = element?.closest<HTMLElement>(
82
+ "[data-quick-question-message-id]",
83
+ );
84
+ const messageId = message?.dataset.quickQuestionMessageId;
85
+ if (!message || !messageId || !root.contains(message)) return undefined;
86
+ return { messageId, quote, range: range.cloneRange() };
87
+ }
88
+
89
+ /**
90
+ * Show a "Quick question" button above a text selection in a Junior reply.
91
+ * It hides on scroll, because its position would go stale.
92
+ */
93
+ export function QuickQuestionSelectionButton(props: {
94
+ rootRef: RefObject<HTMLElement | null>;
95
+ onAsk(selection: QuickQuestionSelection): void;
96
+ }) {
97
+ const { rootRef, onAsk } = props;
98
+ const [anchor, setAnchor] = useState<DOMRect>();
99
+ useEffect(() => {
100
+ const update = () =>
101
+ setAnchor(readSelection(rootRef.current)?.range.getBoundingClientRect());
102
+ const clearIfEmpty = () => {
103
+ if (!readSelection(rootRef.current)) setAnchor(undefined);
104
+ };
105
+ const hide = () => setAnchor(undefined);
106
+ document.addEventListener("mouseup", update);
107
+ document.addEventListener("keyup", update);
108
+ document.addEventListener("selectionchange", clearIfEmpty);
109
+ document.addEventListener("scroll", hide, true);
110
+ return () => {
111
+ document.removeEventListener("mouseup", update);
112
+ document.removeEventListener("keyup", update);
113
+ document.removeEventListener("selectionchange", clearIfEmpty);
114
+ document.removeEventListener("scroll", hide, true);
115
+ };
116
+ }, [rootRef]);
117
+ if (!anchor) return null;
118
+ // Show the button above the selection, or below it near the top edge.
119
+ const above = anchor.top >= 56;
120
+ return createPortal(
121
+ <div
122
+ className={cn(
123
+ "fixed z-50 -translate-x-1/2 rounded-lg border border-dashboard-border-emphasis bg-dashboard-surface-raised p-1 shadow-lg",
124
+ above && "-translate-y-full",
125
+ )}
126
+ style={{
127
+ left: anchor.left + anchor.width / 2,
128
+ top: above
129
+ ? anchor.top - 8
130
+ : Math.min(anchor.bottom + 8, window.innerHeight - 56),
131
+ }}
132
+ >
133
+ <Button
134
+ className="h-8 text-dashboard-text"
135
+ // Keep the selection while the button takes the click.
136
+ onMouseDown={(event) => event.preventDefault()}
137
+ onClick={() => {
138
+ const selection = readSelection(rootRef.current);
139
+ setAnchor(undefined);
140
+ if (!selection) return;
141
+ window.getSelection()?.removeAllRanges();
142
+ onAsk(selection);
143
+ }}
144
+ variant="quiet"
145
+ >
146
+ Quick question
147
+ </Button>
148
+ </div>,
149
+ document.body,
150
+ );
151
+ }
152
+
153
+ /**
154
+ * Ask in a Quick question. The first question starts the hidden, read-only
155
+ * fork. A retry with the same thread key finds that fork again.
156
+ */
157
+ function useAskQuickQuestion() {
158
+ const queryClient = useQueryClient();
159
+ return useMutation({
160
+ mutationFn: async (args: {
161
+ forkId?: string;
162
+ /** The composer key of this ask, so a retry cannot add the question twice. */
163
+ idempotencyKey: string;
164
+ messageId: string;
165
+ quote: string;
166
+ sourceConversationId: string;
167
+ text: string;
168
+ threadKey: string;
169
+ }) => {
170
+ const forkId =
171
+ args.forkId ??
172
+ (
173
+ await post(
174
+ forkConversationResponseSchema,
175
+ `/api/conversations/${encodeURIComponent(args.sourceConversationId)}/forks`,
176
+ {
177
+ idempotencyKey: args.threadKey,
178
+ messageId: args.messageId,
179
+ quickQuestion: { quote: args.quote },
180
+ },
181
+ )
182
+ ).conversationId;
183
+ return post(
184
+ acceptedConversationMessageSchema,
185
+ `/api/conversations/${encodeURIComponent(forkId)}/messages`,
186
+ { idempotencyKey: args.idempotencyKey, message: args.text },
187
+ );
188
+ },
189
+ onSuccess: (accepted) =>
190
+ queryClient.invalidateQueries({
191
+ queryKey: conversationDetailQueryKey(accepted.conversationId),
192
+ }),
193
+ });
194
+ }
195
+
196
+ /** Keep a Quick question as a fork that lists show. */
197
+ function useKeepQuickQuestion() {
198
+ const queryClient = useQueryClient();
199
+ return useMutation({
200
+ mutationFn: (forkId: string) =>
201
+ post(
202
+ forkConversationResponseSchema,
203
+ `/api/conversations/${encodeURIComponent(forkId)}/quick-question/keep`,
204
+ {},
205
+ ),
206
+ onSuccess: () =>
207
+ queryClient.invalidateQueries({
208
+ queryKey: ["dashboard", "conversations"],
209
+ }),
210
+ });
211
+ }
212
+
213
+ /**
214
+ * Own one Quick question thread for a Conversation page. The page keeps it,
215
+ * so an answer that arrives while another panel shows is not lost. Closing
216
+ * leaves the hidden fork unlisted, so the question is gone for the reader.
217
+ */
218
+ export function useQuickQuestionThread(conversationId: string) {
219
+ const [saved, setThread] = useState<QuickQuestionThread>();
220
+ // The page stays mounted across Conversations. A thread belongs to one.
221
+ const thread = saved?.conversationId === conversationId ? saved : undefined;
222
+ const ask = useAskQuickQuestion();
223
+ const { mutateAsync, reset } = ask;
224
+
225
+ const open = useCallback(
226
+ (selection: QuickQuestionSelection) => {
227
+ reset();
228
+ setHighlight(selection.range);
229
+ setThread({
230
+ ...selection,
231
+ asked: [],
232
+ conversationId,
233
+ id: crypto.randomUUID(),
234
+ });
235
+ },
236
+ [conversationId, reset],
237
+ );
238
+ const close = useCallback(() => {
239
+ reset();
240
+ setHighlight(undefined);
241
+ setThread(undefined);
242
+ }, [reset]);
243
+ // Leaving the Conversation discards its thread.
244
+ useEffect(
245
+ () => () => {
246
+ setHighlight(undefined);
247
+ setThread(undefined);
248
+ },
249
+ [conversationId],
250
+ );
251
+
252
+ const submit = useCallback(
253
+ async (text: string, idempotencyKey: string) => {
254
+ if (!thread) return;
255
+ const accepted = await mutateAsync({
256
+ forkId: thread.forkId,
257
+ idempotencyKey,
258
+ messageId: thread.messageId,
259
+ quote: thread.quote,
260
+ sourceConversationId: conversationId,
261
+ text,
262
+ threadKey: thread.id,
263
+ });
264
+ // A closed or replaced thread drops its late result.
265
+ setThread((current) =>
266
+ current?.id === thread.id
267
+ ? {
268
+ ...current,
269
+ asked: [...current.asked, text],
270
+ askedAtMs: Date.now(),
271
+ forkId: accepted.conversationId,
272
+ }
273
+ : current,
274
+ );
275
+ },
276
+ [conversationId, mutateAsync, thread],
277
+ );
278
+
279
+ return {
280
+ close,
281
+ error: ask.error,
282
+ open,
283
+ pending: ask.isPending,
284
+ submit,
285
+ thread,
286
+ };
287
+ }
288
+
289
+ /** Messages of the fork after the selected reply: the questions and answers. */
290
+ function forkThreadMessages(
291
+ snapshot: ConversationDetailReport | undefined,
292
+ replyMessageId: string,
293
+ ): QuickQuestionMessage[] {
294
+ const messages = (snapshot?.events ?? []).flatMap(({ data }) =>
295
+ data.type === "message" &&
296
+ (data.role === "user" || data.role === "assistant") &&
297
+ data.text
298
+ ? [{ id: data.messageId, role: data.role, text: data.text }]
299
+ : [],
300
+ );
301
+ // The copied history ends with the selected reply. A page that does not
302
+ // reach back to it holds only new messages.
303
+ const reply = messages.findIndex((message) => message.id === replyMessageId);
304
+ return messages.slice(reply + 1);
305
+ }
306
+
307
+ /** Side panel for one Quick question thread. */
308
+ export function QuickQuestionPanel(props: {
309
+ conversationId: string;
310
+ error: Error | null;
311
+ onClose(): void;
312
+ onSubmit(text: string, idempotencyKey: string): Promise<void>;
313
+ pending: boolean;
314
+ thread: QuickQuestionThread;
315
+ }) {
316
+ const { thread } = props;
317
+ const scrollerRef = useRef<HTMLDivElement>(null);
318
+ const footerRef = useRef<HTMLDivElement>(null);
319
+ const me = useDashboardCoreData().data?.me.user;
320
+ const viewerName = me?.name?.trim() || me?.email || "You";
321
+ const agentName = getDashboardAgentName();
322
+ const fork = useQuery(
323
+ conversationDetailQueryOptions(thread.forkId, {
324
+ fastPollUntilMs: thread.askedAtMs && thread.askedAtMs + 10_000,
325
+ }),
326
+ ).data;
327
+ const saved = forkThreadMessages(fork, thread.messageId);
328
+ const unsaved = thread.asked
329
+ .slice(saved.filter((message) => message.role === "user").length)
330
+ .map((text) => ({ role: "user" as const, text }));
331
+ const messages = [...saved, ...unsaved];
332
+ // A failed Turn ends without an answer. The person can then ask again.
333
+ const thinking =
334
+ props.pending ||
335
+ unsaved.length > 0 ||
336
+ (fork?.status === "active" && messages.at(-1)?.role === "user");
337
+
338
+ useEffect(() => {
339
+ footerRef.current?.querySelector("textarea")?.focus();
340
+ }, [thread.id]);
341
+
342
+ // Keep the newest message in view.
343
+ useEffect(() => {
344
+ const scroller = scrollerRef.current;
345
+ if (scroller) scroller.scrollTop = scroller.scrollHeight;
346
+ }, [messages.length, thinking]);
347
+
348
+ return (
349
+ <DetailsPanel
350
+ label="Quick question"
351
+ sheetTitle="Quick question"
352
+ onClose={props.onClose}
353
+ title={
354
+ <h2 className="m-0 font-sans text-sm font-semibold text-dashboard-text">
355
+ Quick question
356
+ </h2>
357
+ }
358
+ >
359
+ <div className="grid h-full min-h-0 grid-rows-[minmax(0,1fr)_auto]">
360
+ <div
361
+ className="grid content-start gap-5 overflow-y-auto px-5 py-4"
362
+ ref={scrollerRef}
363
+ >
364
+ <blockquote className="m-0 border-l-2 border-dashboard-accent pl-3 text-sm whitespace-pre-wrap text-dashboard-text">
365
+ {thread.quote}
366
+ </blockquote>
367
+ {messages.length === 0 && !props.pending ? (
368
+ <div className="flex flex-wrap gap-2">
369
+ {STARTER_PROMPTS.map((prompt) => (
370
+ <Button
371
+ className="h-8 rounded-full"
372
+ key={prompt}
373
+ // Each click is a new ask. The panel shows the error.
374
+ onClick={() =>
375
+ void props
376
+ .onSubmit(prompt, crypto.randomUUID())
377
+ .catch(() => {})
378
+ }
379
+ >
380
+ {prompt}
381
+ </Button>
382
+ ))}
383
+ </div>
384
+ ) : (
385
+ messages.map((message, index) => (
386
+ <TranscriptMessageShell
387
+ actor={message.role === "user" ? viewerName : agentName}
388
+ avatarUrl={message.role === "user" ? me?.avatarUrl : undefined}
389
+ key={index}
390
+ role={message.role}
391
+ >
392
+ <span
393
+ className={
394
+ message.role === "user"
395
+ ? "text-sm font-semibold text-dashboard-text"
396
+ : "text-sm font-semibold text-dashboard-accent-text"
397
+ }
398
+ >
399
+ {message.role === "user" ? viewerName : agentName}
400
+ </span>
401
+ <DisplayText chat text={message.text} />
402
+ </TranscriptMessageShell>
403
+ ))
404
+ )}
405
+ {thinking ? (
406
+ <TranscriptMessageShell actor={agentName} role="assistant">
407
+ <span className="text-sm font-semibold text-dashboard-accent-text">
408
+ {agentName}
409
+ </span>
410
+ <ShimmerText active className="text-sm text-dashboard-text-muted">
411
+ Thinking…
412
+ </ShimmerText>
413
+ </TranscriptMessageShell>
414
+ ) : null}
415
+ </div>
416
+ <div
417
+ className="grid gap-1 border-t border-dashboard-border px-3 pt-3 pb-2"
418
+ ref={footerRef}
419
+ >
420
+ <ConversationComposer
421
+ compact
422
+ disabled={thinking}
423
+ draftId={`quick-question:${props.conversationId}`}
424
+ error={
425
+ props.error
426
+ ? (props.error instanceof DashboardApiError &&
427
+ props.error.apiError) ||
428
+ "Could not ask. Try again."
429
+ : undefined
430
+ }
431
+ key={thread.id}
432
+ label="Quick question"
433
+ onSubmit={props.onSubmit}
434
+ placeholder={
435
+ messages.length ? "Ask a follow-up…" : "Ask a quick question…"
436
+ }
437
+ restoreDraftOnError
438
+ submitLabel="Ask"
439
+ />
440
+ <div className="flex items-center justify-between gap-2 px-1 text-xs text-dashboard-text-muted">
441
+ <span>Closing discards this question.</span>
442
+ <KeepAsForkButton
443
+ disabled={thinking}
444
+ forkId={thread.forkId}
445
+ // A new thread needs a new keep request.
446
+ key={thread.id}
447
+ />
448
+ </div>
449
+ </div>
450
+ </div>
451
+ </DetailsPanel>
452
+ );
453
+ }
454
+
455
+ /** Keep the hidden fork: it shows in lists and is no longer read-only. */
456
+ function KeepAsForkButton(props: { disabled: boolean; forkId?: string }) {
457
+ const navigate = useNavigate();
458
+ const keep = useKeepQuickQuestion();
459
+ const forkId = props.forkId;
460
+ return (
461
+ <Button
462
+ className="h-7 px-2 text-xs"
463
+ disabled={!forkId || props.disabled || keep.isPending}
464
+ onClick={() =>
465
+ forkId &&
466
+ keep.mutate(forkId, {
467
+ onSuccess: () => navigate(conversationPath(forkId)),
468
+ })
469
+ }
470
+ variant="quiet"
471
+ >
472
+ <GitFork aria-hidden="true" size={13} />
473
+ {keep.isError ? "Retry keep" : "Keep as fork"}
474
+ </Button>
475
+ );
476
+ }
@@ -350,6 +350,7 @@ export function CostMetric(props: {
350
350
  liveModelId?: string;
351
351
  modelUsage?: ConversationModelUsage[];
352
352
  summary: CostUsageSummary | undefined;
353
+ tooltipPlacement?: "above" | "below";
353
354
  }) {
354
355
  // Model history can include calls not yet counted in saved Run totals.
355
356
  const summary = summarizeModelCost(props.modelUsage) ?? props.summary;
@@ -369,7 +370,7 @@ export function CostMetric(props: {
369
370
  return (
370
371
  <MetricValue
371
372
  align={props.align}
372
- tooltipPlacement="above"
373
+ tooltipPlacement={props.tooltipPlacement ?? "above"}
373
374
  tooltip={
374
375
  !card && props.liveModelId
375
376
  ? [modelHeading(props.liveModelId, props.liveModelId)]
@@ -10,6 +10,7 @@ import {
10
10
  } from "../components/ui/tooltip";
11
11
  import { formatMs } from "../format";
12
12
  import { reviewItemIdFromToolCall } from "../review-desk/reviewItemFormat";
13
+ import { isMemoryCaptureEvent } from "./conversationMemory";
13
14
  import type { RenderedTranscriptEntry } from "./transcriptRenderModel";
14
15
  import { cn } from "../styles";
15
16
  import { useTranscriptSearch } from "./transcriptSearch";
@@ -30,6 +31,10 @@ export function isCollapsibleActivityEntry(
30
31
  // Child agent rows stay in the main view, like their result events.
31
32
  if (entry.kind === "subagent") return false;
32
33
  if (entry.kind === "message") return false;
34
+ // A Turn's captured memories stay visible, so the reader sees what it saved.
35
+ if (entry.kind === "structured_event" && isMemoryCaptureEvent(entry.part)) {
36
+ return false;
37
+ }
33
38
  // A review card stays visible where the agent added the item.
34
39
  if (entry.kind === "tool" && reviewItemIdFromToolCall(entry.part)) {
35
40
  return false;
@@ -4,6 +4,7 @@ import { useState } from "react";
4
4
  import { Collapsible, CollapsibleContent } from "../components/ui/collapsible";
5
5
  import { cn, dashboardDisclosureChevronClass } from "../styles";
6
6
  import { DisplayText } from "../display-text/DisplayText";
7
+ import { ReviewItemChangeRow } from "../review-desk/ReviewItemChangeRow";
7
8
  import { messageRawText } from "./transcriptRenderModel";
8
9
  import { HighlightText, useTranscriptSearch } from "./transcriptSearch";
9
10
  import { RedactedMarker } from "./TranscriptRedacted";
@@ -82,8 +83,8 @@ export function TranscriptEventView(props: { message: TranscriptViewMessage }) {
82
83
  </span>
83
84
  );
84
85
 
85
- return (
86
- <div className="min-w-0 py-1">
86
+ const view = (
87
+ <>
87
88
  {text ? (
88
89
  <Collapsible
89
90
  className="min-w-0"
@@ -120,6 +121,28 @@ export function TranscriptEventView(props: { message: TranscriptViewMessage }) {
120
121
  ) : null}
121
122
  </>
122
123
  )}
124
+ </>
125
+ );
126
+
127
+ // A relay of a person change shows as its change row, like the person's
128
+ // own message, not as an Event line. A message with changes lists its own
129
+ // Events in `events`, so with none it only records changes.
130
+ const changes = props.message.reviewItemChanges ?? [];
131
+ const onlyChanges = changes.length > 0 && events.length === 0;
132
+ return (
133
+ <div className="min-w-0 py-1">
134
+ {onlyChanges ? null : view}
135
+ {changes.length ? (
136
+ <div className={cn("grid min-w-0 gap-2", !onlyChanges && "mt-2")}>
137
+ {changes.map((record, index) => (
138
+ <ReviewItemChangeRow
139
+ fallback={onlyChanges && changes.length === 1 ? view : null}
140
+ key={index}
141
+ record={record}
142
+ />
143
+ ))}
144
+ </div>
145
+ ) : null}
123
146
  </div>
124
147
  );
125
148
  }