@neurocode-ai/session-ui 1.18.8

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 (114) hide show
  1. package/package.json +71 -0
  2. package/src/components/apply-patch-file.test.ts +43 -0
  3. package/src/components/apply-patch-file.ts +78 -0
  4. package/src/components/basic-tool.css +358 -0
  5. package/src/components/basic-tool.stories.tsx +133 -0
  6. package/src/components/basic-tool.tsx +343 -0
  7. package/src/components/dock-prompt.stories.tsx +62 -0
  8. package/src/components/dock-prompt.tsx +23 -0
  9. package/src/components/file-media.tsx +291 -0
  10. package/src/components/file-search.tsx +72 -0
  11. package/src/components/file-ssr.tsx +197 -0
  12. package/src/components/file.css +46 -0
  13. package/src/components/file.tsx +1202 -0
  14. package/src/components/line-comment-annotations.tsx +637 -0
  15. package/src/components/line-comment-styles.ts +292 -0
  16. package/src/components/line-comment.stories.tsx +115 -0
  17. package/src/components/line-comment.tsx +439 -0
  18. package/src/components/markdown-cache.tsx +78 -0
  19. package/src/components/markdown-code-state.test.ts +32 -0
  20. package/src/components/markdown-code-state.ts +22 -0
  21. package/src/components/markdown-inline-code-kind.test.ts +46 -0
  22. package/src/components/markdown-inline-code-kind.ts +1915 -0
  23. package/src/components/markdown-preload.test.ts +18 -0
  24. package/src/components/markdown-shiki.worker.ts +104 -0
  25. package/src/components/markdown-stream.test.ts +194 -0
  26. package/src/components/markdown-stream.ts +110 -0
  27. package/src/components/markdown-worker-protocol.test.ts +81 -0
  28. package/src/components/markdown-worker-protocol.ts +48 -0
  29. package/src/components/markdown-worker-queue.test.ts +49 -0
  30. package/src/components/markdown-worker-queue.ts +64 -0
  31. package/src/components/markdown-worker-transport.test.ts +56 -0
  32. package/src/components/markdown-worker-transport.ts +41 -0
  33. package/src/components/markdown-worker.ts +122 -0
  34. package/src/components/markdown.css +377 -0
  35. package/src/components/markdown.stories.tsx +53 -0
  36. package/src/components/markdown.tsx +678 -0
  37. package/src/components/message-file.test.ts +63 -0
  38. package/src/components/message-file.ts +34 -0
  39. package/src/components/message-nav.css +127 -0
  40. package/src/components/message-nav.stories.tsx +7 -0
  41. package/src/components/message-nav.tsx +102 -0
  42. package/src/components/message-part-text.ts +3 -0
  43. package/src/components/message-part.css +1569 -0
  44. package/src/components/message-part.stories.tsx +7 -0
  45. package/src/components/message-part.test.ts +28 -0
  46. package/src/components/message-part.tsx +2662 -0
  47. package/src/components/session-diff.test.ts +135 -0
  48. package/src/components/session-diff.ts +145 -0
  49. package/src/components/session-retry.tsx +74 -0
  50. package/src/components/session-review.css +247 -0
  51. package/src/components/session-review.stories.tsx +7 -0
  52. package/src/components/session-review.tsx +657 -0
  53. package/src/components/session-turn.css +241 -0
  54. package/src/components/session-turn.stories.tsx +7 -0
  55. package/src/components/session-turn.tsx +543 -0
  56. package/src/components/shell-submessage-motion.stories.tsx +346 -0
  57. package/src/components/shell-submessage.css +23 -0
  58. package/src/components/timeline-playground.stories.tsx +2090 -0
  59. package/src/components/tool-count-label.css +57 -0
  60. package/src/components/tool-count-label.tsx +58 -0
  61. package/src/components/tool-count-summary.css +102 -0
  62. package/src/components/tool-count-summary.stories.tsx +238 -0
  63. package/src/components/tool-count-summary.tsx +52 -0
  64. package/src/components/tool-error-card.css +91 -0
  65. package/src/components/tool-error-card.stories.tsx +92 -0
  66. package/src/components/tool-error-card.tsx +157 -0
  67. package/src/components/tool-status-title.css +89 -0
  68. package/src/components/tool-status-title.tsx +133 -0
  69. package/src/context/data.tsx +64 -0
  70. package/src/context/index.ts +1 -0
  71. package/src/pierre/comment-hover.ts +83 -0
  72. package/src/pierre/commented-lines.ts +91 -0
  73. package/src/pierre/diff-selection.ts +71 -0
  74. package/src/pierre/file-find.ts +485 -0
  75. package/src/pierre/file-runtime.ts +114 -0
  76. package/src/pierre/file-selection.ts +85 -0
  77. package/src/pierre/index.ts +186 -0
  78. package/src/pierre/media.ts +110 -0
  79. package/src/pierre/selection-bridge.ts +132 -0
  80. package/src/pierre/virtualizer.ts +100 -0
  81. package/src/pierre/worker.ts +52 -0
  82. package/src/styles/index.css +14 -0
  83. package/src/v2/components/attachment-card-v2.css +70 -0
  84. package/src/v2/components/attachment-card-v2.tsx +33 -0
  85. package/src/v2/components/basic-tool-v2.css +163 -0
  86. package/src/v2/components/basic-tool-v2.stories.tsx +137 -0
  87. package/src/v2/components/basic-tool-v2.tsx +139 -0
  88. package/src/v2/components/comment-card-v2.tsx +64 -0
  89. package/src/v2/components/line-comment-annotations-v2.tsx +220 -0
  90. package/src/v2/components/prompt-input/attachments.ts +266 -0
  91. package/src/v2/components/prompt-input/index.tsx +706 -0
  92. package/src/v2/components/prompt-input/interaction.ts +482 -0
  93. package/src/v2/components/prompt-input/machine.test.ts +164 -0
  94. package/src/v2/components/prompt-input/machine.ts +261 -0
  95. package/src/v2/components/prompt-input/prompt-input.stories.tsx +221 -0
  96. package/src/v2/components/prompt-input/store.test.ts +116 -0
  97. package/src/v2/components/prompt-input/store.ts +152 -0
  98. package/src/v2/components/prompt-input/types.ts +106 -0
  99. package/src/v2/components/session-file-panel-v2.tsx +43 -0
  100. package/src/v2/components/session-progress-indicator-v2.css +875 -0
  101. package/src/v2/components/session-progress-indicator-v2.stories.tsx +66 -0
  102. package/src/v2/components/session-progress-indicator-v2.tsx +32 -0
  103. package/src/v2/components/session-review-empty-changes-v2.tsx +17 -0
  104. package/src/v2/components/session-review-empty-no-git-v2.tsx +28 -0
  105. package/src/v2/components/session-review-file-preview-v2-virtualize.test.ts +13 -0
  106. package/src/v2/components/session-review-file-preview-v2-virtualize.ts +5 -0
  107. package/src/v2/components/session-review-file-preview-v2.tsx +290 -0
  108. package/src/v2/components/session-review-v2.css +458 -0
  109. package/src/v2/components/session-review-v2.tsx +340 -0
  110. package/src/v2/components/tool-error-card-v2.css +200 -0
  111. package/src/v2/components/tool-error-card-v2.stories.tsx +91 -0
  112. package/src/v2/components/tool-error-card-v2.tsx +166 -0
  113. package/sst-env.d.ts +10 -0
  114. package/tsconfig.json +19 -0
@@ -0,0 +1,678 @@
1
+ import { useMarked } from "@neurocode-ai/ui/context/marked"
2
+ import { useI18n } from "@neurocode-ai/ui/context/i18n"
3
+ import morphdom from "morphdom"
4
+ import { checksum } from "@neurocode-ai/core/util/encode"
5
+ import {
6
+ type Accessor,
7
+ type ComponentProps,
8
+ createEffect,
9
+ createMemo,
10
+ createResource,
11
+ createSignal,
12
+ createUniqueId,
13
+ onCleanup,
14
+ type Setter,
15
+ splitProps,
16
+ } from "solid-js"
17
+ import { isServer, render } from "solid-js/web"
18
+ import { Icon as IconV2 } from "@neurocode-ai/ui/v2/icon"
19
+ import { IconButtonV2 } from "@neurocode-ai/ui/v2/icon-button-v2"
20
+ import { TooltipV2 } from "@neurocode-ai/ui/v2/tooltip-v2"
21
+ import { bundledLanguages } from "shiki"
22
+ import { canReusePendingBlock, project, type Block, type Projection } from "./markdown-stream"
23
+ import {
24
+ disposeStreamingCode,
25
+ highlightStreamingCode,
26
+ MarkdownWorkerDisposedError,
27
+ MarkdownWorkerSupersededError,
28
+ MarkdownWorkerUnavailableError,
29
+ } from "./markdown-worker"
30
+ import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol"
31
+ import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
32
+ import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache"
33
+ import { inlineCodeKind } from "./markdown-inline-code-kind"
34
+
35
+ type RenderedBlock =
36
+ | (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
37
+ | {
38
+ key: string
39
+ mode: "code"
40
+ raw: string
41
+ hash: string
42
+ language: string
43
+ complete: boolean
44
+ generation: number
45
+ stable: MarkdownToken[]
46
+ unstable: MarkdownToken[]
47
+ }
48
+
49
+ type RenderResult = {
50
+ text: string
51
+ blocks: RenderedBlock[]
52
+ }
53
+
54
+ const renderedCodeTokens = new WeakMap<HTMLDivElement, RenderedCodeState>()
55
+
56
+ function escape(text: string) {
57
+ return text
58
+ .replace(/&/g, "&amp;")
59
+ .replace(/</g, "&lt;")
60
+ .replace(/>/g, "&gt;")
61
+ .replace(/"/g, "&quot;")
62
+ .replace(/'/g, "&#39;")
63
+ }
64
+
65
+ function fallback(markdown: string) {
66
+ return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
67
+ }
68
+
69
+ async function code(text: string, language: string | undefined, key: string, complete = false) {
70
+ const name = language && language in bundledLanguages ? language : "text"
71
+ try {
72
+ const result = await highlightStreamingCode(key, text, name, complete)
73
+ return { language: name, generation: result.generation, stable: result.stable, unstable: result.unstable }
74
+ } catch (error) {
75
+ if (
76
+ !(error instanceof MarkdownWorkerDisposedError) &&
77
+ !(error instanceof MarkdownWorkerSupersededError) &&
78
+ !(error instanceof MarkdownWorkerUnavailableError)
79
+ )
80
+ console.error("Markdown highlighting worker failed", error)
81
+ return { language: name, generation: 0, stable: [], unstable: [[text, ""] as MarkdownToken] }
82
+ }
83
+ }
84
+
85
+ type CopyLabels = {
86
+ copy: string
87
+ copied: string
88
+ }
89
+
90
+ type CopyButtonState = {
91
+ setLabels: Setter<CopyLabels>
92
+ setCopied: Setter<boolean>
93
+ dispose: () => void
94
+ }
95
+
96
+ const copyButtonState = new WeakMap<HTMLElement, CopyButtonState>()
97
+
98
+ const urlPattern = /^https?:\/\/[^\s<>()`"']+$/
99
+
100
+ function codeUrl(text: string) {
101
+ const href = text.trim().replace(/[),.;!?]+$/, "")
102
+ if (!urlPattern.test(href)) return
103
+ try {
104
+ const url = new URL(href)
105
+ return url.toString()
106
+ } catch {
107
+ return
108
+ }
109
+ }
110
+
111
+ function createCopyButton(labels: CopyLabels) {
112
+ const host = document.createElement("div")
113
+ host.setAttribute("data-slot", "markdown-copy-button")
114
+
115
+ const state: Partial<CopyButtonState> = {}
116
+ const dispose = render(() => {
117
+ const [labelState, setLabels] = createSignal(labels, { equals: false })
118
+ const [copied, setCopied] = createSignal(false)
119
+ state.setLabels = setLabels
120
+ state.setCopied = setCopied
121
+ return <MarkdownCopyButton labels={labelState} copied={copied} />
122
+ }, host)
123
+ state.dispose = dispose
124
+ copyButtonState.set(host, state as CopyButtonState)
125
+ return host
126
+ }
127
+
128
+ function MarkdownCopyButton(props: { labels: Accessor<CopyLabels>; copied: Accessor<boolean> }) {
129
+ const label = () => (props.copied() ? props.labels().copied : props.labels().copy)
130
+ return (
131
+ <TooltipV2 placement="top" value={label()}>
132
+ <IconButtonV2
133
+ type="button"
134
+ size="normal"
135
+ variant="ghost-muted"
136
+ aria-label={label()}
137
+ icon={
138
+ <>
139
+ <IconV2 name="outline-copy" data-copy-icon />
140
+ <IconV2 name="check" data-check-icon />
141
+ </>
142
+ }
143
+ />
144
+ </TooltipV2>
145
+ )
146
+ }
147
+
148
+ function setCopyState(host: HTMLElement, labels: CopyLabels, copied: boolean) {
149
+ const state = copyButtonState.get(host)
150
+ state?.setLabels(labels)
151
+ state?.setCopied(copied)
152
+ if (copied) {
153
+ host.setAttribute("data-copied", "true")
154
+ return
155
+ }
156
+ host.removeAttribute("data-copied")
157
+ }
158
+
159
+ function disposeCopyButton(host: HTMLElement) {
160
+ copyButtonState.get(host)?.dispose()
161
+ copyButtonState.delete(host)
162
+ }
163
+
164
+ function disposeCopyButtons(root: Element) {
165
+ const hosts = [
166
+ ...(root instanceof HTMLElement && root.getAttribute("data-slot") === "markdown-copy-button" ? [root] : []),
167
+ ...Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]')).filter(
168
+ (el): el is HTMLElement => el instanceof HTMLElement,
169
+ ),
170
+ ]
171
+ hosts.forEach(disposeCopyButton)
172
+ }
173
+
174
+ const shellLanguages = new Set(["bash", "sh", "shell", "zsh", "fish", "console", "terminal"])
175
+
176
+ function codeKind(language: string | undefined) {
177
+ const value = language?.toLowerCase()
178
+ if (!value) return
179
+ if (shellLanguages.has(value)) return "shell"
180
+ }
181
+
182
+ function codeLanguage(block: HTMLPreElement) {
183
+ const code = block.querySelector("code")
184
+ if (!(code instanceof HTMLElement)) return
185
+ return code.className.match(/(?:^|\s)language-([^\s]+)/)?.[1]
186
+ }
187
+
188
+ function applyCodeMetadata(wrapper: HTMLElement, language: string | undefined) {
189
+ if (!document.body.hasAttribute("data-new-layout")) {
190
+ delete wrapper.dataset.language
191
+ delete wrapper.dataset.codeKind
192
+ return
193
+ }
194
+
195
+ if (language) wrapper.dataset.language = language
196
+ else delete wrapper.dataset.language
197
+
198
+ const kind = codeKind(language)
199
+ if (kind) wrapper.dataset.codeKind = kind
200
+ else delete wrapper.dataset.codeKind
201
+ }
202
+
203
+ function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
204
+ const parent = block.parentElement
205
+ if (!parent) return
206
+ const wrapped = parent.getAttribute("data-component") === "markdown-code"
207
+ if (!wrapped) {
208
+ const wrapper = document.createElement("div")
209
+ wrapper.setAttribute("data-component", "markdown-code")
210
+ applyCodeMetadata(wrapper, codeLanguage(block))
211
+ parent.replaceChild(wrapper, block)
212
+ wrapper.appendChild(block)
213
+ wrapper.appendChild(createCopyButton(labels))
214
+ return
215
+ }
216
+
217
+ applyCodeMetadata(parent, codeLanguage(block))
218
+
219
+ const buttons = Array.from(parent.querySelectorAll('[data-slot="markdown-copy-button"]')).filter(
220
+ (el): el is HTMLButtonElement => el instanceof HTMLButtonElement,
221
+ )
222
+
223
+ if (buttons.length === 0) {
224
+ parent.appendChild(createCopyButton(labels))
225
+ return
226
+ }
227
+
228
+ for (const button of buttons.slice(1)) {
229
+ disposeCopyButton(button)
230
+ button.remove()
231
+ }
232
+ }
233
+
234
+ function markCodeLinks(root: HTMLDivElement) {
235
+ const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
236
+ for (const code of codeNodes) {
237
+ const href = codeUrl(code.textContent ?? "")
238
+ const parentLink =
239
+ code.parentElement instanceof HTMLAnchorElement && code.parentElement.classList.contains("external-link")
240
+ ? code.parentElement
241
+ : null
242
+
243
+ if (!href) {
244
+ if (parentLink) parentLink.replaceWith(code)
245
+ continue
246
+ }
247
+
248
+ if (parentLink) {
249
+ parentLink.href = href
250
+ continue
251
+ }
252
+
253
+ const link = document.createElement("a")
254
+ link.href = href
255
+ link.className = "external-link"
256
+ link.target = "_blank"
257
+ link.rel = "noopener noreferrer"
258
+ code.parentNode?.replaceChild(link, code)
259
+ link.appendChild(code)
260
+ }
261
+ }
262
+
263
+ function markInlineCode(root: HTMLDivElement) {
264
+ const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
265
+ for (const code of codeNodes) {
266
+ if (!(code instanceof HTMLElement)) continue
267
+ delete code.dataset.inlineCodeKind
268
+ const kind = inlineCodeKind(code.textContent ?? "")
269
+ if (kind) code.dataset.inlineCodeKind = kind
270
+ }
271
+ }
272
+
273
+ function decorate(root: HTMLDivElement, labels: CopyLabels) {
274
+ const blocks = Array.from(root.querySelectorAll("pre"))
275
+ for (const block of blocks) {
276
+ ensureCodeWrapper(block, labels)
277
+ }
278
+ if (!document.body.hasAttribute("data-new-layout")) return
279
+ markInlineCode(root)
280
+ markCodeLinks(root)
281
+ }
282
+
283
+ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
284
+ const timeouts = new Map<HTMLElement, ReturnType<typeof setTimeout>>()
285
+
286
+ const updateLabel = (button: HTMLElement) => {
287
+ const labels = getLabels()
288
+ const copied = button.getAttribute("data-copied") === "true"
289
+ setCopyState(button, labels, copied)
290
+ }
291
+
292
+ const handleClick = async (event: MouseEvent) => {
293
+ const target = event.target
294
+ if (!(target instanceof Element)) return
295
+
296
+ const button = target.closest('[data-slot="markdown-copy-button"]')
297
+ if (!(button instanceof HTMLElement)) return
298
+ const code = button.closest('[data-component="markdown-code"]')?.querySelector("code")
299
+ const content = code?.textContent ?? ""
300
+ if (!content) return
301
+ const clipboard = navigator?.clipboard
302
+ if (!clipboard) return
303
+ await clipboard.writeText(content)
304
+ const labels = getLabels()
305
+ setCopyState(button, labels, true)
306
+ const existing = timeouts.get(button)
307
+ if (existing) clearTimeout(existing)
308
+ const timeout = setTimeout(() => setCopyState(button, labels, false), 2000)
309
+ timeouts.set(button, timeout)
310
+ }
311
+
312
+ const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
313
+ for (const button of buttons) {
314
+ if (button instanceof HTMLElement) updateLabel(button)
315
+ }
316
+
317
+ root.addEventListener("click", handleClick)
318
+
319
+ return () => {
320
+ root.removeEventListener("click", handleClick)
321
+ for (const timeout of timeouts.values()) {
322
+ clearTimeout(timeout)
323
+ }
324
+ disposeCopyButtons(root)
325
+ }
326
+ }
327
+
328
+ function initialResult(text: string, key: string | undefined, projection: Projection, owner: string): RenderResult {
329
+ if (!text) return { text, blocks: [] }
330
+ const base = key ?? checksum(text)
331
+ if (base) {
332
+ const blocks = projection.blocks.flatMap((block, index) => {
333
+ if (block.mode === "code") return []
334
+ const cacheKey = `${base}:${index}:${block.mode}`
335
+ const cached = getCachedMarkdown(cacheKey)
336
+ if (cached?.raw !== block.raw) return []
337
+ return [{ key: `${owner}:${cacheKey}`, mode: block.mode, ...cached }]
338
+ })
339
+ if (blocks.length === projection.blocks.length) return { text, blocks }
340
+ }
341
+ return {
342
+ text,
343
+ blocks: [
344
+ {
345
+ key: "initial",
346
+ mode: "full",
347
+ raw: text,
348
+ hash: checksum(text) ?? "",
349
+ html: fallback(text),
350
+ },
351
+ ],
352
+ }
353
+ }
354
+
355
+ export function Markdown(
356
+ props: ComponentProps<"div"> & {
357
+ text: string
358
+ cacheKey?: string
359
+ streaming?: boolean
360
+ class?: string
361
+ classList?: Record<string, boolean>
362
+ },
363
+ ) {
364
+ const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"])
365
+ const marked = useMarked()
366
+ const i18n = useI18n()
367
+ const [root, setRoot] = createSignal<HTMLDivElement>()
368
+ const owner = createUniqueId()
369
+ const activeCodeKeys = new Set<string>()
370
+ const completedCode = new Map<string, Extract<RenderedBlock, { mode: "code" }>>()
371
+ const projection = createMemo((previous: Projection | undefined) =>
372
+ project(previous, local.text, local.streaming ?? false),
373
+ )
374
+ const [html] = createResource(
375
+ () => {
376
+ return {
377
+ text: local.text,
378
+ key: local.cacheKey,
379
+ projection: projection(),
380
+ }
381
+ },
382
+ async (src) => {
383
+ if (isServer)
384
+ return {
385
+ text: src.text,
386
+ blocks: [
387
+ {
388
+ key: "server",
389
+ mode: "full" as const,
390
+ raw: src.text,
391
+ hash: checksum(src.text) ?? "",
392
+ html: fallback(src.text),
393
+ },
394
+ ],
395
+ } satisfies RenderResult
396
+ if (!src.text) return { text: src.text, blocks: [] } satisfies RenderResult
397
+
398
+ const base = src.key ?? checksum(src.text)
399
+ return Promise.all(
400
+ src.projection.blocks.map(async (block, index) => {
401
+ const key = base ? `${base}:${index}:${block.mode}` : undefined
402
+ const blockKey = markdownBlockKey(owner, src.key, index, block.mode)
403
+
404
+ if (block.mode === "code") {
405
+ const cached = completedCode.get(blockKey)
406
+ if (block.complete && cached?.raw === block.raw) return cached
407
+ const result = await code(block.src, block.language, blockKey, block.complete)
408
+ const rendered = {
409
+ key: blockKey,
410
+ mode: block.mode,
411
+ raw: block.raw,
412
+ hash: String(block.raw.length),
413
+ complete: !!block.complete,
414
+ ...result,
415
+ }
416
+ if (block.complete) completedCode.set(blockKey, rendered)
417
+ return rendered
418
+ }
419
+
420
+ if (key) {
421
+ const cached = getCachedMarkdown(key)
422
+ if (cached?.raw === block.raw) {
423
+ touchCachedMarkdown(key, cached)
424
+ return { key: blockKey, mode: block.mode, ...cached }
425
+ }
426
+ }
427
+
428
+ const hash = checksum(block.raw)
429
+ const safe = sanitizeMarkdown(await Promise.resolve(marked.parse(block.src)))
430
+ if (key && hash) touchCachedMarkdown(key, { raw: block.raw, hash, html: safe })
431
+ return { key: blockKey, mode: block.mode, raw: block.raw, hash: hash ?? "", html: safe }
432
+ }),
433
+ )
434
+ .then((blocks) => ({ text: src.text, blocks }) satisfies RenderResult)
435
+ .catch(
436
+ () =>
437
+ ({
438
+ text: src.text,
439
+ blocks: [
440
+ {
441
+ key: base ?? "fallback",
442
+ mode: "full" as const,
443
+ raw: src.text,
444
+ hash: checksum(src.text) ?? "",
445
+ html: fallback(src.text),
446
+ },
447
+ ],
448
+ }) satisfies RenderResult,
449
+ )
450
+ },
451
+ {
452
+ initialValue: initialResult(local.text, local.cacheKey, projection(), owner),
453
+ },
454
+ )
455
+
456
+ let copyCleanup: (() => void) | undefined
457
+
458
+ createEffect(() => {
459
+ const container = root()
460
+ const result = html.latest ?? html()
461
+ const projected = projection()
462
+ const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner) : []
463
+ if (!container) return
464
+ if (isServer) return
465
+ if (content.length === 0) {
466
+ disposeCopyButtons(container)
467
+ container.innerHTML = ""
468
+ return
469
+ }
470
+
471
+ const labels = {
472
+ copy: i18n.t("ui.message.copy"),
473
+ copied: i18n.t("ui.message.copied"),
474
+ }
475
+ const nextCodeKeys = new Set(content.filter((block) => block.mode === "code").map((block) => block.key))
476
+ activeCodeKeys.forEach((key) => {
477
+ if (!nextCodeKeys.has(key)) disposeCode(key)
478
+ })
479
+ activeCodeKeys.clear()
480
+ nextCodeKeys.forEach((key) => activeCodeKeys.add(key))
481
+ content.forEach((block, index) => updateBlock(container, index, block, labels))
482
+ while (container.children.length > content.length) {
483
+ const child = container.lastElementChild
484
+ if (!child) break
485
+ disposeCopyButtons(child)
486
+ child.remove()
487
+ }
488
+ container
489
+ .querySelectorAll<HTMLElement>('[data-slot="markdown-copy-button"]')
490
+ .forEach((button) => setCopyState(button, labels, button.dataset.copied === "true"))
491
+ if (!copyCleanup)
492
+ copyCleanup = setupCodeCopy(container, () => ({
493
+ copy: i18n.t("ui.message.copy"),
494
+ copied: i18n.t("ui.message.copied"),
495
+ }))
496
+ })
497
+
498
+ onCleanup(() => {
499
+ if (copyCleanup) copyCleanup()
500
+ activeCodeKeys.forEach(disposeCode)
501
+ completedCode.clear()
502
+ })
503
+
504
+ return (
505
+ <div
506
+ data-component="markdown"
507
+ classList={{
508
+ ...local.classList,
509
+ [local.class ?? ""]: !!local.class,
510
+ }}
511
+ ref={setRoot}
512
+ {...others}
513
+ />
514
+ )
515
+ }
516
+
517
+ function pendingBlocks(
518
+ result: RenderResult | undefined,
519
+ projection: Projection | undefined,
520
+ cacheKey: string | undefined,
521
+ owner: string,
522
+ ) {
523
+ if (!result) return []
524
+ if (!projection || result.text === projection.text) return result.blocks
525
+ const initial = result.blocks.length === 1 && result.blocks[0]?.key === "initial"
526
+ return projection.blocks.map((block, index) => {
527
+ const current = initial ? undefined : result.blocks[index]
528
+ if (current && canReusePendingBlock(current, block)) return current
529
+ const key = markdownBlockKey(owner, cacheKey, index, block.mode)
530
+ if (block.mode !== "code")
531
+ return { key, mode: block.mode, raw: block.raw, hash: String(block.raw.length), html: fallback(block.src) }
532
+ return {
533
+ key,
534
+ mode: block.mode,
535
+ raw: block.raw,
536
+ hash: String(block.raw.length),
537
+ language: block.language ?? "text",
538
+ complete: !!block.complete,
539
+ stable: [],
540
+ generation: 0,
541
+ unstable: [[block.src, ""] as MarkdownToken],
542
+ }
543
+ })
544
+ }
545
+
546
+ function disposeCode(key: string) {
547
+ disposeStreamingCode(key)
548
+ }
549
+
550
+ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBlock, labels: CopyLabels) {
551
+ const current = container.children[index]
552
+ if (block.mode === "code") {
553
+ updateCodeBlock(container, current, block, labels)
554
+ return
555
+ }
556
+ if (
557
+ current instanceof HTMLDivElement &&
558
+ current.dataset.markdownKey === block.key &&
559
+ current.dataset.markdownHash === block.hash
560
+ )
561
+ return
562
+
563
+ const next = document.createElement("div")
564
+ next.dataset.markdownBlock = ""
565
+ next.dataset.markdownKey = block.key
566
+ next.dataset.markdownHash = block.hash
567
+ next.style.display = "contents"
568
+ next.innerHTML = block.html
569
+ decorate(next, labels)
570
+
571
+ if (!(current instanceof HTMLDivElement)) {
572
+ container.appendChild(next)
573
+ return
574
+ }
575
+
576
+ morphdom(current, next, {
577
+ onBeforeElUpdated: (fromEl, toEl) => {
578
+ if (
579
+ fromEl instanceof HTMLElement &&
580
+ toEl instanceof HTMLElement &&
581
+ fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
582
+ toEl.getAttribute("data-slot") === "markdown-copy-button"
583
+ ) {
584
+ return false
585
+ }
586
+ if (fromEl.isEqualNode(toEl)) return false
587
+ return true
588
+ },
589
+ onBeforeNodeDiscarded: (node) => {
590
+ if (node instanceof Element) disposeCopyButtons(node)
591
+ return true
592
+ },
593
+ })
594
+ }
595
+
596
+ function updateCodeBlock(
597
+ container: HTMLDivElement,
598
+ current: Element | undefined,
599
+ block: Extract<RenderedBlock, { mode: "code" }>,
600
+ labels: CopyLabels,
601
+ ) {
602
+ const existing = current instanceof HTMLDivElement && current.dataset.markdownKey === block.key ? current : undefined
603
+ const next = existing ?? document.createElement("div")
604
+ next.dataset.markdownBlock = ""
605
+ next.dataset.markdownKey = block.key
606
+ next.dataset.markdownHash = block.hash
607
+ next.dataset.markdownComplete = block.complete ? "true" : "false"
608
+ next.style.display = "contents"
609
+
610
+ const code = existing?.querySelector("code")
611
+ if (code instanceof HTMLElement) {
612
+ const wrapper = code.closest('[data-component="markdown-code"]')
613
+ if (wrapper instanceof HTMLElement) applyCodeMetadata(wrapper, block.language)
614
+ code.className = `language-${block.language}`
615
+ const previous = renderedCodeTokens.get(next)
616
+ const reset = shouldResetCodeTokens(previous, {
617
+ language: block.language,
618
+ generation: block.generation,
619
+ stableCount: block.stable.length,
620
+ raw: block.raw,
621
+ })
622
+ const stableCount = reset ? 0 : previous!.stableCount
623
+ const tail = [...block.stable.slice(stableCount), ...block.unstable]
624
+ const prior = reset ? [] : previous!.unstable
625
+ const prefix = prior.findIndex((token, index) => !sameToken(token, tail[index]))
626
+ const keep = stableCount + (prefix < 0 ? Math.min(prior.length, tail.length) : prefix)
627
+ while (code.children.length > keep) code.lastElementChild?.remove()
628
+ tail
629
+ .slice(keep - stableCount)
630
+ .map(createTokenSpan)
631
+ .forEach((span) => code.appendChild(span))
632
+ renderedCodeTokens.set(next, {
633
+ language: block.language,
634
+ generation: block.generation,
635
+ stableCount: block.stable.length,
636
+ unstable: block.unstable,
637
+ raw: block.raw,
638
+ })
639
+ return
640
+ }
641
+
642
+ const wrapper = document.createElement("div")
643
+ wrapper.setAttribute("data-component", "markdown-code")
644
+ applyCodeMetadata(wrapper, block.language)
645
+ const pre = document.createElement("pre")
646
+ pre.className = "shiki OpenCode"
647
+ const codeElement = document.createElement("code")
648
+ codeElement.className = `language-${block.language}`
649
+ ;[...block.stable, ...block.unstable].map(createTokenSpan).forEach((span) => codeElement.appendChild(span))
650
+ pre.appendChild(codeElement)
651
+ wrapper.appendChild(pre)
652
+ wrapper.appendChild(createCopyButton(labels))
653
+ next.appendChild(wrapper)
654
+ renderedCodeTokens.set(next, {
655
+ language: block.language,
656
+ generation: block.generation,
657
+ stableCount: block.stable.length,
658
+ unstable: block.unstable,
659
+ raw: block.raw,
660
+ })
661
+ if (current) {
662
+ disposeCopyButtons(current)
663
+ current.replaceWith(next)
664
+ return
665
+ }
666
+ container.appendChild(next)
667
+ }
668
+
669
+ function sameToken(left: MarkdownToken, right: MarkdownToken | undefined) {
670
+ return !!right && left[0] === right[0] && left[1] === right[1]
671
+ }
672
+
673
+ function createTokenSpan(token: MarkdownToken) {
674
+ const span = document.createElement("span")
675
+ span.setAttribute("style", token[1])
676
+ span.textContent = token[0]
677
+ return span
678
+ }