@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.
- package/dist/app.d.ts +2 -0
- package/dist/app.js +1285 -39
- package/dist/assets.d.ts +2 -0
- package/dist/client/api.d.ts +1 -1
- package/dist/client/components/Button.d.ts +15 -0
- package/dist/client/components/Metric.d.ts +22 -0
- package/dist/client/components/StatusBadge.d.ts +2 -1
- package/dist/client/components/TelemetryMetrics.d.ts +24 -0
- package/dist/client/components/ToolFrame.d.ts +3 -1
- package/dist/client/components/Transcript.d.ts +2 -0
- package/dist/client/components/TranscriptHeader.d.ts +2 -0
- package/dist/client/components/TranscriptHeadingRow.d.ts +16 -0
- package/dist/client/components/TranscriptThinkingView.d.ts +7 -0
- package/dist/client/components/TranscriptToolRun.d.ts +9 -0
- package/dist/client/components/transcriptRenderModel.d.ts +25 -9
- package/dist/client/format.d.ts +45 -8
- package/dist/client/markdownExport.d.ts +3 -0
- package/dist/client/toolInvocations.d.ts +7 -0
- package/dist/client/types.d.ts +17 -100
- package/dist/client.js +85 -55998
- package/dist/dashboardLoader.d.ts +1 -0
- package/dist/handler.js +1288 -40
- package/dist/index.d.ts +8 -0
- package/dist/index.js +1807 -0
- package/dist/mock-conversations.d.ts +3 -0
- package/dist/mock-release-conversation.d.ts +3 -0
- package/dist/nitro.d.ts +7 -1
- package/dist/tailwind.css +1 -1
- package/dist/url.d.ts +13 -0
- package/package.json +9 -5
- package/src/app.ts +42 -18
- package/src/assets.ts +2 -0
- package/src/auth.ts +2 -35
- package/src/client/App.tsx +33 -74
- package/src/client/code.tsx +1 -1
- package/src/client/components/Button.tsx +75 -0
- package/src/client/components/ConversationRowStats.tsx +3 -2
- package/src/client/components/FilterTabs.tsx +10 -11
- package/src/client/components/LoadingView.tsx +7 -2
- package/src/client/components/Metric.tsx +159 -0
- package/src/client/components/StatusBadge.tsx +10 -1
- package/src/client/components/TelemetryMetrics.tsx +124 -0
- package/src/client/components/ToolFrame.tsx +57 -14
- package/src/client/components/Transcript.tsx +6 -2
- package/src/client/components/TranscriptHeader.tsx +18 -19
- package/src/client/components/TranscriptHeadingRow.tsx +64 -0
- package/src/client/components/TranscriptThinkingView.tsx +157 -0
- package/src/client/components/TranscriptToolRun.tsx +66 -0
- package/src/client/components/TranscriptToolView.tsx +16 -8
- package/src/client/components/TranscriptTurn.tsx +368 -132
- package/src/client/components/TurnDurationChart.tsx +236 -78
- package/src/client/components/transcriptRenderModel.ts +60 -20
- package/src/client/format.ts +329 -87
- package/src/client/markdownExport.ts +360 -0
- package/src/client/pages/CommandCenter.tsx +1 -1
- package/src/client/pages/ConversationPage.tsx +142 -45
- package/src/client/pages/ConversationsPage.tsx +1 -1
- package/src/client/toolInvocations.ts +16 -0
- package/src/client/types.ts +34 -90
- package/src/config.ts +4 -0
- package/src/dashboardLoader.ts +12 -0
- package/src/index.ts +78 -0
- package/src/mock-conversations.ts +726 -0
- package/src/mock-release-conversation.ts +605 -0
- package/src/nitro.ts +7 -1
- package/src/tailwind.css +11 -0
- 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
|
-
|
|
14
|
-
|
|
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="
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
-
{
|
|
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
|
|
116
|
-
? props.detail.turns
|
|
117
|
-
(count, turn) => count + turnMessageCount(turn),
|
|
118
|
-
0,
|
|
119
|
-
)
|
|
175
|
+
const messageSummary = props.detail
|
|
176
|
+
? summarizeMessages(props.detail.turns)
|
|
120
177
|
: undefined;
|
|
121
|
-
const
|
|
122
|
-
? props.detail.turns
|
|
123
|
-
(count, turn) => count + turnToolCallCount(turn),
|
|
124
|
-
0,
|
|
125
|
-
)
|
|
178
|
+
const toolSummary = props.detail
|
|
179
|
+
? summarizeToolCalls(props.detail.turns)
|
|
126
180
|
: undefined;
|
|
127
|
-
const
|
|
181
|
+
const tokenSummary = summarizeUsage(
|
|
128
182
|
(props.detail?.turns ?? props.conversation.turns).map(
|
|
129
183
|
(turn) => turn.cumulativeUsage,
|
|
130
184
|
),
|
|
131
185
|
);
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
<
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
+
}
|