From b7a06e193952a66a8efa07feb4e105f44bf7ea8b Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 27 Mar 2026 01:13:30 +0530 Subject: [PATCH] fix(ui): reduce markdown jank while responses stream (#19304) --- packages/app/src/app.tsx | 10 +- .../src/pages/session/message-timeline.tsx | 5 +- packages/ui/src/components/markdown.tsx | 128 +++++++++++++----- packages/ui/src/components/message-part.tsx | 10 +- 4 files changed, 109 insertions(+), 44 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 0eb5b4e9e0..a248ebb944 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -37,7 +37,6 @@ import { LayoutProvider } from "@/context/layout" import { ModelsProvider } from "@/context/models" import { NotificationProvider } from "@/context/notification" import { PermissionProvider } from "@/context/permission" -import { usePlatform } from "@/context/platform" import { PromptProvider } from "@/context/prompt" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { SettingsProvider } from "@/context/settings" @@ -77,11 +76,6 @@ declare global { } } -function MarkedProviderWithNativeParser(props: ParentProps) { - const platform = usePlatform() - return {props.children} -} - function QueryProvider(props: ParentProps) { const client = new QueryClient() return {props.children} @@ -144,9 +138,9 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { }> - + {props.children} - + diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 33437ce9c9..a246abaf3f 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -943,7 +943,10 @@ export function MessageTimeline(props: { "min-w-0 w-full max-w-full": true, "md:max-w-200 2xl:max-w-[1000px]": props.centered, }} - style={{ "content-visibility": "auto", "contain-intrinsic-size": "auto 500px" }} + style={{ + "content-visibility": active() ? undefined : "auto", + "contain-intrinsic-size": active() ? undefined : "auto 500px", + }} > 0}>
diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 01254f1189..ce6bdb7e0d 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -2,6 +2,7 @@ import { useMarked } from "../context/marked" import { useI18n } from "../context/i18n" import DOMPurify from "dompurify" import morphdom from "morphdom" +import { marked, type Tokens } from "marked" import { checksum } from "@opencode-ai/util/encode" import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js" import { isServer } from "solid-js/web" @@ -57,6 +58,47 @@ function fallback(markdown: string) { return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "
") } +type Block = { + raw: string + mode: "full" | "live" +} + +function references(markdown: string) { + return /^\[[^\]]+\]:\s+\S+/m.test(markdown) || /^\[\^[^\]]+\]:\s+/m.test(markdown) +} + +function incomplete(raw: string) { + const open = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/) + if (!open) return false + const mark = open[1] + if (!mark) return false + const char = mark[0] + const size = mark.length + const last = raw.trimEnd().split("\n").at(-1)?.trim() ?? "" + return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last) +} + +function blocks(markdown: string, streaming: boolean) { + if (!streaming || references(markdown)) return [{ raw: markdown, mode: "full" }] satisfies Block[] + const tokens = marked.lexer(markdown) + const last = tokens.findLast((token) => token.type !== "space") + if (!last || last.type !== "code") return [{ raw: markdown, mode: "full" }] satisfies Block[] + const code = last as Tokens.Code + if (!incomplete(code.raw)) return [{ raw: markdown, mode: "full" }] satisfies Block[] + const head = tokens + .slice( + 0, + tokens.findLastIndex((token) => token.type !== "space"), + ) + .map((token) => token.raw) + .join("") + if (!head) return [{ raw: code.raw, mode: "live" }] satisfies Block[] + return [ + { raw: head, mode: "full" }, + { raw: code.raw, mode: "live" }, + ] satisfies Block[] +} + type CopyLabels = { copy: string copied: string @@ -180,10 +222,11 @@ function decorate(root: HTMLDivElement, labels: CopyLabels) { markCodeLinks(root) } -function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) { +function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) { const timeouts = new Map>() const updateLabel = (button: HTMLButtonElement) => { + const labels = getLabels() const copied = button.getAttribute("data-copied") === "true" setCopyState(button, labels, copied) } @@ -200,6 +243,7 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) { const clipboard = navigator?.clipboard if (!clipboard) return await clipboard.writeText(content) + const labels = getLabels() setCopyState(button, labels, true) const existing = timeouts.get(button) if (existing) clearTimeout(existing) @@ -207,7 +251,7 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) { timeouts.set(button, timeout) } - decorate(root, labels) + decorate(root, getLabels()) const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]')) for (const button of buttons) { @@ -239,44 +283,56 @@ export function Markdown( props: ComponentProps<"div"> & { text: string cacheKey?: string + streaming?: boolean class?: string classList?: Record }, ) { - const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"]) + const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"]) const marked = useMarked() const i18n = useI18n() const [root, setRoot] = createSignal() const [html] = createResource( - () => local.text, - async (markdown) => { - if (isServer) return fallback(markdown) + () => ({ + text: local.text, + key: local.cacheKey, + streaming: local.streaming ?? false, + }), + async (src) => { + if (isServer) return fallback(src.text) + if (!src.text) return "" - const hash = checksum(markdown) - const key = local.cacheKey ?? hash + const base = src.key ?? checksum(src.text) + return Promise.all( + blocks(src.text, src.streaming).map(async (block, index) => { + const hash = checksum(block.raw) + const key = base ? `${base}:${index}:${block.mode}` : hash - if (key && hash) { - const cached = cache.get(key) - if (cached && cached.hash === hash) { - touch(key, cached) - return cached.html - } - } + if (key && hash) { + const cached = cache.get(key) + if (cached && cached.hash === hash) { + touch(key, cached) + return cached.html + } + } - const next = await marked.parse(markdown) - const safe = sanitize(next) - if (key && hash) touch(key, { hash, html: safe }) - return safe + const next = await Promise.resolve(marked.parse(block.raw)) + const safe = sanitize(next) + if (key && hash) touch(key, { hash, html: safe }) + return safe + }), + ) + .then((list) => list.join("")) + .catch(() => fallback(src.text)) }, - { initialValue: isServer ? fallback(local.text) : "" }, + { initialValue: fallback(local.text) }, ) - let copySetupTimer: ReturnType | undefined let copyCleanup: (() => void) | undefined createEffect(() => { const container = root() - const content = html() + const content = local.text ? (html.latest ?? html() ?? "") : "" if (!container) return if (isServer) return @@ -285,33 +341,39 @@ export function Markdown( return } - const temp = document.createElement("div") - temp.innerHTML = content - decorate(temp, { + const labels = { copy: i18n.t("ui.message.copy"), copied: i18n.t("ui.message.copied"), - }) + } + const temp = document.createElement("div") + temp.innerHTML = content + decorate(temp, labels) morphdom(container, temp, { childrenOnly: true, onBeforeElUpdated: (fromEl, toEl) => { + if ( + fromEl instanceof HTMLButtonElement && + toEl instanceof HTMLButtonElement && + fromEl.getAttribute("data-slot") === "markdown-copy-button" && + toEl.getAttribute("data-slot") === "markdown-copy-button" && + fromEl.getAttribute("data-copied") === "true" + ) { + setCopyState(toEl, labels, true) + } if (fromEl.isEqualNode(toEl)) return false return true }, }) - if (copySetupTimer) clearTimeout(copySetupTimer) - copySetupTimer = setTimeout(() => { - if (copyCleanup) copyCleanup() - copyCleanup = setupCodeCopy(container, { + if (!copyCleanup) + copyCleanup = setupCodeCopy(container, () => ({ copy: i18n.t("ui.message.copy"), copied: i18n.t("ui.message.copied"), - }) - }, 150) + })) }) onCleanup(() => { - if (copySetupTimer) clearTimeout(copySetupTimer) if (copyCleanup) copyCleanup() }) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index a15e2e0c17..8b572aff81 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1334,6 +1334,9 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { const displayText = () => (part().text ?? "").trim() const throttledText = createThrottledValue(displayText) + const streaming = createMemo( + () => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number", + ) const isLastTextPart = createMemo(() => { const last = (data.store.part?.[props.message.id] ?? []) .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim()) @@ -1360,7 +1363,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
- +
@@ -1394,11 +1397,14 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { const part = () => props.part as ReasoningPart const text = () => part().text.trim() const throttledText = createThrottledValue(text) + const streaming = createMemo( + () => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number", + ) return (
- +
)