@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.
Files changed (38) hide show
  1. package/dist/app.d.ts +1 -0
  2. package/dist/app.js +44 -37
  3. package/dist/assets.d.ts +2 -0
  4. package/dist/client/api.d.ts +1 -1
  5. package/dist/client/components/Metric.d.ts +22 -0
  6. package/dist/client/components/TelemetryMetrics.d.ts +24 -0
  7. package/dist/client/components/TranscriptText.d.ts +1 -0
  8. package/dist/client/components/transcriptRenderModel.d.ts +24 -8
  9. package/dist/client/format.d.ts +65 -13
  10. package/dist/client/toolInvocations.d.ts +7 -0
  11. package/dist/client/types.d.ts +17 -100
  12. package/dist/client.js +59 -55992
  13. package/dist/handler.js +44 -37
  14. package/dist/index.d.ts +8 -0
  15. package/dist/index.js +564 -0
  16. package/dist/nitro.d.ts +6 -1
  17. package/dist/tailwind.css +1 -1
  18. package/dist/url.d.ts +13 -0
  19. package/package.json +8 -5
  20. package/src/app.ts +24 -16
  21. package/src/assets.ts +2 -0
  22. package/src/auth.ts +2 -35
  23. package/src/client/components/ConversationRowStats.tsx +3 -2
  24. package/src/client/components/Metric.tsx +159 -0
  25. package/src/client/components/TelemetryMetrics.tsx +124 -0
  26. package/src/client/components/ToolFrame.tsx +3 -3
  27. package/src/client/components/TranscriptText.tsx +5 -2
  28. package/src/client/components/TranscriptToolView.tsx +9 -7
  29. package/src/client/components/TranscriptTurn.tsx +435 -84
  30. package/src/client/components/TurnDurationChart.tsx +229 -78
  31. package/src/client/components/transcriptRenderModel.ts +66 -22
  32. package/src/client/format.ts +369 -103
  33. package/src/client/pages/ConversationPage.tsx +77 -38
  34. package/src/client/toolInvocations.ts +16 -0
  35. package/src/client/types.ts +34 -90
  36. package/src/index.ts +74 -0
  37. package/src/nitro.ts +6 -1
  38. package/src/url.ts +68 -0
@@ -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>,
@@ -204,9 +218,22 @@ function transcriptSource(turn: ConversationTurn) {
204
218
  : (turn.transcriptMetadata ?? []);
205
219
  }
206
220
 
207
- function isConversationMessageRole(role: string): boolean {
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
- return normalized === "user" || normalized === "assistant";
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
- if (!isConversationMessageRole(message.role)) return false;
226
- if (message.role.toLowerCase() === "assistant") return hasTextPart(message);
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
- /** Count tool calls from visible transcripts or safe redacted metadata. */
240
- export function turnToolCallCount(turn: ConversationTurn): number {
241
- return transcriptSource(turn).reduce((count, message) => {
242
- return (
243
- count + message.parts.filter((part) => part.type === "tool_call").length
244
- );
245
- }, 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";
246
286
  }
247
287
 
248
- function totalUsageTokens(usage: TurnUsage | undefined): number | undefined {
249
- if (!usage) return undefined;
250
- return (
251
- getUsageComponentTotal(usage) ?? getFiniteTokenCount(usage.totalTokens)
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
- /** Format known token counters without estimating per-message usage. */
256
- export function formatTokenTotal(usage: TurnUsage | undefined): string {
257
- const total = totalUsageTokens(usage);
258
- 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` : "";
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
- const total = usages.reduce<number | undefined>((sum, usage) => {
264
- const tokens = totalUsageTokens(usage);
265
- if (tokens === undefined) return sum;
266
- return (sum ?? 0) + tokens;
267
- }, undefined);
268
- return total === undefined ? "" : `${formatNumber(total)} tokens`;
269
- }
270
-
271
- /** Format known token counters with available input/output detail. */
272
- export function formatUsage(usage: TurnUsage | undefined): string {
273
- const total = totalUsageTokens(usage);
274
- if (total === undefined) return "";
275
- const pieces = [
276
- usage?.inputTokens !== undefined
277
- ? `${formatNumber(usage.inputTokens)} in`
278
- : undefined,
279
- usage?.outputTokens !== undefined
280
- ? `${formatNumber(usage.outputTokens)} out`
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) ?? Date.now();
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 || session.id;
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
- return (
343
- requester?.email ??
344
- requester?.slackUserName ??
345
- requester?.fullName ??
346
- fallback ??
347
- requester?.slackUserId
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 ?? "missing conversation id";
357
- const owner = requesterLabel(
358
- conversation?.requesterIdentity,
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 fenced block can use the interactive markup renderer.
534
- * Structured XML/HTML rendering is only enabled for explicitly-fenced blocks;
535
- * auto-detected prose is never eligible regardless of inferred language.
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
- /** Parse markdown into renderable code blocks while preserving plain text blocks. */
545
- export function parseMarkdownBlocks(text: string): CodeBlock[] {
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 = detectOutputLanguage(prose);
554
- blocks.push({ code: formatCodeBlock(prose, language), fenced: false, language });
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 = detectOutputLanguage(rest);
567
- blocks.push({ code: formatCodeBlock(rest, language), fenced: false, language });
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 = detectOutputLanguage(text);
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 newest = [...turns].sort((a, b) =>
635
- compareTimeDesc(
636
- a.lastSeenAt ?? a.startedAt,
637
- b.lastSeenAt ?? b.startedAt,
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
- sortedTurns.find((turn) => turn.requesterIdentity) ??
655
- sortedTurns.find((turn) => turn.requester);
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: sortedTurns.find((turn) => turn.channelName)?.channelName,
660
- conversationTitle: sortedTurns.find((turn) => turn.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 || id,
940
+ title: newest.title,
675
941
  traceId: newest.traceId,
676
942
  turns: sortedTurns,
677
943
  };