@sentry/junior-dashboard 0.274.0 → 0.276.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 (52) hide show
  1. package/dist/app.js +28 -9
  2. package/dist/client.js +115 -92
  3. package/dist/index.js +28 -9
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +5 -4
  6. package/src/app.ts +8 -3
  7. package/src/client/App.tsx +11 -0
  8. package/src/client/components/Metric.tsx +0 -32
  9. package/src/client/components/ProfileMenu.tsx +56 -4
  10. package/src/client/components/layout/DashboardChrome.tsx +3 -3
  11. package/src/client/components/layout/DashboardHeader.tsx +41 -4
  12. package/src/client/components/layout/NavRail.tsx +43 -40
  13. package/src/client/conversations/ConversationComposer.tsx +42 -26
  14. package/src/client/conversations/ConversationDetails.tsx +149 -166
  15. package/src/client/conversations/ConversationHeader.tsx +126 -132
  16. package/src/client/conversations/ConversationHeaderActions.tsx +92 -120
  17. package/src/client/conversations/ConversationListStatusIcon.tsx +16 -11
  18. package/src/client/conversations/ConversationMemories.tsx +118 -78
  19. package/src/client/conversations/ConversationMeta.tsx +94 -84
  20. package/src/client/conversations/ConversationOverflowMenu.tsx +59 -0
  21. package/src/client/conversations/ConversationPage.tsx +197 -422
  22. package/src/client/conversations/ConversationReplyFooter.tsx +342 -0
  23. package/src/client/conversations/ConversationSidebar.tsx +5 -7
  24. package/src/client/conversations/ConversationTranscript.tsx +37 -8
  25. package/src/client/conversations/ConversationWatches.tsx +37 -15
  26. package/src/client/conversations/PendingMailboxStack.tsx +11 -1
  27. package/src/client/conversations/QuickQuestion.tsx +476 -0
  28. package/src/client/conversations/TelemetryMetrics.tsx +2 -15
  29. package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
  30. package/src/client/conversations/TranscriptEventView.tsx +25 -2
  31. package/src/client/conversations/TranscriptMemoryPopover.tsx +179 -0
  32. package/src/client/conversations/TranscriptMessageView.tsx +46 -16
  33. package/src/client/conversations/TranscriptRows.tsx +9 -2
  34. package/src/client/conversations/TranscriptView.tsx +15 -32
  35. package/src/client/conversations/UsageCard.tsx +4 -18
  36. package/src/client/conversations/conversationMemory.ts +138 -0
  37. package/src/client/conversations/conversationSections.ts +18 -14
  38. package/src/client/conversations/eventTranscript.ts +8 -0
  39. package/src/client/review-desk/ConversationReviewItems.tsx +99 -0
  40. package/src/client/review-desk/ReviewItemActions.tsx +1 -1
  41. package/src/client/review-desk/ReviewItemChangeRow.tsx +85 -0
  42. package/src/client/review-desk/ReviewItemContext.tsx +41 -23
  43. package/src/client/review-desk/reviewItemFormat.ts +16 -0
  44. package/src/client/sentry.ts +77 -0
  45. package/src/client/types.ts +6 -0
  46. package/src/client.tsx +3 -0
  47. package/src/mock-reporting/watches.ts +2 -0
  48. package/src/shell.ts +27 -2
  49. package/src/tailwind.css +12 -19
  50. package/src/client/components/ActiveIndicator.tsx +0 -14
  51. package/src/client/conversations/ConversationHeaderMeta.tsx +0 -20
  52. package/src/client/review-desk/ConversationReviewBar.tsx +0 -85
@@ -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
+ }
@@ -115,7 +115,6 @@ function modelHeading(
115
115
  /** Rows shown before the rest of a list rolls into one "N more" line. */
116
116
  const MODEL_LIMIT = 4;
117
117
  const AUXILIARY_LIMIT = 3;
118
- const DETAILS_FOOTER = "Full breakdown in Details";
119
118
 
120
119
  type RankedRow = UsageCardRow & { amount: number };
121
120
 
@@ -222,7 +221,6 @@ function tokenCard(
222
221
  compactionCount: number | undefined,
223
222
  liveModelId: string | undefined,
224
223
  childAgentTotals: ConversationChildAgentTotals | undefined,
225
- detailsHint: boolean,
226
224
  ): UsageCardProps {
227
225
  const models = modelRows(
228
226
  modelUsage,
@@ -232,7 +230,6 @@ function tokenCard(
232
230
  formatCompactNumber,
233
231
  );
234
232
  return {
235
- footer: detailsHint && models.length ? DETAILS_FOOTER : undefined,
236
233
  note: compactionCount ? plural("compaction", compactionCount) : undefined,
237
234
  sections: models.length
238
235
  ? [
@@ -278,7 +275,6 @@ function costCard(
278
275
  auxiliaryCosts: ConversationAuxiliaryCosts | undefined,
279
276
  liveModelId: string | undefined,
280
277
  childAgentTotals: ConversationChildAgentTotals | undefined,
281
- detailsHint: boolean,
282
278
  ): UsageCardProps | undefined {
283
279
  const total = totalConversationCost(summary, auxiliaryCosts);
284
280
  if (!total) return undefined;
@@ -315,10 +311,6 @@ function costCard(
315
311
  });
316
312
  }
317
313
  return {
318
- footer:
319
- detailsHint && (models.length || operations.length)
320
- ? DETAILS_FOOTER
321
- : undefined,
322
314
  sections,
323
315
  split: agentSplit(
324
316
  childAgentTotals,
@@ -354,12 +346,11 @@ export function CostMetric(props: {
354
346
  align?: "left" | "right";
355
347
  auxiliaryCosts?: ConversationAuxiliaryCosts;
356
348
  childAgentTotals?: ConversationChildAgentTotals;
357
- /** Point to conversation details, which has the full breakdown. */
358
- detailsHint?: boolean;
359
349
  live?: boolean;
360
350
  liveModelId?: string;
361
351
  modelUsage?: ConversationModelUsage[];
362
352
  summary: CostUsageSummary | undefined;
353
+ tooltipPlacement?: "above" | "below";
363
354
  }) {
364
355
  // Model history can include calls not yet counted in saved Run totals.
365
356
  const summary = summarizeModelCost(props.modelUsage) ?? props.summary;
@@ -375,12 +366,11 @@ export function CostMetric(props: {
375
366
  props.auxiliaryCosts,
376
367
  props.liveModelId,
377
368
  props.childAgentTotals,
378
- Boolean(props.detailsHint),
379
369
  );
380
370
  return (
381
371
  <MetricValue
382
372
  align={props.align}
383
- tooltipPlacement="above"
373
+ tooltipPlacement={props.tooltipPlacement ?? "above"}
384
374
  tooltip={
385
375
  !card && props.liveModelId
386
376
  ? [modelHeading(props.liveModelId, props.liveModelId)]
@@ -398,8 +388,6 @@ export function TokenMetric(props: {
398
388
  align?: "left" | "right";
399
389
  childAgentTotals?: ConversationChildAgentTotals;
400
390
  compactionCount?: number;
401
- /** Point to conversation details, which has the full breakdown. */
402
- detailsHint?: boolean;
403
391
  live?: boolean;
404
392
  liveModelId?: string;
405
393
  modelUsage?: ConversationModelUsage[];
@@ -417,7 +405,6 @@ export function TokenMetric(props: {
417
405
  props.compactionCount,
418
406
  props.liveModelId,
419
407
  props.childAgentTotals,
420
- Boolean(props.detailsHint),
421
408
  )}
422
409
  />
423
410
  }
@@ -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
  }