Skip to content

Commit b7a06e1

Browse files
authored
fix(ui): reduce markdown jank while responses stream (#19304)
1 parent 311ba41 commit b7a06e1

File tree

4 files changed

+112
-47
lines changed

4 files changed

+112
-47
lines changed

packages/app/src/app.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ import { LayoutProvider } from "@/context/layout"
3737
import { ModelsProvider } from "@/context/models"
3838
import { NotificationProvider } from "@/context/notification"
3939
import { PermissionProvider } from "@/context/permission"
40-
import { usePlatform } from "@/context/platform"
4140
import { PromptProvider } from "@/context/prompt"
4241
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
4342
import { SettingsProvider } from "@/context/settings"
@@ -77,11 +76,6 @@ declare global {
7776
}
7877
}
7978

80-
function MarkedProviderWithNativeParser(props: ParentProps) {
81-
const platform = usePlatform()
82-
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
83-
}
84-
8579
function QueryProvider(props: ParentProps) {
8680
const client = new QueryClient()
8781
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
@@ -144,9 +138,9 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
144138
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
145139
<QueryProvider>
146140
<DialogProvider>
147-
<MarkedProviderWithNativeParser>
141+
<MarkedProvider>
148142
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
149-
</MarkedProviderWithNativeParser>
143+
</MarkedProvider>
150144
</DialogProvider>
151145
</QueryProvider>
152146
</ErrorBoundary>

packages/app/src/pages/session/message-timeline.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -943,7 +943,10 @@ export function MessageTimeline(props: {
943943
"min-w-0 w-full max-w-full": true,
944944
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
945945
}}
946-
style={{ "content-visibility": "auto", "contain-intrinsic-size": "auto 500px" }}
946+
style={{
947+
"content-visibility": active() ? undefined : "auto",
948+
"contain-intrinsic-size": active() ? undefined : "auto 500px",
949+
}}
947950
>
948951
<Show when={commentCount() > 0}>
949952
<div class="w-full px-4 md:px-5 pb-2">

packages/ui/src/components/markdown.tsx

Lines changed: 98 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useMarked } from "../context/marked"
22
import { useI18n } from "../context/i18n"
33
import DOMPurify from "dompurify"
44
import morphdom from "morphdom"
5+
import { marked, type Tokens } from "marked"
56
import { checksum } from "@opencode-ai/util/encode"
67
import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js"
78
import { isServer } from "solid-js/web"
@@ -57,6 +58,47 @@ function fallback(markdown: string) {
5758
return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
5859
}
5960

61+
type Block = {
62+
raw: string
63+
mode: "full" | "live"
64+
}
65+
66+
function references(markdown: string) {
67+
return /^\[[^\]]+\]:\s+\S+/m.test(markdown) || /^\[\^[^\]]+\]:\s+/m.test(markdown)
68+
}
69+
70+
function incomplete(raw: string) {
71+
const open = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)
72+
if (!open) return false
73+
const mark = open[1]
74+
if (!mark) return false
75+
const char = mark[0]
76+
const size = mark.length
77+
const last = raw.trimEnd().split("\n").at(-1)?.trim() ?? ""
78+
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last)
79+
}
80+
81+
function blocks(markdown: string, streaming: boolean) {
82+
if (!streaming || references(markdown)) return [{ raw: markdown, mode: "full" }] satisfies Block[]
83+
const tokens = marked.lexer(markdown)
84+
const last = tokens.findLast((token) => token.type !== "space")
85+
if (!last || last.type !== "code") return [{ raw: markdown, mode: "full" }] satisfies Block[]
86+
const code = last as Tokens.Code
87+
if (!incomplete(code.raw)) return [{ raw: markdown, mode: "full" }] satisfies Block[]
88+
const head = tokens
89+
.slice(
90+
0,
91+
tokens.findLastIndex((token) => token.type !== "space"),
92+
)
93+
.map((token) => token.raw)
94+
.join("")
95+
if (!head) return [{ raw: code.raw, mode: "live" }] satisfies Block[]
96+
return [
97+
{ raw: head, mode: "full" },
98+
{ raw: code.raw, mode: "live" },
99+
] satisfies Block[]
100+
}
101+
60102
type CopyLabels = {
61103
copy: string
62104
copied: string
@@ -180,10 +222,11 @@ function decorate(root: HTMLDivElement, labels: CopyLabels) {
180222
markCodeLinks(root)
181223
}
182224

183-
function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
225+
function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
184226
const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>()
185227

186228
const updateLabel = (button: HTMLButtonElement) => {
229+
const labels = getLabels()
187230
const copied = button.getAttribute("data-copied") === "true"
188231
setCopyState(button, labels, copied)
189232
}
@@ -200,14 +243,15 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
200243
const clipboard = navigator?.clipboard
201244
if (!clipboard) return
202245
await clipboard.writeText(content)
246+
const labels = getLabels()
203247
setCopyState(button, labels, true)
204248
const existing = timeouts.get(button)
205249
if (existing) clearTimeout(existing)
206250
const timeout = setTimeout(() => setCopyState(button, labels, false), 2000)
207251
timeouts.set(button, timeout)
208252
}
209253

210-
decorate(root, labels)
254+
decorate(root, getLabels())
211255

212256
const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
213257
for (const button of buttons) {
@@ -239,44 +283,56 @@ export function Markdown(
239283
props: ComponentProps<"div"> & {
240284
text: string
241285
cacheKey?: string
286+
streaming?: boolean
242287
class?: string
243288
classList?: Record<string, boolean>
244289
},
245290
) {
246-
const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"])
291+
const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"])
247292
const marked = useMarked()
248293
const i18n = useI18n()
249294
const [root, setRoot] = createSignal<HTMLDivElement>()
250295
const [html] = createResource(
251-
() => local.text,
252-
async (markdown) => {
253-
if (isServer) return fallback(markdown)
254-
255-
const hash = checksum(markdown)
256-
const key = local.cacheKey ?? hash
257-
258-
if (key && hash) {
259-
const cached = cache.get(key)
260-
if (cached && cached.hash === hash) {
261-
touch(key, cached)
262-
return cached.html
263-
}
264-
}
265-
266-
const next = await marked.parse(markdown)
267-
const safe = sanitize(next)
268-
if (key && hash) touch(key, { hash, html: safe })
269-
return safe
296+
() => ({
297+
text: local.text,
298+
key: local.cacheKey,
299+
streaming: local.streaming ?? false,
300+
}),
301+
async (src) => {
302+
if (isServer) return fallback(src.text)
303+
if (!src.text) return ""
304+
305+
const base = src.key ?? checksum(src.text)
306+
return Promise.all(
307+
blocks(src.text, src.streaming).map(async (block, index) => {
308+
const hash = checksum(block.raw)
309+
const key = base ? `${base}:${index}:${block.mode}` : hash
310+
311+
if (key && hash) {
312+
const cached = cache.get(key)
313+
if (cached && cached.hash === hash) {
314+
touch(key, cached)
315+
return cached.html
316+
}
317+
}
318+
319+
const next = await Promise.resolve(marked.parse(block.raw))
320+
const safe = sanitize(next)
321+
if (key && hash) touch(key, { hash, html: safe })
322+
return safe
323+
}),
324+
)
325+
.then((list) => list.join(""))
326+
.catch(() => fallback(src.text))
270327
},
271-
{ initialValue: isServer ? fallback(local.text) : "" },
328+
{ initialValue: fallback(local.text) },
272329
)
273330

274-
let copySetupTimer: ReturnType<typeof setTimeout> | undefined
275331
let copyCleanup: (() => void) | undefined
276332

277333
createEffect(() => {
278334
const container = root()
279-
const content = html()
335+
const content = local.text ? (html.latest ?? html() ?? "") : ""
280336
if (!container) return
281337
if (isServer) return
282338

@@ -285,33 +341,39 @@ export function Markdown(
285341
return
286342
}
287343

288-
const temp = document.createElement("div")
289-
temp.innerHTML = content
290-
decorate(temp, {
344+
const labels = {
291345
copy: i18n.t("ui.message.copy"),
292346
copied: i18n.t("ui.message.copied"),
293-
})
347+
}
348+
const temp = document.createElement("div")
349+
temp.innerHTML = content
350+
decorate(temp, labels)
294351

295352
morphdom(container, temp, {
296353
childrenOnly: true,
297354
onBeforeElUpdated: (fromEl, toEl) => {
355+
if (
356+
fromEl instanceof HTMLButtonElement &&
357+
toEl instanceof HTMLButtonElement &&
358+
fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
359+
toEl.getAttribute("data-slot") === "markdown-copy-button" &&
360+
fromEl.getAttribute("data-copied") === "true"
361+
) {
362+
setCopyState(toEl, labels, true)
363+
}
298364
if (fromEl.isEqualNode(toEl)) return false
299365
return true
300366
},
301367
})
302368

303-
if (copySetupTimer) clearTimeout(copySetupTimer)
304-
copySetupTimer = setTimeout(() => {
305-
if (copyCleanup) copyCleanup()
306-
copyCleanup = setupCodeCopy(container, {
369+
if (!copyCleanup)
370+
copyCleanup = setupCodeCopy(container, () => ({
307371
copy: i18n.t("ui.message.copy"),
308372
copied: i18n.t("ui.message.copied"),
309-
})
310-
}, 150)
373+
}))
311374
})
312375

313376
onCleanup(() => {
314-
if (copySetupTimer) clearTimeout(copySetupTimer)
315377
if (copyCleanup) copyCleanup()
316378
})
317379

packages/ui/src/components/message-part.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1334,6 +1334,9 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
13341334

13351335
const displayText = () => (part().text ?? "").trim()
13361336
const throttledText = createThrottledValue(displayText)
1337+
const streaming = createMemo(
1338+
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
1339+
)
13371340
const isLastTextPart = createMemo(() => {
13381341
const last = (data.store.part?.[props.message.id] ?? [])
13391342
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
@@ -1360,7 +1363,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
13601363
<Show when={throttledText()}>
13611364
<div data-component="text-part">
13621365
<div data-slot="text-part-body">
1363-
<Markdown text={throttledText()} cacheKey={part().id} />
1366+
<Markdown text={throttledText()} cacheKey={part().id} streaming={streaming()} />
13641367
</div>
13651368
<Show when={showCopy()}>
13661369
<div data-slot="text-part-copy-wrapper" data-interrupted={interrupted() ? "" : undefined}>
@@ -1394,11 +1397,14 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
13941397
const part = () => props.part as ReasoningPart
13951398
const text = () => part().text.trim()
13961399
const throttledText = createThrottledValue(text)
1400+
const streaming = createMemo(
1401+
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
1402+
)
13971403

13981404
return (
13991405
<Show when={throttledText()}>
14001406
<div data-component="reasoning-part">
1401-
<Markdown text={throttledText()} cacheKey={part().id} />
1407+
<Markdown text={throttledText()} cacheKey={part().id} streaming={streaming()} />
14021408
</div>
14031409
</Show>
14041410
)

0 commit comments

Comments
 (0)