From e76534986515bc2e2249cbb09f31b9777e1cc036 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:00:46 +0800 Subject: [PATCH] feat(session-ui): render streaming markdown with Solid (#44354) Co-authored-by: Brendonovich --- bun.lock | 17 +-- packages/session-ui/package.json | 2 +- .../src/components/markdown-solid.test.tsx | 42 ++++++ .../src/components/markdown-solid.tsx | 73 +++++++++++ .../session-ui/src/components/markdown.css | 23 ++++ .../src/components/markdown.stories.tsx | 20 +++ .../session-ui/src/components/markdown.tsx | 121 ++++++------------ 7 files changed, 202 insertions(+), 96 deletions(-) create mode 100644 packages/session-ui/src/components/markdown-solid.test.tsx create mode 100644 packages/session-ui/src/components/markdown-solid.tsx diff --git a/bun.lock b/bun.lock index e153b991331..850ca79e8c6 100644 --- a/bun.lock +++ b/bun.lock @@ -717,7 +717,6 @@ "luxon": "catalog:", "marked": "catalog:", "mermaid": "11.16.1", - "morphdom": "2.7.8", "motion": "12.34.5", "remeda": "catalog:", "remend": "catalog:", @@ -727,6 +726,7 @@ "strip-ansi": "7.1.2", }, "devDependencies": { + "@happy-dom/global-registrator": "20.0.11", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", "@types/luxon": "catalog:", @@ -6652,8 +6652,6 @@ "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], - - "oxc-parser/@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -7552,18 +7550,6 @@ "miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], - - - - - - - - - - - - "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], @@ -8398,7 +8384,6 @@ "js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 6d95bc910f0..d04e47d1510 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -50,6 +50,7 @@ "test": "bun test src --only-failures" }, "devDependencies": { + "@happy-dom/global-registrator": "20.0.11", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", "@types/luxon": "catalog:", @@ -75,7 +76,6 @@ "luxon": "catalog:", "marked": "catalog:", "mermaid": "11.16.1", - "morphdom": "2.7.8", "motion": "12.34.5", "remeda": "catalog:", "remend": "catalog:", diff --git a/packages/session-ui/src/components/markdown-solid.test.tsx b/packages/session-ui/src/components/markdown-solid.test.tsx new file mode 100644 index 00000000000..5cd4c9a58b0 --- /dev/null +++ b/packages/session-ui/src/components/markdown-solid.test.tsx @@ -0,0 +1,42 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator" +import { afterAll, beforeAll, expect, test } from "bun:test" +import { parseMarkdownNodes } from "./markdown-solid" + +beforeAll(() => GlobalRegistrator.register()) +afterAll(() => GlobalRegistrator.unregister()) + +test("assigns stable paths to elements and individual words", () => { + expect(parseMarkdownNodes('

Hello streaming world

', true)).toEqual([ + { + key: "0", + type: "element", + tag: "p", + attributes: { class: "lead" }, + children: [ + { key: "0.0:0", type: "word", text: "Hello" }, + { key: "0.0:1", type: "text", text: " " }, + { + key: "0.1", + type: "element", + tag: "strong", + attributes: {}, + children: [{ key: "0.1.0:0", type: "word", text: "streaming" }], + }, + { key: "0.2:1", type: "text", text: " " }, + { key: "0.2:2", type: "word", text: "world" }, + ], + }, + ]) +}) + +test("keeps completed block text compact", () => { + expect(parseMarkdownNodes("

Hello world

", false)).toEqual([ + { + key: "0", + type: "element", + tag: "p", + attributes: {}, + children: [{ key: "0.0", type: "text", text: "Hello world" }], + }, + ]) +}) diff --git a/packages/session-ui/src/components/markdown-solid.tsx b/packages/session-ui/src/components/markdown-solid.tsx new file mode 100644 index 00000000000..1ea2befb14a --- /dev/null +++ b/packages/session-ui/src/components/markdown-solid.tsx @@ -0,0 +1,73 @@ +import { For, onMount } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { Dynamic, render } from "solid-js/web" + +type MarkdownNode = + | { key: string; type: "element"; tag: string; attributes: Record; children: MarkdownNode[] } + | { key: string; type: "text"; text: string } + | { key: string; type: "word"; text: string } + +export function createMarkdownRenderer(root: HTMLDivElement, html: string, words: boolean) { + const [nodes, setNodes] = createStore(parseMarkdownNodes(html, words)) + let ready = false + const dispose = render( + () => {(node) => ready} />}, + root, + ) + ready = true + + return { + update(next: string, nextWords: boolean) { + setNodes(reconcile(parseMarkdownNodes(next, nextWords), { key: "key" })) + }, + dispose, + } +} + +function MarkdownDomNode(props: { node: MarkdownNode; animate: () => boolean }) { + const node = props.node + if (node.type === "text") return node.text + if (node.type === "word") { + let ref: HTMLSpanElement | undefined + onMount(() => { + if (props.animate()) ref?.setAttribute("data-markdown-enter", "") + }) + return ( + + {node.text} + + ) + } + return ( + + {(node) => } + + ) +} + +export function parseMarkdownNodes(html: string, words: boolean) { + const template = document.createElement("template") + template.innerHTML = html + return Array.from(template.content.childNodes).flatMap((node, index) => parseNode(node, `${index}`, words)) +} + +function parseNode(node: Node, key: string, words: boolean): MarkdownNode[] { + if (node instanceof Text) { + if (!words) return [{ key, type: "text", text: node.data }] + return node.data.split(/(\s+)/).flatMap((text, index): MarkdownNode[] => { + if (!text) return [] + if (/^\s+$/.test(text)) return [{ key: `${key}:${index}`, type: "text", text }] + return [{ key: `${key}:${index}`, type: "word", text }] + }) + } + if (!(node instanceof Element)) return [] + return [ + { + key, + type: "element", + tag: node.tagName.toLowerCase(), + attributes: Object.fromEntries(Array.from(node.attributes).map((attribute) => [attribute.name, attribute.value])), + children: Array.from(node.childNodes).flatMap((child, index) => parseNode(child, `${key}.${index}`, words)), + }, + ] +} diff --git a/packages/session-ui/src/components/markdown.css b/packages/session-ui/src/components/markdown.css index 7adafc5dfe0..d4eb5805fd3 100644 --- a/packages/session-ui/src/components/markdown.css +++ b/packages/session-ui/src/components/markdown.css @@ -16,6 +16,14 @@ font-size: var(--font-size-base); /* 14px */ line-height: 160%; + [data-markdown-word] { + display: inline; + } + + [data-markdown-word][data-markdown-enter] { + animation: markdown-word-enter 180ms ease-out both; + } + /* Spacing for flow */ > *:first-child { margin-top: 0; @@ -329,6 +337,21 @@ } } +@keyframes markdown-word-enter { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + [data-component="markdown"] [data-markdown-word][data-markdown-enter] { + animation: none; + } +} + [data-component="markdown"] > * { unicode-bidi: plaintext; } diff --git a/packages/session-ui/src/components/markdown.stories.tsx b/packages/session-ui/src/components/markdown.stories.tsx index 3e80aa07daf..7cb70ad5462 100644 --- a/packages/session-ui/src/components/markdown.stories.tsx +++ b/packages/session-ui/src/components/markdown.stories.tsx @@ -1,4 +1,5 @@ import { markdown } from "@opencode-ai/ui/storybook/fixtures" +import { createSignal, onCleanup } from "solid-js" import { Markdown } from "./markdown" export default { @@ -32,3 +33,22 @@ export const CompactResult = { ), } + +const streamed = + "Streaming Markdown now keeps existing elements alive while each newly arriving word fades into the response." + +function StreamingMarkdown() { + const words = streamed.match(/\S+\s*/g) ?? [] + const [count, setCount] = createSignal(1) + const timer = setInterval(() => setCount((value) => Math.min(value + 1, words.length)), 180) + onCleanup(() => clearInterval(timer)) + return +} + +export const StreamingResponse = { + render: () => ( +
+ +
+ ), +} diff --git a/packages/session-ui/src/components/markdown.tsx b/packages/session-ui/src/components/markdown.tsx index 2f2f5141638..2852674b7eb 100644 --- a/packages/session-ui/src/components/markdown.tsx +++ b/packages/session-ui/src/components/markdown.tsx @@ -1,5 +1,4 @@ import { useI18n } from "@opencode-ai/ui/context/i18n" -import morphdom from "morphdom" import { checksum } from "@opencode-ai/util/encode" import { type ComponentProps, @@ -32,6 +31,7 @@ import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-s import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache" import { inlineCodeKind } from "./markdown-inline-code-kind" import { renderMermaidSvg } from "./markdown-mermaid" +import { createMarkdownRenderer } from "./markdown-solid" type RenderedBlock = | (MarkdownCacheEntry & { key: string; mode: Exclude }) @@ -54,6 +54,7 @@ type RenderResult = { } const renderedCodeTokens = new WeakMap() +const renderedMarkdown = new WeakMap>() function escape(text: string) { return text @@ -177,6 +178,17 @@ function disposeCopyButtons(root: Element) { hosts.forEach(disposeCopyButton) } +function disposeRenderedMarkdown(root: Element) { + const blocks = [ + ...(root instanceof HTMLDivElement && root.hasAttribute("data-markdown-block") ? [root] : []), + ...Array.from(root.querySelectorAll("[data-markdown-block]")), + ] + blocks.forEach((block) => { + renderedMarkdown.get(block)?.dispose() + renderedMarkdown.delete(block) + }) +} + const shellLanguages = new Set(["bash", "sh", "shell", "zsh", "fish", "console", "terminal"]) function codeKind(language: string | undefined) { @@ -185,12 +197,6 @@ function codeKind(language: string | undefined) { if (shellLanguages.has(value)) return "shell" } -function codeLanguage(block: HTMLPreElement) { - const code = block.querySelector("code") - if (!(code instanceof HTMLElement)) return - return code.className.match(/(?:^|\s)language-([^\s]+)/)?.[1] -} - function applyCodeMetadata(wrapper: HTMLElement, language: string | undefined) { if (language) wrapper.dataset.language = language else delete wrapper.dataset.language @@ -200,37 +206,6 @@ function applyCodeMetadata(wrapper: HTMLElement, language: string | undefined) { else delete wrapper.dataset.codeKind } -function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) { - const parent = block.parentElement - if (!parent) return - const wrapped = parent.getAttribute("data-component") === "markdown-code" - if (!wrapped) { - const wrapper = document.createElement("div") - wrapper.setAttribute("data-component", "markdown-code") - applyCodeMetadata(wrapper, codeLanguage(block)) - parent.replaceChild(wrapper, block) - wrapper.appendChild(block) - wrapper.appendChild(createCopyButton(labels)) - return - } - - applyCodeMetadata(parent, codeLanguage(block)) - - const buttons = Array.from(parent.querySelectorAll('[data-slot="markdown-copy-button"]')).filter( - (el): el is HTMLButtonElement => el instanceof HTMLButtonElement, - ) - - if (buttons.length === 0) { - parent.appendChild(createCopyButton(labels)) - return - } - - for (const button of buttons.slice(1)) { - disposeCopyButton(button) - button.remove() - } -} - function decorateMermaid(wrapper: HTMLElement, code: HTMLElement, complete: boolean) { if (!code.classList.contains("language-mermaid")) { clearMermaid(wrapper) @@ -311,18 +286,6 @@ function markInlineCode(root: HTMLDivElement) { } } -function decorate(root: HTMLDivElement, labels: CopyLabels, complete: boolean) { - const blocks = Array.from(root.querySelectorAll("pre")) - for (const block of blocks) { - ensureCodeWrapper(block, labels) - const wrapper = block.parentElement - const code = block.querySelector("code") - if (wrapper instanceof HTMLElement && code instanceof HTMLElement) decorateMermaid(wrapper, code, complete) - } - markInlineCode(root) - markCodeLinks(root) -} - function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) { const timeouts = new Map>() @@ -552,6 +515,7 @@ export function Markdown( delete container.dataset.markdownReady if (content.length === 0) { disposeCopyButtons(container) + disposeRenderedMarkdown(container) container.innerHTML = "" if (result?.ready && result.text === local.text) container.dataset.markdownReady = "" return @@ -572,6 +536,7 @@ export function Markdown( const child = container.lastElementChild if (!child) break disposeCopyButtons(child) + disposeRenderedMarkdown(child) child.remove() } container @@ -587,6 +552,8 @@ export function Markdown( onCleanup(() => { if (copyCleanup) copyCleanup() + const container = root() + if (container) disposeRenderedMarkdown(container) disposeMarkdownProjection(owner) activeCodeKeys.forEach(disposeCode) completedCode.clear() @@ -647,44 +614,39 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl updateCodeBlock(container, current, block, labels) return } - if ( - current instanceof HTMLDivElement && - current.dataset.markdownKey === block.key && - current.dataset.markdownHash === block.hash - ) - return + const existing = current instanceof HTMLDivElement && current.dataset.markdownKey === block.key ? current : undefined + if (existing?.dataset.markdownHash === block.hash) return - const next = document.createElement("div") + const next = existing ?? document.createElement("div") next.dataset.markdownBlock = "" next.dataset.markdownKey = block.key next.dataset.markdownHash = block.hash next.style.display = "contents" - next.innerHTML = block.html - decorate(next, labels, block.mode === "full") + const source = document.createElement("div") + source.innerHTML = block.html + markInlineCode(source) + markCodeLinks(source) + const html = source.innerHTML - if (!(current instanceof HTMLDivElement)) { - container.appendChild(next) + if (existing) { + const renderer = renderedMarkdown.get(existing) + if (renderer) { + renderer.update(html, block.mode === "live") + return + } + existing.innerHTML = "" + renderedMarkdown.set(existing, createMarkdownRenderer(existing, html, block.mode === "live")) return } - morphdom(current, next, { - onBeforeElUpdated: (fromEl, toEl) => { - if ( - fromEl instanceof HTMLElement && - toEl instanceof HTMLElement && - fromEl.getAttribute("data-slot") === "markdown-copy-button" && - toEl.getAttribute("data-slot") === "markdown-copy-button" - ) { - return false - } - if (fromEl.isEqualNode(toEl)) return false - return true - }, - onBeforeNodeDiscarded: (node) => { - if (node instanceof Element) disposeCopyButtons(node) - return true - }, - }) + renderedMarkdown.set(next, createMarkdownRenderer(next, html, block.mode === "live")) + if (!current) { + container.appendChild(next) + return + } + disposeCopyButtons(current) + disposeRenderedMarkdown(current) + current.replaceWith(next) } function updateCodeBlock( @@ -756,6 +718,7 @@ function updateCodeBlock( }) if (current) { disposeCopyButtons(current) + disposeRenderedMarkdown(current) current.replaceWith(next) return }