Skip to content

Commit 2e5cc4f

Browse files
committed
perf(ui): add fast assistant streaming render path
Minimal UI changes for the dual-lane streaming pipeline: - Dispatch display-lane chunks outside solidBatch (server-events.ts) - FastStreamingText component with textNode.appendData() for zero-rerender DOM updates - Assistant stream render mode decision (streaming_preview / complete_rich) - Per-key signal store for assistant stream preview text - Delta coalescing queue with rAF-based flushing (session-events.ts) - Fix stop button: compacting->idle guard, optimistic idle, isSessionBusy check - Active stream target tracking in App.tsx for Tauri transport - Draft prompt debounce (150ms + flush on blur) - ensureLanguages() cache for Shiki highlighting
1 parent e11a570 commit 2e5cc4f

29 files changed

Lines changed: 936 additions & 107 deletions

packages/ui/src/App.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import { initReleaseNotifications } from "./stores/releases"
2222
import { runtimeEnv } from "./lib/runtime-env"
2323
import { useI18n } from "./lib/i18n"
2424
import { setWakeLockDesired } from "./lib/native/wake-lock"
25+
import { setTauriDesktopActiveSession } from "./lib/native/desktop-events"
26+
import { clearAssistantStreamSession } from "./stores/assistant-stream"
2527
import {
2628
hasInstances,
2729
isSelectingFolder,
@@ -226,6 +228,35 @@ const App: Component = () => {
226228
return activeSessionId().get(instance.id) || null
227229
})
228230

231+
const activeStreamTarget = createMemo(() => {
232+
const instance = activeInstance()
233+
const sessionId = activeSessionIdForInstance()
234+
if (!instance || !sessionId) return null
235+
return {
236+
instanceId: instance.id,
237+
sessionId,
238+
}
239+
})
240+
241+
let previousActiveStreamTarget: ReturnType<typeof activeStreamTarget> = null
242+
243+
createEffect(() => {
244+
if (runtimeEnv.host !== "tauri") {
245+
return
246+
}
247+
248+
const currentTarget = activeStreamTarget()
249+
if (previousActiveStreamTarget) {
250+
clearAssistantStreamSession(
251+
previousActiveStreamTarget.instanceId,
252+
previousActiveStreamTarget.sessionId,
253+
)
254+
}
255+
previousActiveStreamTarget = currentTarget
256+
257+
void setTauriDesktopActiveSession(currentTarget)
258+
})
259+
229260
const launchErrorPath = () => {
230261
const value = launchError()?.binaryPath
231262
if (!value) return "opencode"
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { MessageStatus } from "../stores/message-v2/types"
2+
import type { ClientPart } from "../types/message"
3+
4+
export type AssistantStreamRenderMode =
5+
| "streaming_preview"
6+
| "stabilizing_preview"
7+
| "complete_rich"
8+
9+
export interface AssistantStreamRenderDecision {
10+
mode: AssistantStreamRenderMode
11+
text: string
12+
}
13+
14+
interface ResolveAssistantStreamRenderDecisionInput {
15+
messageType?: "user" | "assistant"
16+
messageStatus?: MessageStatus
17+
part: ClientPart
18+
previewText?: string
19+
}
20+
21+
export function resolveAssistantStreamRenderDecision(
22+
input: ResolveAssistantStreamRenderDecisionInput,
23+
): AssistantStreamRenderDecision {
24+
const canonicalText =
25+
input.part?.type === "text" && typeof input.part.text === "string" ? input.part.text : ""
26+
const previewText = input.previewText ?? ""
27+
const hasPreview = previewText.length > 0
28+
29+
if (input.messageType !== "assistant" || input.part?.type !== "text") {
30+
return {
31+
mode: "complete_rich",
32+
text: canonicalText,
33+
}
34+
}
35+
36+
if (input.messageStatus === "streaming") {
37+
if (hasPreview) {
38+
return {
39+
mode: "streaming_preview",
40+
text: previewText,
41+
}
42+
}
43+
}
44+
45+
if (hasPreview && canonicalText.length >= previewText.length) {
46+
return {
47+
mode: "complete_rich",
48+
text: canonicalText,
49+
}
50+
}
51+
52+
if ((input.messageStatus === "complete" || input.messageStatus === "error") && canonicalText.length > 0) {
53+
return {
54+
mode: "complete_rich",
55+
text: canonicalText,
56+
}
57+
}
58+
59+
if (hasPreview) {
60+
return {
61+
mode: "stabilizing_preview",
62+
text: previewText,
63+
}
64+
}
65+
66+
return {
67+
mode: "complete_rich",
68+
text: canonicalText,
69+
}
70+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { createEffect, onCleanup } from "solid-js"
2+
3+
interface FastStreamingTextProps {
4+
text: string
5+
class?: string
6+
dir?: "auto" | "ltr" | "rtl"
7+
onRendered?: () => void
8+
}
9+
10+
export default function FastStreamingText(props: FastStreamingTextProps) {
11+
let element: HTMLDivElement | undefined
12+
let textNode: Text | null = null
13+
let previousText = ""
14+
let pendingFrame: number | null = null
15+
16+
const notifyRendered = () => {
17+
if (!props.onRendered || typeof requestAnimationFrame !== "function") {
18+
return
19+
}
20+
21+
if (pendingFrame !== null) {
22+
cancelAnimationFrame(pendingFrame)
23+
}
24+
25+
pendingFrame = requestAnimationFrame(() => {
26+
pendingFrame = null
27+
props.onRendered?.()
28+
})
29+
}
30+
31+
createEffect(() => {
32+
const nextText = props.text ?? ""
33+
if (!element) {
34+
previousText = nextText
35+
return
36+
}
37+
38+
if (!textNode) {
39+
textNode = document.createTextNode(nextText)
40+
element.replaceChildren(textNode)
41+
previousText = nextText
42+
notifyRendered()
43+
return
44+
}
45+
46+
if (nextText === previousText) {
47+
return
48+
}
49+
50+
if (nextText.startsWith(previousText)) {
51+
textNode.appendData(nextText.slice(previousText.length))
52+
} else {
53+
textNode.data = nextText
54+
}
55+
56+
previousText = nextText
57+
notifyRendered()
58+
})
59+
60+
onCleanup(() => {
61+
if (pendingFrame !== null) {
62+
cancelAnimationFrame(pendingFrame)
63+
}
64+
})
65+
66+
return <div ref={element} class={props.class} dir={props.dir ?? "auto"} />
67+
}

packages/ui/src/components/markdown.tsx

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,17 @@ interface MarkdownProps {
8787
onRendered?: () => void
8888
}
8989

90+
/** Default throttle delay for expensive Shiki re-renders (ms). */
91+
const MARKDOWN_RENDER_THROTTLE_MS = 120
92+
9093
export function Markdown(props: MarkdownProps) {
9194
const { t } = useI18n()
9295
const [html, setHtml] = createSignal("")
9396
let containerRef: HTMLDivElement | undefined
9497
let latestRequestKey = ""
9598
let cleanupLanguageListener: (() => void) | undefined
99+
let renderTimer: ReturnType<typeof setTimeout> | undefined
100+
let hasRenderedOnce = false
96101

97102
const notifyRendered = () => {
98103
Promise.resolve().then(() => props.onRendered?.())
@@ -155,6 +160,32 @@ export function Markdown(props: MarkdownProps) {
155160
}
156161
}
157162

163+
/** Schedule a Shiki render, debounced after the first paint. */
164+
const scheduleRender = (snapshot: ReturnType<typeof resolved>) => {
165+
const doRender = () => {
166+
void renderSnapshot(snapshot).catch((error) => {
167+
log.error("Failed to render markdown:", error)
168+
if (latestRequestKey === snapshot.requestKey) {
169+
commitCacheEntry(snapshot, renderFallbackHtml(snapshot.text))
170+
}
171+
})
172+
}
173+
174+
// First render is always immediate to avoid a prolonged fallback flash.
175+
if (!hasRenderedOnce) {
176+
hasRenderedOnce = true
177+
doRender()
178+
return
179+
}
180+
181+
// Subsequent renders are debounced to coalesce Shiki language-load cascades.
182+
if (renderTimer) clearTimeout(renderTimer)
183+
renderTimer = setTimeout(() => {
184+
renderTimer = undefined
185+
doRender()
186+
}, MARKDOWN_RENDER_THROTTLE_MS)
187+
}
188+
158189
createEffect(() => {
159190
const snapshot = resolved()
160191
latestRequestKey = snapshot.requestKey
@@ -182,12 +213,7 @@ export function Markdown(props: MarkdownProps) {
182213
setHtml(renderFallbackHtml(snapshot.text))
183214
notifyRendered()
184215

185-
void renderSnapshot(snapshot).catch((error) => {
186-
log.error("Failed to render markdown:", error)
187-
if (latestRequestKey === snapshot.requestKey) {
188-
commitCacheEntry(snapshot, renderFallbackHtml(snapshot.text))
189-
}
190-
})
216+
scheduleRender(snapshot)
191217
})
192218

193219
onMount(() => {
@@ -234,9 +260,7 @@ export function Markdown(props: MarkdownProps) {
234260
}
235261

236262
latestRequestKey = snapshot.requestKey
237-
void renderSnapshot(snapshot).catch((error) => {
238-
log.error("Failed to re-render markdown after language load:", error)
239-
})
263+
scheduleRender(snapshot)
240264
})
241265
})
242266
.catch((error) => {
@@ -245,6 +269,8 @@ export function Markdown(props: MarkdownProps) {
245269

246270
onCleanup(() => {
247271
disposed = true
272+
if (renderTimer) clearTimeout(renderTimer)
273+
renderTimer = undefined
248274
containerRef?.removeEventListener("click", handleClick)
249275
cleanupLanguageListener?.()
250276
cleanupLanguageListener = undefined

0 commit comments

Comments
 (0)