fix(ui): reduce markdown jank while responses stream (#19304)

This commit is contained in:
Shoubhit Dash
2026-03-27 01:13:30 +05:30
committed by GitHub
parent 311ba4179a
commit b7a06e1939
4 changed files with 109 additions and 44 deletions

View File

@@ -37,7 +37,6 @@ import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models" import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification" import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission" import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt" import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings" import { SettingsProvider } from "@/context/settings"
@@ -77,11 +76,6 @@ declare global {
} }
} }
function MarkedProviderWithNativeParser(props: ParentProps) {
const platform = usePlatform()
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
}
function QueryProvider(props: ParentProps) { function QueryProvider(props: ParentProps) {
const client = new QueryClient() const client = new QueryClient()
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider> return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
@@ -144,9 +138,9 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<QueryProvider> <QueryProvider>
<DialogProvider> <DialogProvider>
<MarkedProviderWithNativeParser> <MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider> <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProviderWithNativeParser> </MarkedProvider>
</DialogProvider> </DialogProvider>
</QueryProvider> </QueryProvider>
</ErrorBoundary> </ErrorBoundary>

View File

@@ -943,7 +943,10 @@ export function MessageTimeline(props: {
"min-w-0 w-full max-w-full": true, "min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered, "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",
}}
> >
<Show when={commentCount() > 0}> <Show when={commentCount() > 0}>
<div class="w-full px-4 md:px-5 pb-2"> <div class="w-full px-4 md:px-5 pb-2">

View File

@@ -2,6 +2,7 @@ import { useMarked } from "../context/marked"
import { useI18n } from "../context/i18n" import { useI18n } from "../context/i18n"
import DOMPurify from "dompurify" import DOMPurify from "dompurify"
import morphdom from "morphdom" import morphdom from "morphdom"
import { marked, type Tokens } from "marked"
import { checksum } from "@opencode-ai/util/encode" import { checksum } from "@opencode-ai/util/encode"
import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js" import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js"
import { isServer } from "solid-js/web" 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, "<br>") return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
} }
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 = { type CopyLabels = {
copy: string copy: string
copied: string copied: string
@@ -180,10 +222,11 @@ function decorate(root: HTMLDivElement, labels: CopyLabels) {
markCodeLinks(root) markCodeLinks(root)
} }
function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) { function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>() const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>()
const updateLabel = (button: HTMLButtonElement) => { const updateLabel = (button: HTMLButtonElement) => {
const labels = getLabels()
const copied = button.getAttribute("data-copied") === "true" const copied = button.getAttribute("data-copied") === "true"
setCopyState(button, labels, copied) setCopyState(button, labels, copied)
} }
@@ -200,6 +243,7 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
const clipboard = navigator?.clipboard const clipboard = navigator?.clipboard
if (!clipboard) return if (!clipboard) return
await clipboard.writeText(content) await clipboard.writeText(content)
const labels = getLabels()
setCopyState(button, labels, true) setCopyState(button, labels, true)
const existing = timeouts.get(button) const existing = timeouts.get(button)
if (existing) clearTimeout(existing) if (existing) clearTimeout(existing)
@@ -207,7 +251,7 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
timeouts.set(button, timeout) timeouts.set(button, timeout)
} }
decorate(root, labels) decorate(root, getLabels())
const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]')) const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
for (const button of buttons) { for (const button of buttons) {
@@ -239,44 +283,56 @@ export function Markdown(
props: ComponentProps<"div"> & { props: ComponentProps<"div"> & {
text: string text: string
cacheKey?: string cacheKey?: string
streaming?: boolean
class?: string class?: string
classList?: Record<string, boolean> classList?: Record<string, boolean>
}, },
) { ) {
const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"]) const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"])
const marked = useMarked() const marked = useMarked()
const i18n = useI18n() const i18n = useI18n()
const [root, setRoot] = createSignal<HTMLDivElement>() const [root, setRoot] = createSignal<HTMLDivElement>()
const [html] = createResource( const [html] = createResource(
() => local.text, () => ({
async (markdown) => { text: local.text,
if (isServer) return fallback(markdown) key: local.cacheKey,
streaming: local.streaming ?? false,
}),
async (src) => {
if (isServer) return fallback(src.text)
if (!src.text) return ""
const hash = checksum(markdown) const base = src.key ?? checksum(src.text)
const key = local.cacheKey ?? hash 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) { if (key && hash) {
const cached = cache.get(key) const cached = cache.get(key)
if (cached && cached.hash === hash) { if (cached && cached.hash === hash) {
touch(key, cached) touch(key, cached)
return cached.html return cached.html
} }
} }
const next = await marked.parse(markdown) const next = await Promise.resolve(marked.parse(block.raw))
const safe = sanitize(next) const safe = sanitize(next)
if (key && hash) touch(key, { hash, html: safe }) if (key && hash) touch(key, { hash, html: safe })
return safe return safe
}),
)
.then((list) => list.join(""))
.catch(() => fallback(src.text))
}, },
{ initialValue: isServer ? fallback(local.text) : "" }, { initialValue: fallback(local.text) },
) )
let copySetupTimer: ReturnType<typeof setTimeout> | undefined
let copyCleanup: (() => void) | undefined let copyCleanup: (() => void) | undefined
createEffect(() => { createEffect(() => {
const container = root() const container = root()
const content = html() const content = local.text ? (html.latest ?? html() ?? "") : ""
if (!container) return if (!container) return
if (isServer) return if (isServer) return
@@ -285,33 +341,39 @@ export function Markdown(
return return
} }
const temp = document.createElement("div") const labels = {
temp.innerHTML = content
decorate(temp, {
copy: i18n.t("ui.message.copy"), copy: i18n.t("ui.message.copy"),
copied: i18n.t("ui.message.copied"), copied: i18n.t("ui.message.copied"),
}) }
const temp = document.createElement("div")
temp.innerHTML = content
decorate(temp, labels)
morphdom(container, temp, { morphdom(container, temp, {
childrenOnly: true, childrenOnly: true,
onBeforeElUpdated: (fromEl, toEl) => { 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 if (fromEl.isEqualNode(toEl)) return false
return true return true
}, },
}) })
if (copySetupTimer) clearTimeout(copySetupTimer) if (!copyCleanup)
copySetupTimer = setTimeout(() => { copyCleanup = setupCodeCopy(container, () => ({
if (copyCleanup) copyCleanup()
copyCleanup = setupCodeCopy(container, {
copy: i18n.t("ui.message.copy"), copy: i18n.t("ui.message.copy"),
copied: i18n.t("ui.message.copied"), copied: i18n.t("ui.message.copied"),
}) }))
}, 150)
}) })
onCleanup(() => { onCleanup(() => {
if (copySetupTimer) clearTimeout(copySetupTimer)
if (copyCleanup) copyCleanup() if (copyCleanup) copyCleanup()
}) })

View File

@@ -1334,6 +1334,9 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
const displayText = () => (part().text ?? "").trim() const displayText = () => (part().text ?? "").trim()
const throttledText = createThrottledValue(displayText) const throttledText = createThrottledValue(displayText)
const streaming = createMemo(
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
)
const isLastTextPart = createMemo(() => { const isLastTextPart = createMemo(() => {
const last = (data.store.part?.[props.message.id] ?? []) const last = (data.store.part?.[props.message.id] ?? [])
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim()) .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
@@ -1360,7 +1363,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
<Show when={throttledText()}> <Show when={throttledText()}>
<div data-component="text-part"> <div data-component="text-part">
<div data-slot="text-part-body"> <div data-slot="text-part-body">
<Markdown text={throttledText()} cacheKey={part().id} /> <Markdown text={throttledText()} cacheKey={part().id} streaming={streaming()} />
</div> </div>
<Show when={showCopy()}> <Show when={showCopy()}>
<div data-slot="text-part-copy-wrapper" data-interrupted={interrupted() ? "" : undefined}> <div data-slot="text-part-copy-wrapper" data-interrupted={interrupted() ? "" : undefined}>
@@ -1394,11 +1397,14 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
const part = () => props.part as ReasoningPart const part = () => props.part as ReasoningPart
const text = () => part().text.trim() const text = () => part().text.trim()
const throttledText = createThrottledValue(text) const throttledText = createThrottledValue(text)
const streaming = createMemo(
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
)
return ( return (
<Show when={throttledText()}> <Show when={throttledText()}>
<div data-component="reasoning-part"> <div data-component="reasoning-part">
<Markdown text={throttledText()} cacheKey={part().id} /> <Markdown text={throttledText()} cacheKey={part().id} streaming={streaming()} />
</div> </div>
</Show> </Show>
) )