@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
@@ -0,0 +1,360 @@
1
+ import {
2
+ conversationDisplayTitle,
3
+ formatMs,
4
+ formatTurnDuration,
5
+ formatUsageTotal,
6
+ requesterLabel,
7
+ slackLocationLabel,
8
+ stringifyPartValue,
9
+ transcriptRoleKind,
10
+ unavailableTranscriptLabel,
11
+ } from "./format";
12
+ import {
13
+ groupTranscriptMessages,
14
+ messageRawText,
15
+ } from "./components/transcriptRenderModel";
16
+ import type {
17
+ Conversation,
18
+ ConversationDetailFeed,
19
+ ConversationTurn,
20
+ TranscriptMessage,
21
+ TranscriptPart,
22
+ } from "./types";
23
+
24
+ /** Build a clipboard Markdown transcript from the already-authorized dashboard report. */
25
+ export function buildConversationMarkdown(
26
+ detail: ConversationDetailFeed,
27
+ conversation?: Conversation,
28
+ ): string {
29
+ const lines: string[] = [];
30
+ const firstTurn = detail.turns[0];
31
+
32
+ lines.push(`# ${headingText(conversationTitle(detail, conversation))}`, "");
33
+ addMetaLine(lines, "Conversation ID", inlineCode(detail.conversationId));
34
+ addMetaLine(lines, "Generated", detail.generatedAt);
35
+ addMetaLine(
36
+ lines,
37
+ "Requester",
38
+ conversationRequester(conversation, firstTurn),
39
+ );
40
+ addMetaLine(lines, "Location", conversationLocation(conversation, firstTurn));
41
+ addMetaLine(lines, "Turns", String(detail.turns.length));
42
+ addMetaLine(
43
+ lines,
44
+ "Sentry conversation",
45
+ conversation?.sentryConversationUrl ?? firstTurn?.sentryConversationUrl,
46
+ );
47
+
48
+ if (detail.turns.length === 0) {
49
+ lines.push("", "## Transcript", "", "No transcript is available.");
50
+ return finishMarkdown(lines);
51
+ }
52
+
53
+ detail.turns.forEach((turn, index) => {
54
+ lines.push("", `## ${turnHeading(turn, index)}`, "");
55
+ addMetaLine(lines, "Turn ID", inlineCode(turn.id));
56
+ addMetaLine(lines, "Status", inlineCode(turn.status));
57
+ addMetaLine(lines, "Started", turn.startedAt);
58
+ addMetaLine(lines, "Completed", turn.completedAt);
59
+ addMetaLine(lines, "Last seen", turn.lastSeenAt);
60
+ addMetaLine(lines, "Duration", formatTurnDuration(turn));
61
+ addMetaLine(lines, "Usage", formatUsageTotal([turn.cumulativeUsage]));
62
+ addMetaLine(
63
+ lines,
64
+ "Trace ID",
65
+ turn.traceId ? inlineCode(turn.traceId) : "",
66
+ );
67
+ addMetaLine(lines, "Sentry trace", turn.sentryTraceUrl);
68
+
69
+ appendTurnTranscript(lines, turn);
70
+ });
71
+
72
+ return finishMarkdown(lines);
73
+ }
74
+
75
+ function appendTurnTranscript(lines: string[], turn: ConversationTurn): void {
76
+ if (turn.transcriptAvailable) {
77
+ appendTranscriptMessages(lines, turn, turn.transcript, false);
78
+ return;
79
+ }
80
+
81
+ if (turn.transcriptRedacted && turn.transcriptMetadata?.length) {
82
+ lines.push(
83
+ "",
84
+ "Transcript hidden because this conversation is not public.",
85
+ );
86
+ appendTranscriptMessages(lines, turn, turn.transcriptMetadata, true);
87
+ return;
88
+ }
89
+
90
+ lines.push("", unavailableTranscriptLabel(turn));
91
+ }
92
+
93
+ function appendTranscriptMessages(
94
+ lines: string[],
95
+ turn: ConversationTurn,
96
+ messages: TranscriptMessage[],
97
+ redacted: boolean,
98
+ ): void {
99
+ for (const entry of groupTranscriptMessages(messages)) {
100
+ if (entry.kind === "message") {
101
+ appendMessage(lines, turn, entry.message, redacted);
102
+ continue;
103
+ }
104
+
105
+ if (entry.kind === "thinking") {
106
+ appendThinking(lines, turn, entry.part, entry.timestamp, redacted);
107
+ continue;
108
+ }
109
+
110
+ if (redacted) {
111
+ appendRedactedTool(
112
+ lines,
113
+ turn,
114
+ entry.call,
115
+ entry.result,
116
+ entry.timestamp,
117
+ entry.resultTimestamp,
118
+ );
119
+ continue;
120
+ }
121
+
122
+ appendTool(
123
+ lines,
124
+ turn,
125
+ entry.call,
126
+ entry.result,
127
+ entry.timestamp,
128
+ entry.resultTimestamp,
129
+ );
130
+ }
131
+ }
132
+
133
+ function appendMessage(
134
+ lines: string[],
135
+ turn: ConversationTurn,
136
+ message: TranscriptMessage,
137
+ redacted: boolean,
138
+ ): void {
139
+ lines.push("", `### ${messageRoleLabel(message, turn)}`);
140
+ addEventMeta(lines, turn, message.timestamp);
141
+
142
+ if (redacted) {
143
+ const redactedLines = message.parts.map(redactedPartLabel);
144
+ lines.push("", ...redactedLines.map((line) => `- ${line}`));
145
+ return;
146
+ }
147
+
148
+ const rawText = messageRawText(message);
149
+ lines.push("", rawText.trim().length ? rawText : "_No content._");
150
+ }
151
+
152
+ function appendThinking(
153
+ lines: string[],
154
+ turn: ConversationTurn,
155
+ part: TranscriptPart,
156
+ timestamp: number | undefined,
157
+ redacted: boolean,
158
+ ): void {
159
+ lines.push("", "### Thinking");
160
+ addEventMeta(lines, turn, timestamp);
161
+
162
+ if (redacted) {
163
+ lines.push("", `- ${redactedPartLabel(part)}`);
164
+ return;
165
+ }
166
+
167
+ lines.push("", fencedBlock(stringifyPartValue(part.output), "text"));
168
+ }
169
+
170
+ function appendTool(
171
+ lines: string[],
172
+ turn: ConversationTurn,
173
+ call: TranscriptPart | undefined,
174
+ result: TranscriptPart | undefined,
175
+ timestamp: number | undefined,
176
+ resultTimestamp: number | undefined,
177
+ ): void {
178
+ appendToolHeader(lines, turn, call, result, timestamp, resultTimestamp);
179
+ lines.push("", fencedBlock(stringifyPartValue({ call, result }), "json"));
180
+ }
181
+
182
+ function appendRedactedTool(
183
+ lines: string[],
184
+ turn: ConversationTurn,
185
+ call: TranscriptPart | undefined,
186
+ result: TranscriptPart | undefined,
187
+ timestamp: number | undefined,
188
+ resultTimestamp: number | undefined,
189
+ ): void {
190
+ appendToolHeader(lines, turn, call, result, timestamp, resultTimestamp);
191
+
192
+ const redactedLines = [call, result]
193
+ .filter((part): part is TranscriptPart => part !== undefined)
194
+ .map(redactedPartLabel);
195
+ lines.push("", ...redactedLines.map((line) => `- ${line}`));
196
+ }
197
+
198
+ function appendToolHeader(
199
+ lines: string[],
200
+ turn: ConversationTurn,
201
+ call: TranscriptPart | undefined,
202
+ result: TranscriptPart | undefined,
203
+ timestamp: number | undefined,
204
+ resultTimestamp: number | undefined,
205
+ ): void {
206
+ lines.push("", `### Tool: ${headingText(toolName(call, result))}`);
207
+ addEventMeta(lines, turn, timestamp);
208
+ addMetaLine(lines, "Result timestamp", eventTimestamp(resultTimestamp));
209
+ addMetaLine(lines, "Duration", toolDuration(timestamp, resultTimestamp));
210
+ if (!result) {
211
+ addMetaLine(lines, "Result", "missing");
212
+ }
213
+ }
214
+
215
+ function addEventMeta(
216
+ lines: string[],
217
+ turn: ConversationTurn,
218
+ timestamp: number | undefined,
219
+ ): void {
220
+ const meta = [eventTimestamp(timestamp), eventOffset(turn, timestamp)].filter(
221
+ isNonEmptyString,
222
+ );
223
+ if (meta.length) {
224
+ lines.push("", `_${meta.join(" - ")}_`);
225
+ }
226
+ }
227
+
228
+ function conversationTitle(
229
+ detail: ConversationDetailFeed,
230
+ conversation: Conversation | undefined,
231
+ ): string {
232
+ if (conversation) return conversationDisplayTitle(conversation);
233
+ const firstTurn = detail.turns[0];
234
+ return firstTurn?.conversationTitle ?? firstTurn?.title ?? "Conversation";
235
+ }
236
+
237
+ function conversationRequester(
238
+ conversation: Conversation | undefined,
239
+ turn: ConversationTurn | undefined,
240
+ ): string {
241
+ return (
242
+ requesterLabel(
243
+ conversation?.requesterIdentity ?? turn?.requesterIdentity,
244
+ ) ?? ""
245
+ );
246
+ }
247
+
248
+ function conversationLocation(
249
+ conversation: Conversation | undefined,
250
+ turn: ConversationTurn | undefined,
251
+ ): string {
252
+ if (conversation) return slackLocationLabel(conversation) ?? "";
253
+ return turn ? (slackLocationLabel(turn) ?? "") : "";
254
+ }
255
+
256
+ function turnHeading(turn: ConversationTurn, index: number): string {
257
+ const title = turn.title.trim();
258
+ const label = `Turn ${index + 1}`;
259
+ if (!title || title === turn.id || title === `Turn ${turn.id}`) return label;
260
+ return `${label}: ${headingText(title)}`;
261
+ }
262
+
263
+ function messageRoleLabel(
264
+ message: TranscriptMessage,
265
+ turn: ConversationTurn,
266
+ ): string {
267
+ const kind = transcriptRoleKind(message.role);
268
+ if (kind === "assistant") return "Junior";
269
+ if (kind === "user") return requesterLabel(turn.requesterIdentity) ?? "User";
270
+ if (kind === "system") return "System";
271
+ if (kind === "tool") return "Tool";
272
+ return headingText(message.role || "Unknown");
273
+ }
274
+
275
+ function redactedPartLabel(part: TranscriptPart): string {
276
+ const meta = [
277
+ part.type !== "text" ? part.type : "",
278
+ part.name ? `name: ${inlineCode(part.name)}` : "",
279
+ part.chars !== undefined ? `${part.chars} chars` : "",
280
+ part.bytes !== undefined ? `${part.bytes} bytes` : "",
281
+ part.inputType ? `input: ${part.inputType}` : "",
282
+ part.outputType ? `output: ${part.outputType}` : "",
283
+ part.inputKeys?.length ? `input keys: ${part.inputKeys.join(", ")}` : "",
284
+ part.outputKeys?.length ? `output keys: ${part.outputKeys.join(", ")}` : "",
285
+ ].filter(isNonEmptyString);
286
+ return ["<redacted>", ...meta].join(" - ");
287
+ }
288
+
289
+ function toolName(
290
+ call: TranscriptPart | undefined,
291
+ result: TranscriptPart | undefined,
292
+ ): string {
293
+ return call?.name ?? result?.name ?? call?.id ?? result?.id ?? "unknown";
294
+ }
295
+
296
+ function toolDuration(
297
+ timestamp: number | undefined,
298
+ resultTimestamp: number | undefined,
299
+ ): string {
300
+ if (
301
+ typeof timestamp !== "number" ||
302
+ typeof resultTimestamp !== "number" ||
303
+ !Number.isFinite(timestamp) ||
304
+ !Number.isFinite(resultTimestamp) ||
305
+ resultTimestamp < timestamp
306
+ ) {
307
+ return "";
308
+ }
309
+ return formatMs(resultTimestamp - timestamp);
310
+ }
311
+
312
+ function eventTimestamp(timestamp: number | undefined): string {
313
+ if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) return "";
314
+ return new Date(timestamp).toISOString();
315
+ }
316
+
317
+ function eventOffset(
318
+ turn: ConversationTurn,
319
+ timestamp: number | undefined,
320
+ ): string {
321
+ if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) return "";
322
+ const start = Date.parse(turn.startedAt);
323
+ if (!Number.isFinite(start) || timestamp < start) return "";
324
+ return `+${formatMs(timestamp - start)}`;
325
+ }
326
+
327
+ function addMetaLine(
328
+ lines: string[],
329
+ label: string,
330
+ value: string | undefined,
331
+ ): void {
332
+ if (!value) return;
333
+ lines.push(`- ${label}: ${value}`);
334
+ }
335
+
336
+ function headingText(value: string): string {
337
+ return value.replace(/\s+/g, " ").trim() || "Untitled";
338
+ }
339
+
340
+ function inlineCode(value: string): string {
341
+ const fence = value.includes("`") ? "``" : "`";
342
+ return `${fence}${value}${fence}`;
343
+ }
344
+
345
+ function fencedBlock(value: string, language: string): string {
346
+ const longestBacktickRun = [...value.matchAll(/`+/g)].reduce(
347
+ (longest, match) => Math.max(longest, match[0].length),
348
+ 0,
349
+ );
350
+ const fence = "`".repeat(Math.max(3, longestBacktickRun + 1));
351
+ return `${fence}${language}\n${value}\n${fence}`;
352
+ }
353
+
354
+ function finishMarkdown(lines: string[]): string {
355
+ return `${lines.join("\n")}\n`;
356
+ }
357
+
358
+ function isNonEmptyString(value: string): boolean {
359
+ return value.length > 0;
360
+ }
@@ -16,7 +16,7 @@ export function CommandCenter(props: {
16
16
  const conversations = buildConversations(sessions);
17
17
 
18
18
  return (
19
- <div className="grid min-w-0 gap-4 px-4 py-4 md:px-8 lg:grid-cols-[minmax(21rem,0.32fr)_minmax(0,1fr)]">
19
+ <div className="mx-auto grid w-full min-w-0 max-w-screen-xl gap-4 px-4 py-4 md:px-8 lg:grid-cols-[minmax(21rem,0.32fr)_minmax(0,1fr)]">
20
20
  <CommandRail data={props.data} error={props.queryError} />
21
21
 
22
22
  <section className="min-w-0">
@@ -1,19 +1,31 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Check, Copy } from "lucide-react";
1
3
  import { useParams } from "react-router";
2
4
 
3
5
  import { useConversationData } from "../api";
6
+ import { buildConversationMarkdown } from "../markdownExport";
7
+ import { Button } from "../components/Button";
4
8
  import { StatusBadge } from "../components/StatusBadge";
5
9
  import {
6
10
  buildConversations,
11
+ conversationIdentityMeta,
7
12
  conversationDisplayTitle,
8
13
  formatConversationDuration,
9
14
  formatRelativeTime,
10
15
  formatTime,
11
- formatUsageTotal,
12
16
  slackLocationLabel,
13
- turnMessageCount,
14
- turnToolCallCount,
17
+ summarizeMessages,
18
+ summarizeToolCalls,
19
+ summarizeUsage,
15
20
  visualStatusForConversation,
16
21
  } from "../format";
22
+ import { MetricList, type MetricListItem } from "../components/Metric";
23
+ import {
24
+ DurationMetric,
25
+ MessagesMetric,
26
+ TokenMetric,
27
+ ToolCallsMetric,
28
+ } from "../components/TelemetryMetrics";
17
29
  import { Transcript } from "../components/Transcript";
18
30
  import { TranscriptLoading } from "../components/TranscriptLoading";
19
31
  import type {
@@ -37,7 +49,7 @@ export function ConversationPage(props: { data?: DashboardData }) {
37
49
  : undefined;
38
50
 
39
51
  return (
40
- <div className="min-w-0 px-4 py-5 md:px-8">
52
+ <div className="mx-auto w-full min-w-0 max-w-screen-xl px-4 py-5 md:px-8">
41
53
  <section className="min-w-0">
42
54
  <header className="mb-6 grid gap-3 border-l-4 border-[#beaaff]/70 pl-4 md:grid-cols-[minmax(0,1fr)_auto]">
43
55
  <div className="min-w-0">
@@ -54,11 +66,13 @@ export function ConversationPage(props: { data?: DashboardData }) {
54
66
  />
55
67
  </div>
56
68
  </div>
57
- <div className="self-start break-words text-[0.82rem] leading-relaxed text-[#b8b8b8] md:text-right">
58
- updated{" "}
59
- {formatRelativeTime(
60
- conversation?.lastSeenAt ?? detail.data?.generatedAt,
61
- )}
69
+ <div className="flex min-w-0 flex-col items-start gap-2 self-start text-[0.82rem] leading-relaxed text-[#b8b8b8] md:items-end md:text-right">
70
+ <div className="break-words">
71
+ updated{" "}
72
+ {formatRelativeTime(
73
+ conversation?.lastSeenAt ?? detail.data?.generatedAt,
74
+ )}
75
+ </div>
62
76
  </div>
63
77
  <ConversationStats conversation={conversation} detail={detail.data} />
64
78
  </header>
@@ -70,26 +84,72 @@ export function ConversationPage(props: { data?: DashboardData }) {
70
84
  {detail.error.message}
71
85
  </div>
72
86
  ) : (
73
- <Transcript turns={detail.data?.turns ?? []} />
87
+ <Transcript
88
+ actions={
89
+ <CopyMarkdownButton
90
+ conversation={conversation}
91
+ detail={detail.data}
92
+ />
93
+ }
94
+ turns={detail.data?.turns ?? []}
95
+ />
74
96
  )}
75
97
  </section>
76
98
  </div>
77
99
  );
78
100
  }
79
101
 
102
+ function CopyMarkdownButton(props: {
103
+ conversation: Conversation | undefined;
104
+ detail: ConversationDetailFeed | undefined;
105
+ }) {
106
+ const [status, setStatus] = useState<"copied" | "failed" | "idle">("idle");
107
+ const disabled = !props.detail;
108
+ const label =
109
+ status === "copied"
110
+ ? "Copied"
111
+ : status === "failed"
112
+ ? "Copy failed"
113
+ : "Copy as Markdown";
114
+ const Icon = status === "copied" ? Check : Copy;
115
+
116
+ useEffect(() => {
117
+ setStatus("idle");
118
+ }, [props.detail?.conversationId, props.detail?.generatedAt]);
119
+
120
+ async function copyMarkdown() {
121
+ if (!props.detail) return;
122
+
123
+ try {
124
+ await navigator.clipboard.writeText(
125
+ buildConversationMarkdown(props.detail, props.conversation),
126
+ );
127
+ setStatus("copied");
128
+ } catch {
129
+ setStatus("failed");
130
+ }
131
+ }
132
+
133
+ return (
134
+ <Button
135
+ aria-label={label}
136
+ disabled={disabled}
137
+ onClick={() => void copyMarkdown()}
138
+ size="icon"
139
+ title={label}
140
+ >
141
+ <Icon aria-hidden="true" size={15} strokeWidth={2} />
142
+ </Button>
143
+ );
144
+ }
145
+
80
146
  function ConversationIdentity(props: {
81
147
  conversation: Conversation | undefined;
82
148
  conversationId: string | undefined;
83
149
  }) {
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
150
  return (
90
151
  <>
91
- {owner ? `${owner} · ` : ""}
92
- {id}
152
+ {conversationIdentityMeta(props.conversation, props.conversationId)}
93
153
  {props.conversation?.sentryConversationUrl ? (
94
154
  <>
95
155
  {" · "}
@@ -112,41 +172,78 @@ function ConversationStats(props: {
112
172
  detail?: ConversationDetailFeed;
113
173
  }) {
114
174
  if (!props.conversation) return null;
115
- const messages = props.detail
116
- ? props.detail.turns.reduce(
117
- (count, turn) => count + turnMessageCount(turn),
118
- 0,
119
- )
175
+ const messageSummary = props.detail
176
+ ? summarizeMessages(props.detail.turns)
120
177
  : undefined;
121
- const toolCalls = props.detail
122
- ? props.detail.turns.reduce(
123
- (count, turn) => count + turnToolCallCount(turn),
124
- 0,
125
- )
178
+ const toolSummary = props.detail
179
+ ? summarizeToolCalls(props.detail.turns)
126
180
  : undefined;
127
- const tokens = formatUsageTotal(
181
+ const tokenSummary = summarizeUsage(
128
182
  (props.detail?.turns ?? props.conversation.turns).map(
129
183
  (turn) => turn.cumulativeUsage,
130
184
  ),
131
185
  );
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);
186
+ const location = slackLocationLabel(props.conversation, {
187
+ includeId: false,
188
+ });
189
+ const durationLabel = formatConversationDuration(props.conversation);
190
+ const turnCount = props.conversation.turns.length;
191
+ const rawStats: Array<MetricListItem | undefined> = [
192
+ location
193
+ ? {
194
+ content: location,
195
+ key: "location",
196
+ }
197
+ : undefined,
198
+ {
199
+ content: `${turnCount} ${turnCount === 1 ? "turn" : "turns"}`,
200
+ key: "turns",
201
+ },
202
+ {
203
+ content: (
204
+ <MessagesMetric loading={!props.detail} summary={messageSummary} />
205
+ ),
206
+ key: "messages",
207
+ },
208
+ !props.detail || (toolSummary && toolSummary.total > 0)
209
+ ? {
210
+ content: (
211
+ <ToolCallsMetric loading={!props.detail} summary={toolSummary} />
212
+ ),
213
+ key: "tools",
214
+ }
215
+ : undefined,
216
+ tokenSummary
217
+ ? {
218
+ content: <TokenMetric summary={tokenSummary} />,
219
+ key: "tokens",
220
+ }
221
+ : undefined,
222
+ durationLabel !== "none"
223
+ ? {
224
+ content: (
225
+ <DurationMetric
226
+ endedAt={props.conversation.lastSeenAt}
227
+ label={durationLabel}
228
+ startedAt={props.conversation.startedAt}
229
+ />
230
+ ),
231
+ key: "duration",
232
+ }
233
+ : undefined,
234
+ {
235
+ content: `started ${formatTime(props.conversation.startedAt)}`,
236
+ key: "started",
237
+ },
238
+ ];
239
+ const stats = rawStats.filter(
240
+ (item): item is MetricListItem => item !== undefined,
241
+ );
141
242
 
142
243
  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>
244
+ <MetricList
245
+ className="col-span-full break-words text-[0.76rem] leading-[1.45] text-[#888]"
246
+ items={stats}
247
+ />
151
248
  );
152
249
  }
@@ -33,7 +33,7 @@ export function ConversationsPage(props: { data?: DashboardData }) {
33
33
  }
34
34
 
35
35
  return (
36
- <div className="min-w-0 px-4 py-4 md:px-8">
36
+ <div className="mx-auto w-full min-w-0 max-w-screen-xl px-4 py-4 md:px-8">
37
37
  <section className="min-w-0">
38
38
  <Section>
39
39
  <SectionHeader
@@ -0,0 +1,16 @@
1
+ type ToolInvocationRef = {
2
+ id?: string;
3
+ name?: string;
4
+ };
5
+
6
+ /** Match tool call/result refs without inferring relationships from missing metadata. */
7
+ export function sameToolInvocation(
8
+ left: ToolInvocationRef,
9
+ right: ToolInvocationRef,
10
+ ): boolean {
11
+ if (left.id || right.id) {
12
+ return Boolean(left.id && right.id && left.id === right.id);
13
+ }
14
+ if (left.name && right.name) return left.name === right.name;
15
+ return false;
16
+ }