@sentry/junior-dashboard 0.62.0 → 0.64.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 (67) hide show
  1. package/dist/app.d.ts +2 -0
  2. package/dist/app.js +1285 -39
  3. package/dist/assets.d.ts +2 -0
  4. package/dist/client/api.d.ts +1 -1
  5. package/dist/client/components/Button.d.ts +15 -0
  6. package/dist/client/components/Metric.d.ts +22 -0
  7. package/dist/client/components/StatusBadge.d.ts +2 -1
  8. package/dist/client/components/TelemetryMetrics.d.ts +24 -0
  9. package/dist/client/components/ToolFrame.d.ts +3 -1
  10. package/dist/client/components/Transcript.d.ts +2 -0
  11. package/dist/client/components/TranscriptHeader.d.ts +2 -0
  12. package/dist/client/components/TranscriptHeadingRow.d.ts +16 -0
  13. package/dist/client/components/TranscriptThinkingView.d.ts +7 -0
  14. package/dist/client/components/TranscriptToolRun.d.ts +9 -0
  15. package/dist/client/components/transcriptRenderModel.d.ts +25 -9
  16. package/dist/client/format.d.ts +45 -8
  17. package/dist/client/markdownExport.d.ts +3 -0
  18. package/dist/client/toolInvocations.d.ts +7 -0
  19. package/dist/client/types.d.ts +17 -100
  20. package/dist/client.js +85 -55998
  21. package/dist/dashboardLoader.d.ts +1 -0
  22. package/dist/handler.js +1288 -40
  23. package/dist/index.d.ts +8 -0
  24. package/dist/index.js +1807 -0
  25. package/dist/mock-conversations.d.ts +3 -0
  26. package/dist/mock-release-conversation.d.ts +3 -0
  27. package/dist/nitro.d.ts +7 -1
  28. package/dist/tailwind.css +1 -1
  29. package/dist/url.d.ts +13 -0
  30. package/package.json +9 -5
  31. package/src/app.ts +42 -18
  32. package/src/assets.ts +2 -0
  33. package/src/auth.ts +2 -35
  34. package/src/client/App.tsx +33 -74
  35. package/src/client/code.tsx +1 -1
  36. package/src/client/components/Button.tsx +75 -0
  37. package/src/client/components/ConversationRowStats.tsx +3 -2
  38. package/src/client/components/FilterTabs.tsx +10 -11
  39. package/src/client/components/LoadingView.tsx +7 -2
  40. package/src/client/components/Metric.tsx +159 -0
  41. package/src/client/components/StatusBadge.tsx +10 -1
  42. package/src/client/components/TelemetryMetrics.tsx +124 -0
  43. package/src/client/components/ToolFrame.tsx +57 -14
  44. package/src/client/components/Transcript.tsx +6 -2
  45. package/src/client/components/TranscriptHeader.tsx +18 -19
  46. package/src/client/components/TranscriptHeadingRow.tsx +64 -0
  47. package/src/client/components/TranscriptThinkingView.tsx +157 -0
  48. package/src/client/components/TranscriptToolRun.tsx +66 -0
  49. package/src/client/components/TranscriptToolView.tsx +16 -8
  50. package/src/client/components/TranscriptTurn.tsx +368 -132
  51. package/src/client/components/TurnDurationChart.tsx +236 -78
  52. package/src/client/components/transcriptRenderModel.ts +60 -20
  53. package/src/client/format.ts +329 -87
  54. package/src/client/markdownExport.ts +360 -0
  55. package/src/client/pages/CommandCenter.tsx +1 -1
  56. package/src/client/pages/ConversationPage.tsx +142 -45
  57. package/src/client/pages/ConversationsPage.tsx +1 -1
  58. package/src/client/toolInvocations.ts +16 -0
  59. package/src/client/types.ts +34 -90
  60. package/src/config.ts +4 -0
  61. package/src/dashboardLoader.ts +12 -0
  62. package/src/index.ts +78 -0
  63. package/src/mock-conversations.ts +726 -0
  64. package/src/mock-release-conversation.ts +605 -0
  65. package/src/nitro.ts +7 -1
  66. package/src/tailwind.css +11 -0
  67. package/src/url.ts +68 -0
@@ -1,17 +1,24 @@
1
- import { useState, type ClipboardEventHandler, type ReactNode } from "react";
1
+ import {
2
+ Fragment,
3
+ useState,
4
+ type ClipboardEventHandler,
5
+ type ReactNode,
6
+ } from "react";
2
7
 
3
8
  import { HighlightedCode } from "../code";
4
9
  import {
5
10
  detectLanguage,
6
- detectOutputLanguage,
7
11
  transcriptRoleKind,
8
- type TranscriptRoleKind,
9
12
  formatBytes,
10
13
  formatMessageOffset,
11
14
  formatMessageTimestamp,
12
15
  formatMs,
13
- formatUsage,
16
+ formatTurnDuration,
14
17
  requesterLabel,
18
+ summarizeMessages,
19
+ summarizeToolCalls,
20
+ summarizeUsage,
21
+ turnMessageCount,
15
22
  stringifyPartValue,
16
23
  unavailableTranscriptLabel,
17
24
  visualStatusForSession,
@@ -24,7 +31,21 @@ import type {
24
31
  } from "../types";
25
32
  import { StatusBadge } from "./StatusBadge";
26
33
  import { ToolFrame, toolFrameClass } from "./ToolFrame";
34
+ import {
35
+ TranscriptHeadingMeta,
36
+ TranscriptHeadingRow,
37
+ TranscriptThoughtLabel,
38
+ } from "./TranscriptHeadingRow";
39
+ import { MetricList, type MetricListItem } from "./Metric";
40
+ import {
41
+ DurationMetric,
42
+ MessagesMetric,
43
+ TokenMetric,
44
+ ToolCallsMetric,
45
+ } from "./TelemetryMetrics";
27
46
  import { TranscriptText } from "./TranscriptText";
47
+ import { TranscriptThinkingView } from "./TranscriptThinkingView";
48
+ import { TranscriptToolRun } from "./TranscriptToolRun";
28
49
  import { TranscriptToolView } from "./TranscriptToolView";
29
50
  import {
30
51
  countRenderedTranscriptChildren,
@@ -40,6 +61,11 @@ import {
40
61
  } from "./transcriptStyles";
41
62
  import { previewToolValue } from "./transcriptPreview";
42
63
 
64
+ type TranscriptEntry = ReturnType<typeof groupTranscriptMessages>[number];
65
+ type TranscriptMessageEntry = Extract<TranscriptEntry, { kind: "message" }>;
66
+ type TranscriptThinkingEntry = Extract<TranscriptEntry, { kind: "thinking" }>;
67
+ type TranscriptToolEntry = Extract<TranscriptEntry, { kind: "tool" }>;
68
+
43
69
  /** Render one conversation turn as actor messages and tool events. */
44
70
  export function TurnTranscript(props: {
45
71
  number: number;
@@ -87,10 +113,10 @@ function transcriptMessageClass(role: string): string {
87
113
  const kind = transcriptRoleKind(role);
88
114
 
89
115
  return cn(
90
- "grid min-w-0 gap-2 border-l-4 py-2 pl-3",
116
+ "grid min-w-0 grid-cols-[minmax(0,1fr)] gap-2 border-l-4 py-2 pl-3",
91
117
  kind === "assistant" &&
92
- "border-l-violet-300 bg-[rgba(190,170,255,0.08)] pr-3 text-white",
93
- kind === "user" && "border-l-white/70 bg-white/[0.04] pr-3 text-[#f4f4f4]",
118
+ "border-l-violet-300 bg-[rgba(190,170,255,0.14)] pr-3 text-white",
119
+ kind === "user" && "border-l-white/70 bg-white/[0.08] pr-3 text-[#f4f4f4]",
94
120
  kind === "system" &&
95
121
  "border-l-amber-300 bg-amber-300/[0.06] pr-3 text-[#f4f4f4]",
96
122
  kind === "tool" && "border-l-[#888] text-[#b8b8b8]",
@@ -102,7 +128,7 @@ function transcriptRoleClass(role: string): string {
102
128
  const kind = transcriptRoleKind(role);
103
129
 
104
130
  return cn(
105
- "flex flex-wrap items-baseline gap-2 text-[0.88rem] leading-snug",
131
+ "text-[0.88rem] leading-snug",
106
132
  kind === "assistant" && "text-[#d8ccff]",
107
133
  kind === "user" && "text-white",
108
134
  kind === "system" && "text-amber-200",
@@ -139,6 +165,32 @@ function TranscriptMessageShell(props: {
139
165
  );
140
166
  }
141
167
 
168
+ function TranscriptMessageHeader(props: {
169
+ meta?: Array<string | undefined>;
170
+ role: string;
171
+ turn: ConversationTurn;
172
+ }) {
173
+ const metaText = props.meta?.filter(isString).join(" · ");
174
+
175
+ return (
176
+ <TranscriptHeadingRow
177
+ left={
178
+ <span className={transcriptRoleLabelClass(props.role)}>
179
+ {transcriptRoleLabel(props.role, props.turn)}
180
+ </span>
181
+ }
182
+ leftClassName={transcriptRoleClass(props.role)}
183
+ right={
184
+ metaText ? (
185
+ <TranscriptHeadingMeta className="text-[0.78rem] text-[#888]">
186
+ {metaText}
187
+ </TranscriptHeadingMeta>
188
+ ) : undefined
189
+ }
190
+ />
191
+ );
192
+ }
193
+
142
194
  function TurnHeader(props: { number: number; turn: ConversationTurn }) {
143
195
  const status = visualStatusForSession(props.turn);
144
196
 
@@ -148,22 +200,10 @@ function TurnHeader(props: { number: number; turn: ConversationTurn }) {
148
200
  <div className="break-all text-[1.05rem] font-bold leading-tight tracking-normal">
149
201
  Turn {props.number}
150
202
  </div>
151
- <div className={cn(mutedTranscriptMetaClass(), "mt-1")}>
152
- {turnMeta(props.turn).join(" · ")}
153
- {props.turn.sentryTraceUrl ? (
154
- <>
155
- {" · "}
156
- <a
157
- className="text-white no-underline hover:underline"
158
- href={props.turn.sentryTraceUrl}
159
- rel="noreferrer"
160
- target="_blank"
161
- >
162
- View in Sentry
163
- </a>
164
- </>
165
- ) : null}
166
- </div>
203
+ <MetricList
204
+ className={cn(mutedTranscriptMetaClass(), "mt-1")}
205
+ items={turnMeta(props.turn)}
206
+ />
167
207
  </div>
168
208
  <StatusBadge status={status} />
169
209
  </div>
@@ -175,10 +215,28 @@ function TurnEvents(props: {
175
215
  view: TranscriptViewMode;
176
216
  }) {
177
217
  return (
178
- <div className="grid gap-3 pt-3">
218
+ <div className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-2 pt-3">
179
219
  {props.turn.transcriptAvailable ? (
180
- groupTranscriptMessages(props.turn.transcript).map((entry, index) =>
181
- entry.kind === "tool" ? (
220
+ <TranscriptEntryList
221
+ entries={groupTranscriptMessages(props.turn.transcript)}
222
+ keyPrefix={props.turn.id}
223
+ renderMessage={(entry, index) => (
224
+ <TranscriptMessageView
225
+ key={`${props.turn.id}:${index}`}
226
+ message={entry.message}
227
+ turn={props.turn}
228
+ view={props.view}
229
+ />
230
+ )}
231
+ renderThinking={(entry, index) => (
232
+ <TranscriptThinkingView
233
+ key={`${props.turn.id}:thinking:${index}`}
234
+ timestamp={entry.timestamp}
235
+ turn={props.turn}
236
+ value={entry.part.output}
237
+ />
238
+ )}
239
+ renderTool={(entry, index) => (
182
240
  <TranscriptToolView
183
241
  call={entry.call}
184
242
  key={`${props.turn.id}:${index}`}
@@ -187,15 +245,8 @@ function TurnEvents(props: {
187
245
  timestamp={entry.timestamp}
188
246
  view={props.view}
189
247
  />
190
- ) : (
191
- <TranscriptMessageView
192
- key={`${props.turn.id}:${index}`}
193
- message={entry.message}
194
- turn={props.turn}
195
- view={props.view}
196
- />
197
- ),
198
- )
248
+ )}
249
+ />
199
250
  ) : props.turn.transcriptRedacted &&
200
251
  props.turn.transcriptMetadata?.length ? (
201
252
  <RedactedTranscriptView turn={props.turn} />
@@ -208,28 +259,79 @@ function TurnEvents(props: {
208
259
  );
209
260
  }
210
261
 
262
+ function TranscriptEntryList(props: {
263
+ entries: TranscriptEntry[];
264
+ keyPrefix: string;
265
+ renderMessage: (entry: TranscriptMessageEntry, index: number) => ReactNode;
266
+ renderThinking: (entry: TranscriptThinkingEntry, index: number) => ReactNode;
267
+ renderTool: (entry: TranscriptToolEntry, index: number) => ReactNode;
268
+ }) {
269
+ const rows: ReactNode[] = [];
270
+
271
+ for (let index = 0; index < props.entries.length; ) {
272
+ const entry = props.entries[index]!;
273
+
274
+ if (entry.kind === "tool") {
275
+ const startIndex = index;
276
+ const tools: TranscriptToolEntry[] = [];
277
+ while (props.entries[index]?.kind === "tool") {
278
+ tools.push(props.entries[index] as TranscriptToolEntry);
279
+ index += 1;
280
+ }
281
+ rows.push(
282
+ <TranscriptToolRun
283
+ entries={tools}
284
+ key={`${props.keyPrefix}:tool-run:${startIndex}`}
285
+ keyPrefix={props.keyPrefix}
286
+ renderTool={props.renderTool}
287
+ startIndex={startIndex}
288
+ />,
289
+ );
290
+ continue;
291
+ }
292
+
293
+ rows.push(
294
+ <Fragment key={`${props.keyPrefix}:${entry.kind}:${index}`}>
295
+ {entry.kind === "thinking"
296
+ ? props.renderThinking(entry, index)
297
+ : props.renderMessage(entry, index)}
298
+ </Fragment>,
299
+ );
300
+ index += 1;
301
+ }
302
+
303
+ return <>{rows}</>;
304
+ }
305
+
211
306
  function RedactedTranscriptView(props: { turn: ConversationTurn }) {
212
307
  return (
213
- <>
214
- {groupTranscriptMessages(props.turn.transcriptMetadata ?? []).map(
215
- (entry, index) =>
216
- entry.kind === "tool" ? (
217
- <RedactedToolView
218
- call={entry.call}
219
- key={`${props.turn.id}:redacted:${index}`}
220
- result={entry.result}
221
- resultTimestamp={entry.resultTimestamp}
222
- timestamp={entry.timestamp}
223
- />
224
- ) : (
225
- <RedactedMessageView
226
- key={`${props.turn.id}:redacted:${index}`}
227
- message={entry.message}
228
- turn={props.turn}
229
- />
230
- ),
308
+ <TranscriptEntryList
309
+ entries={groupTranscriptMessages(props.turn.transcriptMetadata ?? [])}
310
+ keyPrefix={`${props.turn.id}:redacted`}
311
+ renderMessage={(entry, index) => (
312
+ <RedactedMessageView
313
+ key={`${props.turn.id}:redacted:${index}`}
314
+ message={entry.message}
315
+ turn={props.turn}
316
+ />
317
+ )}
318
+ renderThinking={(entry, index) => (
319
+ <RedactedThinkingView
320
+ key={`${props.turn.id}:redacted:thinking:${index}`}
321
+ timestamp={entry.timestamp}
322
+ turn={props.turn}
323
+ />
324
+ )}
325
+ renderTool={(entry, index) => (
326
+ <RedactedToolView
327
+ call={entry.call}
328
+ key={`${props.turn.id}:redacted:${index}`}
329
+ result={entry.result}
330
+ resultTimestamp={entry.resultTimestamp}
331
+ timestamp={entry.timestamp}
332
+ />
231
333
  )}
232
- </>
334
+ />
233
335
  );
234
336
  }
235
337
 
@@ -244,20 +346,12 @@ function RedactedMessageView(props: {
244
346
 
245
347
  return (
246
348
  <TranscriptMessageShell role={props.message.role}>
247
- <div className={transcriptRoleClass(props.message.role)}>
248
- <span className={transcriptRoleLabelClass(props.message.role)}>
249
- {transcriptRoleLabel(props.message.role, props.turn)}
250
- </span>
251
- {meta.map((value, index) => (
252
- <span
253
- className="font-mono text-[0.78rem] text-[#888]"
254
- key={`${index}-${value}`}
255
- >
256
- {value}
257
- </span>
258
- ))}
259
- </div>
260
- <div className="grid min-w-0 gap-1 font-mono text-[0.9rem] leading-snug text-[#b8b8b8]">
349
+ <TranscriptMessageHeader
350
+ meta={meta}
351
+ role={props.message.role}
352
+ turn={props.turn}
353
+ />
354
+ <div className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-1 font-mono text-[0.9rem] leading-snug text-[#b8b8b8]">
261
355
  {props.message.parts.map((part, index) => (
262
356
  <RedactedPartLine key={index} part={part} />
263
357
  ))}
@@ -297,6 +391,41 @@ function RedactedMarker() {
297
391
  );
298
392
  }
299
393
 
394
+ function RedactedThinkingView(props: {
395
+ timestamp?: number;
396
+ turn: ConversationTurn;
397
+ }) {
398
+ const offset = formatMessageOffset(props.turn, props.timestamp);
399
+ const meta = [
400
+ typeof props.timestamp === "number"
401
+ ? formatMessageTimestamp(props.timestamp)
402
+ : undefined,
403
+ offset,
404
+ ].filter(isString);
405
+ const metaText = meta.join(" · ");
406
+
407
+ return (
408
+ <div className="py-1.5 text-[0.84rem] leading-relaxed text-[#888]">
409
+ <TranscriptHeadingRow
410
+ left={
411
+ <>
412
+ <TranscriptThoughtLabel />
413
+ <RedactedMarker />
414
+ </>
415
+ }
416
+ leftClassName="gap-3"
417
+ right={
418
+ metaText ? (
419
+ <TranscriptHeadingMeta className="text-[0.78rem] text-[#777]">
420
+ {metaText}
421
+ </TranscriptHeadingMeta>
422
+ ) : undefined
423
+ }
424
+ />
425
+ </div>
426
+ );
427
+ }
428
+
300
429
  function RedactedToolView(props: {
301
430
  call?: TranscriptPart;
302
431
  result?: TranscriptPart;
@@ -316,22 +445,27 @@ function RedactedToolView(props: {
316
445
  ? formatMs(props.resultTimestamp - props.timestamp)
317
446
  : undefined;
318
447
  const meta = [
319
- props.timestamp ? formatMessageTimestamp(props.timestamp) : undefined,
448
+ typeof props.timestamp === "number"
449
+ ? formatMessageTimestamp(props.timestamp)
450
+ : undefined,
320
451
  duration,
321
452
  props.result ? undefined : "missing result",
322
453
  ].filter(isString);
454
+ const mobileSummaryMeta =
455
+ duration ?? (props.call && !props.result ? "missing result" : undefined);
323
456
 
324
457
  return (
325
458
  <ToolFrame
326
459
  meta={meta}
460
+ mobileSummaryMeta={mobileSummaryMeta}
327
461
  raw
328
462
  signature={
329
463
  <>
330
- <strong className="min-w-0 break-words font-bold text-white">
464
+ <strong className="min-w-0 break-words font-bold text-[#d6d6d6]">
331
465
  {toolName}
332
466
  </strong>
333
467
  {props.call?.inputKeys?.length ? (
334
- <code className="min-w-0 break-words font-[inherit] text-[#b8b8b8]">
468
+ <code className="min-w-0 break-words font-[inherit] text-[#b8b8b8] max-md:hidden">
335
469
  ({props.call.inputKeys.join(", ")})
336
470
  </code>
337
471
  ) : null}
@@ -347,16 +481,135 @@ function redactedMessageSize(part: TranscriptPart): string | undefined {
347
481
  }
348
482
 
349
483
  function turnActorLabel(turn: ConversationTurn): string {
350
- return (
351
- requesterLabel(turn.requesterIdentity, turn.requester) ?? "unknown actor"
352
- );
484
+ return requesterLabel(turn.requesterIdentity) ?? "User";
485
+ }
486
+
487
+ function turnMessageSummary(turn: ConversationTurn) {
488
+ const summary = summarizeMessages([turn]);
489
+ if (summary.total > 0) return summary;
490
+ const total = turnMessageCount(turn);
491
+ return total > 0 ? { items: [], total } : undefined;
492
+ }
493
+
494
+ function turnMeta(turn: ConversationTurn): MetricListItem[] {
495
+ const duration = formatTurnDuration(turn);
496
+ const tokenSummary = summarizeUsage([turn.cumulativeUsage]);
497
+ const toolSummary = summarizeToolCalls([turn]);
498
+ const messageSummary = turnMessageSummary(turn);
499
+ const items: Array<MetricListItem | undefined> = [
500
+ duration !== "none"
501
+ ? {
502
+ content: (
503
+ <DurationMetric
504
+ endedAt={turn.completedAt ?? turn.lastSeenAt}
505
+ label={duration}
506
+ startedAt={turn.startedAt}
507
+ />
508
+ ),
509
+ key: "duration",
510
+ }
511
+ : undefined,
512
+ tokenSummary
513
+ ? {
514
+ content: <TokenMetric summary={tokenSummary} />,
515
+ key: "tokens",
516
+ }
517
+ : undefined,
518
+ messageSummary
519
+ ? {
520
+ content: <MessagesMetric summary={messageSummary} />,
521
+ key: "messages",
522
+ }
523
+ : undefined,
524
+ toolSummary.total > 0
525
+ ? {
526
+ content: <ToolCallsMetric summary={toolSummary} />,
527
+ key: "tools",
528
+ }
529
+ : undefined,
530
+ turn.sentryTraceUrl
531
+ ? {
532
+ content: (
533
+ <a
534
+ className="text-white no-underline hover:underline"
535
+ href={turn.sentryTraceUrl}
536
+ rel="noreferrer"
537
+ target="_blank"
538
+ >
539
+ View in Sentry
540
+ </a>
541
+ ),
542
+ key: "sentry",
543
+ }
544
+ : undefined,
545
+ ];
546
+
547
+ return items.filter((item): item is MetricListItem => Boolean(item));
353
548
  }
354
549
 
355
- function turnMeta(turn: ConversationTurn): string[] {
356
- return [
357
- formatMs(turn.cumulativeDurationMs),
358
- formatUsage(turn.cumulativeUsage),
359
- ].filter((value) => value && value !== "none");
550
+ /**
551
+ * Render the system prompt as a collapsed disclosure. Uses the same
552
+ * groupTranscriptParts → TranscriptPartView → TranscriptText pipeline as every
553
+ * other message so XML tag collapsing, syntax highlighting, and copy behaviour
554
+ * stay consistent. detectLanguage returns "xml" for the system prompt once the
555
+ * block-level XML heuristic in format.ts fires.
556
+ */
557
+ function SystemMessageView(props: {
558
+ message: TranscriptMessage;
559
+ turn: ConversationTurn;
560
+ view: TranscriptViewMode;
561
+ }) {
562
+ const [open, setOpen] = useState(false);
563
+ const rawText = messageRawText(props.message);
564
+ const role = props.message.role;
565
+ const byteCount = new TextEncoder().encode(rawText).byteLength;
566
+ const renderedParts = groupTranscriptParts(props.message.parts);
567
+ const totalRenderedChildren = renderedParts.reduce(
568
+ (count, part) => count + countRenderedTranscriptChildren(part, role),
569
+ 0,
570
+ );
571
+ let seenRenderedChildren = 0;
572
+
573
+ return (
574
+ <details
575
+ className={cn(transcriptMessageClass(role), !open && "gap-y-0")}
576
+ onToggle={(event) => {
577
+ if (event.currentTarget !== event.target) return;
578
+ setOpen(event.currentTarget.open);
579
+ }}
580
+ open={open}
581
+ >
582
+ <summary className="block min-h-6 cursor-pointer list-none [&::-webkit-details-marker]:hidden">
583
+ <TranscriptMessageHeader
584
+ meta={[formatBytes(byteCount)]}
585
+ role={role}
586
+ turn={props.turn}
587
+ />
588
+ </summary>
589
+ {props.view === "raw" ? (
590
+ <HighlightedCode
591
+ code={rawText || "{}"}
592
+ language={detectLanguage(rawText)}
593
+ />
594
+ ) : (
595
+ <div className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-2">
596
+ {renderedParts.map((part, index) => {
597
+ const firstChildIndex = seenRenderedChildren;
598
+ seenRenderedChildren += countRenderedTranscriptChildren(part, role);
599
+ return (
600
+ <TranscriptPartView
601
+ firstChildIndex={firstChildIndex}
602
+ key={index}
603
+ lastChildIndex={totalRenderedChildren - 1}
604
+ part={part}
605
+ role={role}
606
+ />
607
+ );
608
+ })}
609
+ </div>
610
+ )}
611
+ </details>
612
+ );
360
613
  }
361
614
 
362
615
  function TranscriptMessageView(props: {
@@ -364,6 +617,16 @@ function TranscriptMessageView(props: {
364
617
  turn: ConversationTurn;
365
618
  view: TranscriptViewMode;
366
619
  }) {
620
+ if (transcriptRoleKind(props.message.role) === "system") {
621
+ return (
622
+ <SystemMessageView
623
+ message={props.message}
624
+ turn={props.turn}
625
+ view={props.view}
626
+ />
627
+ );
628
+ }
629
+
367
630
  const offset = formatMessageOffset(props.turn, props.message.timestamp);
368
631
  const renderedParts = groupTranscriptParts(props.message.parts);
369
632
  const rawText = messageRawText(props.message);
@@ -383,24 +646,18 @@ function TranscriptMessageView(props: {
383
646
  event.preventDefault();
384
647
  }}
385
648
  >
386
- <div className={transcriptRoleClass(props.message.role)}>
387
- <span className={transcriptRoleLabelClass(props.message.role)}>
388
- {transcriptRoleLabel(props.message.role, props.turn)}
389
- </span>
390
- <span className="font-mono text-[0.78rem] text-[#888]">
391
- {formatMessageTimestamp(props.message.timestamp)}
392
- </span>
393
- {offset ? (
394
- <span className="font-mono text-[0.78rem] text-[#888]">{offset}</span>
395
- ) : null}
396
- </div>
649
+ <TranscriptMessageHeader
650
+ meta={[formatMessageTimestamp(props.message.timestamp), offset]}
651
+ role={props.message.role}
652
+ turn={props.turn}
653
+ />
397
654
  {props.view === "raw" ? (
398
655
  <HighlightedCode
399
656
  code={rawText || "{}"}
400
657
  language={detectLanguage(rawText)}
401
658
  />
402
659
  ) : (
403
- <div className="grid min-w-0 gap-2">
660
+ <div className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-2">
404
661
  {renderedParts.map((part, index) => {
405
662
  const firstChildIndex = seenRenderedChildren;
406
663
  seenRenderedChildren += countRenderedTranscriptChildren(part, role);
@@ -446,57 +703,36 @@ function TranscriptPartView(props: {
446
703
 
447
704
  const value = part.output;
448
705
  if (part.type === "thinking") {
449
- return <ThinkingPartView value={value} />;
706
+ return <TranscriptThinkingView value={value} />;
450
707
  }
451
708
 
452
709
  const rendered = stringifyPartValue(value);
453
710
  return (
454
711
  <details className={toolFrameClass()}>
455
- <summary className="grid cursor-pointer grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-3 px-3 py-2 font-mono text-[0.86rem] leading-tight text-[#b8b8b8] hover:bg-white/[0.04] max-md:grid-cols-1 max-md:gap-1">
456
- <span className="text-[#888]">{part.type}</span>
457
- <strong className="min-w-0 break-words font-bold text-white">
458
- {part.name ?? part.id ?? "unknown"}
459
- </strong>
460
- <span className="min-w-0 break-words text-right max-md:text-left">
461
- {previewToolValue(value)}
462
- </span>
712
+ <summary className="block cursor-pointer list-none py-1.5 font-mono text-[0.82rem] leading-tight text-[#b8b8b8] transition-colors hover:text-[#d6d6d6] [&::-webkit-details-marker]:hidden">
713
+ <TranscriptHeadingRow
714
+ left={
715
+ <>
716
+ <span className="text-[#888] max-md:hidden">{part.type}</span>
717
+ <strong className="min-w-0 break-words font-bold text-[#d6d6d6]">
718
+ {part.name ?? part.id ?? "unknown"}
719
+ </strong>
720
+ </>
721
+ }
722
+ leftClassName="gap-3"
723
+ right={
724
+ <span className="min-w-0 break-words text-right max-md:hidden">
725
+ {previewToolValue(value)}
726
+ </span>
727
+ }
728
+ rightClassName="min-w-0 max-md:hidden"
729
+ />
463
730
  </summary>
464
731
  <HighlightedCode code={rendered || "{}"} language="json" />
465
732
  </details>
466
733
  );
467
734
  }
468
735
 
469
- function ThinkingPartView(props: { value: unknown }) {
470
- const [open, setOpen] = useState(false);
471
- const rendered = stringifyPartValue(props.value);
472
-
473
- return (
474
- <details
475
- className="border border-[#beaaff]/20 bg-white/[0.03] transition-colors hover:border-[#beaaff]/45 hover:bg-[rgba(190,170,255,0.06)]"
476
- onToggle={(event) => {
477
- if (event.currentTarget !== event.target) return;
478
- setOpen(event.currentTarget.open);
479
- }}
480
- open={open}
481
- >
482
- <summary className="grid cursor-pointer grid-cols-[auto_minmax(0,1fr)] items-center gap-3 px-3 py-2 font-mono text-[0.8rem] leading-tight text-[#888] hover:bg-[rgba(190,170,255,0.07)] max-md:grid-cols-1 max-md:gap-1">
483
- <span className="uppercase text-[#b8b8b8]">thinking</span>
484
- {open ? null : (
485
- <span className="min-w-0 truncate">
486
- {previewToolValue(props.value)}
487
- </span>
488
- )}
489
- </summary>
490
- <div className="border-t border-[#beaaff]/15 px-3 py-3">
491
- <HighlightedCode
492
- code={rendered || "{}"}
493
- language={detectOutputLanguage(rendered)}
494
- />
495
- </div>
496
- </details>
497
- );
498
- }
499
-
500
736
  function isString(value: string | undefined): value is string {
501
737
  return typeof value === "string" && value.length > 0;
502
738
  }