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