@sentry/junior-dashboard 0.61.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.
- package/dist/app.d.ts +1 -0
- package/dist/app.js +44 -37
- package/dist/assets.d.ts +2 -0
- package/dist/client/api.d.ts +1 -1
- package/dist/client/components/Metric.d.ts +22 -0
- package/dist/client/components/TelemetryMetrics.d.ts +24 -0
- package/dist/client/components/TranscriptText.d.ts +1 -0
- package/dist/client/components/transcriptRenderModel.d.ts +24 -8
- package/dist/client/format.d.ts +65 -13
- package/dist/client/toolInvocations.d.ts +7 -0
- package/dist/client/types.d.ts +17 -100
- package/dist/client.js +59 -55992
- package/dist/handler.js +44 -37
- package/dist/index.d.ts +8 -0
- package/dist/index.js +564 -0
- package/dist/nitro.d.ts +6 -1
- package/dist/tailwind.css +1 -1
- package/dist/url.d.ts +13 -0
- package/package.json +8 -5
- package/src/app.ts +24 -16
- package/src/assets.ts +2 -0
- package/src/auth.ts +2 -35
- package/src/client/components/ConversationRowStats.tsx +3 -2
- package/src/client/components/Metric.tsx +159 -0
- package/src/client/components/TelemetryMetrics.tsx +124 -0
- package/src/client/components/ToolFrame.tsx +3 -3
- package/src/client/components/TranscriptText.tsx +5 -2
- package/src/client/components/TranscriptToolView.tsx +9 -7
- package/src/client/components/TranscriptTurn.tsx +435 -84
- package/src/client/components/TurnDurationChart.tsx +229 -78
- package/src/client/components/transcriptRenderModel.ts +66 -22
- package/src/client/format.ts +369 -103
- package/src/client/pages/ConversationPage.tsx +77 -38
- package/src/client/toolInvocations.ts +16 -0
- package/src/client/types.ts +34 -90
- package/src/index.ts +74 -0
- package/src/nitro.ts +6 -1
- package/src/url.ts +68 -0
package/src/client/format.ts
CHANGED
|
@@ -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"
|
|
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
|
|
103
|
-
const
|
|
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>,
|
|
@@ -204,9 +218,22 @@ function transcriptSource(turn: ConversationTurn) {
|
|
|
204
218
|
: (turn.transcriptMetadata ?? []);
|
|
205
219
|
}
|
|
206
220
|
|
|
207
|
-
|
|
221
|
+
/** Normalized role category for transcript messages. */
|
|
222
|
+
export type TranscriptRoleKind =
|
|
223
|
+
| "assistant"
|
|
224
|
+
| "other"
|
|
225
|
+
| "system"
|
|
226
|
+
| "tool"
|
|
227
|
+
| "user";
|
|
228
|
+
|
|
229
|
+
/** Normalize a raw transcript role string to a canonical kind. */
|
|
230
|
+
export function transcriptRoleKind(role: string): TranscriptRoleKind {
|
|
208
231
|
const normalized = role.toLowerCase();
|
|
209
|
-
|
|
232
|
+
if (normalized === "assistant") return "assistant";
|
|
233
|
+
if (normalized === "user") return "user";
|
|
234
|
+
if (normalized === "system") return "system";
|
|
235
|
+
if (normalized.includes("tool")) return "tool";
|
|
236
|
+
return "other";
|
|
210
237
|
}
|
|
211
238
|
|
|
212
239
|
function hasTextPart(
|
|
@@ -222,8 +249,9 @@ function hasTextPart(
|
|
|
222
249
|
function isConversationMessage(
|
|
223
250
|
message: Pick<ConversationTurn["transcript"][number], "parts" | "role">,
|
|
224
251
|
): boolean {
|
|
225
|
-
|
|
226
|
-
if (
|
|
252
|
+
const kind = transcriptRoleKind(message.role);
|
|
253
|
+
if (kind !== "user" && kind !== "assistant") return false;
|
|
254
|
+
if (kind === "assistant") return hasTextPart(message);
|
|
227
255
|
return message.parts.length > 0;
|
|
228
256
|
}
|
|
229
257
|
|
|
@@ -236,66 +264,261 @@ export function turnMessageCount(turn: ConversationTurn): number {
|
|
|
236
264
|
return turn.transcriptMessageCount ?? 0;
|
|
237
265
|
}
|
|
238
266
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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";
|
|
246
286
|
}
|
|
247
287
|
|
|
248
|
-
function
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
)
|
|
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 };
|
|
253
358
|
}
|
|
254
359
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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` : "";
|
|
259
493
|
}
|
|
260
494
|
|
|
261
495
|
/** Format the aggregate token count across conversation turns. */
|
|
262
496
|
export function formatUsageTotal(usages: Array<TurnUsage | undefined>): string {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
: undefined,
|
|
282
|
-
usage?.cachedInputTokens !== undefined
|
|
283
|
-
? `${formatNumber(usage.cachedInputTokens)} cached`
|
|
284
|
-
: undefined,
|
|
285
|
-
usage?.cacheCreationTokens !== undefined
|
|
286
|
-
? `${formatNumber(usage.cacheCreationTokens)} cache-write`
|
|
287
|
-
: undefined,
|
|
288
|
-
].filter(Boolean);
|
|
289
|
-
return pieces.length > 0
|
|
290
|
-
? `${formatNumber(total)} tokens (${pieces.join(" / ")})`
|
|
291
|
-
: `${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));
|
|
292
515
|
}
|
|
293
516
|
|
|
294
517
|
/** Format a conversation span from first turn start to latest activity. */
|
|
295
518
|
export function formatConversationDuration(conversation: Conversation): string {
|
|
296
519
|
const start = parseTime(conversation.startedAt);
|
|
297
|
-
const end = parseTime(conversation.lastSeenAt)
|
|
298
|
-
if (start == null || end < start) return "none";
|
|
520
|
+
const end = parseTime(conversation.lastSeenAt);
|
|
521
|
+
if (start == null || end == null || end < start) return "none";
|
|
299
522
|
const seconds = Math.max(1, Math.round((end - start) / 1000));
|
|
300
523
|
if (seconds < 60) return `${seconds}s`;
|
|
301
524
|
const minutes = Math.round(seconds / 60);
|
|
@@ -305,7 +528,7 @@ export function formatConversationDuration(conversation: Conversation): string {
|
|
|
305
528
|
|
|
306
529
|
/** Resolve the owning conversation id for a turn/session summary. */
|
|
307
530
|
export function conversationIdForSession(session: Session): string {
|
|
308
|
-
return session.conversationId
|
|
531
|
+
return session.conversationId;
|
|
309
532
|
}
|
|
310
533
|
|
|
311
534
|
function compareTimeDesc(a: string | undefined, b: string | undefined): number {
|
|
@@ -316,7 +539,27 @@ function compareTimeAsc(a: string | undefined, b: string | undefined): number {
|
|
|
316
539
|
return (parseTime(a) ?? 0) - (parseTime(b) ?? 0);
|
|
317
540
|
}
|
|
318
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
|
+
|
|
319
560
|
function getConversationTitle(conversation: Conversation): string {
|
|
561
|
+
const title = meaningfulConversationTitle(conversation);
|
|
562
|
+
if (title) return title;
|
|
320
563
|
if (conversation.surface === "slack") {
|
|
321
564
|
return (
|
|
322
565
|
slackLocationLabel(conversation, { includeId: false }) ??
|
|
@@ -337,15 +580,18 @@ export function conversationDisplayTitle(
|
|
|
337
580
|
/** Prefer stable requester identifiers while keeping Slack ids as a last resort. */
|
|
338
581
|
export function requesterLabel(
|
|
339
582
|
requester: RequesterIdentity | undefined,
|
|
340
|
-
fallback: string | undefined,
|
|
341
583
|
): string | undefined {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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);
|
|
349
595
|
}
|
|
350
596
|
|
|
351
597
|
/** Format the owner and permalink id line shared by conversation rows and headers. */
|
|
@@ -353,20 +599,15 @@ export function conversationIdentityMeta(
|
|
|
353
599
|
conversation: Conversation | undefined,
|
|
354
600
|
conversationId: string | undefined,
|
|
355
601
|
): string {
|
|
356
|
-
const id = conversationId ??
|
|
357
|
-
const owner =
|
|
358
|
-
|
|
359
|
-
conversation?.requester,
|
|
360
|
-
);
|
|
602
|
+
const id = conversationId ?? conversation?.id;
|
|
603
|
+
const owner = conversationRequesterLabel(conversation);
|
|
604
|
+
if (!id) return owner ?? "";
|
|
361
605
|
return owner ? `${owner} · ${id}` : id;
|
|
362
606
|
}
|
|
363
607
|
|
|
364
608
|
/** Convert Slack channel ids and names into user-facing location labels. */
|
|
365
609
|
export function slackLocationLabel(
|
|
366
|
-
input: Pick<
|
|
367
|
-
Session,
|
|
368
|
-
"channel" | "channelName" | "requester" | "requesterIdentity"
|
|
369
|
-
>,
|
|
610
|
+
input: Pick<Session, "channel" | "channelName">,
|
|
370
611
|
options: { includeId?: boolean } = {},
|
|
371
612
|
): string | undefined {
|
|
372
613
|
const channelId = input.channel;
|
|
@@ -465,6 +706,18 @@ export function detectLanguage(text: string): BundledLanguage {
|
|
|
465
706
|
if (/^<[\s\S]+>$/.test(trimmed) && /<\/?[a-zA-Z][^>]*>/.test(trimmed)) {
|
|
466
707
|
return "xml";
|
|
467
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
|
+
}
|
|
468
721
|
if (/```|^#{1,6}\s|\n[-*]\s|\n\d+\.\s|\[[^\]]+\]\([^)]+\)/m.test(trimmed)) {
|
|
469
722
|
return "markdown";
|
|
470
723
|
}
|
|
@@ -530,19 +783,29 @@ export function detectOutputLanguage(text: string): BundledLanguage {
|
|
|
530
783
|
}
|
|
531
784
|
|
|
532
785
|
/**
|
|
533
|
-
* Decide whether a
|
|
534
|
-
*
|
|
535
|
-
*
|
|
786
|
+
* Decide whether a block can use the interactive markup renderer.
|
|
787
|
+
* Only xml/html language blocks qualify; fenced is tracked as metadata but
|
|
788
|
+
* does not gate eligibility — caller controls whether XML detection runs.
|
|
536
789
|
*/
|
|
537
790
|
export function canRenderStructuredMarkup(block: CodeBlock): boolean {
|
|
538
|
-
return
|
|
539
|
-
block.fenced === true &&
|
|
540
|
-
(block.language === "xml" || block.language === "html")
|
|
541
|
-
);
|
|
791
|
+
return block.language === "xml" || block.language === "html";
|
|
542
792
|
}
|
|
543
793
|
|
|
544
|
-
/**
|
|
545
|
-
|
|
794
|
+
/**
|
|
795
|
+
* Parse markdown into renderable code blocks while preserving plain text blocks.
|
|
796
|
+
*
|
|
797
|
+
* `outputOnly` (default `false`): when `true`, prose sections use
|
|
798
|
+
* `detectOutputLanguage` (json or markdown only — no xml/html heuristics).
|
|
799
|
+
* Use `outputOnly: true` for LLM-generated text (assistant messages) to
|
|
800
|
+
* prevent Slack autolinks and HTML snippets from triggering the XML tree
|
|
801
|
+
* renderer. Leave `false` (default) for user/system messages that may
|
|
802
|
+
* contain genuine XML runtime context.
|
|
803
|
+
*/
|
|
804
|
+
export function parseMarkdownBlocks(
|
|
805
|
+
text: string,
|
|
806
|
+
opts: { outputOnly?: boolean } = {},
|
|
807
|
+
): CodeBlock[] {
|
|
808
|
+
const detectProse = opts.outputOnly ? detectOutputLanguage : detectLanguage;
|
|
546
809
|
const blocks: CodeBlock[] = [];
|
|
547
810
|
const fence = /```([A-Za-z0-9_-]+)?\n([\s\S]*?)```/g;
|
|
548
811
|
let cursor = 0;
|
|
@@ -550,8 +813,12 @@ export function parseMarkdownBlocks(text: string): CodeBlock[] {
|
|
|
550
813
|
while ((match = fence.exec(text))) {
|
|
551
814
|
const prose = text.slice(cursor, match.index).trim();
|
|
552
815
|
if (prose) {
|
|
553
|
-
const language =
|
|
554
|
-
blocks.push({
|
|
816
|
+
const language = detectProse(prose);
|
|
817
|
+
blocks.push({
|
|
818
|
+
code: formatCodeBlock(prose, language),
|
|
819
|
+
fenced: false,
|
|
820
|
+
language,
|
|
821
|
+
});
|
|
555
822
|
}
|
|
556
823
|
const language = normalizeLanguage(match[1]);
|
|
557
824
|
blocks.push({
|
|
@@ -563,11 +830,15 @@ export function parseMarkdownBlocks(text: string): CodeBlock[] {
|
|
|
563
830
|
}
|
|
564
831
|
const rest = text.slice(cursor).trim();
|
|
565
832
|
if (rest) {
|
|
566
|
-
const language =
|
|
567
|
-
blocks.push({
|
|
833
|
+
const language = detectProse(rest);
|
|
834
|
+
blocks.push({
|
|
835
|
+
code: formatCodeBlock(rest, language),
|
|
836
|
+
fenced: false,
|
|
837
|
+
language,
|
|
838
|
+
});
|
|
568
839
|
}
|
|
569
840
|
if (blocks.length > 0) return blocks;
|
|
570
|
-
const language =
|
|
841
|
+
const language = detectProse(text);
|
|
571
842
|
return [{ code: formatCodeBlock(text, language), fenced: false, language }];
|
|
572
843
|
}
|
|
573
844
|
|
|
@@ -631,12 +902,10 @@ export function buildConversations(sessions: Session[]): Conversation[] {
|
|
|
631
902
|
const sortedTurns = [...turns].sort((a, b) =>
|
|
632
903
|
compareTimeAsc(a.startedAt, b.startedAt),
|
|
633
904
|
);
|
|
634
|
-
const
|
|
635
|
-
compareTimeDesc(
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
),
|
|
639
|
-
)[0]!;
|
|
905
|
+
const recentTurns = [...turns].sort((a, b) =>
|
|
906
|
+
compareTimeDesc(a.lastSeenAt, b.lastSeenAt),
|
|
907
|
+
);
|
|
908
|
+
const newest = recentTurns[0]!;
|
|
640
909
|
const oldest = sortedTurns.reduce((current, next) =>
|
|
641
910
|
(parseTime(next.startedAt) ?? Number.MAX_SAFE_INTEGER) <
|
|
642
911
|
(parseTime(current.startedAt) ?? Number.MAX_SAFE_INTEGER)
|
|
@@ -650,28 +919,25 @@ export function buildConversations(sessions: Session[]): Conversation[] {
|
|
|
650
919
|
: sortedTurns.some(isFailedSession)
|
|
651
920
|
? "failed"
|
|
652
921
|
: newest.status;
|
|
653
|
-
const requesterTurn =
|
|
654
|
-
|
|
655
|
-
|
|
922
|
+
const requesterTurn = sortedTurns.find((turn) => turn.requesterIdentity);
|
|
923
|
+
const conversationTitle = recentTurns.find(
|
|
924
|
+
(turn) => turn.conversationTitle,
|
|
925
|
+
)?.conversationTitle;
|
|
656
926
|
|
|
657
927
|
return {
|
|
658
928
|
channel: newest.channel,
|
|
659
|
-
channelName:
|
|
660
|
-
conversationTitle
|
|
661
|
-
?.conversationTitle,
|
|
929
|
+
channelName: recentTurns.find((turn) => turn.channelName)?.channelName,
|
|
930
|
+
conversationTitle,
|
|
662
931
|
id,
|
|
932
|
+
lastProgressAt: newest.lastProgressAt,
|
|
663
933
|
lastSeenAt: newest.lastSeenAt,
|
|
664
|
-
requester: requesterLabel(
|
|
665
|
-
requesterTurn?.requesterIdentity,
|
|
666
|
-
requesterTurn?.requester,
|
|
667
|
-
),
|
|
668
934
|
requesterIdentity: requesterTurn?.requesterIdentity,
|
|
669
935
|
sentryConversationUrl: newest.sentryConversationUrl,
|
|
670
936
|
sentryTraceUrl: newest.sentryTraceUrl,
|
|
671
937
|
startedAt: oldest.startedAt,
|
|
672
938
|
status,
|
|
673
939
|
surface: newest.surface,
|
|
674
|
-
title: newest.title
|
|
940
|
+
title: newest.title,
|
|
675
941
|
traceId: newest.traceId,
|
|
676
942
|
turns: sortedTurns,
|
|
677
943
|
};
|