@sentry/junior-dashboard 0.60.1 → 0.62.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.
@@ -2,5 +2,6 @@
2
2
  export declare function TranscriptText(props: {
3
3
  firstChildIndex: number;
4
4
  lastChildIndex: number;
5
+ role?: string;
5
6
  text: string;
6
7
  }): import("react/jsx-runtime").JSX.Element;
@@ -26,5 +26,5 @@ export declare function groupTranscriptMessages(messages: TranscriptMessage[]):
26
26
  /** Build the plain-text clipboard/raw view for one transcript message. */
27
27
  export declare function messageRawText(message: TranscriptMessage): string;
28
28
  /** Count rendered rows so structured transcript expansion opens the newest node. */
29
- export declare function countRenderedTranscriptChildren(part: RenderedTranscriptPart): number;
29
+ export declare function countRenderedTranscriptChildren(part: RenderedTranscriptPart, role?: string): number;
30
30
  export {};
@@ -18,6 +18,10 @@ export declare function formatMessageTimestamp(value: number | undefined): strin
18
18
  export declare function formatMessageOffset(turn: ConversationTurn, value: number | undefined): string | undefined;
19
19
  /** Format byte counts in lowercase compact units for transcript metadata. */
20
20
  export declare function formatBytes(value: number | undefined): string;
21
+ /** Normalized role category for transcript messages. */
22
+ export type TranscriptRoleKind = "assistant" | "other" | "system" | "tool" | "user";
23
+ /** Normalize a raw transcript role string to a canonical kind. */
24
+ export declare function transcriptRoleKind(role: string): TranscriptRoleKind;
21
25
  /** Count visible or redacted message records for a turn. */
22
26
  export declare function turnMessageCount(turn: ConversationTurn): number;
23
27
  /** Count tool calls from visible transcripts or safe redacted metadata. */
@@ -52,10 +56,31 @@ export declare function unavailableTranscriptLabel(turn: ConversationTurn): stri
52
56
  export declare function conversationPath(conversationId: string): string;
53
57
  /** Detect the syntax highlighter language for raw transcript blocks. */
54
58
  export declare function detectLanguage(text: string): BundledLanguage;
55
- /** Decide whether a fenced block can use the interactive markup renderer. */
56
- export declare function canRenderStructuredMarkup(language: BundledLanguage): boolean;
57
- /** Parse markdown into renderable code blocks while preserving plain text blocks. */
58
- export declare function parseMarkdownBlocks(text: string): CodeBlock[];
59
+ /**
60
+ * Detect the language for LLM text output prose: json if the text is valid
61
+ * JSON or JSONL, markdown otherwise. Never auto-detects XML, HTML, TypeScript,
62
+ * or shell those heuristics are unreliable for rendered assistant output.
63
+ */
64
+ export declare function detectOutputLanguage(text: string): BundledLanguage;
65
+ /**
66
+ * Decide whether a block can use the interactive markup renderer.
67
+ * Only xml/html language blocks qualify; fenced is tracked as metadata but
68
+ * does not gate eligibility — caller controls whether XML detection runs.
69
+ */
70
+ export declare function canRenderStructuredMarkup(block: CodeBlock): boolean;
71
+ /**
72
+ * Parse markdown into renderable code blocks while preserving plain text blocks.
73
+ *
74
+ * `outputOnly` (default `false`): when `true`, prose sections use
75
+ * `detectOutputLanguage` (json or markdown only — no xml/html heuristics).
76
+ * Use `outputOnly: true` for LLM-generated text (assistant messages) to
77
+ * prevent Slack autolinks and HTML snippets from triggering the XML tree
78
+ * renderer. Leave `false` (default) for user/system messages that may
79
+ * contain genuine XML runtime context.
80
+ */
81
+ export declare function parseMarkdownBlocks(text: string, opts?: {
82
+ outputOnly?: boolean;
83
+ }): CodeBlock[];
59
84
  /** Parse XML/HTML-ish fragments for the collapsible transcript renderer. */
60
85
  export declare function parseMarkupNodes(code: string, language: BundledLanguage): MarkupNode[];
61
86
  /** Group recent turn summaries into conversation rows. */
@@ -140,6 +140,7 @@ export type SessionFilter = "active" | "recent" | "hung" | "failed" | "all";
140
140
  export type VisualStatus = "active" | "failed" | "hung" | "idle";
141
141
  export type CodeBlock = {
142
142
  code: string;
143
+ fenced?: boolean;
143
144
  language: BundledLanguage;
144
145
  };
145
146
  export type MarkupNode = {
package/dist/client.js CHANGED
@@ -30836,9 +30836,13 @@ function formatBytes(value) {
30836
30836
  function transcriptSource(turn) {
30837
30837
  return turn.transcriptAvailable ? turn.transcript : turn.transcriptMetadata ?? [];
30838
30838
  }
30839
- function isConversationMessageRole(role) {
30839
+ function transcriptRoleKind(role) {
30840
30840
  const normalized = role.toLowerCase();
30841
- return normalized === "user" || normalized === "assistant";
30841
+ if (normalized === "assistant") return "assistant";
30842
+ if (normalized === "user") return "user";
30843
+ if (normalized === "system") return "system";
30844
+ if (normalized.includes("tool")) return "tool";
30845
+ return "other";
30842
30846
  }
30843
30847
  function hasTextPart(message) {
30844
30848
  return message.parts.some((part) => {
@@ -30848,8 +30852,9 @@ function hasTextPart(message) {
30848
30852
  });
30849
30853
  }
30850
30854
  function isConversationMessage(message) {
30851
- if (!isConversationMessageRole(message.role)) return false;
30852
- if (message.role.toLowerCase() === "assistant") return hasTextPart(message);
30855
+ const kind = transcriptRoleKind(message.role);
30856
+ if (kind !== "user" && kind !== "assistant") return false;
30857
+ if (kind === "assistant") return hasTextPart(message);
30853
30858
  return message.parts.length > 0;
30854
30859
  }
30855
30860
  function turnMessageCount(turn) {
@@ -31045,10 +31050,22 @@ function prettyJsonData(text2) {
31045
31050
  function formatCodeBlock(code, language) {
31046
31051
  return language === "json" ? prettyJsonData(code) ?? code : code;
31047
31052
  }
31048
- function canRenderStructuredMarkup(language) {
31049
- return language === "xml" || language === "html";
31053
+ function detectOutputLanguage(text2) {
31054
+ const trimmed = text2.trim();
31055
+ if (!trimmed) return "markdown";
31056
+ try {
31057
+ JSON.parse(trimmed);
31058
+ return "json";
31059
+ } catch {
31060
+ }
31061
+ if (prettyJsonl(trimmed)) return "json";
31062
+ return "markdown";
31063
+ }
31064
+ function canRenderStructuredMarkup(block) {
31065
+ return block.language === "xml" || block.language === "html";
31050
31066
  }
31051
- function parseMarkdownBlocks(text2) {
31067
+ function parseMarkdownBlocks(text2, opts = {}) {
31068
+ const detectProse = opts.outputOnly ? detectOutputLanguage : detectLanguage;
31052
31069
  const blocks = [];
31053
31070
  const fence = /```([A-Za-z0-9_-]+)?\n([\s\S]*?)```/g;
31054
31071
  let cursor = 0;
@@ -31056,24 +31073,25 @@ function parseMarkdownBlocks(text2) {
31056
31073
  while (match = fence.exec(text2)) {
31057
31074
  const prose = text2.slice(cursor, match.index).trim();
31058
31075
  if (prose) {
31059
- const language3 = detectLanguage(prose);
31060
- blocks.push({ code: formatCodeBlock(prose, language3), language: language3 });
31076
+ const language3 = detectProse(prose);
31077
+ blocks.push({ code: formatCodeBlock(prose, language3), fenced: false, language: language3 });
31061
31078
  }
31062
31079
  const language2 = normalizeLanguage(match[1]);
31063
31080
  blocks.push({
31064
31081
  code: formatCodeBlock(match[2] ?? "", language2),
31082
+ fenced: true,
31065
31083
  language: language2
31066
31084
  });
31067
31085
  cursor = match.index + match[0].length;
31068
31086
  }
31069
31087
  const rest = text2.slice(cursor).trim();
31070
31088
  if (rest) {
31071
- const language2 = detectLanguage(rest);
31072
- blocks.push({ code: formatCodeBlock(rest, language2), language: language2 });
31089
+ const language2 = detectProse(rest);
31090
+ blocks.push({ code: formatCodeBlock(rest, language2), fenced: false, language: language2 });
31073
31091
  }
31074
31092
  if (blocks.length > 0) return blocks;
31075
- const language = detectLanguage(text2);
31076
- return [{ code: formatCodeBlock(text2, language), language }];
31093
+ const language = detectProse(text2);
31094
+ return [{ code: formatCodeBlock(text2, language), fenced: false, language }];
31077
31095
  }
31078
31096
  function parseMarkupNodes(code, language) {
31079
31097
  const parser = new DOMParser();
@@ -54731,7 +54749,7 @@ var import_react51 = __toESM(require_react(), 1);
54731
54749
  var import_react50 = __toESM(require_react(), 1);
54732
54750
  var import_jsx_runtime17 = __toESM(require_jsx_runtime(), 1);
54733
54751
  function countStructuredBlockChildren(block) {
54734
- if (!canRenderStructuredMarkup(block.language)) return 1;
54752
+ if (!canRenderStructuredMarkup(block)) return 1;
54735
54753
  const rootCount = parseMarkupNodes(block.code, block.language).length;
54736
54754
  return rootCount > 0 ? rootCount : 1;
54737
54755
  }
@@ -54882,13 +54900,15 @@ function toolHeaderClass(interactive) {
54882
54900
  // src/client/components/TranscriptText.tsx
54883
54901
  var import_jsx_runtime19 = __toESM(require_jsx_runtime(), 1);
54884
54902
  function TranscriptText(props) {
54885
- const blocks = parseMarkdownBlocks(props.text);
54903
+ const blocks = parseMarkdownBlocks(props.text, {
54904
+ outputOnly: transcriptRoleKind(props.role ?? "") === "assistant"
54905
+ });
54886
54906
  let seenChildren = props.firstChildIndex;
54887
54907
  return /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("div", { className: "grid min-w-0 gap-2", children: blocks.map((block, index) => {
54888
54908
  const firstChildIndex = seenChildren;
54889
54909
  const childCount = countStructuredBlockChildren(block);
54890
54910
  seenChildren += childCount;
54891
- if (!canRenderStructuredMarkup(block.language)) {
54911
+ if (!canRenderStructuredMarkup(block)) {
54892
54912
  return /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
54893
54913
  HighlightedCode,
54894
54914
  {
@@ -55183,15 +55203,18 @@ function messageRawText(message) {
55183
55203
  return stringifyPartValue(part.output ?? part.input ?? part.text ?? part);
55184
55204
  }).filter((part) => part.trim().length > 0).join("\n\n");
55185
55205
  }
55186
- function countTextRenderedChildren(text2) {
55187
- return parseMarkdownBlocks(text2).reduce((count, block) => {
55206
+ function countTextRenderedChildren(text2, outputOnly) {
55207
+ return parseMarkdownBlocks(text2, { outputOnly }).reduce((count, block) => {
55188
55208
  return count + countStructuredBlockChildren(block);
55189
55209
  }, 0);
55190
55210
  }
55191
- function countRenderedTranscriptChildren(part) {
55211
+ function countRenderedTranscriptChildren(part, role) {
55192
55212
  if (part.kind === "tool") return 1;
55193
55213
  if (part.part.type === "text") {
55194
- return countTextRenderedChildren(part.part.text ?? "");
55214
+ return countTextRenderedChildren(
55215
+ part.part.text ?? "",
55216
+ transcriptRoleKind(role ?? "") === "assistant"
55217
+ );
55195
55218
  }
55196
55219
  return 1;
55197
55220
  }
@@ -55228,14 +55251,6 @@ function turnMarkerClass(status) {
55228
55251
  status === "idle" && "border-[#beaaff]/70 bg-[#beaaff]/50"
55229
55252
  );
55230
55253
  }
55231
- function transcriptRoleKind(role) {
55232
- const normalized = role.toLowerCase();
55233
- if (normalized === "assistant") return "assistant";
55234
- if (normalized === "user") return "user";
55235
- if (normalized === "system") return "system";
55236
- if (normalized.includes("tool")) return "tool";
55237
- return "other";
55238
- }
55239
55254
  function transcriptRoleLabel(role, turn) {
55240
55255
  const kind = transcriptRoleKind(role);
55241
55256
  if (kind === "assistant") return "Junior";
@@ -55438,8 +55453,9 @@ function TranscriptMessageView(props) {
55438
55453
  const offset = formatMessageOffset(props.turn, props.message.timestamp);
55439
55454
  const renderedParts = groupTranscriptParts(props.message.parts);
55440
55455
  const rawText = messageRawText(props.message);
55456
+ const role = props.message.role;
55441
55457
  const totalRenderedChildren = renderedParts.reduce(
55442
- (count, part) => count + countRenderedTranscriptChildren(part),
55458
+ (count, part) => count + countRenderedTranscriptChildren(part, role),
55443
55459
  0
55444
55460
  );
55445
55461
  let seenRenderedChildren = 0;
@@ -55466,13 +55482,14 @@ function TranscriptMessageView(props) {
55466
55482
  }
55467
55483
  ) : /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("div", { className: "grid min-w-0 gap-2", children: renderedParts.map((part, index) => {
55468
55484
  const firstChildIndex = seenRenderedChildren;
55469
- seenRenderedChildren += countRenderedTranscriptChildren(part);
55485
+ seenRenderedChildren += countRenderedTranscriptChildren(part, role);
55470
55486
  return /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
55471
55487
  TranscriptPartView,
55472
55488
  {
55473
55489
  firstChildIndex,
55474
55490
  lastChildIndex: totalRenderedChildren - 1,
55475
- part
55491
+ part,
55492
+ role
55476
55493
  },
55477
55494
  index
55478
55495
  );
@@ -55492,6 +55509,7 @@ function TranscriptPartView(props) {
55492
55509
  {
55493
55510
  firstChildIndex: props.firstChildIndex,
55494
55511
  lastChildIndex: props.lastChildIndex,
55512
+ role: props.role,
55495
55513
  text: part.text ?? ""
55496
55514
  }
55497
55515
  );
@@ -55531,7 +55549,7 @@ function ThinkingPartView(props) {
55531
55549
  HighlightedCode,
55532
55550
  {
55533
55551
  code: rendered || "{}",
55534
- language: detectLanguage(rendered)
55552
+ language: detectOutputLanguage(rendered)
55535
55553
  }
55536
55554
  ) })
55537
55555
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-dashboard",
3
- "version": "0.60.1",
3
+ "version": "0.62.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -39,7 +39,7 @@
39
39
  "react-router": "^7.16.0",
40
40
  "recharts": "^3.8.1",
41
41
  "shiki": "4.1.0",
42
- "@sentry/junior": "0.60.1"
42
+ "@sentry/junior": "0.62.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@tailwindcss/cli": "^4.3.0",
@@ -7,7 +7,7 @@ import type { CodeBlock, MarkupNode } from "./types";
7
7
 
8
8
  /** Count rendered children so transcripts can decide which markup node expands. */
9
9
  export function countStructuredBlockChildren(block: CodeBlock): number {
10
- if (!canRenderStructuredMarkup(block.language)) return 1;
10
+ if (!canRenderStructuredMarkup(block)) return 1;
11
11
  const rootCount = parseMarkupNodes(block.code, block.language).length;
12
12
  return rootCount > 0 ? rootCount : 1;
13
13
  }
@@ -3,15 +3,18 @@ import {
3
3
  HighlightedCode,
4
4
  StructuredMarkup,
5
5
  } from "../code";
6
- import { canRenderStructuredMarkup, parseMarkdownBlocks } from "../format";
6
+ import { canRenderStructuredMarkup, parseMarkdownBlocks, transcriptRoleKind } from "../format";
7
7
 
8
8
  /** Render transcript markdown/code blocks with structured markup expansion. */
9
9
  export function TranscriptText(props: {
10
10
  firstChildIndex: number;
11
11
  lastChildIndex: number;
12
+ role?: string;
12
13
  text: string;
13
14
  }) {
14
- const blocks = parseMarkdownBlocks(props.text);
15
+ const blocks = parseMarkdownBlocks(props.text, {
16
+ outputOnly: transcriptRoleKind(props.role ?? "") === "assistant",
17
+ });
15
18
  let seenChildren = props.firstChildIndex;
16
19
 
17
20
  return (
@@ -21,7 +24,7 @@ export function TranscriptText(props: {
21
24
  const childCount = countStructuredBlockChildren(block);
22
25
  seenChildren += childCount;
23
26
 
24
- if (!canRenderStructuredMarkup(block.language)) {
27
+ if (!canRenderStructuredMarkup(block)) {
25
28
  return (
26
29
  <HighlightedCode
27
30
  code={block.code}
@@ -3,6 +3,9 @@ import { useState, type ClipboardEventHandler, type ReactNode } from "react";
3
3
  import { HighlightedCode } from "../code";
4
4
  import {
5
5
  detectLanguage,
6
+ detectOutputLanguage,
7
+ transcriptRoleKind,
8
+ type TranscriptRoleKind,
6
9
  formatBytes,
7
10
  formatMessageOffset,
8
11
  formatMessageTimestamp,
@@ -71,17 +74,6 @@ function turnMarkerClass(
71
74
  );
72
75
  }
73
76
 
74
- type TranscriptRoleKind = "assistant" | "other" | "system" | "tool" | "user";
75
-
76
- function transcriptRoleKind(role: string): TranscriptRoleKind {
77
- const normalized = role.toLowerCase();
78
- if (normalized === "assistant") return "assistant";
79
- if (normalized === "user") return "user";
80
- if (normalized === "system") return "system";
81
- if (normalized.includes("tool")) return "tool";
82
- return "other";
83
- }
84
-
85
77
  function transcriptRoleLabel(role: string, turn: ConversationTurn): string {
86
78
  const kind = transcriptRoleKind(role);
87
79
  if (kind === "assistant") return "Junior";
@@ -375,8 +367,9 @@ function TranscriptMessageView(props: {
375
367
  const offset = formatMessageOffset(props.turn, props.message.timestamp);
376
368
  const renderedParts = groupTranscriptParts(props.message.parts);
377
369
  const rawText = messageRawText(props.message);
370
+ const role = props.message.role;
378
371
  const totalRenderedChildren = renderedParts.reduce(
379
- (count, part) => count + countRenderedTranscriptChildren(part),
372
+ (count, part) => count + countRenderedTranscriptChildren(part, role),
380
373
  0,
381
374
  );
382
375
  let seenRenderedChildren = 0;
@@ -410,13 +403,14 @@ function TranscriptMessageView(props: {
410
403
  <div className="grid min-w-0 gap-2">
411
404
  {renderedParts.map((part, index) => {
412
405
  const firstChildIndex = seenRenderedChildren;
413
- seenRenderedChildren += countRenderedTranscriptChildren(part);
406
+ seenRenderedChildren += countRenderedTranscriptChildren(part, role);
414
407
  return (
415
408
  <TranscriptPartView
416
409
  firstChildIndex={firstChildIndex}
417
410
  key={index}
418
411
  lastChildIndex={totalRenderedChildren - 1}
419
412
  part={part}
413
+ role={role}
420
414
  />
421
415
  );
422
416
  })}
@@ -430,6 +424,7 @@ function TranscriptPartView(props: {
430
424
  firstChildIndex: number;
431
425
  lastChildIndex: number;
432
426
  part: RenderedTranscriptPart;
427
+ role?: string;
433
428
  }) {
434
429
  if (props.part.kind === "tool") {
435
430
  return (
@@ -443,6 +438,7 @@ function TranscriptPartView(props: {
443
438
  <TranscriptText
444
439
  firstChildIndex={props.firstChildIndex}
445
440
  lastChildIndex={props.lastChildIndex}
441
+ role={props.role}
446
442
  text={part.text ?? ""}
447
443
  />
448
444
  );
@@ -494,7 +490,7 @@ function ThinkingPartView(props: { value: unknown }) {
494
490
  <div className="border-t border-[#beaaff]/15 px-3 py-3">
495
491
  <HighlightedCode
496
492
  code={rendered || "{}"}
497
- language={detectLanguage(rendered)}
493
+ language={detectOutputLanguage(rendered)}
498
494
  />
499
495
  </div>
500
496
  </details>
@@ -1,5 +1,5 @@
1
1
  import { countStructuredBlockChildren } from "../code";
2
- import { parseMarkdownBlocks, stringifyPartValue } from "../format";
2
+ import { parseMarkdownBlocks, stringifyPartValue, transcriptRoleKind } from "../format";
3
3
  import type { TranscriptMessage, TranscriptPart } from "../types";
4
4
 
5
5
  export type RenderedTranscriptPart =
@@ -175,8 +175,8 @@ export function messageRawText(message: TranscriptMessage): string {
175
175
  .join("\n\n");
176
176
  }
177
177
 
178
- function countTextRenderedChildren(text: string): number {
179
- return parseMarkdownBlocks(text).reduce((count, block) => {
178
+ function countTextRenderedChildren(text: string, outputOnly: boolean): number {
179
+ return parseMarkdownBlocks(text, { outputOnly }).reduce((count, block) => {
180
180
  return count + countStructuredBlockChildren(block);
181
181
  }, 0);
182
182
  }
@@ -184,10 +184,14 @@ function countTextRenderedChildren(text: string): number {
184
184
  /** Count rendered rows so structured transcript expansion opens the newest node. */
185
185
  export function countRenderedTranscriptChildren(
186
186
  part: RenderedTranscriptPart,
187
+ role?: string,
187
188
  ): number {
188
189
  if (part.kind === "tool") return 1;
189
190
  if (part.part.type === "text") {
190
- return countTextRenderedChildren(part.part.text ?? "");
191
+ return countTextRenderedChildren(
192
+ part.part.text ?? "",
193
+ transcriptRoleKind(role ?? "") === "assistant",
194
+ );
191
195
  }
192
196
  return 1;
193
197
  }
@@ -204,9 +204,22 @@ function transcriptSource(turn: ConversationTurn) {
204
204
  : (turn.transcriptMetadata ?? []);
205
205
  }
206
206
 
207
- function isConversationMessageRole(role: string): boolean {
207
+ /** Normalized role category for transcript messages. */
208
+ export type TranscriptRoleKind =
209
+ | "assistant"
210
+ | "other"
211
+ | "system"
212
+ | "tool"
213
+ | "user";
214
+
215
+ /** Normalize a raw transcript role string to a canonical kind. */
216
+ export function transcriptRoleKind(role: string): TranscriptRoleKind {
208
217
  const normalized = role.toLowerCase();
209
- return normalized === "user" || normalized === "assistant";
218
+ if (normalized === "assistant") return "assistant";
219
+ if (normalized === "user") return "user";
220
+ if (normalized === "system") return "system";
221
+ if (normalized.includes("tool")) return "tool";
222
+ return "other";
210
223
  }
211
224
 
212
225
  function hasTextPart(
@@ -222,8 +235,9 @@ function hasTextPart(
222
235
  function isConversationMessage(
223
236
  message: Pick<ConversationTurn["transcript"][number], "parts" | "role">,
224
237
  ): boolean {
225
- if (!isConversationMessageRole(message.role)) return false;
226
- if (message.role.toLowerCase() === "assistant") return hasTextPart(message);
238
+ const kind = transcriptRoleKind(message.role);
239
+ if (kind !== "user" && kind !== "assistant") return false;
240
+ if (kind === "assistant") return hasTextPart(message);
227
241
  return message.parts.length > 0;
228
242
  }
229
243
 
@@ -511,13 +525,48 @@ function formatCodeBlock(code: string, language: BundledLanguage): string {
511
525
  return language === "json" ? (prettyJsonData(code) ?? code) : code;
512
526
  }
513
527
 
514
- /** Decide whether a fenced block can use the interactive markup renderer. */
515
- export function canRenderStructuredMarkup(language: BundledLanguage): boolean {
516
- return language === "xml" || language === "html";
528
+ /**
529
+ * Detect the language for LLM text output prose: json if the text is valid
530
+ * JSON or JSONL, markdown otherwise. Never auto-detects XML, HTML, TypeScript,
531
+ * or shell — those heuristics are unreliable for rendered assistant output.
532
+ */
533
+ export function detectOutputLanguage(text: string): BundledLanguage {
534
+ const trimmed = text.trim();
535
+ if (!trimmed) return "markdown";
536
+ try {
537
+ JSON.parse(trimmed);
538
+ return "json";
539
+ } catch {
540
+ // continue
541
+ }
542
+ if (prettyJsonl(trimmed)) return "json";
543
+ return "markdown";
517
544
  }
518
545
 
519
- /** Parse markdown into renderable code blocks while preserving plain text blocks. */
520
- export function parseMarkdownBlocks(text: string): CodeBlock[] {
546
+ /**
547
+ * Decide whether a block can use the interactive markup renderer.
548
+ * Only xml/html language blocks qualify; fenced is tracked as metadata but
549
+ * does not gate eligibility — caller controls whether XML detection runs.
550
+ */
551
+ export function canRenderStructuredMarkup(block: CodeBlock): boolean {
552
+ return block.language === "xml" || block.language === "html";
553
+ }
554
+
555
+ /**
556
+ * Parse markdown into renderable code blocks while preserving plain text blocks.
557
+ *
558
+ * `outputOnly` (default `false`): when `true`, prose sections use
559
+ * `detectOutputLanguage` (json or markdown only — no xml/html heuristics).
560
+ * Use `outputOnly: true` for LLM-generated text (assistant messages) to
561
+ * prevent Slack autolinks and HTML snippets from triggering the XML tree
562
+ * renderer. Leave `false` (default) for user/system messages that may
563
+ * contain genuine XML runtime context.
564
+ */
565
+ export function parseMarkdownBlocks(
566
+ text: string,
567
+ opts: { outputOnly?: boolean } = {},
568
+ ): CodeBlock[] {
569
+ const detectProse = opts.outputOnly ? detectOutputLanguage : detectLanguage;
521
570
  const blocks: CodeBlock[] = [];
522
571
  const fence = /```([A-Za-z0-9_-]+)?\n([\s\S]*?)```/g;
523
572
  let cursor = 0;
@@ -525,24 +574,25 @@ export function parseMarkdownBlocks(text: string): CodeBlock[] {
525
574
  while ((match = fence.exec(text))) {
526
575
  const prose = text.slice(cursor, match.index).trim();
527
576
  if (prose) {
528
- const language = detectLanguage(prose);
529
- blocks.push({ code: formatCodeBlock(prose, language), language });
577
+ const language = detectProse(prose);
578
+ blocks.push({ code: formatCodeBlock(prose, language), fenced: false, language });
530
579
  }
531
580
  const language = normalizeLanguage(match[1]);
532
581
  blocks.push({
533
582
  code: formatCodeBlock(match[2] ?? "", language),
583
+ fenced: true,
534
584
  language,
535
585
  });
536
586
  cursor = match.index + match[0].length;
537
587
  }
538
588
  const rest = text.slice(cursor).trim();
539
589
  if (rest) {
540
- const language = detectLanguage(rest);
541
- blocks.push({ code: formatCodeBlock(rest, language), language });
590
+ const language = detectProse(rest);
591
+ blocks.push({ code: formatCodeBlock(rest, language), fenced: false, language });
542
592
  }
543
593
  if (blocks.length > 0) return blocks;
544
- const language = detectLanguage(text);
545
- return [{ code: formatCodeBlock(text, language), language }];
594
+ const language = detectProse(text);
595
+ return [{ code: formatCodeBlock(text, language), fenced: false, language }];
546
596
  }
547
597
 
548
598
  /** Parse XML/HTML-ish fragments for the collapsible transcript renderer. */
@@ -141,7 +141,7 @@ export type SessionFilter = "active" | "recent" | "hung" | "failed" | "all";
141
141
 
142
142
  export type VisualStatus = "active" | "failed" | "hung" | "idle";
143
143
 
144
- export type CodeBlock = { code: string; language: BundledLanguage };
144
+ export type CodeBlock = { code: string; fenced?: boolean; language: BundledLanguage };
145
145
 
146
146
  export type MarkupNode =
147
147
  | {