@sentry/junior-dashboard 0.266.0 → 0.268.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 (96) hide show
  1. package/dist/app.js +85 -12
  2. package/dist/client.js +94 -64
  3. package/dist/index.js +85 -12
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +11 -5
  6. package/src/app.ts +0 -2
  7. package/src/auth.ts +4 -7
  8. package/src/client/App.tsx +55 -53
  9. package/src/client/components/DetailsPanel.tsx +12 -13
  10. package/src/client/components/Drawer.tsx +46 -23
  11. package/src/client/components/ImageAttachment.tsx +1 -1
  12. package/src/client/components/ParticipantAvatarStack.tsx +6 -2
  13. package/src/client/components/README.md +15 -2
  14. package/src/client/components/UserInputForm.tsx +283 -0
  15. package/src/client/components/layout/DashboardHeader.tsx +13 -0
  16. package/src/client/components/ui/alert.tsx +1 -1
  17. package/src/client/components/ui/button-group.tsx +59 -0
  18. package/src/client/components/ui/button.tsx +11 -1
  19. package/src/client/components/ui/dialog.tsx +29 -35
  20. package/src/client/components/ui/drawer.tsx +140 -0
  21. package/src/client/components/ui/empty.tsx +30 -1
  22. package/src/client/components/ui/separator.tsx +22 -0
  23. package/src/client/components/ui/useSheet.ts +5 -1
  24. package/src/client/conversations/ComposerImages.tsx +66 -14
  25. package/src/client/conversations/ConversationComposer.tsx +84 -12
  26. package/src/client/conversations/ConversationDetails.tsx +3 -27
  27. package/src/client/conversations/ConversationEventDetails.tsx +26 -10
  28. package/src/client/conversations/ConversationHomeList.tsx +6 -0
  29. package/src/client/conversations/ConversationListStatusIcon.tsx +15 -1
  30. package/src/client/conversations/ConversationPage.tsx +160 -2
  31. package/src/client/conversations/ConversationSidebar.tsx +6 -0
  32. package/src/client/conversations/ConversationTranscript.tsx +48 -16
  33. package/src/client/conversations/ConversationWorkspace.tsx +4 -0
  34. package/src/client/conversations/DetailsList.tsx +33 -0
  35. package/src/client/conversations/EventDetails.tsx +46 -19
  36. package/src/client/conversations/ForkConversationButton.tsx +79 -73
  37. package/src/client/conversations/PendingMailboxStack.tsx +310 -112
  38. package/src/client/conversations/README.md +30 -13
  39. package/src/client/conversations/SubagentTranscriptDrawer.tsx +19 -22
  40. package/src/client/conversations/TranscriptActivityGroup.tsx +27 -19
  41. package/src/client/conversations/TranscriptAttachment.tsx +39 -4
  42. package/src/client/conversations/TranscriptAutomationTriggerView.tsx +14 -19
  43. package/src/client/conversations/TranscriptFailureView.tsx +10 -13
  44. package/src/client/conversations/TranscriptMarkdown.tsx +232 -436
  45. package/src/client/conversations/TranscriptMessageContextView.tsx +12 -1
  46. package/src/client/conversations/TranscriptMessageView.tsx +10 -1
  47. package/src/client/conversations/TranscriptSummary.tsx +5 -31
  48. package/src/client/conversations/TranscriptToolView.tsx +13 -1
  49. package/src/client/conversations/TranscriptTurnContextView.tsx +174 -161
  50. package/src/client/conversations/TranscriptView.tsx +7 -3
  51. package/src/client/conversations/UserInputRequestCard.tsx +39 -0
  52. package/src/client/conversations/conversationOutbox.ts +46 -0
  53. package/src/client/conversations/eventTime.ts +13 -1
  54. package/src/client/conversations/eventTranscript.ts +12 -5
  55. package/src/client/conversations/queries.ts +127 -26
  56. package/src/client/conversations/transcriptMarkdownLinks.ts +5 -367
  57. package/src/client/conversations/turnContext.ts +15 -0
  58. package/src/client/conversations/userInputRequest.ts +56 -0
  59. package/src/client/format.ts +31 -8
  60. package/src/client/pages/SettingsPage.tsx +103 -3
  61. package/src/client/pages/automations/{AutomationLifecycle.tsx → AutomationActions.tsx} +60 -69
  62. package/src/client/pages/automations/AutomationDraftChanges.tsx +171 -0
  63. package/src/client/pages/automations/AutomationEditor.tsx +182 -156
  64. package/src/client/pages/automations/AutomationEventFields.tsx +119 -45
  65. package/src/client/pages/automations/AutomationExecutionStatusChart.tsx +1 -1
  66. package/src/client/pages/automations/AutomationFormSection.tsx +20 -2
  67. package/src/client/pages/automations/AutomationOutcomeFields.tsx +1 -1
  68. package/src/client/pages/automations/AutomationOverview.tsx +238 -0
  69. package/src/client/pages/automations/AutomationPage.tsx +343 -0
  70. package/src/client/pages/automations/AutomationRecentRuns.tsx +3 -2
  71. package/src/client/pages/automations/AutomationRow.tsx +11 -13
  72. package/src/client/pages/automations/AutomationRunDetails.tsx +4 -2
  73. package/src/client/pages/automations/{AutomationExecutionsPage.tsx → AutomationRunsTab.tsx} +94 -103
  74. package/src/client/pages/automations/AutomationStatus.tsx +164 -108
  75. package/src/client/pages/automations/AutomationVersionsTab.tsx +518 -0
  76. package/src/client/pages/automations/AutomationsPage.tsx +10 -98
  77. package/src/client/pages/automations/AutomationsPageLayout.tsx +5 -23
  78. package/src/client/pages/automations/README.md +56 -46
  79. package/src/client/pages/code/RepositorySections.tsx +1 -0
  80. package/src/client/pages/code/RepositoryTabs.tsx +3 -0
  81. package/src/client/primaryNav.ts +15 -2
  82. package/src/client/review-desk/ConversationReviewBar.tsx +62 -0
  83. package/src/client/review-desk/ReviewDeskPage.tsx +277 -0
  84. package/src/client/review-desk/ReviewItemActions.tsx +165 -0
  85. package/src/client/review-desk/ReviewItemCard.tsx +47 -0
  86. package/src/client/review-desk/ReviewItemDetail.tsx +115 -0
  87. package/src/client/review-desk/queries.ts +164 -0
  88. package/src/client/review-desk/reviewItemFormat.ts +61 -0
  89. package/src/client/types.ts +3 -0
  90. package/src/mock-reporting/fixtures.ts +54 -2
  91. package/src/mock-reporting/routes.ts +20 -0
  92. package/src/shell.ts +3 -0
  93. package/src/client/conversations/transcriptStyles.ts +0 -10
  94. package/src/client/pages/automations/AutomationDetails.tsx +0 -193
  95. package/src/client/pages/automations/AutomationEditPage.tsx +0 -176
  96. package/src/client/pages/automations/AutomationVersionsPage.tsx +0 -390
@@ -1,4 +1,7 @@
1
- import type { ReactNode } from "react";
1
+ import { Children, type ReactNode } from "react";
2
+ import Markdown, { type Components } from "react-markdown";
3
+ import remarkBreaks from "remark-breaks";
4
+ import remarkGfm from "remark-gfm";
2
5
 
3
6
  import {
4
7
  Table,
@@ -10,32 +13,24 @@ import {
10
13
  } from "../components/ui/table";
11
14
  import { cn } from "../styles";
12
15
  import {
13
- findTranscriptMarkdownLinks,
16
+ safeTranscriptHref,
14
17
  TRANSCRIPT_ANCHOR_CLASS,
15
18
  } from "./transcriptMarkdownLinks";
16
19
  import { renderMentionText } from "./transcriptMentions";
17
20
  import { HighlightText, useTranscriptSearch } from "./transcriptSearch";
18
21
 
19
- type TableAlignment = "left" | "center" | "right" | null;
20
-
21
- type MarkdownBlock =
22
- | { type: "heading"; level: 1 | 2 | 3 | 4 | 5 | 6; text: string }
23
- | { type: "list"; ordered: boolean; items: string[]; start?: number }
24
- | {
25
- type: "table";
26
- alignments: TableAlignment[];
27
- headers: string[];
28
- rows: string[][];
29
- }
30
- | { type: "paragraph"; lines: string[] };
31
-
32
- /** Render transcript markdown as readable prose with GFM-style hard breaks. */
22
+ /**
23
+ * Render transcript markdown as readable prose.
24
+ *
25
+ * The text is CommonMark plus GFM, with a hard break for each newline. Agent
26
+ * replies also use Slack-style `•` bullets and bold-only section labels, so
27
+ * those render as lists and headings too.
28
+ */
33
29
  export function TranscriptMarkdown(props: {
34
30
  compact?: boolean;
35
31
  text: string;
36
32
  }): ReactNode {
37
- const blocks = parseMarkdownProseBlocks(props.text);
38
- if (blocks.length === 0) return null;
33
+ if (!props.text.trim()) return null;
39
34
 
40
35
  return (
41
36
  <div
@@ -47,436 +42,251 @@ export function TranscriptMarkdown(props: {
47
42
  )}
48
43
  >
49
44
  <div className={cn("grid min-w-0", props.compact ? "gap-3" : "gap-2")}>
50
- {blocks.map((block, index) => (
51
- <MarkdownBlockView block={block} key={index} />
52
- ))}
53
- </div>
54
- </div>
55
- );
56
- }
57
-
58
- function MarkdownBlockView(props: { block: MarkdownBlock }) {
59
- switch (props.block.type) {
60
- case "heading": {
61
- const className = cn(
62
- "m-0 min-w-0 font-semibold tracking-[-0.01em] text-dashboard-text [&:not(:first-child)]:mt-1 [&+*]:-mt-1",
63
- props.block.level <= 2 ? "text-[1.05em]" : "text-[1em]",
64
- );
65
- const content = renderInlineMarkdown(props.block.text);
66
- switch (props.block.level) {
67
- case 1:
68
- return <h1 className={className}>{content}</h1>;
69
- case 2:
70
- return <h2 className={className}>{content}</h2>;
71
- case 3:
72
- return <h3 className={className}>{content}</h3>;
73
- case 4:
74
- return <h4 className={className}>{content}</h4>;
75
- case 5:
76
- return <h5 className={className}>{content}</h5>;
77
- default:
78
- return <h6 className={className}>{content}</h6>;
79
- }
80
- }
81
- case "list": {
82
- const ListTag = props.block.ordered ? "ol" : "ul";
83
- return (
84
- <ListTag
85
- className={cn(
86
- "m-0 grid min-w-0 list-outside gap-1.5 pl-6 text-dashboard-text marker:text-dashboard-text-muted",
87
- props.block.ordered ? "list-decimal" : "list-disc",
88
- )}
89
- start={props.block.start}
45
+ <Markdown
46
+ components={COMPONENTS}
47
+ remarkPlugins={[
48
+ remarkGfm,
49
+ remarkBreaks,
50
+ remarkBareUrlStops,
51
+ remarkSectionLabels,
52
+ ]}
53
+ urlTransform={safeTranscriptHref}
90
54
  >
91
- {props.block.items.map((item, index) => (
92
- <li className="min-w-0 pl-1" key={index}>
93
- {renderInlineMarkdown(item)}
94
- </li>
95
- ))}
96
- </ListTag>
97
- );
98
- }
99
- case "table":
100
- return <MarkdownTable block={props.block} />;
101
- case "paragraph":
102
- return (
103
- <p className="m-0 min-w-0 whitespace-normal text-dashboard-text">
104
- {props.block.lines.map((line, index) => (
105
- <span key={index}>
106
- {index > 0 ? <br /> : null}
107
- {renderInlineMarkdown(line)}
108
- </span>
109
- ))}
110
- </p>
111
- );
112
- }
113
- }
114
-
115
- function parseMarkdownProseBlocks(text: string): MarkdownBlock[] {
116
- const normalized = text.replace(/\r\n?/g, "\n").replace(/^\n+|\n+$/g, "");
117
- if (!normalized) return [];
118
-
119
- const blocks: MarkdownBlock[] = [];
120
- for (const chunk of normalized.split(/\n{2,}/)) {
121
- const lines = chunk.split("\n");
122
- let index = 0;
123
- while (index < lines.length) {
124
- const line = lines[index] ?? "";
125
- const heading = matchHeading(line);
126
- if (heading) {
127
- blocks.push(heading);
128
- index += 1;
129
- continue;
130
- }
131
-
132
- const ordered = isOrderedListItem(line);
133
- const unordered = isUnorderedListItem(line);
134
- if (ordered || unordered) {
135
- const start = ordered ? Number(/\d+/.exec(line)?.[0]) : undefined;
136
- const items: string[] = [];
137
- while (
138
- index < lines.length &&
139
- (ordered
140
- ? isOrderedListItem(lines[index] ?? "")
141
- : isUnorderedListItem(lines[index] ?? ""))
142
- ) {
143
- items.push(
144
- (lines[index] ?? "").replace(
145
- ordered ? ORDERED_LIST_MARKER : UNORDERED_LIST_MARKER,
146
- "",
147
- ),
148
- );
149
- index += 1;
150
- }
151
- blocks.push({
152
- items,
153
- ordered,
154
- start: start === 1 ? undefined : start,
155
- type: "list",
156
- });
157
- continue;
158
- }
159
-
160
- const table = matchTable(lines, index);
161
- if (table) {
162
- blocks.push(table.block);
163
- index = table.nextIndex;
164
- continue;
165
- }
166
-
167
- const paragraphLines: string[] = [];
168
- while (
169
- index < lines.length &&
170
- !matchHeading(lines[index] ?? "") &&
171
- !isOrderedListItem(lines[index] ?? "") &&
172
- !isUnorderedListItem(lines[index] ?? "") &&
173
- !matchTable(lines, index)
174
- ) {
175
- paragraphLines.push(lines[index] ?? "");
176
- index += 1;
177
- }
178
- blocks.push({ lines: paragraphLines, type: "paragraph" });
179
- }
180
- }
181
-
182
- return blocks;
183
- }
184
-
185
- function MarkdownTable(props: {
186
- block: Extract<MarkdownBlock, { type: "table" }>;
187
- }) {
188
- const columnCount = Math.max(
189
- props.block.headers.length,
190
- ...props.block.rows.map((row) => row.length),
191
- props.block.alignments.length,
192
- );
193
-
194
- return (
195
- <div className="min-w-0 max-w-full overflow-hidden rounded-md border border-dashboard-border bg-dashboard-fill-faint">
196
- <Table className="w-max min-w-full">
197
- <TableHeader className="bg-dashboard-overlay-soft">
198
- <TableRow className="border-dashboard-border">
199
- {Array.from({ length: columnCount }, (_, index) => {
200
- const alignment = props.block.alignments[index] ?? null;
201
- return (
202
- <TableHead
203
- className={cn(
204
- "px-2.5 py-1.5",
205
- tableCellAlignClass(alignment),
206
- )}
207
- key={`h-${index}`}
208
- scope="col"
209
- >
210
- {renderInlineMarkdown(props.block.headers[index] ?? "")}
211
- </TableHead>
212
- );
213
- })}
214
- </TableRow>
215
- </TableHeader>
216
- <TableBody>
217
- {props.block.rows.map((row, rowIndex) => (
218
- <TableRow key={`r-${rowIndex}`}>
219
- {Array.from({ length: columnCount }, (_, index) => {
220
- const alignment = props.block.alignments[index] ?? null;
221
- return (
222
- <TableCell
223
- className={cn(
224
- "px-2.5 py-1.5 align-top",
225
- tableCellAlignClass(alignment),
226
- )}
227
- key={`c-${rowIndex}-${index}`}
228
- >
229
- {renderInlineMarkdown(row[index] ?? "")}
230
- </TableCell>
231
- );
232
- })}
233
- </TableRow>
234
- ))}
235
- </TableBody>
236
- </Table>
55
+ {normalizeSlackBullets(props.text)}
56
+ </Markdown>
57
+ </div>
237
58
  </div>
238
59
  );
239
60
  }
240
61
 
241
- function tableCellAlignClass(alignment: TableAlignment): string | undefined {
242
- if (alignment === "center") return "text-center";
243
- if (alignment === "right") return "text-right";
244
- return undefined;
62
+ // Slack-style replies use "•" and "◦" bullets. CommonMark only knows -, *, +.
63
+ function normalizeSlackBullets(text: string): string {
64
+ return text.replace(/^([ \t]*)[•◦](?=[ \t])/gm, "$1-");
245
65
  }
246
66
 
247
- function matchTable(
248
- lines: readonly string[],
249
- index: number,
250
- ):
251
- | { block: Extract<MarkdownBlock, { type: "table" }>; nextIndex: number }
252
- | undefined {
253
- const headerLine = lines[index] ?? "";
254
- const separatorLine = lines[index + 1] ?? "";
255
- if (!isTableRowLine(headerLine) || !isTableSeparatorLine(separatorLine)) {
256
- return undefined;
257
- }
258
-
259
- const headers = splitTableRow(headerLine);
260
- const alignments = parseTableAlignments(separatorLine);
261
- if (headers.length === 0 || alignments.length === 0) {
262
- return undefined;
263
- }
264
-
265
- const rows: string[][] = [];
266
- let cursor = index + 2;
267
- while (cursor < lines.length && isTableRowLine(lines[cursor] ?? "")) {
268
- rows.push(splitTableRow(lines[cursor] ?? ""));
269
- cursor += 1;
270
- }
271
-
272
- return {
273
- block: {
274
- alignments,
275
- headers,
276
- rows,
277
- type: "table",
278
- },
279
- nextIndex: cursor,
67
+ type MarkdownNode = {
68
+ children?: MarkdownNode[];
69
+ depth?: number;
70
+ position?: { start: { offset?: number } };
71
+ type: string;
72
+ url?: string;
73
+ value?: string;
74
+ };
75
+
76
+ /**
77
+ * End a bare URL at `*`, as Slack output does (`readBareUrl` in
78
+ * `packages/junior/src/chat/slack/mrkdwn.ts`). GFM keeps `*` in the URL, so
79
+ * `https://example.com**bold**` would link to `https://example.com**bold`.
80
+ */
81
+ function remarkBareUrlStops() {
82
+ return (tree: MarkdownNode, file: { value: unknown }) => {
83
+ splitBareUrls(tree, String(file.value));
280
84
  };
281
85
  }
282
86
 
283
- function isTableRowLine(line: string): boolean {
284
- const trimmed = line.trim();
285
- if (!trimmed.includes("|")) return false;
286
- if (isTableSeparatorLine(trimmed)) return false;
287
- return /^\|?.+\|/.test(trimmed) || /\|.+\|?$/.test(trimmed);
288
- }
289
-
290
- function isTableSeparatorLine(line: string): boolean {
291
- const cells = splitTableRow(line);
292
- if (cells.length === 0) return false;
293
- return cells.every((cell) => /^:?-{3,}:?$/.test(cell.replace(/\s+/g, "")));
294
- }
295
-
296
- function parseTableAlignments(line: string): TableAlignment[] {
297
- return splitTableRow(line).map((cell) => {
298
- const token = cell.replace(/\s+/g, "");
299
- const left = token.startsWith(":");
300
- const right = token.endsWith(":");
301
- if (left && right) return "center";
302
- if (right) return "right";
303
- if (left) return "left";
304
- return null;
87
+ function splitBareUrls(parent: MarkdownNode, source: string): void {
88
+ parent.children = parent.children?.flatMap((node) => {
89
+ splitBareUrls(node, source);
90
+ const text = node.children?.[0];
91
+ const stop = node.url?.indexOf("*") ?? -1;
92
+ const bare =
93
+ node.type === "link" &&
94
+ node.children?.length === 1 &&
95
+ text?.value === node.url &&
96
+ source[node.position?.start.offset ?? -1] !== "<";
97
+ if (!bare || stop <= 0) return [node];
98
+
99
+ const url = node.url!.slice(0, stop);
100
+ return [
101
+ { ...node, children: [{ type: "text", value: url }], url },
102
+ { type: "text", value: node.url!.slice(stop) },
103
+ ];
305
104
  });
306
105
  }
307
106
 
308
- function splitTableRow(line: string): string[] {
309
- let trimmed = line.trim();
310
- if (!trimmed) return [];
311
- if (trimmed.startsWith("|")) trimmed = trimmed.slice(1);
312
- if (trimmed.endsWith("|")) trimmed = trimmed.slice(0, -1);
313
-
314
- const cells: string[] = [];
315
- let current = "";
316
- let escaped = false;
317
- for (const char of trimmed) {
318
- if (escaped) {
319
- current += char;
320
- escaped = false;
321
- continue;
322
- }
323
- if (char === "\\") {
324
- escaped = true;
325
- continue;
326
- }
327
- if (char === "|") {
328
- cells.push(current.trim());
329
- current = "";
330
- continue;
331
- }
332
- current += char;
333
- }
334
- cells.push(current.trim());
335
- return cells;
336
- }
337
-
338
- function matchHeading(line: string): MarkdownBlock | undefined {
339
- // Agents often write a bold-only line as a section label. Give it heading
340
- // spacing so the section reads as a group.
341
- if (/^\s*(?:\*\*[^*]+\*\*|__[^_]+__):?\s*$/.test(line)) {
342
- return { level: 3, text: line.trim(), type: "heading" };
343
- }
344
- const match = /^(#{1,6})\s+(.+)$/.exec(line);
345
- if (!match) return undefined;
346
- return {
347
- level: match[1]!.length as 1 | 2 | 3 | 4 | 5 | 6,
348
- text: match[2]!.trimEnd(),
349
- type: "heading",
107
+ /**
108
+ * Agents often start a top-level paragraph with a bold-only line, such as
109
+ * `**Gaps**`, as a section label. Make that line a heading so the section
110
+ * reads as a group.
111
+ */
112
+ function remarkSectionLabels() {
113
+ return (tree: MarkdownNode) => {
114
+ tree.children = tree.children?.flatMap(splitSectionLabel);
350
115
  };
351
116
  }
352
117
 
353
- // Slack-style replies use "•" bullets and "1)" numbers; render them as lists.
354
- const UNORDERED_LIST_MARKER = /^\s*[-*+•◦]\s+(?=\S)/;
355
- const ORDERED_LIST_MARKER = /^\s*\d+[.)]\s+(?=\S)/;
118
+ function splitSectionLabel(node: MarkdownNode): MarkdownNode[] {
119
+ const [label, ...rest] = node.children ?? [];
120
+ if (node.type !== "paragraph" || label?.type !== "strong") return [node];
356
121
 
357
- function isUnorderedListItem(line: string): boolean {
358
- return UNORDERED_LIST_MARKER.test(line);
359
- }
122
+ const colon = rest[0]?.type === "text" && rest[0].value === ":";
123
+ const afterLabel = colon ? rest.slice(1) : rest;
124
+ if (afterLabel.length > 0 && afterLabel[0]?.type !== "break") return [node];
360
125
 
361
- function isOrderedListItem(line: string): boolean {
362
- return ORDERED_LIST_MARKER.test(line);
126
+ const heading: MarkdownNode = {
127
+ children: colon ? [label, rest[0]!] : [label],
128
+ depth: 3,
129
+ type: "heading",
130
+ };
131
+ const body = afterLabel.slice(1);
132
+ return body.length > 0
133
+ ? [heading, { children: body, type: "paragraph" }]
134
+ : [heading];
363
135
  }
364
136
 
365
- function renderInlineMarkdown(text: string): ReactNode[] {
366
- // code -> emphasis -> links, so bold/italic can wrap URLs without leaking ** into hrefs
367
- return renderInlineCode(text, 0);
137
+ const HEADING_CLASS =
138
+ "m-0 min-w-0 font-semibold tracking-[-0.01em] text-dashboard-text [&:not(:first-child)]:mt-1 [&+*]:-mt-1";
139
+
140
+ const COMPONENTS: Components = {
141
+ a: ({ children, href }) => (
142
+ <TranscriptAnchor href={href}>{children}</TranscriptAnchor>
143
+ ),
144
+ blockquote: ({ children }) => (
145
+ <blockquote className="m-0 grid min-w-0 gap-2 border-l-2 border-dashboard-border pl-3 text-dashboard-text-muted">
146
+ {children}
147
+ </blockquote>
148
+ ),
149
+ code: ({ children }) => (
150
+ <code className="rounded-[0.25rem] bg-dashboard-fill-strong px-1 py-0.5 font-mono text-[0.9em] text-dashboard-text">
151
+ {Children.map(children, (child) =>
152
+ typeof child === "string" ? <HighlightText text={child} /> : child,
153
+ )}
154
+ </code>
155
+ ),
156
+ del: ({ children }) => (
157
+ <del className="text-dashboard-text-muted">{renderText(children)}</del>
158
+ ),
159
+ em: ({ children }) => (
160
+ <em className="italic text-dashboard-text">{renderText(children)}</em>
161
+ ),
162
+ h1: ({ children }) => (
163
+ <h1 className={cn(HEADING_CLASS, "text-[1.05em]")}>
164
+ {renderText(children)}
165
+ </h1>
166
+ ),
167
+ h2: ({ children }) => (
168
+ <h2 className={cn(HEADING_CLASS, "text-[1.05em]")}>
169
+ {renderText(children)}
170
+ </h2>
171
+ ),
172
+ h3: ({ children }) => (
173
+ <h3 className={cn(HEADING_CLASS, "text-[1em]")}>{renderText(children)}</h3>
174
+ ),
175
+ h4: ({ children }) => (
176
+ <h4 className={cn(HEADING_CLASS, "text-[1em]")}>{renderText(children)}</h4>
177
+ ),
178
+ h5: ({ children }) => (
179
+ <h5 className={cn(HEADING_CLASS, "text-[1em]")}>{renderText(children)}</h5>
180
+ ),
181
+ h6: ({ children }) => (
182
+ <h6 className={cn(HEADING_CLASS, "text-[1em]")}>{renderText(children)}</h6>
183
+ ),
184
+ hr: () => <hr className="m-0 w-full border-dashboard-border" />,
185
+ // Do not load remote images from transcript text. Link to them instead.
186
+ img: ({ alt, src }) => (
187
+ <TranscriptAnchor href={typeof src === "string" ? src : undefined}>
188
+ {alt || src}
189
+ </TranscriptAnchor>
190
+ ),
191
+ li: ({ children, className }) => (
192
+ <li
193
+ className={cn(
194
+ "min-w-0 pl-1 [&>*+*]:mt-1.5",
195
+ className?.includes("task-list-item") && "-ml-5 list-none",
196
+ )}
197
+ >
198
+ {renderText(children)}
199
+ </li>
200
+ ),
201
+ ol: ({ children, start }) => (
202
+ <ol
203
+ className="m-0 grid min-w-0 list-outside list-decimal gap-1.5 pl-6 text-dashboard-text marker:text-dashboard-text-muted"
204
+ start={start}
205
+ >
206
+ {children}
207
+ </ol>
208
+ ),
209
+ p: ({ children }) => (
210
+ <p className="m-0 min-w-0 whitespace-normal">{renderText(children)}</p>
211
+ ),
212
+ pre: ({ children }) => (
213
+ <pre className="m-0 min-w-0 overflow-x-auto rounded-md border border-dashboard-border-subtle bg-dashboard-surface-panel px-3 py-2.5 font-mono text-sm [&>code]:bg-transparent [&>code]:p-0">
214
+ {children}
215
+ </pre>
216
+ ),
217
+ strong: ({ children }) => (
218
+ <strong className="font-semibold text-dashboard-text">
219
+ {renderText(children)}
220
+ </strong>
221
+ ),
222
+ table: ({ children }) => (
223
+ <div className="min-w-0 max-w-full overflow-hidden rounded-md border border-dashboard-border bg-dashboard-fill-faint">
224
+ <Table className="w-max min-w-full">{children}</Table>
225
+ </div>
226
+ ),
227
+ tbody: ({ children }) => <TableBody>{children}</TableBody>,
228
+ td: ({ children, style }) => (
229
+ <TableCell
230
+ className={cn("px-2.5 py-1.5 align-top", alignClass(style?.textAlign))}
231
+ >
232
+ {renderText(children)}
233
+ </TableCell>
234
+ ),
235
+ th: ({ children, style }) => (
236
+ <TableHead
237
+ className={cn("px-2.5 py-1.5", alignClass(style?.textAlign))}
238
+ scope="col"
239
+ >
240
+ {renderText(children)}
241
+ </TableHead>
242
+ ),
243
+ thead: ({ children }) => (
244
+ <TableHeader className="bg-dashboard-overlay-soft">{children}</TableHeader>
245
+ ),
246
+ tr: ({ children }) => (
247
+ <TableRow className="[thead_&]:border-dashboard-border">
248
+ {children}
249
+ </TableRow>
250
+ ),
251
+ ul: ({ children }) => (
252
+ <ul className="m-0 grid min-w-0 list-outside list-disc gap-1.5 pl-6 text-dashboard-text marker:text-dashboard-text-muted">
253
+ {children}
254
+ </ul>
255
+ ),
256
+ };
257
+
258
+ // Text nodes can hold Slack mention tokens and transcript search matches.
259
+ function renderText(children: ReactNode): ReactNode {
260
+ return Children.map(children, (child, index) =>
261
+ typeof child === "string" ? renderMentionText(child, `t-${index}`) : child,
262
+ );
368
263
  }
369
264
 
370
- function renderInlineCode(text: string, keyBase: number): ReactNode[] {
371
- if (!text) return [];
372
-
373
- const nodes: ReactNode[] = [];
374
- const codePattern = /(`+)([^`]+?)\1/g;
375
- let cursor = 0;
376
- let match: RegExpExecArray | null;
377
- let part = 0;
378
-
379
- while ((match = codePattern.exec(text))) {
380
- if (match.index > cursor) {
381
- nodes.push(
382
- ...renderEmphasisText(
383
- text.slice(cursor, match.index),
384
- `${keyBase}-${part++}`,
385
- ),
386
- );
387
- }
388
- nodes.push(
389
- <code
390
- className="rounded-[0.25rem] bg-dashboard-fill-strong px-1 py-0.5 font-mono text-[0.9em] text-dashboard-text"
391
- key={`code-${keyBase}-${part++}`}
392
- >
393
- <HighlightText text={match[2] ?? ""} />
394
- </code>,
395
- );
396
- cursor = match.index + match[0].length;
397
- }
398
-
399
- if (cursor < text.length) {
400
- nodes.push(...renderEmphasisText(text.slice(cursor), `${keyBase}-${part}`));
401
- }
402
- return nodes;
265
+ function alignClass(align: unknown): string | undefined {
266
+ if (align === "center") return "text-center";
267
+ if (align === "right") return "text-right";
268
+ return undefined;
403
269
  }
404
270
 
405
- function renderEmphasisText(text: string, keyBase: string): ReactNode[] {
406
- if (!text) return [];
407
-
408
- const nodes: ReactNode[] = [];
409
- const pattern = /(\*\*|__)(.+?)\1|\*([^*]+?)\*/g;
410
- let cursor = 0;
411
- let match: RegExpExecArray | null;
412
- let part = 0;
271
+ function TranscriptAnchor(props: { children: ReactNode; href?: string }) {
272
+ // `safeTranscriptHref` removes unsafe destinations. Keep their label text.
273
+ if (!props.href) return <>{renderText(props.children)}</>;
413
274
 
414
- while ((match = pattern.exec(text))) {
415
- if (match.index > cursor) {
416
- nodes.push(
417
- ...renderLinkText(
418
- text.slice(cursor, match.index),
419
- `${keyBase}-t-${part++}`,
420
- ),
421
- );
422
- }
423
- nodes.push(
424
- match[1] ? (
425
- <strong
426
- className="font-semibold text-dashboard-text"
427
- key={`${keyBase}-b-${part++}`}
428
- >
429
- {renderLinkText(match[2] ?? "", `${keyBase}-bc-${part}`)}
430
- </strong>
275
+ const opensNewTab = /^https?:/i.test(props.href);
276
+ return (
277
+ <a
278
+ className={TRANSCRIPT_ANCHOR_CLASS}
279
+ href={props.href}
280
+ rel={opensNewTab ? "noreferrer" : undefined}
281
+ target={opensNewTab ? "_blank" : undefined}
282
+ >
283
+ {typeof props.children === "string" ? (
284
+ <SearchAwareLinkLabel href={props.href} label={props.children} />
431
285
  ) : (
432
- <em
433
- className="italic text-dashboard-text"
434
- key={`${keyBase}-i-${part++}`}
435
- >
436
- {renderLinkText(match[3] ?? "", `${keyBase}-ic-${part}`)}
437
- </em>
438
- ),
439
- );
440
- cursor = match.index + match[0].length;
441
- }
442
-
443
- if (cursor < text.length) {
444
- nodes.push(...renderLinkText(text.slice(cursor), `${keyBase}-t-${part}`));
445
- }
446
- return nodes;
447
- }
448
-
449
- function renderLinkText(text: string, keyBase: string): ReactNode[] {
450
- if (!text) return [];
451
-
452
- const links = findTranscriptMarkdownLinks(text);
453
- const nodes: ReactNode[] = [];
454
- let cursor = 0;
455
- let part = 0;
456
-
457
- for (const link of links) {
458
- if (link.start > cursor) {
459
- nodes.push(
460
- ...renderMentionText(
461
- text.slice(cursor, link.start),
462
- `${keyBase}-s-${part++}`,
463
- ),
464
- );
465
- }
466
- nodes.push(
467
- <TranscriptAnchor href={link.href} key={`${keyBase}-a-${link.start}`}>
468
- <SearchAwareLinkLabel href={link.href} label={link.label} />
469
- </TranscriptAnchor>,
470
- );
471
- cursor = link.end;
472
- }
473
-
474
- if (cursor < text.length) {
475
- nodes.push(
476
- ...renderMentionText(text.slice(cursor), `${keyBase}-s-${part}`),
477
- );
478
- }
479
- return nodes;
286
+ props.children
287
+ )}
288
+ </a>
289
+ );
480
290
  }
481
291
 
482
292
  function SearchAwareLinkLabel(props: { href: string; label: string }) {
@@ -500,17 +310,3 @@ function SearchAwareLinkLabel(props: { href: string; label: string }) {
500
310
 
501
311
  return <HighlightText text={props.label} />;
502
312
  }
503
-
504
- function TranscriptAnchor(props: { children: ReactNode; href: string }) {
505
- const opensNewTab = /^https?:/i.test(props.href);
506
- return (
507
- <a
508
- className={TRANSCRIPT_ANCHOR_CLASS}
509
- href={props.href}
510
- rel={opensNewTab ? "noreferrer" : undefined}
511
- target={opensNewTab ? "_blank" : undefined}
512
- >
513
- {props.children}
514
- </a>
515
- );
516
- }