@reviewflow/ui 0.0.0-stage → 6.22.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 (92) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +95 -2
  3. package/package.json +101 -3
  4. package/src/board/bucket-section.tsx +94 -0
  5. package/src/board/dataSource.ts +44 -0
  6. package/src/board/facts.ts +183 -0
  7. package/src/board/index.ts +12 -0
  8. package/src/board/pull-request-board.tsx +239 -0
  9. package/src/board/pull-request-row.tsx +181 -0
  10. package/src/components/branch-diff/diff-themes.ts +6 -0
  11. package/src/components/branch-diff/diff-worker-pool.tsx +12 -0
  12. package/src/components/branch-diff/diff-worker-pool.web.tsx +56 -0
  13. package/src/components/branch-diff/diff-worker.ts +7 -0
  14. package/src/components/branch-diff/use-collapsed-files.ts +49 -0
  15. package/src/components/branch-diff/use-diff-settings.tsx +97 -0
  16. package/src/components/branch-diff/whitespace-button.tsx +32 -0
  17. package/src/components/copyable-code-block.tsx +101 -0
  18. package/src/components/label-pill.tsx +84 -0
  19. package/src/components/link-in-row.ts +6 -0
  20. package/src/components/markdown-suggestion.tsx +60 -0
  21. package/src/components/markdown-text.tsx +1067 -0
  22. package/src/components/pull-request-actions-merge.tsx +341 -0
  23. package/src/components/pull-request-actions-review.tsx +218 -0
  24. package/src/components/pull-request-checks.tsx +86 -0
  25. package/src/components/pull-request-commit-files.ts +143 -0
  26. package/src/components/pull-request-commits.tsx +221 -0
  27. package/src/components/pull-request-conversation-card.tsx +220 -0
  28. package/src/components/pull-request-conversation-edit.tsx +147 -0
  29. package/src/components/pull-request-conversation-event.tsx +267 -0
  30. package/src/components/pull-request-conversation-person.tsx +28 -0
  31. package/src/components/pull-request-conversation-thread.tsx +158 -0
  32. package/src/components/pull-request-diff-annotations.tsx +443 -0
  33. package/src/components/pull-request-diff-collapse.ts +43 -0
  34. package/src/components/pull-request-diff-data.ts +174 -0
  35. package/src/components/pull-request-diff-impacts-summary.tsx +97 -0
  36. package/src/components/pull-request-diff-impacts.ts +45 -0
  37. package/src/components/pull-request-diff-jump.ts +87 -0
  38. package/src/components/pull-request-diff-lines.ts +103 -0
  39. package/src/components/pull-request-diff-overview.tsx +416 -0
  40. package/src/components/pull-request-diff-view.tsx +438 -0
  41. package/src/components/pull-request-diff-viewer.tsx +1374 -0
  42. package/src/components/pull-request-files.tsx +533 -0
  43. package/src/components/pull-request-markdown-input.tsx +91 -0
  44. package/src/components/pull-request-panel.tsx +1270 -0
  45. package/src/components/pull-request-part-notice.tsx +82 -0
  46. package/src/components/pull-request-reactions.tsx +178 -0
  47. package/src/components/pull-request-review.tsx +164 -0
  48. package/src/components/pull-request-revisions.tsx +273 -0
  49. package/src/components/pull-request-screen.tsx +299 -0
  50. package/src/components/pull-request-suggestion-actions.tsx +201 -0
  51. package/src/components/pull-request-timeline.tsx +270 -0
  52. package/src/components/repository-attachment-banner.tsx +42 -0
  53. package/src/components/scroll-parent.ts +50 -0
  54. package/src/css.d.ts +1 -0
  55. package/src/dataSource.ts +242 -0
  56. package/src/editor/alerts.ts +105 -0
  57. package/src/editor/code-editor.native.tsx +25 -0
  58. package/src/editor/code-editor.tsx +112 -0
  59. package/src/editor/codeEditorExtensions.ts +65 -0
  60. package/src/editor/codeHighlight.ts +71 -0
  61. package/src/editor/commands.ts +175 -0
  62. package/src/editor/decorations.ts +425 -0
  63. package/src/editor/editable-file.tsx +224 -0
  64. package/src/editor/emojiCompletion.ts +110 -0
  65. package/src/editor/extensions.ts +149 -0
  66. package/src/editor/frontmatter.ts +76 -0
  67. package/src/editor/inlineFormat.ts +114 -0
  68. package/src/editor/lineTypes.ts +55 -0
  69. package/src/editor/markdown-editor.css +511 -0
  70. package/src/editor/markdown-editor.native.tsx +29 -0
  71. package/src/editor/markdown-editor.tsx +295 -0
  72. package/src/editor/nodeClasses.ts +69 -0
  73. package/src/index.ts +75 -0
  74. package/src/markdown-github/flatten.ts +49 -0
  75. package/src/markdown-github/html-tokens.ts +93 -0
  76. package/src/markdown-github/pair-html.ts +354 -0
  77. package/src/markdown-github/safe-url.ts +15 -0
  78. package/src/markdown-github/table-columns.ts +23 -0
  79. package/src/markdown-github/toggle-task.ts +164 -0
  80. package/src/nativewind-env.d.ts +1 -0
  81. package/src/prStatus.ts +90 -0
  82. package/src/services/PullRequestDraftsProvider.tsx +179 -0
  83. package/src/services/PullRequestReviewDraftProvider.tsx +178 -0
  84. package/src/services/PullRequestSuggestionBatchProvider.tsx +112 -0
  85. package/src/services/emojify.ts +13 -0
  86. package/src/services/errorToMessage.ts +6 -0
  87. package/src/services/expiringDraftStore.ts +102 -0
  88. package/src/services/formatDataAge.ts +12 -0
  89. package/src/services/formatRelativeDate.ts +56 -0
  90. package/src/services/githubImages.tsx +33 -0
  91. package/src/services/isViewerAuthor.ts +7 -0
  92. package/src/slots.tsx +34 -0
@@ -0,0 +1,1067 @@
1
+ import type { BlockNode, InlineNode, TableNode } from "@tanstack/markdown";
2
+ import { parseMarkdown } from "@tanstack/markdown/parser";
3
+ import {
4
+ Box,
5
+ Code,
6
+ ErrorMessage,
7
+ ExternalLink,
8
+ ExternalLinkText,
9
+ Icon,
10
+ InteractiveBox,
11
+ PressableBox,
12
+ Separator,
13
+ Text,
14
+ View,
15
+ } from "alouette";
16
+ import { CaretDownRegularIcon } from "alouette-icons/phosphor-icons/CaretDown";
17
+ import { CaretRightRegularIcon } from "alouette-icons/phosphor-icons/CaretRight";
18
+ import { CheckFatFillIcon } from "alouette-icons/phosphor-icons/CheckFat";
19
+ import {
20
+ type ReactNode,
21
+ createContext,
22
+ memo,
23
+ use,
24
+ useEffect,
25
+ useMemo,
26
+ useState,
27
+ } from "react";
28
+ import { Image } from "react-native";
29
+ import type { ViewProps } from "react-native";
30
+ import {
31
+ type GhImage,
32
+ collectImages,
33
+ isImageRun,
34
+ textOf,
35
+ } from "../markdown-github/flatten.ts";
36
+ import {
37
+ type GhBlockNode,
38
+ type GhElement,
39
+ type GhListItem,
40
+ type GhTableCell,
41
+ isInlineNode,
42
+ pairGithubHtml,
43
+ } from "../markdown-github/pair-html.ts";
44
+ import { isSafeUrl } from "../markdown-github/safe-url.ts";
45
+ import { columnWeights } from "../markdown-github/table-columns.ts";
46
+ import { toggleTask } from "../markdown-github/toggle-task.ts";
47
+ import { emojify } from "../services/emojify.ts";
48
+ import { imageSourceOf, useGithubImages } from "../services/githubImages.tsx";
49
+ import { CopyableCodeBlock } from "./copyable-code-block.tsx";
50
+ import { SuggestedChange } from "./markdown-suggestion.tsx";
51
+
52
+ /**
53
+ * An agent answers in markdown, so a transcript that shows the source shows
54
+ * `**this**` and a table as a row of pipes — which is most of what an agent's
55
+ * summary is made of.
56
+ *
57
+ * `@tanstack/markdown/parser` is the parser and nothing else: its `./react`
58
+ * entry point renders dom elements, and the webapp is react-native-web where a
59
+ * raw `<p>` is the exception that had to be argued for (see the archive
60
+ * button's `title`). What comes back is a typed tree, and every node below is
61
+ * an alouette component, so this renders on native too.
62
+ *
63
+ * The parser is pre-1.0 (0.0.13). The blast radius is this file: nothing else
64
+ * imports it, and a tree it refuses is caught where it is parsed.
65
+ */
66
+
67
+ const markdownSeparatorClass = "my-xs border-border-muted";
68
+
69
+ const headingClass = (depth: number): string => {
70
+ if (depth === 1) return "font-heading-extrabold text-2xl";
71
+ if (depth === 2) return "font-heading-bold text-xl";
72
+ if (depth === 3) return "font-heading-bold text-lg";
73
+ return "font-body-bold text-sm";
74
+ };
75
+
76
+ /** A link needs its label as one string, so the run is flattened for it. */
77
+ const inlineText = (nodes: InlineNode[]): string =>
78
+ nodes
79
+ .map((node) => {
80
+ if (node.type === "text" || node.type === "inlineCode") return node.value;
81
+ if ("children" in node) return inlineText(node.children);
82
+ return "";
83
+ })
84
+ .join("");
85
+
86
+ /**
87
+ * Inline nodes are rendered as children of one `Text`, which is what lets bold
88
+ * and code sit inside a sentence rather than breaking it into boxes: nested
89
+ * `Text` is how react-native does a run of styled text.
90
+ */
91
+ function InlineNodes({ nodes }: { nodes: InlineNode[] }): ReactNode {
92
+ return nodes.map((node, index) => {
93
+ const key = `${node.type}-${String(index)}`;
94
+
95
+ if (node.type === "text") {
96
+ return <Text key={key}>{node.value}</Text>;
97
+ }
98
+ if (node.type === "inlineCode") {
99
+ return (
100
+ <Code key={key} accent="info" className="text-accent">
101
+ {node.value}
102
+ </Code>
103
+ );
104
+ }
105
+ if (node.type === "strong") {
106
+ return (
107
+ <Text key={key} className="font-body-bold">
108
+ <InlineNodes nodes={node.children} />
109
+ </Text>
110
+ );
111
+ }
112
+ if (node.type === "emphasis") {
113
+ return (
114
+ <Text key={key} className="italic">
115
+ <InlineNodes nodes={node.children} />
116
+ </Text>
117
+ );
118
+ }
119
+ if (node.type === "strike") {
120
+ return (
121
+ <Text key={key} className="line-through">
122
+ <InlineNodes nodes={node.children} />
123
+ </Text>
124
+ );
125
+ }
126
+ if (node.type === "link") {
127
+ if (!isSafeUrl(node.href)) {
128
+ return <Text key={key}>{inlineText(node.children)}</Text>;
129
+ }
130
+ return (
131
+ <ExternalLinkText
132
+ key={key}
133
+ accent="info"
134
+ icon={null}
135
+ size="sm"
136
+ href={node.href}
137
+ text={inlineText(node.children)}
138
+ />
139
+ );
140
+ }
141
+ if (node.type === "image") {
142
+ return <Text key={key}>{`[${node.alt}]`}</Text>;
143
+ }
144
+ if (node.type === "break") return <Text key={key}>{"\n"}</Text>;
145
+ // Raw html is shown as its source rather than dropped: an agent writing a
146
+ // tag meant something by it, and this renders no dom to give it to.
147
+ if (node.type === "inlineHtml") {
148
+ return <Text key={key}>{node.value}</Text>;
149
+ }
150
+ return null;
151
+ });
152
+ }
153
+
154
+ /**
155
+ * A table is a grid of `Text`, not a dom `<table>`: the same reason the rest of
156
+ * this file is alouette. Every cell wraps and each column grows by the weight of
157
+ * its content (`columnWeights`). On web a flex item will not shrink below its
158
+ * min-content width, so without `min-w-0` and `wrap-anywhere` one unbreakable
159
+ * identifier widens its cell and shifts that row out of the grid.
160
+ */
161
+ function MarkdownTable({ node }: { node: TableNode }): ReactNode {
162
+ const weights = columnWeights(
163
+ [node.header, ...node.rows].map((row) =>
164
+ row.map((cell) => inlineText(cell.children)),
165
+ ),
166
+ );
167
+ return (
168
+ <View className="gap-0 rounded-sm border border-border-muted">
169
+ <View className="flex-row gap-xs border-border-muted border-b px-xs py-xxs">
170
+ {node.header.map((cell, index) => (
171
+ <Text
172
+ key={`header-${String(index)}`}
173
+ className="min-w-0 basis-0 text-xs font-body-bold web:wrap-anywhere"
174
+ style={{ flexGrow: weights[index] }}
175
+ >
176
+ <InlineNodes nodes={cell.children} />
177
+ </Text>
178
+ ))}
179
+ </View>
180
+ {node.rows.map((row, rowIndex) => (
181
+ <View
182
+ key={`row-${String(rowIndex)}`}
183
+ className="flex-row gap-xs px-xs py-xxs"
184
+ >
185
+ {row.map((cell, index) => (
186
+ <Text
187
+ key={`cell-${String(index)}`}
188
+ className="min-w-0 basis-0 select-auto text-xs web:wrap-anywhere"
189
+ style={{ flexGrow: weights[index] }}
190
+ >
191
+ <InlineNodes nodes={cell.children} />
192
+ </Text>
193
+ ))}
194
+ </View>
195
+ ))}
196
+ </View>
197
+ );
198
+ }
199
+
200
+ const blockquoteRole = "blockquote" as NonNullable<ViewProps["role"]>;
201
+
202
+ /**
203
+ * alouette's `Blockquote` wraps its children in a `Paragraph`, a `<p>` on web,
204
+ * and a markdown quote holds blocks — a list or a table there is a `<div>` in a
205
+ * `<p>`. Same frame, block children — until alouette#374 lands.
206
+ */
207
+ function MarkdownQuote({ children }: { children: ReactNode }): ReactNode {
208
+ return (
209
+ <Box role={blockquoteRole} className="gap-xs border-l-4 border-accent pl-m">
210
+ {children}
211
+ </Box>
212
+ );
213
+ }
214
+
215
+ function BlockNodes({ nodes }: { nodes: BlockNode[] }): ReactNode {
216
+ return nodes.map((node, index) => {
217
+ const key = `${node.type}-${String(index)}`;
218
+
219
+ if (node.type === "paragraph") {
220
+ return (
221
+ <Text key={key} className="select-auto text-sm">
222
+ <InlineNodes nodes={node.children} />
223
+ </Text>
224
+ );
225
+ }
226
+ if (node.type === "heading") {
227
+ return (
228
+ <Text key={key} className={`select-auto ${headingClass(node.depth)}`}>
229
+ <InlineNodes nodes={node.children} />
230
+ </Text>
231
+ );
232
+ }
233
+ if (node.type === "code") {
234
+ return (
235
+ <CopyableCodeBlock
236
+ key={key}
237
+ size="sm"
238
+ title={node.title ?? node.lang}
239
+ text={node.value}
240
+ />
241
+ );
242
+ }
243
+ if (node.type === "list") {
244
+ // Inline rather than its own component, because an item holds blocks and
245
+ // a list is therefore mutually recursive with this: one of the two would
246
+ // have to be used before it is defined.
247
+ return (
248
+ <View key={key} className="gap-xxs">
249
+ {node.items.map((item, itemIndex) => (
250
+ <View
251
+ key={`item-${String(itemIndex)}`}
252
+ className="flex-row items-baseline gap-xs pl-xs"
253
+ >
254
+ <Text className="text-sm text-muted">
255
+ {node.ordered
256
+ ? `${String((node.start ?? 1) + itemIndex)}.`
257
+ : "•"}
258
+ </Text>
259
+ <View className="min-w-0 flex-1 gap-xxs">
260
+ <BlockNodes nodes={item.children} />
261
+ </View>
262
+ </View>
263
+ ))}
264
+ </View>
265
+ );
266
+ }
267
+ if (node.type === "table") return <MarkdownTable key={key} node={node} />;
268
+ if (node.type === "blockquote" || node.type === "callout") {
269
+ return (
270
+ <MarkdownQuote key={key}>
271
+ <BlockNodes nodes={node.children} />
272
+ </MarkdownQuote>
273
+ );
274
+ }
275
+ if (node.type === "thematicBreak") {
276
+ return <Separator key={key} className={markdownSeparatorClass} />;
277
+ }
278
+ if (node.type === "html") {
279
+ return (
280
+ <Text key={key} className="select-auto font-mono text-xs text-muted">
281
+ {node.value}
282
+ </Text>
283
+ );
284
+ }
285
+ return null;
286
+ });
287
+ }
288
+
289
+ /*
290
+ * GitHub rendering. A second set of components over the paired tree
291
+ * (`markdown-github/pair-html.ts`) rather than flags in the ones above, so the
292
+ * transcript's rendering cannot change with it.
293
+ */
294
+
295
+ interface TaskToggleValue {
296
+ /** Null: the boxes are read-only. */
297
+ toggle: ((taskIndex: number) => void) | null;
298
+ busy: boolean;
299
+ }
300
+
301
+ const TaskToggleContext = createContext<TaskToggleValue>({
302
+ toggle: null,
303
+ busy: false,
304
+ });
305
+
306
+ const externalLinkBehavior = {
307
+ native: "webBrowser",
308
+ web: "targetBlank",
309
+ } as const;
310
+
311
+ const maxImageWidth = 600;
312
+
313
+ /**
314
+ * An image has no size until it is loaded, and react-native-web renders it at
315
+ * 0x0 without one. The size is data read at runtime, so it goes through `style`,
316
+ * like a label's colour in `label-pill.tsx`.
317
+ */
318
+ function GithubImage({ src, alt }: GhImage): ReactNode {
319
+ const safe = isSafeUrl(src, ["http:", "https:"]);
320
+ const proxies = useGithubImages();
321
+ const source = safe ? imageSourceOf(src, proxies) : null;
322
+ const [size, setSize] = useState<
323
+ "failed" | { width: number; height: number } | null
324
+ >(null);
325
+
326
+ useEffect(() => {
327
+ if (source === null) return undefined;
328
+ let cancelled = false;
329
+ Image.getSize(
330
+ source,
331
+ (width, height) => {
332
+ if (!cancelled) setSize({ width, height });
333
+ },
334
+ () => {
335
+ if (!cancelled) setSize("failed");
336
+ },
337
+ );
338
+ return () => {
339
+ cancelled = true;
340
+ };
341
+ }, [source]);
342
+
343
+ if (!safe) return <Text className="text-sm">{alt || src}</Text>;
344
+ // a host GitHub does not proxy would learn who reads the pull request
345
+ if (source === null || size === "failed") {
346
+ return (
347
+ <ExternalLinkText
348
+ accent="info"
349
+ size="sm"
350
+ href={src}
351
+ text={alt || src}
352
+ icon={null}
353
+ />
354
+ );
355
+ }
356
+ if (size === null) {
357
+ return <Text className="text-xs text-muted">{alt}</Text>;
358
+ }
359
+ return (
360
+ <Image
361
+ source={{ uri: source }}
362
+ alt={alt}
363
+ resizeMode="contain"
364
+ style={{
365
+ width: Math.min(size.width, maxImageWidth),
366
+ maxWidth: "100%",
367
+ aspectRatio: size.width / Math.max(size.height, 1),
368
+ }}
369
+ />
370
+ );
371
+ }
372
+
373
+ function GithubImageLink({
374
+ href,
375
+ images,
376
+ }: {
377
+ href: string;
378
+ images: GhImage[];
379
+ }): ReactNode {
380
+ return (
381
+ <ExternalLink
382
+ as={PressableBox}
383
+ variant="soft"
384
+ href={href}
385
+ openLinkBehavior={externalLinkBehavior}
386
+ className="rounded-sm"
387
+ >
388
+ <View className="flex-row flex-wrap items-center gap-xs">
389
+ {images.map((image, index) => (
390
+ <GithubImage key={`${image.src}-${String(index)}`} {...image} />
391
+ ))}
392
+ </View>
393
+ </ExternalLink>
394
+ );
395
+ }
396
+
397
+ const droppedTags = new Set([
398
+ "script",
399
+ "style",
400
+ "iframe",
401
+ "object",
402
+ "embed",
403
+ "textarea",
404
+ "title",
405
+ "head",
406
+ ]);
407
+
408
+ /** The class of a tag that only styles its text, or none. */
409
+ const textTagClass = (tag: string): string | undefined => {
410
+ if (tag === "b" || tag === "strong") return "font-body-bold";
411
+ if (tag === "i" || tag === "em" || tag === "cite" || tag === "var") {
412
+ return "italic";
413
+ }
414
+ if (tag === "s" || tag === "del" || tag === "strike") return "line-through";
415
+ if (tag === "ins" || tag === "u") return "underline";
416
+ // No baseline shift outside the web: the size alone sets them apart there.
417
+ if (tag === "sub") return "text-xs web:align-sub";
418
+ if (tag === "sup") return "text-xs web:align-super";
419
+ if (tag === "small") return "text-xs";
420
+ return undefined;
421
+ };
422
+
423
+ function GithubInlines({ nodes }: { nodes: GhBlockNode[] }): ReactNode {
424
+ const renderElement = (node: GhElement, key: string): ReactNode => {
425
+ const { tag, attrs, children } = node;
426
+
427
+ if (droppedTags.has(tag)) return null;
428
+ if (tag === "br") return <Text key={key}>{"\n"}</Text>;
429
+ if (tag === "img") {
430
+ return (
431
+ <GithubImage key={key} src={attrs.src ?? ""} alt={attrs.alt ?? ""} />
432
+ );
433
+ }
434
+ if (tag === "picture") {
435
+ return collectImages(children)
436
+ .slice(0, 1)
437
+ .map((image) => <GithubImage key={image.src} {...image} />);
438
+ }
439
+ if (tag === "code") {
440
+ return (
441
+ <Code key={key} accent="info" className="text-accent">
442
+ {textOf(children)}
443
+ </Code>
444
+ );
445
+ }
446
+ if (tag === "kbd") return <Code key={key}>{textOf(children)}</Code>;
447
+ if (tag === "a") {
448
+ const href = attrs.href;
449
+ if (href === undefined || !isSafeUrl(href)) {
450
+ return <GithubInlines key={key} nodes={children} />;
451
+ }
452
+ const images = collectImages(children);
453
+ if (images.length > 0) {
454
+ return <GithubImageLink key={key} href={href} images={images} />;
455
+ }
456
+ return (
457
+ <ExternalLinkText
458
+ key={key}
459
+ accent="info"
460
+ icon={null}
461
+ size="sm"
462
+ href={href}
463
+ text={emojify(textOf(children))}
464
+ />
465
+ );
466
+ }
467
+
468
+ const className = textTagClass(tag);
469
+ if (className === undefined) {
470
+ return <GithubInlines key={key} nodes={children} />;
471
+ }
472
+ return (
473
+ <Text key={key} className={className}>
474
+ <GithubInlines nodes={children} />
475
+ </Text>
476
+ );
477
+ };
478
+
479
+ return nodes.map((node, index) => {
480
+ const key = `${node.type}-${String(index)}`;
481
+
482
+ switch (node.type) {
483
+ case "text":
484
+ return <Text key={key}>{emojify(node.value)}</Text>;
485
+ case "inlineCode":
486
+ return (
487
+ <Code key={key} accent="info" className="text-accent">
488
+ {node.value}
489
+ </Code>
490
+ );
491
+ case "strong":
492
+ return (
493
+ <Text key={key} className="font-body-bold">
494
+ <GithubInlines nodes={node.children} />
495
+ </Text>
496
+ );
497
+ case "emphasis":
498
+ return (
499
+ <Text key={key} className="italic">
500
+ <GithubInlines nodes={node.children} />
501
+ </Text>
502
+ );
503
+ case "strike":
504
+ return (
505
+ <Text key={key} className="line-through">
506
+ <GithubInlines nodes={node.children} />
507
+ </Text>
508
+ );
509
+ case "link": {
510
+ if (!isSafeUrl(node.href)) {
511
+ return <GithubInlines key={key} nodes={node.children} />;
512
+ }
513
+ const images = collectImages(node.children);
514
+ if (images.length > 0) {
515
+ return <GithubImageLink key={key} href={node.href} images={images} />;
516
+ }
517
+ return (
518
+ <ExternalLinkText
519
+ key={key}
520
+ accent="info"
521
+ icon={null}
522
+ size="sm"
523
+ href={node.href}
524
+ text={emojify(textOf(node.children))}
525
+ />
526
+ );
527
+ }
528
+ case "image":
529
+ return <GithubImage key={key} src={node.src} alt={node.alt} />;
530
+ case "break":
531
+ return <Text key={key}>{"\n"}</Text>;
532
+ case "element":
533
+ return renderElement(node, key);
534
+ case "footnoteReference":
535
+ case "paragraph":
536
+ case "heading":
537
+ case "code":
538
+ case "list":
539
+ case "table":
540
+ case "blockquote":
541
+ case "callout":
542
+ case "thematicBreak":
543
+ default:
544
+ return null;
545
+ }
546
+ });
547
+ }
548
+
549
+ function GithubTable({
550
+ header,
551
+ rows,
552
+ }: {
553
+ header: GhTableCell[];
554
+ rows: GhTableCell[][];
555
+ }): ReactNode {
556
+ const weights = columnWeights(
557
+ [header, ...rows].map((row) => row.map((cell) => textOf(cell.children))),
558
+ );
559
+ return (
560
+ <View className="gap-0 rounded-sm border border-border-muted">
561
+ <View className="flex-row gap-xs border-border-muted border-b px-xs py-xxs">
562
+ {header.map((cell, index) => (
563
+ <Text
564
+ key={`header-${String(index)}`}
565
+ className="min-w-0 basis-0 text-xs font-body-bold web:wrap-anywhere"
566
+ style={{ flexGrow: weights[index] }}
567
+ >
568
+ <GithubInlines nodes={cell.children} />
569
+ </Text>
570
+ ))}
571
+ </View>
572
+ {rows.map((row, rowIndex) => (
573
+ <View
574
+ key={`row-${String(rowIndex)}`}
575
+ className="flex-row gap-xs px-xs py-xxs"
576
+ >
577
+ {row.map((cell, index) => (
578
+ <Text
579
+ key={`cell-${String(index)}`}
580
+ className="min-w-0 basis-0 select-auto text-xs web:wrap-anywhere"
581
+ style={{ flexGrow: weights[index] }}
582
+ >
583
+ <GithubInlines nodes={cell.children} />
584
+ </Text>
585
+ ))}
586
+ </View>
587
+ ))}
588
+ </View>
589
+ );
590
+ }
591
+
592
+ /** Its content arrives rendered: this is defined before the component that renders blocks. */
593
+ function GithubDetails({
594
+ initiallyOpen,
595
+ summary,
596
+ children,
597
+ }: {
598
+ initiallyOpen: boolean;
599
+ summary: ReactNode;
600
+ children: ReactNode;
601
+ }): ReactNode {
602
+ const [open, setOpen] = useState(initiallyOpen);
603
+
604
+ return (
605
+ <View className="gap-xs">
606
+ <PressableBox
607
+ variant="soft"
608
+ role="button"
609
+ aria-expanded={open}
610
+ className="flex-row items-center gap-xs self-start rounded-sm px-xs py-xxs"
611
+ onPress={() => {
612
+ setOpen((value) => !value);
613
+ }}
614
+ >
615
+ <Icon
616
+ icon={open ? <CaretDownRegularIcon /> : <CaretRightRegularIcon />}
617
+ size={14}
618
+ className="text-muted"
619
+ />
620
+ {summary}
621
+ </PressableBox>
622
+ {open ? (
623
+ <View className="gap-xs border-border-muted border-l pl-m">
624
+ {children}
625
+ </View>
626
+ ) : null}
627
+ </View>
628
+ );
629
+ }
630
+
631
+ /**
632
+ * alouette's `Checkbox` owns a plain-text label, and a task's text is rich (a
633
+ * link, emphasis, code), so the box is drawn here beside the rendered content.
634
+ * Its name is the flattened text.
635
+ */
636
+ function GithubTask({
637
+ item,
638
+ label,
639
+ }: {
640
+ item: GhListItem;
641
+ label: string;
642
+ }): ReactNode {
643
+ const { toggle, busy } = use(TaskToggleContext);
644
+ const { taskIndex } = item;
645
+ const disabled = toggle === null || busy || taskIndex === undefined;
646
+ const checked = item.checked === true;
647
+
648
+ // Set in a line of text so the row can align it on the content's baseline: a
649
+ // box pinned to the top sat above the text.
650
+ return (
651
+ <Text className="text-sm">
652
+ <InteractiveBox
653
+ withFocusVisibleOutline
654
+ withPressEffect={false}
655
+ role="checkbox"
656
+ aria-checked={checked}
657
+ aria-disabled={disabled}
658
+ aria-label={label}
659
+ disabled={disabled}
660
+ className="size-[18px] items-center justify-center rounded-[5px] border-2 border-interactive-pressable web:align-middle"
661
+ onPress={() => {
662
+ if (taskIndex !== undefined) toggle?.(taskIndex);
663
+ }}
664
+ >
665
+ {checked ? (
666
+ <Icon
667
+ icon={<CheckFatFillIcon />}
668
+ size={12}
669
+ className="text-interactive-pressable"
670
+ />
671
+ ) : null}
672
+ </InteractiveBox>
673
+ </Text>
674
+ );
675
+ }
676
+
677
+ const elementItems = (node: GhElement): GhListItem[] =>
678
+ node.children.flatMap((child): GhListItem[] =>
679
+ child.type === "element" && child.tag === "li"
680
+ ? [{ type: "listItem", children: child.children }]
681
+ : [],
682
+ );
683
+
684
+ const headingDepth = (tag: string): 1 | 2 | 3 | 4 | 5 | 6 | undefined => {
685
+ switch (tag) {
686
+ case "h1":
687
+ return 1;
688
+ case "h2":
689
+ return 2;
690
+ case "h3":
691
+ return 3;
692
+ case "h4":
693
+ return 4;
694
+ case "h5":
695
+ return 5;
696
+ case "h6":
697
+ return 6;
698
+ default:
699
+ return undefined;
700
+ }
701
+ };
702
+
703
+ /** Null for a plain item; a task's accessible name is its flattened text. */
704
+ const taskLabel = (item: GhListItem): string | null => {
705
+ if (item.checked === undefined) return null;
706
+ return emojify(textOf(item.children)).trim() || "Task item";
707
+ };
708
+
709
+ /**
710
+ * A run of inline nodes is one `Text`, so a sentence with a `<sub>` in it stays
711
+ * a sentence; a run of nothing but pictures is a row of them, which is what a
712
+ * line of badges is.
713
+ */
714
+ function GithubRun({ nodes }: { nodes: GhBlockNode[] }): ReactNode {
715
+ if (isImageRun(nodes)) {
716
+ return (
717
+ <View className="flex-row flex-wrap items-center gap-xs">
718
+ <GithubInlines
719
+ nodes={nodes.filter(
720
+ (node) => node.type !== "text" && node.type !== "break",
721
+ )}
722
+ />
723
+ </View>
724
+ );
725
+ }
726
+ return (
727
+ <Text className="select-auto text-sm">
728
+ <GithubInlines nodes={nodes} />
729
+ </Text>
730
+ );
731
+ }
732
+
733
+ function GithubCode({
734
+ node,
735
+ }: {
736
+ node: Extract<GhBlockNode, { type: "code" }>;
737
+ }): ReactNode {
738
+ if (node.lang === "suggestion") return <SuggestedChange value={node.value} />;
739
+ return (
740
+ <CopyableCodeBlock
741
+ size="sm"
742
+ title={node.title ?? node.lang}
743
+ text={node.value}
744
+ />
745
+ );
746
+ }
747
+
748
+ function GithubBlocks({ nodes }: { nodes: GhBlockNode[] }): ReactNode {
749
+ const rendered: ReactNode[] = [];
750
+ let run: GhBlockNode[] = [];
751
+
752
+ const flush = (): void => {
753
+ if (run.length === 0) return;
754
+ const blank = textOf(run).trim() === "" && collectImages(run).length === 0;
755
+ if (!blank) {
756
+ rendered.push(
757
+ <GithubRun key={`run-${String(rendered.length)}`} nodes={run} />,
758
+ );
759
+ }
760
+ run = [];
761
+ };
762
+
763
+ const renderList = ({
764
+ key,
765
+ ordered,
766
+ start,
767
+ items,
768
+ }: {
769
+ key: string;
770
+ ordered: boolean;
771
+ start: number;
772
+ items: GhListItem[];
773
+ }): ReactNode => (
774
+ <View key={key} className="gap-xxs">
775
+ {items.map((item, itemIndex) => {
776
+ const label = taskLabel(item);
777
+
778
+ return (
779
+ <View
780
+ key={`item-${String(itemIndex)}`}
781
+ className="flex-row items-baseline gap-xs pl-xs"
782
+ >
783
+ {label === null ? (
784
+ <Text className="text-sm text-muted">
785
+ {ordered ? `${String(start + itemIndex)}.` : "•"}
786
+ </Text>
787
+ ) : (
788
+ <GithubTask item={item} label={label} />
789
+ )}
790
+ <View className="min-w-0 flex-1 gap-xxs">
791
+ <GithubBlocks nodes={item.children} />
792
+ </View>
793
+ </View>
794
+ );
795
+ })}
796
+ </View>
797
+ );
798
+
799
+ const renderElement = (node: GhElement, key: string): ReactNode => {
800
+ const { tag, attrs, children } = node;
801
+ const depth = headingDepth(tag);
802
+
803
+ if (droppedTags.has(tag)) return null;
804
+ if (tag === "details") {
805
+ const summary = children.find(
806
+ (child): child is GhElement =>
807
+ child.type === "element" && child.tag === "summary",
808
+ );
809
+ return (
810
+ <GithubDetails
811
+ key={key}
812
+ initiallyOpen={"open" in attrs}
813
+ summary={
814
+ <Text className="select-auto font-body-bold text-sm">
815
+ {summary ? <GithubInlines nodes={summary.children} /> : "Details"}
816
+ </Text>
817
+ }
818
+ >
819
+ <GithubBlocks nodes={children.filter((child) => child !== summary)} />
820
+ </GithubDetails>
821
+ );
822
+ }
823
+ if (depth !== undefined) {
824
+ return (
825
+ <Text key={key} className={`select-auto ${headingClass(depth)}`}>
826
+ <GithubInlines nodes={children} />
827
+ </Text>
828
+ );
829
+ }
830
+ if (tag === "hr") {
831
+ return <Separator key={key} className={markdownSeparatorClass} />;
832
+ }
833
+ if (tag === "ul" || tag === "ol") {
834
+ const start = Number.parseInt(attrs.start ?? "1", 10);
835
+ return renderList({
836
+ key,
837
+ ordered: tag === "ol",
838
+ start: Number.isNaN(start) ? 1 : start,
839
+ items: elementItems(node),
840
+ });
841
+ }
842
+ if (tag === "pre") {
843
+ return (
844
+ <CopyableCodeBlock
845
+ key={key}
846
+ size="sm"
847
+ text={textOf(children).replace(/^\n+|\n+$/g, "")}
848
+ />
849
+ );
850
+ }
851
+ if (tag === "blockquote") {
852
+ return (
853
+ <MarkdownQuote key={key}>
854
+ <GithubBlocks nodes={children} />
855
+ </MarkdownQuote>
856
+ );
857
+ }
858
+ if (tag === "tr") {
859
+ return (
860
+ <View key={key} className="flex-row gap-xs">
861
+ <GithubBlocks nodes={children} />
862
+ </View>
863
+ );
864
+ }
865
+ return (
866
+ <View
867
+ key={key}
868
+ className={
869
+ tag === "td" || tag === "th" ? "min-w-0 flex-1 gap-xs" : "gap-xs"
870
+ }
871
+ >
872
+ <GithubBlocks nodes={children} />
873
+ </View>
874
+ );
875
+ };
876
+
877
+ nodes.forEach((node, index) => {
878
+ const key = `${node.type}-${String(index)}`;
879
+
880
+ if (isInlineNode(node)) {
881
+ run.push(node);
882
+ return;
883
+ }
884
+ flush();
885
+
886
+ switch (node.type) {
887
+ case "paragraph":
888
+ rendered.push(<GithubRun key={key} nodes={node.children} />);
889
+ break;
890
+ case "heading":
891
+ rendered.push(
892
+ <Text key={key} className={`select-auto ${headingClass(node.depth)}`}>
893
+ <GithubInlines nodes={node.children} />
894
+ </Text>,
895
+ );
896
+ break;
897
+ case "code":
898
+ rendered.push(<GithubCode key={key} node={node} />);
899
+ break;
900
+ case "list":
901
+ rendered.push(
902
+ renderList({
903
+ key,
904
+ ordered: node.ordered,
905
+ start: node.start ?? 1,
906
+ items: node.items,
907
+ }),
908
+ );
909
+ break;
910
+ case "table":
911
+ rendered.push(
912
+ <GithubTable key={key} header={node.header} rows={node.rows} />,
913
+ );
914
+ break;
915
+ case "blockquote":
916
+ case "callout":
917
+ rendered.push(
918
+ <MarkdownQuote key={key}>
919
+ <GithubBlocks nodes={node.children} />
920
+ </MarkdownQuote>,
921
+ );
922
+ break;
923
+ case "thematicBreak":
924
+ rendered.push(
925
+ <Separator key={key} className={markdownSeparatorClass} />,
926
+ );
927
+ break;
928
+ case "element":
929
+ rendered.push(renderElement(node, key));
930
+ break;
931
+ case "text":
932
+ case "inlineCode":
933
+ case "strong":
934
+ case "emphasis":
935
+ case "strike":
936
+ case "footnoteReference":
937
+ case "link":
938
+ case "image":
939
+ case "break":
940
+ default:
941
+ break;
942
+ }
943
+ });
944
+ flush();
945
+
946
+ return rendered;
947
+ }
948
+
949
+ const errorText = (error: unknown): string =>
950
+ error instanceof Error ? error.message : String(error);
951
+
952
+ function GithubMarkdown({
953
+ text,
954
+ onTaskToggle,
955
+ }: Pick<MarkdownTextProps, "onTaskToggle" | "text">): ReactNode {
956
+ const [busy, setBusy] = useState(false);
957
+ const [error, setError] = useState<string | null>(null);
958
+
959
+ const paired = useMemo(() => {
960
+ try {
961
+ return pairGithubHtml(parseMarkdown(text, { allowHtml: true }).children);
962
+ } catch {
963
+ return null;
964
+ }
965
+ }, [text]);
966
+
967
+ if (paired === null) {
968
+ return <Text className="select-auto text-sm">{text}</Text>;
969
+ }
970
+
971
+ // Boxes are pressable only when every marker in the source can be matched to
972
+ // a task item of the tree; `toggleTask` refuses otherwise.
973
+ const editable =
974
+ onTaskToggle !== undefined &&
975
+ paired.taskCount > 0 &&
976
+ toggleTask(text, 0, paired.tasks) !== null;
977
+
978
+ const toggle = (taskIndex: number): void => {
979
+ const next = toggleTask(text, taskIndex, paired.tasks);
980
+ if (next === null || onTaskToggle === undefined) return;
981
+ setBusy(true);
982
+ setError(null);
983
+ onTaskToggle(next).then(
984
+ () => {
985
+ setBusy(false);
986
+ },
987
+ (error_: unknown) => {
988
+ setError(errorText(error_));
989
+ setBusy(false);
990
+ },
991
+ );
992
+ };
993
+
994
+ return (
995
+ // oxlint-disable-next-line react/jsx-no-constructed-context-values -- built after the early return above, so no hook can hold it; its consumers are one message's task boxes
996
+ <TaskToggleContext value={{ toggle: editable ? toggle : null, busy }}>
997
+ <Box className="gap-xs">
998
+ <GithubBlocks nodes={paired.children} />
999
+ {error === null ? null : (
1000
+ <ErrorMessage size="sm" variant="flat">
1001
+ {error}
1002
+ </ErrorMessage>
1003
+ )}
1004
+ </Box>
1005
+ </TaskToggleContext>
1006
+ );
1007
+ }
1008
+
1009
+ /**
1010
+ * Markdown rendered with alouette components, falling back to the source text.
1011
+ *
1012
+ * The fallback is the point: this is a *display* of something a model wrote,
1013
+ * and the format has no schema either. A parse that throws must cost the
1014
+ * formatting, never the message — the operator opened the screen to read what
1015
+ * the agent said.
1016
+ */
1017
+ export interface MarkdownTextProps {
1018
+ text: string;
1019
+ /**
1020
+ * GitHub-flavoured rendering for a pull request's description and comments:
1021
+ * the HTML GitHub allows (`<details>`, `<sub>`, `<br>`…), `:shortcode:` emoji.
1022
+ * Off for an agent's transcript, which is not GitHub's markdown.
1023
+ */
1024
+ github?: boolean;
1025
+ /**
1026
+ * Makes task-list checkboxes pressable: called with the whole source, the
1027
+ * pressed box flipped, for the caller to save. Absent, they are read-only.
1028
+ */
1029
+ onTaskToggle?: (nextText: string) => Promise<void>;
1030
+ }
1031
+
1032
+ function PlainMarkdown({ text }: Pick<MarkdownTextProps, "text">): ReactNode {
1033
+ const document = useMemo(() => {
1034
+ try {
1035
+ return parseMarkdown(text);
1036
+ } catch {
1037
+ return null;
1038
+ }
1039
+ }, [text]);
1040
+
1041
+ if (document === null) {
1042
+ return <Text className="select-auto text-sm">{text}</Text>;
1043
+ }
1044
+
1045
+ return (
1046
+ <Box className="gap-xs">
1047
+ <BlockNodes nodes={document.children} />
1048
+ </Box>
1049
+ );
1050
+ }
1051
+
1052
+ /**
1053
+ * Memoized because a transcript renders one per message and grows with every
1054
+ * appended event: only the new message should be parsed and rendered.
1055
+ */
1056
+ function MarkdownTextBase({
1057
+ text,
1058
+ github = false,
1059
+ onTaskToggle,
1060
+ }: MarkdownTextProps): ReactNode {
1061
+ if (github) {
1062
+ return <GithubMarkdown text={text} onTaskToggle={onTaskToggle} />;
1063
+ }
1064
+ return <PlainMarkdown text={text} />;
1065
+ }
1066
+
1067
+ export const MarkdownText = memo(MarkdownTextBase);