@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.
@@ -8,9 +8,12 @@ import type {
8
8
  RequesterIdentity,
9
9
  Session,
10
10
  SessionFilter,
11
+ TranscriptMessage,
12
+ TranscriptPart,
11
13
  TurnUsage,
12
14
  VisualStatus,
13
15
  } from "./types";
16
+ import { sameToolInvocation } from "./toolInvocations";
14
17
 
15
18
  let dashboardTimeZone = "America/Los_Angeles";
16
19
 
@@ -24,7 +27,7 @@ function displayTimeZone(): string {
24
27
  }
25
28
 
26
29
  function isActiveSession(session: Session): boolean {
27
- return session.status === "active" || session.status === "running";
30
+ return session.status === "active";
28
31
  }
29
32
 
30
33
  /** Identify turn summaries that should appear in failed conversation filters. */
@@ -99,11 +102,22 @@ export function formatMs(value: number | undefined): string {
99
102
  if (ms < 1000) return `${ms}ms`;
100
103
  const seconds = ms / 1000;
101
104
  if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`;
102
- const minutes = Math.floor(seconds / 60);
103
- const remainingSeconds = Math.round(seconds % 60);
105
+ const roundedSeconds = Math.round(seconds);
106
+ const minutes = Math.floor(roundedSeconds / 60);
107
+ const remainingSeconds = roundedSeconds % 60;
104
108
  return `${minutes}m ${remainingSeconds}s`;
105
109
  }
106
110
 
111
+ /** Format chart duration ticks without long labels wrapping on the Y axis. */
112
+ export function formatDurationTick(value: number | undefined): string {
113
+ if (typeof value !== "number" || !Number.isFinite(value)) return "none";
114
+ const ms = Math.max(0, Math.floor(value));
115
+ if (Math.round(ms / 1000) >= 10 * 60) {
116
+ return `${Math.round(ms / (60 * 1000))}m`;
117
+ }
118
+ return formatMs(ms);
119
+ }
120
+
107
121
  /** Format aggregate runtime across turn summaries when duration data exists. */
108
122
  export function formatDurationTotal(
109
123
  durations: Array<number | undefined>,
@@ -250,66 +264,261 @@ export function turnMessageCount(turn: ConversationTurn): number {
250
264
  return turn.transcriptMessageCount ?? 0;
251
265
  }
252
266
 
253
- /** Count tool calls from visible transcripts or safe redacted metadata. */
254
- export function turnToolCallCount(turn: ConversationTurn): number {
255
- return transcriptSource(turn).reduce((count, message) => {
256
- return (
257
- count + message.parts.filter((part) => part.type === "tool_call").length
258
- );
259
- }, 0);
267
+ export type ToolCallSummaryItem = {
268
+ count: number;
269
+ name: string;
270
+ totalDurationMs?: number;
271
+ };
272
+
273
+ export type ToolCallSummary = {
274
+ items: ToolCallSummaryItem[];
275
+ total: number;
276
+ };
277
+
278
+ type PendingToolCall = {
279
+ id?: string;
280
+ name: string;
281
+ timestamp?: number;
282
+ };
283
+
284
+ function toolCallName(part: TranscriptPart): string {
285
+ return part.name ?? part.id ?? "unknown";
260
286
  }
261
287
 
262
- function totalUsageTokens(usage: TurnUsage | undefined): number | undefined {
263
- if (!usage) return undefined;
264
- return (
265
- getUsageComponentTotal(usage) ?? getFiniteTokenCount(usage.totalTokens)
266
- );
288
+ function findPendingToolCallIndex(
289
+ calls: PendingToolCall[],
290
+ result: TranscriptPart,
291
+ ): number {
292
+ for (let index = calls.length - 1; index >= 0; index -= 1) {
293
+ if (sameToolInvocation(calls[index]!, result)) {
294
+ return index;
295
+ }
296
+ }
297
+ return -1;
298
+ }
299
+
300
+ /** Summarize tool calls and matched result durations from transcript metadata. */
301
+ export function summarizeToolCalls(
302
+ turns: ConversationTurn[],
303
+ limit = 5,
304
+ ): ToolCallSummary {
305
+ const byName = new Map<string, ToolCallSummaryItem>();
306
+ let total = 0;
307
+
308
+ for (const turn of turns) {
309
+ const pending: PendingToolCall[] = [];
310
+ for (const message of transcriptSource(turn)) {
311
+ for (const part of message.parts) {
312
+ if (part.type === "tool_call") {
313
+ const name = toolCallName(part);
314
+ const item = byName.get(name) ?? { count: 0, name };
315
+ item.count += 1;
316
+ byName.set(name, item);
317
+ pending.push({
318
+ ...(part.id ? { id: part.id } : {}),
319
+ name,
320
+ ...(typeof message.timestamp === "number"
321
+ ? { timestamp: message.timestamp }
322
+ : {}),
323
+ });
324
+ total += 1;
325
+ continue;
326
+ }
327
+
328
+ if (part.type !== "tool_result") continue;
329
+ const pendingIndex = findPendingToolCallIndex(pending, part);
330
+ if (pendingIndex < 0) continue;
331
+ const [call] = pending.splice(pendingIndex, 1);
332
+ if (
333
+ !call ||
334
+ typeof call.timestamp !== "number" ||
335
+ typeof message.timestamp !== "number" ||
336
+ message.timestamp < call.timestamp
337
+ ) {
338
+ continue;
339
+ }
340
+ const item = byName.get(call.name);
341
+ if (!item) continue;
342
+ item.totalDurationMs =
343
+ (item.totalDurationMs ?? 0) + (message.timestamp - call.timestamp);
344
+ }
345
+ }
346
+ }
347
+
348
+ const items = [...byName.values()]
349
+ .sort(
350
+ (left, right) =>
351
+ right.count - left.count ||
352
+ (right.totalDurationMs ?? 0) - (left.totalDurationMs ?? 0) ||
353
+ left.name.localeCompare(right.name),
354
+ )
355
+ .slice(0, limit);
356
+
357
+ return { items, total };
267
358
  }
268
359
 
269
- /** Format known token counters without estimating per-message usage. */
270
- export function formatTokenTotal(usage: TurnUsage | undefined): string {
271
- const total = totalUsageTokens(usage);
272
- return total === undefined ? "" : `${formatNumber(total)} tokens`;
360
+ export type MessageSummaryItem = {
361
+ author: string;
362
+ bytes: number;
363
+ };
364
+
365
+ export type MessageSummary = {
366
+ items: MessageSummaryItem[];
367
+ total: number;
368
+ };
369
+
370
+ function transcriptMessageAuthor(
371
+ turn: ConversationTurn,
372
+ message: TranscriptMessage,
373
+ ): string {
374
+ const kind = transcriptRoleKind(message.role);
375
+ if (kind === "assistant") return "Junior";
376
+ if (kind === "user") {
377
+ return requesterLabel(turn.requesterIdentity) ?? "User";
378
+ }
379
+ if (kind === "system") return "System";
380
+ if (kind === "tool") return "Tool";
381
+ return message.role || "Unknown";
382
+ }
383
+
384
+ function transcriptPartBytes(part: TranscriptPart): number {
385
+ if (typeof part.bytes === "number" && Number.isFinite(part.bytes)) {
386
+ return Math.max(0, Math.floor(part.bytes));
387
+ }
388
+ if (
389
+ typeof part.inputSizeBytes === "number" &&
390
+ Number.isFinite(part.inputSizeBytes)
391
+ ) {
392
+ return Math.max(0, Math.floor(part.inputSizeBytes));
393
+ }
394
+ if (
395
+ typeof part.outputSizeBytes === "number" &&
396
+ Number.isFinite(part.outputSizeBytes)
397
+ ) {
398
+ return Math.max(0, Math.floor(part.outputSizeBytes));
399
+ }
400
+ return new TextEncoder().encode(
401
+ stringifyPartValue(part.text ?? part.input ?? part.output ?? part),
402
+ ).byteLength;
403
+ }
404
+
405
+ /** Summarize conversational messages by author and serialized size. */
406
+ export function summarizeMessages(turns: ConversationTurn[]): MessageSummary {
407
+ const items: MessageSummaryItem[] = [];
408
+
409
+ for (const turn of turns) {
410
+ for (const message of transcriptSource(turn)) {
411
+ if (!isConversationMessage(message)) continue;
412
+ items.push({
413
+ author: transcriptMessageAuthor(turn, message),
414
+ bytes: message.parts.reduce(
415
+ (sum, part) => sum + transcriptPartBytes(part),
416
+ 0,
417
+ ),
418
+ });
419
+ }
420
+ }
421
+
422
+ return { items, total: items.length };
423
+ }
424
+
425
+ /** Format raw counts with the dashboard's compact number rules. */
426
+ export function formatCompactNumber(value: number | undefined): string {
427
+ return formatNumber(value);
428
+ }
429
+
430
+ export type TokenUsageSummary = {
431
+ cachedInputTokens?: number;
432
+ cacheCreationTokens?: number;
433
+ inputTokens?: number;
434
+ outputTokens?: number;
435
+ providerTotalTokens?: number;
436
+ totalTokens: number;
437
+ };
438
+
439
+ function addOptionalCount(
440
+ left: number | undefined,
441
+ right: number | undefined,
442
+ ): number | undefined {
443
+ return right === undefined ? left : (left ?? 0) + right;
444
+ }
445
+
446
+ /** Summarize token usage without double-counting provider total fields. */
447
+ export function summarizeUsage(
448
+ usages: Array<TurnUsage | undefined>,
449
+ ): TokenUsageSummary | undefined {
450
+ const summary: TokenUsageSummary = { totalTokens: 0 };
451
+
452
+ for (const usage of usages) {
453
+ if (!usage) continue;
454
+
455
+ const componentTotal = getUsageComponentTotal(usage);
456
+ if (componentTotal !== undefined) {
457
+ summary.totalTokens += componentTotal;
458
+ summary.inputTokens = addOptionalCount(
459
+ summary.inputTokens,
460
+ getFiniteTokenCount(usage.inputTokens),
461
+ );
462
+ summary.outputTokens = addOptionalCount(
463
+ summary.outputTokens,
464
+ getFiniteTokenCount(usage.outputTokens),
465
+ );
466
+ summary.cachedInputTokens = addOptionalCount(
467
+ summary.cachedInputTokens,
468
+ getFiniteTokenCount(usage.cachedInputTokens),
469
+ );
470
+ summary.cacheCreationTokens = addOptionalCount(
471
+ summary.cacheCreationTokens,
472
+ getFiniteTokenCount(usage.cacheCreationTokens),
473
+ );
474
+ continue;
475
+ }
476
+
477
+ const providerTotal = getFiniteTokenCount(usage.totalTokens);
478
+ if (providerTotal !== undefined) {
479
+ summary.totalTokens += providerTotal;
480
+ summary.providerTotalTokens =
481
+ (summary.providerTotalTokens ?? 0) + providerTotal;
482
+ }
483
+ }
484
+
485
+ return summary.totalTokens > 0 ? summary : undefined;
486
+ }
487
+
488
+ /** Format a summarized token counter for compact metadata. */
489
+ export function formatTokenSummary(
490
+ summary: TokenUsageSummary | undefined,
491
+ ): string {
492
+ return summary ? `${formatNumber(summary.totalTokens)} tokens` : "";
273
493
  }
274
494
 
275
495
  /** Format the aggregate token count across conversation turns. */
276
496
  export function formatUsageTotal(usages: Array<TurnUsage | undefined>): string {
277
- const total = usages.reduce<number | undefined>((sum, usage) => {
278
- const tokens = totalUsageTokens(usage);
279
- if (tokens === undefined) return sum;
280
- return (sum ?? 0) + tokens;
281
- }, undefined);
282
- return total === undefined ? "" : `${formatNumber(total)} tokens`;
283
- }
284
-
285
- /** Format known token counters with available input/output detail. */
286
- export function formatUsage(usage: TurnUsage | undefined): string {
287
- const total = totalUsageTokens(usage);
288
- if (total === undefined) return "";
289
- const pieces = [
290
- usage?.inputTokens !== undefined
291
- ? `${formatNumber(usage.inputTokens)} in`
292
- : undefined,
293
- usage?.outputTokens !== undefined
294
- ? `${formatNumber(usage.outputTokens)} out`
295
- : undefined,
296
- usage?.cachedInputTokens !== undefined
297
- ? `${formatNumber(usage.cachedInputTokens)} cached`
298
- : undefined,
299
- usage?.cacheCreationTokens !== undefined
300
- ? `${formatNumber(usage.cacheCreationTokens)} cache-write`
301
- : undefined,
302
- ].filter(Boolean);
303
- return pieces.length > 0
304
- ? `${formatNumber(total)} tokens (${pieces.join(" / ")})`
305
- : `${formatNumber(total)} tokens`;
497
+ return formatTokenSummary(summarizeUsage(usages));
498
+ }
499
+
500
+ /** Keep turn duration displays aligned on elapsed transcript time. */
501
+ export function turnElapsedDurationMs(
502
+ turn: Pick<ConversationTurn, "completedAt" | "lastSeenAt" | "startedAt">,
503
+ ): number | undefined {
504
+ const start = parseTime(turn.startedAt);
505
+ const end = parseTime(turn.completedAt ?? turn.lastSeenAt);
506
+ if (start == null || end == null || end < start) return undefined;
507
+ return Math.max(0, end - start);
508
+ }
509
+
510
+ /** Format elapsed turn time for chart dots and transcript metadata. */
511
+ export function formatTurnDuration(
512
+ turn: Pick<ConversationTurn, "completedAt" | "lastSeenAt" | "startedAt">,
513
+ ): string {
514
+ return formatMs(turnElapsedDurationMs(turn));
306
515
  }
307
516
 
308
517
  /** Format a conversation span from first turn start to latest activity. */
309
518
  export function formatConversationDuration(conversation: Conversation): string {
310
519
  const start = parseTime(conversation.startedAt);
311
- const end = parseTime(conversation.lastSeenAt) ?? Date.now();
312
- if (start == null || end < start) return "none";
520
+ const end = parseTime(conversation.lastSeenAt);
521
+ if (start == null || end == null || end < start) return "none";
313
522
  const seconds = Math.max(1, Math.round((end - start) / 1000));
314
523
  if (seconds < 60) return `${seconds}s`;
315
524
  const minutes = Math.round(seconds / 60);
@@ -319,7 +528,7 @@ export function formatConversationDuration(conversation: Conversation): string {
319
528
 
320
529
  /** Resolve the owning conversation id for a turn/session summary. */
321
530
  export function conversationIdForSession(session: Session): string {
322
- return session.conversationId || session.id;
531
+ return session.conversationId;
323
532
  }
324
533
 
325
534
  function compareTimeDesc(a: string | undefined, b: string | undefined): number {
@@ -330,7 +539,27 @@ function compareTimeAsc(a: string | undefined, b: string | undefined): number {
330
539
  return (parseTime(a) ?? 0) - (parseTime(b) ?? 0);
331
540
  }
332
541
 
542
+ function isGenericTurnTitle(title: string, conversationId: string): boolean {
543
+ const normalized = title.trim();
544
+ return (
545
+ normalized.length === 0 ||
546
+ normalized === conversationId ||
547
+ /^Turn\s+\S+$/i.test(normalized) ||
548
+ /^Awaiting\s+\w+\s+resume$/i.test(normalized)
549
+ );
550
+ }
551
+
552
+ function meaningfulConversationTitle(
553
+ conversation: Conversation,
554
+ ): string | undefined {
555
+ return isGenericTurnTitle(conversation.title, conversation.id)
556
+ ? undefined
557
+ : conversation.title;
558
+ }
559
+
333
560
  function getConversationTitle(conversation: Conversation): string {
561
+ const title = meaningfulConversationTitle(conversation);
562
+ if (title) return title;
334
563
  if (conversation.surface === "slack") {
335
564
  return (
336
565
  slackLocationLabel(conversation, { includeId: false }) ??
@@ -351,15 +580,18 @@ export function conversationDisplayTitle(
351
580
  /** Prefer stable requester identifiers while keeping Slack ids as a last resort. */
352
581
  export function requesterLabel(
353
582
  requester: RequesterIdentity | undefined,
354
- fallback: string | undefined,
355
583
  ): string | undefined {
356
- return (
357
- requester?.email ??
358
- requester?.slackUserName ??
359
- requester?.fullName ??
360
- fallback ??
361
- requester?.slackUserId
362
- );
584
+ const email = requester?.email?.trim() || undefined;
585
+ const fullName = requester?.fullName?.trim() || undefined;
586
+ const slackUserName = requester?.slackUserName?.trim() || undefined;
587
+ return email ?? fullName ?? slackUserName ?? requester?.slackUserId;
588
+ }
589
+
590
+ /** Derive the conversation owner label from structured requester identity. */
591
+ export function conversationRequesterLabel(
592
+ conversation: Conversation | undefined,
593
+ ): string | undefined {
594
+ return requesterLabel(conversation?.requesterIdentity);
363
595
  }
364
596
 
365
597
  /** Format the owner and permalink id line shared by conversation rows and headers. */
@@ -367,20 +599,15 @@ export function conversationIdentityMeta(
367
599
  conversation: Conversation | undefined,
368
600
  conversationId: string | undefined,
369
601
  ): string {
370
- const id = conversationId ?? "missing conversation id";
371
- const owner = requesterLabel(
372
- conversation?.requesterIdentity,
373
- conversation?.requester,
374
- );
602
+ const id = conversationId ?? conversation?.id;
603
+ const owner = conversationRequesterLabel(conversation);
604
+ if (!id) return owner ?? "";
375
605
  return owner ? `${owner} · ${id}` : id;
376
606
  }
377
607
 
378
608
  /** Convert Slack channel ids and names into user-facing location labels. */
379
609
  export function slackLocationLabel(
380
- input: Pick<
381
- Session,
382
- "channel" | "channelName" | "requester" | "requesterIdentity"
383
- >,
610
+ input: Pick<Session, "channel" | "channelName">,
384
611
  options: { includeId?: boolean } = {},
385
612
  ): string | undefined {
386
613
  const channelId = input.channel;
@@ -479,6 +706,18 @@ export function detectLanguage(text: string): BundledLanguage {
479
706
  if (/^<[\s\S]+>$/.test(trimmed) && /<\/?[a-zA-Z][^>]*>/.test(trimmed)) {
480
707
  return "xml";
481
708
  }
709
+ // Mixed prose + block-level XML: detect when a complete open/close element pair
710
+ // appears on its own lines. Handles system prompts and runtime context blocks
711
+ // that start with plain text but contain structured XML sections.
712
+ const blockOpen = trimmed.match(
713
+ /(?:^|\n)[ \t]*<([A-Za-z_][\w:.-]*)(?:[ \t][^<>]*)?>[ \t]*(?=\n|$)/,
714
+ );
715
+ if (blockOpen?.[1]) {
716
+ const tag = blockOpen[1].replace(/[$()*+.?[\\^{|}]/g, "\\$&");
717
+ if (new RegExp(`(?:^|\\n)[ \\t]*</${tag}>[ \\t]*(?=\\n|$)`).test(trimmed)) {
718
+ return "xml";
719
+ }
720
+ }
482
721
  if (/```|^#{1,6}\s|\n[-*]\s|\n\d+\.\s|\[[^\]]+\]\([^)]+\)/m.test(trimmed)) {
483
722
  return "markdown";
484
723
  }
@@ -575,7 +814,11 @@ export function parseMarkdownBlocks(
575
814
  const prose = text.slice(cursor, match.index).trim();
576
815
  if (prose) {
577
816
  const language = detectProse(prose);
578
- blocks.push({ code: formatCodeBlock(prose, language), fenced: false, language });
817
+ blocks.push({
818
+ code: formatCodeBlock(prose, language),
819
+ fenced: false,
820
+ language,
821
+ });
579
822
  }
580
823
  const language = normalizeLanguage(match[1]);
581
824
  blocks.push({
@@ -588,7 +831,11 @@ export function parseMarkdownBlocks(
588
831
  const rest = text.slice(cursor).trim();
589
832
  if (rest) {
590
833
  const language = detectProse(rest);
591
- blocks.push({ code: formatCodeBlock(rest, language), fenced: false, language });
834
+ blocks.push({
835
+ code: formatCodeBlock(rest, language),
836
+ fenced: false,
837
+ language,
838
+ });
592
839
  }
593
840
  if (blocks.length > 0) return blocks;
594
841
  const language = detectProse(text);
@@ -655,12 +902,10 @@ export function buildConversations(sessions: Session[]): Conversation[] {
655
902
  const sortedTurns = [...turns].sort((a, b) =>
656
903
  compareTimeAsc(a.startedAt, b.startedAt),
657
904
  );
658
- const newest = [...turns].sort((a, b) =>
659
- compareTimeDesc(
660
- a.lastSeenAt ?? a.startedAt,
661
- b.lastSeenAt ?? b.startedAt,
662
- ),
663
- )[0]!;
905
+ const recentTurns = [...turns].sort((a, b) =>
906
+ compareTimeDesc(a.lastSeenAt, b.lastSeenAt),
907
+ );
908
+ const newest = recentTurns[0]!;
664
909
  const oldest = sortedTurns.reduce((current, next) =>
665
910
  (parseTime(next.startedAt) ?? Number.MAX_SAFE_INTEGER) <
666
911
  (parseTime(current.startedAt) ?? Number.MAX_SAFE_INTEGER)
@@ -674,28 +919,25 @@ export function buildConversations(sessions: Session[]): Conversation[] {
674
919
  : sortedTurns.some(isFailedSession)
675
920
  ? "failed"
676
921
  : newest.status;
677
- const requesterTurn =
678
- sortedTurns.find((turn) => turn.requesterIdentity) ??
679
- sortedTurns.find((turn) => turn.requester);
922
+ const requesterTurn = sortedTurns.find((turn) => turn.requesterIdentity);
923
+ const conversationTitle = recentTurns.find(
924
+ (turn) => turn.conversationTitle,
925
+ )?.conversationTitle;
680
926
 
681
927
  return {
682
928
  channel: newest.channel,
683
- channelName: sortedTurns.find((turn) => turn.channelName)?.channelName,
684
- conversationTitle: sortedTurns.find((turn) => turn.conversationTitle)
685
- ?.conversationTitle,
929
+ channelName: recentTurns.find((turn) => turn.channelName)?.channelName,
930
+ conversationTitle,
686
931
  id,
932
+ lastProgressAt: newest.lastProgressAt,
687
933
  lastSeenAt: newest.lastSeenAt,
688
- requester: requesterLabel(
689
- requesterTurn?.requesterIdentity,
690
- requesterTurn?.requester,
691
- ),
692
934
  requesterIdentity: requesterTurn?.requesterIdentity,
693
935
  sentryConversationUrl: newest.sentryConversationUrl,
694
936
  sentryTraceUrl: newest.sentryTraceUrl,
695
937
  startedAt: oldest.startedAt,
696
938
  status,
697
939
  surface: newest.surface,
698
- title: newest.title || id,
940
+ title: newest.title,
699
941
  traceId: newest.traceId,
700
942
  turns: sortedTurns,
701
943
  };
@@ -4,16 +4,24 @@ import { useConversationData } from "../api";
4
4
  import { StatusBadge } from "../components/StatusBadge";
5
5
  import {
6
6
  buildConversations,
7
+ conversationIdentityMeta,
7
8
  conversationDisplayTitle,
8
9
  formatConversationDuration,
9
10
  formatRelativeTime,
10
11
  formatTime,
11
- formatUsageTotal,
12
12
  slackLocationLabel,
13
- turnMessageCount,
14
- turnToolCallCount,
13
+ summarizeMessages,
14
+ summarizeToolCalls,
15
+ summarizeUsage,
15
16
  visualStatusForConversation,
16
17
  } from "../format";
18
+ import { MetricList, type MetricListItem } from "../components/Metric";
19
+ import {
20
+ DurationMetric,
21
+ MessagesMetric,
22
+ TokenMetric,
23
+ ToolCallsMetric,
24
+ } from "../components/TelemetryMetrics";
17
25
  import { Transcript } from "../components/Transcript";
18
26
  import { TranscriptLoading } from "../components/TranscriptLoading";
19
27
  import type {
@@ -81,15 +89,9 @@ function ConversationIdentity(props: {
81
89
  conversation: Conversation | undefined;
82
90
  conversationId: string | undefined;
83
91
  }) {
84
- const id = props.conversationId ?? "missing conversation id";
85
- const owner =
86
- props.conversation?.requesterIdentity?.email ??
87
- props.conversation?.requester ??
88
- props.conversation?.requesterIdentity?.slackUserName;
89
92
  return (
90
93
  <>
91
- {owner ? `${owner} · ` : ""}
92
- {id}
94
+ {conversationIdentityMeta(props.conversation, props.conversationId)}
93
95
  {props.conversation?.sentryConversationUrl ? (
94
96
  <>
95
97
  {" · "}
@@ -112,41 +114,78 @@ function ConversationStats(props: {
112
114
  detail?: ConversationDetailFeed;
113
115
  }) {
114
116
  if (!props.conversation) return null;
115
- const messages = props.detail
116
- ? props.detail.turns.reduce(
117
- (count, turn) => count + turnMessageCount(turn),
118
- 0,
119
- )
117
+ const messageSummary = props.detail
118
+ ? summarizeMessages(props.detail.turns)
120
119
  : undefined;
121
- const toolCalls = props.detail
122
- ? props.detail.turns.reduce(
123
- (count, turn) => count + turnToolCallCount(turn),
124
- 0,
125
- )
120
+ const toolSummary = props.detail
121
+ ? summarizeToolCalls(props.detail.turns)
126
122
  : undefined;
127
- const tokens = formatUsageTotal(
123
+ const tokenSummary = summarizeUsage(
128
124
  (props.detail?.turns ?? props.conversation.turns).map(
129
125
  (turn) => turn.cumulativeUsage,
130
126
  ),
131
127
  );
132
- const stats = [
133
- slackLocationLabel(props.conversation, { includeId: false }),
134
- `${props.conversation.turns.length} turns`,
135
- messages === undefined ? "messages loading" : `${messages} messages`,
136
- toolCalls === undefined ? "tool calls loading" : `${toolCalls} tool calls`,
137
- tokens,
138
- formatConversationDuration(props.conversation),
139
- `started ${formatTime(props.conversation.startedAt)}`,
140
- ].filter(Boolean);
128
+ const location = slackLocationLabel(props.conversation, {
129
+ includeId: false,
130
+ });
131
+ const durationLabel = formatConversationDuration(props.conversation);
132
+ const turnCount = props.conversation.turns.length;
133
+ const rawStats: Array<MetricListItem | undefined> = [
134
+ location
135
+ ? {
136
+ content: location,
137
+ key: "location",
138
+ }
139
+ : undefined,
140
+ {
141
+ content: `${turnCount} ${turnCount === 1 ? "turn" : "turns"}`,
142
+ key: "turns",
143
+ },
144
+ {
145
+ content: (
146
+ <MessagesMetric loading={!props.detail} summary={messageSummary} />
147
+ ),
148
+ key: "messages",
149
+ },
150
+ !props.detail || (toolSummary && toolSummary.total > 0)
151
+ ? {
152
+ content: (
153
+ <ToolCallsMetric loading={!props.detail} summary={toolSummary} />
154
+ ),
155
+ key: "tools",
156
+ }
157
+ : undefined,
158
+ tokenSummary
159
+ ? {
160
+ content: <TokenMetric summary={tokenSummary} />,
161
+ key: "tokens",
162
+ }
163
+ : undefined,
164
+ durationLabel !== "none"
165
+ ? {
166
+ content: (
167
+ <DurationMetric
168
+ endedAt={props.conversation.lastSeenAt}
169
+ label={durationLabel}
170
+ startedAt={props.conversation.startedAt}
171
+ />
172
+ ),
173
+ key: "duration",
174
+ }
175
+ : undefined,
176
+ {
177
+ content: `started ${formatTime(props.conversation.startedAt)}`,
178
+ key: "started",
179
+ },
180
+ ];
181
+ const stats = rawStats.filter(
182
+ (item): item is MetricListItem => item !== undefined,
183
+ );
141
184
 
142
185
  return (
143
- <div className="col-span-full flex flex-wrap gap-x-3 gap-y-1 break-words text-[0.76rem] leading-[1.45] text-[#888]">
144
- {stats.map((value, index) => (
145
- <span key={`${index}-${value}`}>
146
- {index > 0 ? <span className="mr-3 text-[#666]">·</span> : null}
147
- {value}
148
- </span>
149
- ))}
150
- </div>
186
+ <MetricList
187
+ className="col-span-full break-words text-[0.76rem] leading-[1.45] text-[#888]"
188
+ items={stats}
189
+ />
151
190
  );
152
191
  }