Skip to content

Commit d341499

Browse files
authored
fix(ui): keep partial markdown readable while responses stream (#19403)
1 parent 7715252 commit d341499

File tree

6 files changed

+90
-46
lines changed

6 files changed

+90
-46
lines changed

bun.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"luxon": "3.6.1",
5454
"marked": "17.0.1",
5555
"marked-shiki": "1.2.1",
56+
"remend": "1.3.0",
5657
"@playwright/test": "1.51.0",
5758
"typescript": "5.8.2",
5859
"@typescript/native-preview": "7.0.0-dev.20251207.1",

packages/ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"motion-dom": "12.34.3",
6565
"motion-utils": "12.29.2",
6666
"remeda": "catalog:",
67+
"remend": "catalog:",
6768
"shiki": "catalog:",
6869
"solid-js": "catalog:",
6970
"solid-list": "catalog:",
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { stream } from "./markdown-stream"
3+
4+
describe("markdown stream", () => {
5+
test("heals incomplete emphasis while streaming", () => {
6+
expect(stream("hello **world", true)).toEqual([{ raw: "hello **world", src: "hello **world**", mode: "live" }])
7+
expect(stream("say `code", true)).toEqual([{ raw: "say `code", src: "say `code`", mode: "live" }])
8+
})
9+
10+
test("keeps incomplete links non-clickable until they finish", () => {
11+
expect(stream("see [docs](https://example.com/gu", true)).toEqual([
12+
{ raw: "see [docs](https://example.com/gu", src: "see docs", mode: "live" },
13+
])
14+
})
15+
16+
test("splits an unfinished trailing code fence from stable content", () => {
17+
expect(stream("before\n\n```ts\nconst x = 1", true)).toEqual([
18+
{ raw: "before\n\n", src: "before\n\n", mode: "live" },
19+
{ raw: "```ts\nconst x = 1", src: "```ts\nconst x = 1", mode: "live" },
20+
])
21+
})
22+
23+
test("keeps reference-style markdown as one block", () => {
24+
expect(stream("[docs][1]\n\n[1]: https://example.com", true)).toEqual([
25+
{
26+
raw: "[docs][1]\n\n[1]: https://example.com",
27+
src: "[docs][1]\n\n[1]: https://example.com",
28+
mode: "live",
29+
},
30+
])
31+
})
32+
})
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { marked, type Tokens } from "marked"
2+
import remend from "remend"
3+
4+
export type Block = {
5+
raw: string
6+
src: string
7+
mode: "full" | "live"
8+
}
9+
10+
function refs(text: string) {
11+
return /^\[[^\]]+\]:\s+\S+/m.test(text) || /^\[\^[^\]]+\]:\s+/m.test(text)
12+
}
13+
14+
function open(raw: string) {
15+
const match = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)
16+
if (!match) return false
17+
const mark = match[1]
18+
if (!mark) return false
19+
const char = mark[0]
20+
const size = mark.length
21+
const last = raw.trimEnd().split("\n").at(-1)?.trim() ?? ""
22+
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last)
23+
}
24+
25+
function heal(text: string) {
26+
return remend(text, { linkMode: "text-only" })
27+
}
28+
29+
export function stream(text: string, live: boolean) {
30+
if (!live) return [{ raw: text, src: text, mode: "full" }] satisfies Block[]
31+
const src = heal(text)
32+
if (refs(text)) return [{ raw: text, src, mode: "live" }] satisfies Block[]
33+
const tokens = marked.lexer(text)
34+
const tail = tokens.findLastIndex((token) => token.type !== "space")
35+
if (tail < 0) return [{ raw: text, src, mode: "live" }] satisfies Block[]
36+
const last = tokens[tail]
37+
if (!last || last.type !== "code") return [{ raw: text, src, mode: "live" }] satisfies Block[]
38+
const code = last as Tokens.Code
39+
if (!open(code.raw)) return [{ raw: text, src, mode: "live" }] satisfies Block[]
40+
const head = tokens
41+
.slice(0, tail)
42+
.map((token) => token.raw)
43+
.join("")
44+
if (!head) return [{ raw: code.raw, src: code.raw, mode: "live" }] satisfies Block[]
45+
return [
46+
{ raw: head, src: heal(head), mode: "live" },
47+
{ raw: code.raw, src: code.raw, mode: "live" },
48+
] satisfies Block[]
49+
}

packages/ui/src/components/markdown.tsx

Lines changed: 3 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ 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"
65
import { checksum } from "@opencode-ai/util/encode"
76
import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js"
87
import { isServer } from "solid-js/web"
8+
import { stream } from "./markdown-stream"
99

1010
type Entry = {
1111
hash: string
@@ -58,47 +58,6 @@ function fallback(markdown: string) {
5858
return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
5959
}
6060

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-
10261
type CopyLabels = {
10362
copy: string
10463
copied: string
@@ -251,8 +210,6 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
251210
timeouts.set(button, timeout)
252211
}
253212

254-
decorate(root, getLabels())
255-
256213
const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
257214
for (const button of buttons) {
258215
if (button instanceof HTMLButtonElement) updateLabel(button)
@@ -304,7 +261,7 @@ export function Markdown(
304261

305262
const base = src.key ?? checksum(src.text)
306263
return Promise.all(
307-
blocks(src.text, src.streaming).map(async (block, index) => {
264+
stream(src.text, src.streaming).map(async (block, index) => {
308265
const hash = checksum(block.raw)
309266
const key = base ? `${base}:${index}:${block.mode}` : hash
310267

@@ -316,7 +273,7 @@ export function Markdown(
316273
}
317274
}
318275

319-
const next = await Promise.resolve(marked.parse(block.raw))
276+
const next = await Promise.resolve(marked.parse(block.src))
320277
const safe = sanitize(next)
321278
if (key && hash) touch(key, { hash, html: safe })
322279
return safe

0 commit comments

Comments
 (0)