@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,3 +1,4 @@
1
+ import { Fragment, useMemo, useState, type ReactNode } from "react";
1
2
  import { useQuery } from "@tanstack/react-query";
2
3
  import { useNavigate } from "react-router";
3
4
  import {
@@ -12,20 +13,38 @@ import {
12
13
 
13
14
  import { readConversationData } from "../api";
14
15
  import {
16
+ buildConversations,
17
+ conversationDisplayTitle,
18
+ conversationRequesterLabel,
15
19
  conversationIdForSession,
16
20
  conversationPath,
21
+ formatDurationTick,
17
22
  formatMs,
18
- formatTokenTotal,
19
23
  requesterLabel,
20
24
  slackLocationLabel,
21
- turnToolCallCount,
25
+ summarizeMessages,
26
+ summarizeToolCalls,
27
+ summarizeUsage,
22
28
  visualStatusForSession,
29
+ visualStatusForConversation,
23
30
  } from "../format";
24
31
  import { cn } from "../styles";
25
- import type { ConversationDetailFeed, Session, VisualStatus } from "../types";
32
+ import type {
33
+ Conversation,
34
+ ConversationDetailFeed,
35
+ ConversationTurn,
36
+ Session,
37
+ VisualStatus,
38
+ } from "../types";
39
+ import { MetricValue } from "./Metric";
26
40
  import { Section } from "./Section";
27
41
  import { SectionHeader } from "./SectionHeader";
28
- import { SectionTitle } from "./SectionTitle";
42
+ import {
43
+ DurationMetric,
44
+ MessagesMetric,
45
+ TokenMetric,
46
+ ToolCallsMetric,
47
+ } from "./TelemetryMetrics";
29
48
  import { statusBorderClass } from "./statusStyles";
30
49
 
31
50
  /** Render recent turns by start time and duration. */
@@ -33,6 +52,7 @@ export function TurnDurationChart(props: {
33
52
  sessions: Session[];
34
53
  timeZone: string;
35
54
  }) {
55
+ const [mode, setMode] = useState<DurationChartMode>("conversations");
36
56
  const navigate = useNavigate();
37
57
  const nowMs = Date.now();
38
58
  const rangeStartMs = nowMs - 7 * 24 * 60 * 60 * 1000;
@@ -40,9 +60,18 @@ export function TurnDurationChart(props: {
40
60
  const chartEdgePaddingMs = 6 * 60 * 60 * 1000;
41
61
  const chartRangeStartMs = rangeStartMs - chartEdgePaddingMs;
42
62
  const chartRangeEndMs = rangeEndMs + chartEdgePaddingMs;
43
- const points = props.sessions
44
- .map((session) => turnPoint(session, props.timeZone))
45
- .filter((point): point is TurnDurationPoint => Boolean(point))
63
+ const conversations = useMemo(
64
+ () => (mode === "conversations" ? buildConversations(props.sessions) : []),
65
+ [mode, props.sessions],
66
+ );
67
+ const points = (
68
+ mode === "turns"
69
+ ? props.sessions.map((session) => turnPoint(session, props.timeZone))
70
+ : conversations.map((conversation) =>
71
+ conversationPoint(conversation, props.timeZone),
72
+ )
73
+ )
74
+ .filter((point): point is DurationPoint => Boolean(point))
46
75
  .filter((point) => point.x >= rangeStartMs && point.x <= rangeEndMs)
47
76
  .sort((left, right) => left.x - right.x);
48
77
  const totals = points.reduce(
@@ -61,26 +90,27 @@ export function TurnDurationChart(props: {
61
90
  const dayTicks = Array.from({ length: 7 }, (_, index) => {
62
91
  return rangeStartMs + index * 24 * 60 * 60 * 1000;
63
92
  });
64
- const openPoint = (point: TurnDurationPoint) => {
65
- navigate(conversationPath(conversationIdForSession(point.session)));
93
+ const openPoint = (point: DurationPoint) => {
94
+ navigate(conversationPath(point.conversationId));
66
95
  };
96
+ const modeLabel = mode === "turns" ? "turns" : "conversations";
67
97
 
68
98
  return (
69
99
  <Section>
70
100
  <SectionHeader
71
101
  actions={
72
- <div className="flex flex-wrap items-center gap-3 text-[0.78rem] font-semibold uppercase leading-none text-[#888]">
102
+ <div className="flex flex-wrap items-center justify-end gap-3 text-[0.78rem] font-semibold uppercase leading-none text-[#888]">
73
103
  <ChartLegendItem className="bg-[#b8b8b8]" label="Complete" />
74
104
  <ChartLegendItem className="bg-amber-400" label="Hung" />
75
105
  <ChartLegendItem className="bg-rose-400" label="Error" />
76
106
  </div>
77
107
  }
78
108
  >
79
- <SectionTitle>Turns</SectionTitle>
109
+ <ChartModeToggle mode={mode} onChange={setMode} />
80
110
  </SectionHeader>
81
111
  <div
82
112
  className="min-h-48 px-3 pb-2 pt-4"
83
- aria-label="Turn duration over the last 7 days"
113
+ aria-label={`${modeLabel} by duration over the last 7 days`}
84
114
  >
85
115
  <ResponsiveContainer height={190} width="100%">
86
116
  <ScatterChart margin={{ bottom: 0, left: 0, right: 4, top: 14 }}>
@@ -107,7 +137,7 @@ export function TurnDurationChart(props: {
107
137
  axisLine={false}
108
138
  dataKey="durationMs"
109
139
  domain={[0, durationAxisMaxMs]}
110
- tickFormatter={(value) => formatMs(Number(value))}
140
+ tickFormatter={(value) => formatDurationTick(Number(value))}
111
141
  tick={{
112
142
  fill: "#888",
113
143
  fontFamily:
@@ -116,6 +146,7 @@ export function TurnDurationChart(props: {
116
146
  }}
117
147
  tickLine={false}
118
148
  type="number"
149
+ width={48}
119
150
  />
120
151
  <Tooltip
121
152
  content={<TurnDurationTooltip />}
@@ -131,7 +162,7 @@ export function TurnDurationChart(props: {
131
162
  </ResponsiveContainer>
132
163
  </div>
133
164
  <div className="border-t border-white/10 px-4 py-3 text-[0.84rem] leading-tight text-[#888]">
134
- {totals.total} turns / {totals.hung} hung / {totals.failed} errors
165
+ {totals.total} {modeLabel} / {totals.hung} hung / {totals.failed} errors
135
166
  </div>
136
167
  </Section>
137
168
  );
@@ -147,38 +178,133 @@ function ChartLegendItem(props: { className: string; label: string }) {
147
178
  }
148
179
 
149
180
  type PlottedTurnStatus = Exclude<VisualStatus, "active">;
181
+ type DurationChartMode = "conversations" | "turns";
150
182
 
151
- type TurnDurationPoint = {
183
+ type DurationPoint = {
184
+ conversation?: Conversation;
185
+ conversationId: string;
186
+ durationLabel: string;
152
187
  durationMs: number;
153
- tooltipLabel: string;
154
- session: Session;
188
+ endedAt: string;
189
+ kind: DurationChartMode;
190
+ session?: Session;
191
+ startedAt: string;
155
192
  status: PlottedTurnStatus;
193
+ title: string;
194
+ tooltipLabel: string;
156
195
  x: number;
157
196
  };
158
197
 
159
- function turnPoint(
160
- session: Session,
161
- timeZone: string,
162
- ): TurnDurationPoint | null {
163
- const startedAtMs = Date.parse(session.startedAt ?? "");
198
+ function ChartModeToggle(props: {
199
+ mode: DurationChartMode;
200
+ onChange(mode: DurationChartMode): void;
201
+ }) {
202
+ const modes: Array<{ label: string; value: DurationChartMode }> = [
203
+ { label: "Conversations", value: "conversations" },
204
+ { label: "Turns", value: "turns" },
205
+ ];
206
+ return (
207
+ <div
208
+ aria-label="Duration chart mode"
209
+ className="inline-flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1"
210
+ role="group"
211
+ >
212
+ {modes.map((mode, index) => (
213
+ <Fragment key={mode.value}>
214
+ {index > 0 ? (
215
+ <span className="text-[1.05rem] font-bold leading-tight text-[#666]">
216
+ /
217
+ </span>
218
+ ) : null}
219
+ <button
220
+ aria-pressed={props.mode === mode.value}
221
+ className={cn(
222
+ "cursor-pointer border-0 bg-transparent p-0 text-[1.05rem] font-bold leading-tight tracking-normal transition-colors",
223
+ props.mode === mode.value
224
+ ? "text-white"
225
+ : "text-[#888] hover:text-white",
226
+ )}
227
+ onClick={() => props.onChange(mode.value)}
228
+ type="button"
229
+ >
230
+ {mode.label}
231
+ </button>
232
+ </Fragment>
233
+ ))}
234
+ </div>
235
+ );
236
+ }
237
+
238
+ function plottedStatus(status: VisualStatus): PlottedTurnStatus | null {
239
+ return status === "active" ? null : status;
240
+ }
241
+
242
+ function finiteDurationMs(value: number | undefined): number | undefined {
243
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
244
+ return Math.max(0, Math.floor(value));
245
+ }
246
+
247
+ function turnPoint(session: Session, timeZone: string): DurationPoint | null {
248
+ const startedAtMs = Date.parse(session.startedAt);
164
249
  if (!Number.isFinite(startedAtMs)) {
165
250
  return null;
166
251
  }
167
- const status = visualStatusForSession(session);
168
- if (status === "active") {
252
+ const status = plottedStatus(visualStatusForSession(session));
253
+ if (!status) {
169
254
  return null;
170
255
  }
171
256
 
172
- const lastSeenAtMs = Date.parse(session.lastSeenAt ?? "");
173
- const durationMs =
174
- session.cumulativeDurationMs ??
175
- (Number.isFinite(lastSeenAtMs)
176
- ? Math.max(0, lastSeenAtMs - startedAtMs)
177
- : 0);
257
+ const durationMs = finiteDurationMs(session.cumulativeDurationMs);
258
+ if (durationMs === undefined) {
259
+ return null;
260
+ }
178
261
  return {
262
+ conversationId: conversationIdForSession(session),
263
+ durationLabel: formatMs(durationMs),
179
264
  durationMs,
265
+ endedAt: session.completedAt ?? session.lastSeenAt,
266
+ kind: "turns",
180
267
  session,
268
+ startedAt: session.startedAt,
181
269
  status,
270
+ title: turnTooltipTitle(session),
271
+ tooltipLabel: new Date(startedAtMs).toLocaleString(undefined, {
272
+ timeZone,
273
+ }),
274
+ x: startedAtMs,
275
+ };
276
+ }
277
+
278
+ function conversationPoint(
279
+ conversation: Conversation,
280
+ timeZone: string,
281
+ ): DurationPoint | null {
282
+ const startedAtMs = Date.parse(conversation.startedAt);
283
+ if (!Number.isFinite(startedAtMs)) {
284
+ return null;
285
+ }
286
+ const status = plottedStatus(visualStatusForConversation(conversation));
287
+ if (!status) {
288
+ return null;
289
+ }
290
+ const durations = conversation.turns
291
+ .map((turn) => finiteDurationMs(turn.cumulativeDurationMs))
292
+ .filter((duration): duration is number => duration !== undefined);
293
+ if (durations.length === 0) {
294
+ return null;
295
+ }
296
+ const durationMs = durations.reduce((sum, duration) => sum + duration, 0);
297
+
298
+ return {
299
+ conversation,
300
+ conversationId: conversation.id,
301
+ durationLabel: formatMs(durationMs),
302
+ durationMs,
303
+ endedAt: conversation.lastSeenAt,
304
+ kind: "conversations",
305
+ startedAt: conversation.startedAt,
306
+ status,
307
+ title: conversationDisplayTitle(conversation),
182
308
  tooltipLabel: new Date(startedAtMs).toLocaleString(undefined, {
183
309
  timeZone,
184
310
  }),
@@ -189,13 +315,10 @@ function turnPoint(
189
315
  type DurationDotProps = {
190
316
  cx?: number;
191
317
  cy?: number;
192
- payload?: TurnDurationPoint;
318
+ payload?: DurationPoint;
193
319
  };
194
320
 
195
- function durationDot(
196
- onOpen: (point: TurnDurationPoint) => void,
197
- active: boolean,
198
- ) {
321
+ function durationDot(onOpen: (point: DurationPoint) => void, active: boolean) {
199
322
  return (props: DurationDotProps) => {
200
323
  if (props.cx == null || props.cy == null || !props.payload) {
201
324
  return <g />;
@@ -205,7 +328,7 @@ function durationDot(
205
328
  const fill = durationDotFill(point.status, active);
206
329
  return (
207
330
  <circle
208
- aria-label={`Open ${point.session.title ?? point.session.id}`}
331
+ aria-label={`Open ${point.title}`}
209
332
  className="cursor-pointer outline-none transition-[filter,stroke,stroke-width] hover:brightness-125 focus-visible:stroke-emerald-400 focus-visible:stroke-2"
210
333
  cx={props.cx}
211
334
  cy={props.cy}
@@ -239,12 +362,10 @@ function durationDotFill(status: PlottedTurnStatus, active: boolean): string {
239
362
 
240
363
  function TurnDurationTooltip(props: {
241
364
  active?: boolean;
242
- payload?: Array<{ payload: TurnDurationPoint }>;
365
+ payload?: Array<{ payload: DurationPoint }>;
243
366
  }) {
244
367
  const point = props.payload?.[0]?.payload;
245
- const conversationId = point
246
- ? conversationIdForSession(point.session)
247
- : undefined;
368
+ const conversationId = point?.conversationId;
248
369
  const detail = useQuery({
249
370
  enabled: Boolean(props.active && conversationId),
250
371
  queryKey: ["conversation", conversationId],
@@ -256,7 +377,7 @@ function TurnDurationTooltip(props: {
256
377
  if (!props.active || !point) {
257
378
  return null;
258
379
  }
259
- const rows = turnTooltipRows(point, detail.data);
380
+ const rows = chartTooltipRows(point, detail.data);
260
381
  return (
261
382
  <div
262
383
  className={cn(
@@ -264,18 +385,16 @@ function TurnDurationTooltip(props: {
264
385
  statusBorderClass(point.status),
265
386
  )}
266
387
  >
267
- <div className="flex items-start justify-between gap-3">
388
+ <div className="flex items-center justify-between gap-3">
268
389
  <div className="min-w-0">
269
390
  <div className="truncate text-[0.92rem] font-bold leading-tight text-white">
270
- {turnTooltipTitle(point.session)}
391
+ {point.title}
271
392
  </div>
272
393
  <div className="mt-0.5 text-[0.78rem] leading-tight text-[#888]">
273
394
  {point.tooltipLabel}
274
395
  </div>
275
396
  </div>
276
- <span className={chartTooltipStatusClass(point.status)}>
277
- {point.status}
278
- </span>
397
+ {chartTooltipStatus(point.status)}
279
398
  </div>
280
399
  <div className="mt-2 grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1">
281
400
  {rows.map(([label, value]) => (
@@ -293,51 +412,90 @@ function TurnDurationTooltip(props: {
293
412
  );
294
413
  }
295
414
 
296
- function turnTooltipRows(
297
- point: TurnDurationPoint,
415
+ function chartTooltipRows(
416
+ point: DurationPoint,
298
417
  detail: ConversationDetailFeed | undefined,
299
- ): Array<[string, string]> {
300
- const session = point.session;
301
- const requester = requesterLabel(
302
- session.requesterIdentity,
303
- session.requester,
418
+ ): Array<[string, ReactNode]> {
419
+ const session = point.session ?? point.conversation;
420
+ const requester =
421
+ point.kind === "conversations"
422
+ ? conversationRequesterLabel(point.conversation)
423
+ : requesterLabel(session?.requesterIdentity);
424
+ const location = session
425
+ ? slackLocationLabel(session, { includeId: false })
426
+ : undefined;
427
+ const turns = chartTooltipTurns(point, detail);
428
+ const tokenSummary = summarizeUsage(
429
+ point.kind === "turns"
430
+ ? [point.session?.cumulativeUsage]
431
+ : (detail?.turns ?? point.conversation?.turns ?? []).map(
432
+ (turn) => turn.cumulativeUsage,
433
+ ),
304
434
  );
305
- const location = slackLocationLabel(session, { includeId: false });
306
- const tokens = formatTokenTotal(session.cumulativeUsage);
307
- return [
308
- ["duration", formatMs(point.durationMs)],
309
- tokens ? ["tokens", tokens] : null,
310
- ["tool calls", turnTooltipToolCalls(point, detail)],
435
+ const messageSummary = detail ? summarizeMessages(turns) : undefined;
436
+ const toolSummary = detail ? summarizeToolCalls(turns) : undefined;
437
+ const rows: Array<[string, ReactNode] | null> = [
438
+ [
439
+ "duration",
440
+ <DurationMetric
441
+ align="right"
442
+ endedAt={point.endedAt}
443
+ label={point.durationLabel}
444
+ startedAt={point.startedAt}
445
+ />,
446
+ ],
447
+ tokenSummary
448
+ ? ["tokens", <TokenMetric align="right" summary={tokenSummary} />]
449
+ : null,
450
+ [
451
+ "messages",
452
+ detail ? <MessagesMetric summary={messageSummary} /> : "loading",
453
+ ],
454
+ !detail || (toolSummary && toolSummary.total > 0)
455
+ ? [
456
+ "tool calls",
457
+ detail ? (
458
+ <ToolCallsMetric align="right" summary={toolSummary} />
459
+ ) : (
460
+ "loading"
461
+ ),
462
+ ]
463
+ : null,
311
464
  requester ? ["requester", requester] : null,
312
465
  location ? ["surface", location] : null,
313
- ].filter((row): row is [string, string] => Boolean(row));
466
+ ];
467
+ return rows.filter((row): row is [string, ReactNode] => row !== null);
314
468
  }
315
469
 
316
- function turnTooltipToolCalls(
317
- point: TurnDurationPoint,
470
+ function chartTooltipTurns(
471
+ point: DurationPoint,
318
472
  detail: ConversationDetailFeed | undefined,
319
- ): string {
320
- if (!detail) {
321
- return "loading";
473
+ ): ConversationTurn[] {
474
+ if (point.kind === "conversations") {
475
+ return detail?.turns ?? [];
322
476
  }
323
- const turn = detail.turns.find((item) => item.id === point.session.id);
324
- return String(turn ? turnToolCallCount(turn) : 0);
477
+ const turn = detail?.turns.find((item) => item.id === point.session?.id);
478
+ return turn ? [turn] : [];
325
479
  }
326
480
 
327
481
  function turnTooltipTitle(session: Session): string {
328
- return (
329
- session.conversationTitle ??
330
- session.title ??
331
- conversationIdForSession(session)
332
- );
482
+ return session.conversationTitle ?? session.title;
333
483
  }
334
484
 
335
- function chartTooltipStatusClass(status: PlottedTurnStatus): string {
336
- return cn(
337
- "shrink-0 text-[0.68rem] font-bold uppercase leading-none",
338
- status === "failed" && "text-rose-300",
339
- status === "hung" && "text-amber-300",
340
- status === "idle" && "text-[#b8b8b8]",
485
+ function chartTooltipStatus(status: PlottedTurnStatus): ReactNode {
486
+ if (status === "idle") {
487
+ return null;
488
+ }
489
+ return (
490
+ <MetricValue
491
+ className={cn(
492
+ "shrink-0 text-[0.68rem] font-bold uppercase leading-none",
493
+ status === "failed" && "text-rose-300",
494
+ status === "hung" && "text-amber-300",
495
+ )}
496
+ >
497
+ {status === "failed" ? "error" : status}
498
+ </MetricValue>
341
499
  );
342
500
  }
343
501
 
@@ -1,23 +1,58 @@
1
1
  import { countStructuredBlockChildren } from "../code";
2
- import { parseMarkdownBlocks, stringifyPartValue, transcriptRoleKind } from "../format";
2
+ import {
3
+ parseMarkdownBlocks,
4
+ stringifyPartValue,
5
+ transcriptRoleKind,
6
+ } from "../format";
7
+ import { sameToolInvocation } from "../toolInvocations";
3
8
  import type { TranscriptMessage, TranscriptPart } from "../types";
4
9
 
10
+ type RenderedToolPart =
11
+ | { call: TranscriptPart; kind: "tool"; result?: TranscriptPart }
12
+ | { call?: undefined; kind: "tool"; result: TranscriptPart };
13
+
5
14
  export type RenderedTranscriptPart =
6
15
  | { kind: "part"; part: TranscriptPart }
7
- | { kind: "tool"; call?: TranscriptPart; result?: TranscriptPart };
16
+ | RenderedToolPart;
8
17
 
9
- type RenderedTranscriptEntry =
18
+ export type RenderedTranscriptEntry =
10
19
  | { kind: "message"; message: TranscriptMessage }
20
+ | RenderedThinkingEntry
11
21
  | RenderedToolEntry;
12
22
 
13
- type RenderedToolEntry = {
14
- call?: TranscriptPart;
15
- kind: "tool";
16
- result?: TranscriptPart;
17
- resultTimestamp?: number;
23
+ type RenderedThinkingEntry = {
24
+ kind: "thinking";
25
+ part: TranscriptPart;
18
26
  timestamp?: number;
19
27
  };
20
28
 
29
+ export type RenderedToolEntry =
30
+ | {
31
+ call: TranscriptPart;
32
+ kind: "tool";
33
+ result?: TranscriptPart;
34
+ resultTimestamp?: number;
35
+ timestamp?: number;
36
+ }
37
+ | {
38
+ call?: undefined;
39
+ kind: "tool";
40
+ result: TranscriptPart;
41
+ resultTimestamp?: number;
42
+ timestamp?: never;
43
+ };
44
+
45
+ type RenderedToolCallEntry = Extract<
46
+ RenderedToolEntry,
47
+ { call: TranscriptPart }
48
+ >;
49
+
50
+ function isRenderedToolCallEntry(
51
+ entry: RenderedTranscriptEntry,
52
+ ): entry is RenderedToolCallEntry {
53
+ return entry.kind === "tool" && entry.call !== undefined;
54
+ }
55
+
21
56
  export type TranscriptViewMode = "raw" | "rich";
22
57
 
23
58
  function isToolCall(part: TranscriptPart): boolean {
@@ -28,17 +63,12 @@ function isToolResult(part: TranscriptPart): boolean {
28
63
  return part.type === "tool_result";
29
64
  }
30
65
 
31
- function isString(value: string | undefined): value is string {
32
- return typeof value === "string" && value.length > 0;
66
+ function isThinking(part: TranscriptPart): boolean {
67
+ return part.type === "thinking";
33
68
  }
34
69
 
35
- function sameToolInvocation(
36
- call: TranscriptPart,
37
- result: TranscriptPart,
38
- ): boolean {
39
- if (call.id && result.id) return call.id === result.id;
40
- if (call.name && result.name) return call.name === result.name;
41
- return false;
70
+ function isString(value: string | undefined): value is string {
71
+ return typeof value === "string" && value.length > 0;
42
72
  }
43
73
 
44
74
  /** Group inline transcript parts so matching tool calls/results render together. */
@@ -83,11 +113,11 @@ export function groupTranscriptParts(
83
113
  function findToolEntry(
84
114
  entries: RenderedTranscriptEntry[],
85
115
  result: TranscriptPart,
86
- ): RenderedToolEntry | undefined {
116
+ ): RenderedToolCallEntry | undefined {
87
117
  for (let index = entries.length - 1; index >= 0; index -= 1) {
88
118
  const entry = entries[index]!;
89
- if (entry.kind !== "tool" || entry.result) continue;
90
- if (!entry.call || sameToolInvocation(entry.call, result)) {
119
+ if (!isRenderedToolCallEntry(entry) || entry.result) continue;
120
+ if (sameToolInvocation(entry.call, result)) {
91
121
  return entry;
92
122
  }
93
123
  }
@@ -138,6 +168,16 @@ export function groupTranscriptMessages(
138
168
  continue;
139
169
  }
140
170
 
171
+ if (isThinking(part)) {
172
+ flushMessage();
173
+ entries.push({
174
+ kind: "thinking",
175
+ part,
176
+ timestamp: message.timestamp,
177
+ });
178
+ continue;
179
+ }
180
+
141
181
  messageParts.push(part);
142
182
  }
143
183