@sentry/junior-dashboard 0.61.0 → 0.63.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 (38) hide show
  1. package/dist/app.d.ts +1 -0
  2. package/dist/app.js +44 -37
  3. package/dist/assets.d.ts +2 -0
  4. package/dist/client/api.d.ts +1 -1
  5. package/dist/client/components/Metric.d.ts +22 -0
  6. package/dist/client/components/TelemetryMetrics.d.ts +24 -0
  7. package/dist/client/components/TranscriptText.d.ts +1 -0
  8. package/dist/client/components/transcriptRenderModel.d.ts +24 -8
  9. package/dist/client/format.d.ts +65 -13
  10. package/dist/client/toolInvocations.d.ts +7 -0
  11. package/dist/client/types.d.ts +17 -100
  12. package/dist/client.js +59 -55992
  13. package/dist/handler.js +44 -37
  14. package/dist/index.d.ts +8 -0
  15. package/dist/index.js +564 -0
  16. package/dist/nitro.d.ts +6 -1
  17. package/dist/tailwind.css +1 -1
  18. package/dist/url.d.ts +13 -0
  19. package/package.json +8 -5
  20. package/src/app.ts +24 -16
  21. package/src/assets.ts +2 -0
  22. package/src/auth.ts +2 -35
  23. package/src/client/components/ConversationRowStats.tsx +3 -2
  24. package/src/client/components/Metric.tsx +159 -0
  25. package/src/client/components/TelemetryMetrics.tsx +124 -0
  26. package/src/client/components/ToolFrame.tsx +3 -3
  27. package/src/client/components/TranscriptText.tsx +5 -2
  28. package/src/client/components/TranscriptToolView.tsx +9 -7
  29. package/src/client/components/TranscriptTurn.tsx +435 -84
  30. package/src/client/components/TurnDurationChart.tsx +229 -78
  31. package/src/client/components/transcriptRenderModel.ts +66 -22
  32. package/src/client/format.ts +369 -103
  33. package/src/client/pages/ConversationPage.tsx +77 -38
  34. package/src/client/toolInvocations.ts +16 -0
  35. package/src/client/types.ts +34 -90
  36. package/src/index.ts +74 -0
  37. package/src/nitro.ts +6 -1
  38. package/src/url.ts +68 -0
@@ -1,15 +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,
11
+ transcriptRoleKind,
7
12
  formatBytes,
8
13
  formatMessageOffset,
9
14
  formatMessageTimestamp,
10
15
  formatMs,
11
- formatUsage,
16
+ formatTurnDuration,
12
17
  requesterLabel,
18
+ summarizeMessages,
19
+ summarizeToolCalls,
20
+ summarizeUsage,
21
+ turnMessageCount,
13
22
  stringifyPartValue,
14
23
  unavailableTranscriptLabel,
15
24
  visualStatusForSession,
@@ -22,6 +31,13 @@ import type {
22
31
  } from "../types";
23
32
  import { StatusBadge } from "./StatusBadge";
24
33
  import { ToolFrame, toolFrameClass } from "./ToolFrame";
34
+ import { MetricList, type MetricListItem } from "./Metric";
35
+ import {
36
+ DurationMetric,
37
+ MessagesMetric,
38
+ TokenMetric,
39
+ ToolCallsMetric,
40
+ } from "./TelemetryMetrics";
25
41
  import { TranscriptText } from "./TranscriptText";
26
42
  import { TranscriptToolView } from "./TranscriptToolView";
27
43
  import {
@@ -38,6 +54,15 @@ import {
38
54
  } from "./transcriptStyles";
39
55
  import { previewToolValue } from "./transcriptPreview";
40
56
 
57
+ type TranscriptEntry = ReturnType<typeof groupTranscriptMessages>[number];
58
+ type TranscriptMessageEntry = Extract<TranscriptEntry, { kind: "message" }>;
59
+ type TranscriptThinkingEntry = Extract<TranscriptEntry, { kind: "thinking" }>;
60
+ type TranscriptToolEntry = Extract<TranscriptEntry, { kind: "tool" }>;
61
+
62
+ const TOOL_RUN_COLLAPSE_THRESHOLD = 10;
63
+ const TOOL_RUN_HEAD_COUNT = 4;
64
+ const TOOL_RUN_TAIL_COUNT = 2;
65
+
41
66
  /** Render one conversation turn as actor messages and tool events. */
42
67
  export function TurnTranscript(props: {
43
68
  number: number;
@@ -72,17 +97,6 @@ function turnMarkerClass(
72
97
  );
73
98
  }
74
99
 
75
- type TranscriptRoleKind = "assistant" | "other" | "system" | "tool" | "user";
76
-
77
- function transcriptRoleKind(role: string): TranscriptRoleKind {
78
- const normalized = role.toLowerCase();
79
- if (normalized === "assistant") return "assistant";
80
- if (normalized === "user") return "user";
81
- if (normalized === "system") return "system";
82
- if (normalized.includes("tool")) return "tool";
83
- return "other";
84
- }
85
-
86
100
  function transcriptRoleLabel(role: string, turn: ConversationTurn): string {
87
101
  const kind = transcriptRoleKind(role);
88
102
  if (kind === "assistant") return "Junior";
@@ -157,22 +171,10 @@ function TurnHeader(props: { number: number; turn: ConversationTurn }) {
157
171
  <div className="break-all text-[1.05rem] font-bold leading-tight tracking-normal">
158
172
  Turn {props.number}
159
173
  </div>
160
- <div className={cn(mutedTranscriptMetaClass(), "mt-1")}>
161
- {turnMeta(props.turn).join(" · ")}
162
- {props.turn.sentryTraceUrl ? (
163
- <>
164
- {" · "}
165
- <a
166
- className="text-white no-underline hover:underline"
167
- href={props.turn.sentryTraceUrl}
168
- rel="noreferrer"
169
- target="_blank"
170
- >
171
- View in Sentry
172
- </a>
173
- </>
174
- ) : null}
175
- </div>
174
+ <MetricList
175
+ className={cn(mutedTranscriptMetaClass(), "mt-1")}
176
+ items={turnMeta(props.turn)}
177
+ />
176
178
  </div>
177
179
  <StatusBadge status={status} />
178
180
  </div>
@@ -184,10 +186,28 @@ function TurnEvents(props: {
184
186
  view: TranscriptViewMode;
185
187
  }) {
186
188
  return (
187
- <div className="grid gap-3 pt-3">
189
+ <div className="grid gap-2 pt-3">
188
190
  {props.turn.transcriptAvailable ? (
189
- groupTranscriptMessages(props.turn.transcript).map((entry, index) =>
190
- entry.kind === "tool" ? (
191
+ <TranscriptEntryList
192
+ entries={groupTranscriptMessages(props.turn.transcript)}
193
+ keyPrefix={props.turn.id}
194
+ renderMessage={(entry, index) => (
195
+ <TranscriptMessageView
196
+ key={`${props.turn.id}:${index}`}
197
+ message={entry.message}
198
+ turn={props.turn}
199
+ view={props.view}
200
+ />
201
+ )}
202
+ renderThinking={(entry, index) => (
203
+ <ThinkingPartView
204
+ key={`${props.turn.id}:thinking:${index}`}
205
+ timestamp={entry.timestamp}
206
+ turn={props.turn}
207
+ value={entry.part.output}
208
+ />
209
+ )}
210
+ renderTool={(entry, index) => (
191
211
  <TranscriptToolView
192
212
  call={entry.call}
193
213
  key={`${props.turn.id}:${index}`}
@@ -196,15 +216,8 @@ function TurnEvents(props: {
196
216
  timestamp={entry.timestamp}
197
217
  view={props.view}
198
218
  />
199
- ) : (
200
- <TranscriptMessageView
201
- key={`${props.turn.id}:${index}`}
202
- message={entry.message}
203
- turn={props.turn}
204
- view={props.view}
205
- />
206
- ),
207
- )
219
+ )}
220
+ />
208
221
  ) : props.turn.transcriptRedacted &&
209
222
  props.turn.transcriptMetadata?.length ? (
210
223
  <RedactedTranscriptView turn={props.turn} />
@@ -217,31 +230,187 @@ function TurnEvents(props: {
217
230
  );
218
231
  }
219
232
 
220
- function RedactedTranscriptView(props: { turn: ConversationTurn }) {
233
+ function TranscriptEntryList(props: {
234
+ entries: TranscriptEntry[];
235
+ keyPrefix: string;
236
+ renderMessage: (entry: TranscriptMessageEntry, index: number) => ReactNode;
237
+ renderThinking: (entry: TranscriptThinkingEntry, index: number) => ReactNode;
238
+ renderTool: (entry: TranscriptToolEntry, index: number) => ReactNode;
239
+ }) {
240
+ const rows: ReactNode[] = [];
241
+
242
+ for (let index = 0; index < props.entries.length; ) {
243
+ const entry = props.entries[index]!;
244
+
245
+ if (entry.kind === "tool") {
246
+ const startIndex = index;
247
+ const tools: TranscriptToolEntry[] = [];
248
+ while (props.entries[index]?.kind === "tool") {
249
+ tools.push(props.entries[index] as TranscriptToolEntry);
250
+ index += 1;
251
+ }
252
+ rows.push(
253
+ <ToolRunView
254
+ entries={tools}
255
+ key={`${props.keyPrefix}:tool-run:${startIndex}`}
256
+ keyPrefix={props.keyPrefix}
257
+ renderTool={props.renderTool}
258
+ startIndex={startIndex}
259
+ />,
260
+ );
261
+ continue;
262
+ }
263
+
264
+ rows.push(
265
+ <Fragment key={`${props.keyPrefix}:${entry.kind}:${index}`}>
266
+ {entry.kind === "thinking"
267
+ ? props.renderThinking(entry, index)
268
+ : props.renderMessage(entry, index)}
269
+ </Fragment>,
270
+ );
271
+ index += 1;
272
+ }
273
+
274
+ return <>{rows}</>;
275
+ }
276
+
277
+ function ToolRunView(props: {
278
+ entries: TranscriptToolEntry[];
279
+ keyPrefix: string;
280
+ renderTool: (entry: TranscriptToolEntry, index: number) => ReactNode;
281
+ startIndex: number;
282
+ }) {
283
+ const [expanded, setExpanded] = useState(false);
284
+
285
+ if (props.entries.length < TOOL_RUN_COLLAPSE_THRESHOLD) {
286
+ return (
287
+ <>
288
+ {renderToolEntries(
289
+ props.entries,
290
+ props.startIndex,
291
+ props.keyPrefix,
292
+ props.renderTool,
293
+ )}
294
+ </>
295
+ );
296
+ }
297
+
298
+ if (expanded) {
299
+ return (
300
+ <>
301
+ {renderToolEntries(
302
+ props.entries,
303
+ props.startIndex,
304
+ props.keyPrefix,
305
+ props.renderTool,
306
+ )}
307
+ <ToolRunToggle
308
+ expanded
309
+ onClick={() => setExpanded(false)}
310
+ totalCount={props.entries.length}
311
+ />
312
+ </>
313
+ );
314
+ }
315
+
316
+ const hiddenCount =
317
+ props.entries.length - TOOL_RUN_HEAD_COUNT - TOOL_RUN_TAIL_COUNT;
318
+
221
319
  return (
222
320
  <>
223
- {groupTranscriptMessages(props.turn.transcriptMetadata ?? []).map(
224
- (entry, index) =>
225
- entry.kind === "tool" ? (
226
- <RedactedToolView
227
- call={entry.call}
228
- key={`${props.turn.id}:redacted:${index}`}
229
- result={entry.result}
230
- resultTimestamp={entry.resultTimestamp}
231
- timestamp={entry.timestamp}
232
- />
233
- ) : (
234
- <RedactedMessageView
235
- key={`${props.turn.id}:redacted:${index}`}
236
- message={entry.message}
237
- turn={props.turn}
238
- />
239
- ),
321
+ {renderToolEntries(
322
+ props.entries.slice(0, TOOL_RUN_HEAD_COUNT),
323
+ props.startIndex,
324
+ props.keyPrefix,
325
+ props.renderTool,
326
+ )}
327
+ <ToolRunToggle
328
+ hiddenCount={hiddenCount}
329
+ onClick={() => setExpanded(true)}
330
+ totalCount={props.entries.length}
331
+ />
332
+ {renderToolEntries(
333
+ props.entries.slice(-TOOL_RUN_TAIL_COUNT),
334
+ props.startIndex + props.entries.length - TOOL_RUN_TAIL_COUNT,
335
+ props.keyPrefix,
336
+ props.renderTool,
240
337
  )}
241
338
  </>
242
339
  );
243
340
  }
244
341
 
342
+ function renderToolEntries(
343
+ entries: TranscriptToolEntry[],
344
+ startIndex: number,
345
+ keyPrefix: string,
346
+ renderTool: (entry: TranscriptToolEntry, index: number) => ReactNode,
347
+ ): ReactNode[] {
348
+ return entries.map((entry, offset) => {
349
+ const index = startIndex + offset;
350
+ return (
351
+ <Fragment key={`${keyPrefix}:tool:${index}`}>
352
+ {renderTool(entry, index)}
353
+ </Fragment>
354
+ );
355
+ });
356
+ }
357
+
358
+ function ToolRunToggle(props: {
359
+ expanded?: boolean;
360
+ hiddenCount?: number;
361
+ onClick: () => void;
362
+ totalCount: number;
363
+ }) {
364
+ const label = props.expanded
365
+ ? `collapse ${props.totalCount} tool calls`
366
+ : `show ${props.hiddenCount ?? 0} more tool calls`;
367
+
368
+ return (
369
+ <button
370
+ aria-expanded={props.expanded ?? false}
371
+ className="group flex w-full items-center gap-2 py-1 pl-3 text-left font-mono text-[0.78rem] leading-tight text-[#888] transition-colors hover:text-[#d6d6d6] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#beaaff]/55"
372
+ onClick={props.onClick}
373
+ type="button"
374
+ >
375
+ <span className="h-px min-w-4 flex-1 bg-white/10 transition-colors group-hover:bg-white/20" />
376
+ <span className="shrink-0">{label}</span>
377
+ <span className="h-px min-w-4 flex-1 bg-white/10 transition-colors group-hover:bg-white/20" />
378
+ </button>
379
+ );
380
+ }
381
+
382
+ function RedactedTranscriptView(props: { turn: ConversationTurn }) {
383
+ return (
384
+ <TranscriptEntryList
385
+ entries={groupTranscriptMessages(props.turn.transcriptMetadata ?? [])}
386
+ keyPrefix={`${props.turn.id}:redacted`}
387
+ renderMessage={(entry, index) => (
388
+ <RedactedMessageView
389
+ key={`${props.turn.id}:redacted:${index}`}
390
+ message={entry.message}
391
+ turn={props.turn}
392
+ />
393
+ )}
394
+ renderThinking={(entry, index) => (
395
+ <RedactedThinkingView
396
+ key={`${props.turn.id}:redacted:thinking:${index}`}
397
+ timestamp={entry.timestamp}
398
+ turn={props.turn}
399
+ />
400
+ )}
401
+ renderTool={(entry, index) => (
402
+ <RedactedToolView
403
+ call={entry.call}
404
+ key={`${props.turn.id}:redacted:${index}`}
405
+ result={entry.result}
406
+ resultTimestamp={entry.resultTimestamp}
407
+ timestamp={entry.timestamp}
408
+ />
409
+ )}
410
+ />
411
+ );
412
+ }
413
+
245
414
  function RedactedMessageView(props: {
246
415
  message: TranscriptMessage;
247
416
  turn: ConversationTurn;
@@ -306,6 +475,31 @@ function RedactedMarker() {
306
475
  );
307
476
  }
308
477
 
478
+ function RedactedThinkingView(props: {
479
+ timestamp?: number;
480
+ turn: ConversationTurn;
481
+ }) {
482
+ const offset = formatMessageOffset(props.turn, props.timestamp);
483
+ const meta = [
484
+ typeof props.timestamp === "number"
485
+ ? formatMessageTimestamp(props.timestamp)
486
+ : undefined,
487
+ offset,
488
+ ].filter(isString);
489
+
490
+ return (
491
+ <div className="py-0.5 text-[0.84rem] leading-relaxed text-[#888]">
492
+ <div className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-baseline gap-3 max-md:grid-cols-1 max-md:gap-1">
493
+ <span className="font-mono text-[0.78rem] text-[#777]">thought</span>
494
+ <RedactedMarker />
495
+ <span className="min-w-0 break-words text-right font-mono text-[0.78rem] text-[#777] max-md:text-left">
496
+ {meta.join(" · ")}
497
+ </span>
498
+ </div>
499
+ </div>
500
+ );
501
+ }
502
+
309
503
  function RedactedToolView(props: {
310
504
  call?: TranscriptPart;
311
505
  result?: TranscriptPart;
@@ -325,7 +519,9 @@ function RedactedToolView(props: {
325
519
  ? formatMs(props.resultTimestamp - props.timestamp)
326
520
  : undefined;
327
521
  const meta = [
328
- props.timestamp ? formatMessageTimestamp(props.timestamp) : undefined,
522
+ typeof props.timestamp === "number"
523
+ ? formatMessageTimestamp(props.timestamp)
524
+ : undefined,
329
525
  duration,
330
526
  props.result ? undefined : "missing result",
331
527
  ].filter(isString);
@@ -336,7 +532,7 @@ function RedactedToolView(props: {
336
532
  raw
337
533
  signature={
338
534
  <>
339
- <strong className="min-w-0 break-words font-bold text-white">
535
+ <strong className="min-w-0 break-words font-bold text-[#d6d6d6]">
340
536
  {toolName}
341
537
  </strong>
342
538
  {props.call?.inputKeys?.length ? (
@@ -356,16 +552,140 @@ function redactedMessageSize(part: TranscriptPart): string | undefined {
356
552
  }
357
553
 
358
554
  function turnActorLabel(turn: ConversationTurn): string {
359
- return (
360
- requesterLabel(turn.requesterIdentity, turn.requester) ?? "unknown actor"
361
- );
555
+ return requesterLabel(turn.requesterIdentity) ?? "User";
362
556
  }
363
557
 
364
- function turnMeta(turn: ConversationTurn): string[] {
365
- return [
366
- formatMs(turn.cumulativeDurationMs),
367
- formatUsage(turn.cumulativeUsage),
368
- ].filter((value) => value && value !== "none");
558
+ function turnMessageSummary(turn: ConversationTurn) {
559
+ const summary = summarizeMessages([turn]);
560
+ if (summary.total > 0) return summary;
561
+ const total = turnMessageCount(turn);
562
+ return total > 0 ? { items: [], total } : undefined;
563
+ }
564
+
565
+ function turnMeta(turn: ConversationTurn): MetricListItem[] {
566
+ const duration = formatTurnDuration(turn);
567
+ const tokenSummary = summarizeUsage([turn.cumulativeUsage]);
568
+ const toolSummary = summarizeToolCalls([turn]);
569
+ const messageSummary = turnMessageSummary(turn);
570
+ const items: Array<MetricListItem | undefined> = [
571
+ duration !== "none"
572
+ ? {
573
+ content: (
574
+ <DurationMetric
575
+ endedAt={turn.completedAt ?? turn.lastSeenAt}
576
+ label={duration}
577
+ startedAt={turn.startedAt}
578
+ />
579
+ ),
580
+ key: "duration",
581
+ }
582
+ : undefined,
583
+ tokenSummary
584
+ ? {
585
+ content: <TokenMetric summary={tokenSummary} />,
586
+ key: "tokens",
587
+ }
588
+ : undefined,
589
+ messageSummary
590
+ ? {
591
+ content: <MessagesMetric summary={messageSummary} />,
592
+ key: "messages",
593
+ }
594
+ : undefined,
595
+ toolSummary.total > 0
596
+ ? {
597
+ content: <ToolCallsMetric summary={toolSummary} />,
598
+ key: "tools",
599
+ }
600
+ : undefined,
601
+ turn.sentryTraceUrl
602
+ ? {
603
+ content: (
604
+ <a
605
+ className="text-white no-underline hover:underline"
606
+ href={turn.sentryTraceUrl}
607
+ rel="noreferrer"
608
+ target="_blank"
609
+ >
610
+ View in Sentry
611
+ </a>
612
+ ),
613
+ key: "sentry",
614
+ }
615
+ : undefined,
616
+ ];
617
+
618
+ return items.filter((item): item is MetricListItem => Boolean(item));
619
+ }
620
+
621
+ /**
622
+ * Render the system prompt as a collapsed disclosure. Uses the same
623
+ * groupTranscriptParts → TranscriptPartView → TranscriptText pipeline as every
624
+ * other message so XML tag collapsing, syntax highlighting, and copy behaviour
625
+ * stay consistent. detectLanguage returns "xml" for the system prompt once the
626
+ * block-level XML heuristic in format.ts fires.
627
+ */
628
+ function SystemMessageView(props: {
629
+ message: TranscriptMessage;
630
+ turn: ConversationTurn;
631
+ view: TranscriptViewMode;
632
+ }) {
633
+ const [open, setOpen] = useState(false);
634
+ const rawText = messageRawText(props.message);
635
+ const role = props.message.role;
636
+ const byteCount = new TextEncoder().encode(rawText).byteLength;
637
+ const renderedParts = groupTranscriptParts(props.message.parts);
638
+ const totalRenderedChildren = renderedParts.reduce(
639
+ (count, part) => count + countRenderedTranscriptChildren(part, role),
640
+ 0,
641
+ );
642
+ let seenRenderedChildren = 0;
643
+
644
+ return (
645
+ <details
646
+ className={cn(transcriptMessageClass(role), !open && "gap-0")}
647
+ onToggle={(event) => {
648
+ if (event.currentTarget !== event.target) return;
649
+ setOpen(event.currentTarget.open);
650
+ }}
651
+ open={open}
652
+ >
653
+ <summary className="flex min-h-6 cursor-pointer list-none items-center [&::-webkit-details-marker]:hidden">
654
+ <div className={cn(transcriptRoleClass(role), "items-center")}>
655
+ <span className={transcriptRoleLabelClass(role)}>
656
+ {transcriptRoleLabel(role, props.turn)}
657
+ </span>
658
+ {open ? null : (
659
+ <span className="font-mono text-[0.78rem] text-[#888]">
660
+ {formatBytes(byteCount)}
661
+ </span>
662
+ )}
663
+ </div>
664
+ </summary>
665
+ {props.view === "raw" ? (
666
+ <HighlightedCode
667
+ code={rawText || "{}"}
668
+ language={detectLanguage(rawText)}
669
+ />
670
+ ) : (
671
+ <div className="grid min-w-0 gap-2">
672
+ {renderedParts.map((part, index) => {
673
+ const firstChildIndex = seenRenderedChildren;
674
+ seenRenderedChildren += countRenderedTranscriptChildren(part, role);
675
+ return (
676
+ <TranscriptPartView
677
+ firstChildIndex={firstChildIndex}
678
+ key={index}
679
+ lastChildIndex={totalRenderedChildren - 1}
680
+ part={part}
681
+ role={role}
682
+ />
683
+ );
684
+ })}
685
+ </div>
686
+ )}
687
+ </details>
688
+ );
369
689
  }
370
690
 
371
691
  function TranscriptMessageView(props: {
@@ -373,11 +693,22 @@ function TranscriptMessageView(props: {
373
693
  turn: ConversationTurn;
374
694
  view: TranscriptViewMode;
375
695
  }) {
696
+ if (transcriptRoleKind(props.message.role) === "system") {
697
+ return (
698
+ <SystemMessageView
699
+ message={props.message}
700
+ turn={props.turn}
701
+ view={props.view}
702
+ />
703
+ );
704
+ }
705
+
376
706
  const offset = formatMessageOffset(props.turn, props.message.timestamp);
377
707
  const renderedParts = groupTranscriptParts(props.message.parts);
378
708
  const rawText = messageRawText(props.message);
709
+ const role = props.message.role;
379
710
  const totalRenderedChildren = renderedParts.reduce(
380
- (count, part) => count + countRenderedTranscriptChildren(part),
711
+ (count, part) => count + countRenderedTranscriptChildren(part, role),
381
712
  0,
382
713
  );
383
714
  let seenRenderedChildren = 0;
@@ -411,13 +742,14 @@ function TranscriptMessageView(props: {
411
742
  <div className="grid min-w-0 gap-2">
412
743
  {renderedParts.map((part, index) => {
413
744
  const firstChildIndex = seenRenderedChildren;
414
- seenRenderedChildren += countRenderedTranscriptChildren(part);
745
+ seenRenderedChildren += countRenderedTranscriptChildren(part, role);
415
746
  return (
416
747
  <TranscriptPartView
417
748
  firstChildIndex={firstChildIndex}
418
749
  key={index}
419
750
  lastChildIndex={totalRenderedChildren - 1}
420
751
  part={part}
752
+ role={role}
421
753
  />
422
754
  );
423
755
  })}
@@ -431,6 +763,7 @@ function TranscriptPartView(props: {
431
763
  firstChildIndex: number;
432
764
  lastChildIndex: number;
433
765
  part: RenderedTranscriptPart;
766
+ role?: string;
434
767
  }) {
435
768
  if (props.part.kind === "tool") {
436
769
  return (
@@ -444,6 +777,7 @@ function TranscriptPartView(props: {
444
777
  <TranscriptText
445
778
  firstChildIndex={props.firstChildIndex}
446
779
  lastChildIndex={props.lastChildIndex}
780
+ role={props.role}
447
781
  text={part.text ?? ""}
448
782
  />
449
783
  );
@@ -457,9 +791,9 @@ function TranscriptPartView(props: {
457
791
  const rendered = stringifyPartValue(value);
458
792
  return (
459
793
  <details className={toolFrameClass()}>
460
- <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">
794
+ <summary className="grid cursor-pointer list-none grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-3 py-1.5 font-mono text-[0.82rem] leading-tight text-[#b8b8b8] transition-colors hover:text-[#d6d6d6] max-md:grid-cols-1 max-md:gap-1 [&::-webkit-details-marker]:hidden">
461
795
  <span className="text-[#888]">{part.type}</span>
462
- <strong className="min-w-0 break-words font-bold text-white">
796
+ <strong className="min-w-0 break-words font-bold text-[#d6d6d6]">
463
797
  {part.name ?? part.id ?? "unknown"}
464
798
  </strong>
465
799
  <span className="min-w-0 break-words text-right max-md:text-left">
@@ -471,32 +805,49 @@ function TranscriptPartView(props: {
471
805
  );
472
806
  }
473
807
 
474
- function ThinkingPartView(props: { value: unknown }) {
808
+ function ThinkingPartView(props: {
809
+ timestamp?: number;
810
+ turn?: ConversationTurn;
811
+ value: unknown;
812
+ }) {
475
813
  const [open, setOpen] = useState(false);
476
814
  const rendered = stringifyPartValue(props.value);
815
+ const offset = props.turn
816
+ ? formatMessageOffset(props.turn, props.timestamp)
817
+ : undefined;
818
+ const meta = [
819
+ typeof props.timestamp === "number"
820
+ ? formatMessageTimestamp(props.timestamp)
821
+ : undefined,
822
+ offset,
823
+ ].filter(isString);
477
824
 
478
825
  return (
479
826
  <details
480
- className="border border-[#beaaff]/20 bg-white/[0.03] transition-colors hover:border-[#beaaff]/45 hover:bg-[rgba(190,170,255,0.06)]"
827
+ className="py-0.5 text-[0.84rem] leading-relaxed text-[#888]"
481
828
  onToggle={(event) => {
482
829
  if (event.currentTarget !== event.target) return;
483
830
  setOpen(event.currentTarget.open);
484
831
  }}
485
832
  open={open}
486
833
  >
487
- <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">
488
- <span className="uppercase text-[#b8b8b8]">thinking</span>
834
+ <summary className="grid cursor-pointer list-none grid-cols-[auto_minmax(0,1fr)_auto] items-baseline gap-3 transition-colors hover:text-[#b8b8b8] max-md:grid-cols-1 max-md:gap-1 [&::-webkit-details-marker]:hidden">
835
+ <span className="font-mono text-[0.78rem] not-italic text-[#777]">
836
+ thought
837
+ </span>
489
838
  {open ? null : (
490
- <span className="min-w-0 truncate">
839
+ <span className="min-w-0 truncate italic">
491
840
  {previewToolValue(props.value)}
492
841
  </span>
493
842
  )}
843
+ {meta.length ? (
844
+ <span className="min-w-0 break-words text-right font-mono text-[0.78rem] not-italic text-[#777] max-md:text-left">
845
+ {meta.join(" · ")}
846
+ </span>
847
+ ) : null}
494
848
  </summary>
495
- <div className="border-t border-[#beaaff]/15 px-3 py-3">
496
- <HighlightedCode
497
- code={rendered || "{}"}
498
- language={detectOutputLanguage(rendered)}
499
- />
849
+ <div className="min-w-0 whitespace-pre-wrap break-words py-1 italic text-[#9a9a9a]">
850
+ {rendered || "{}"}
500
851
  </div>
501
852
  </details>
502
853
  );