Skip to content

Commit a4cafa6

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 be63a6b commit a4cafa6

25 files changed

Lines changed: 738 additions & 151 deletions

packages/ui/src/App.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import { initReleaseNotifications } from "./stores/releases"
2525
import { runtimeEnv } from "./lib/runtime-env"
2626
import { useI18n } from "./lib/i18n"
2727
import { setWakeLockDesired } from "./lib/native/wake-lock"
28+
import { setTauriDesktopActiveSession } from "./lib/native/desktop-events"
29+
import { clearAssistantStreamSession } from "./stores/assistant-stream"
2830
import {
2931
isSelectingFolder,
3032
setIsSelectingFolder,
@@ -249,6 +251,35 @@ const App: Component = () => {
249251
return activeSessionId().get(instance.id) || null
250252
})
251253

254+
const activeStreamTarget = createMemo(() => {
255+
const instance = activeInstance()
256+
const sessionId = activeSessionIdForInstance()
257+
if (!instance || !sessionId) return null
258+
return {
259+
instanceId: instance.id,
260+
sessionId,
261+
}
262+
})
263+
264+
let previousActiveStreamTarget: ReturnType<typeof activeStreamTarget> = null
265+
266+
createEffect(() => {
267+
if (runtimeEnv.host !== "tauri") {
268+
return
269+
}
270+
271+
const currentTarget = activeStreamTarget()
272+
if (previousActiveStreamTarget) {
273+
clearAssistantStreamSession(
274+
previousActiveStreamTarget.instanceId,
275+
previousActiveStreamTarget.sessionId,
276+
)
277+
}
278+
previousActiveStreamTarget = currentTarget
279+
280+
void setTauriDesktopActiveSession(currentTarget)
281+
})
282+
252283
const launchErrorPath = () => {
253284
const value = launchError()?.binaryPath
254285
if (!value) return "opencode"

packages/ui/src/components/markdown.tsx

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
1+
import { createEffect, createMemo, createSignal, onCleanup, onMount, untrack } from "solid-js"
22
import { useGlobalCache } from "../lib/hooks/use-global-cache"
33
import type { TextPart, RenderCache } from "../types/message"
44
import { getLogger } from "../lib/logger"
@@ -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,45 @@ export function Markdown(props: MarkdownProps) {
155160
}
156161
}
157162

163+
/** Schedule a Shiki render, throttled after the first paint. */
164+
let pendingRenderSnapshot: ReturnType<typeof resolved> | undefined
165+
166+
const scheduleRender = (snapshot: ReturnType<typeof resolved>) => {
167+
const doRender = (snap: ReturnType<typeof resolved>) => {
168+
latestRequestKey = snap.requestKey
169+
void renderSnapshot(snap).catch((error) => {
170+
log.error("Failed to render markdown:", error)
171+
if (latestRequestKey === snap.requestKey) {
172+
commitCacheEntry(snap, renderFallbackHtml(snap.text))
173+
}
174+
})
175+
}
176+
177+
// First render is always immediate to avoid a prolonged fallback flash.
178+
if (!hasRenderedOnce) {
179+
hasRenderedOnce = true
180+
doRender(snapshot)
181+
return
182+
}
183+
184+
// Subsequent renders are throttled: the timer fires at a fixed cadence
185+
// and always uses the latest pending snapshot. Unlike a debounce, the
186+
// timer is NOT reset when new snapshots arrive, so Shiki re-renders
187+
// periodically (~every MARKDOWN_RENDER_THROTTLE_MS) even during
188+
// continuous streaming — preventing the raw↔markdown flash.
189+
pendingRenderSnapshot = snapshot
190+
if (!renderTimer) {
191+
renderTimer = setTimeout(() => {
192+
renderTimer = undefined
193+
const snap = pendingRenderSnapshot
194+
if (snap) {
195+
pendingRenderSnapshot = undefined
196+
doRender(snap)
197+
}
198+
}, MARKDOWN_RENDER_THROTTLE_MS)
199+
}
200+
}
201+
158202
createEffect(() => {
159203
const snapshot = resolved()
160204
latestRequestKey = snapshot.requestKey
@@ -179,15 +223,15 @@ export function Markdown(props: MarkdownProps) {
179223
return
180224
}
181225

182-
setHtml(renderFallbackHtml(snapshot.text))
226+
// Keep the previous rendered markdown visible while Shiki re-renders.
227+
// Only fall back to escaped plain text on the initial render (no prior
228+
// content). This eliminates the raw↔markdown flash during streaming.
229+
if (!untrack(html)) {
230+
setHtml(renderFallbackHtml(snapshot.text))
231+
}
183232
notifyRendered()
184233

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-
})
234+
scheduleRender(snapshot)
191235
})
192236

193237
onMount(() => {
@@ -234,9 +278,7 @@ export function Markdown(props: MarkdownProps) {
234278
}
235279

236280
latestRequestKey = snapshot.requestKey
237-
void renderSnapshot(snapshot).catch((error) => {
238-
log.error("Failed to re-render markdown after language load:", error)
239-
})
281+
scheduleRender(snapshot)
240282
})
241283
})
242284
.catch((error) => {
@@ -245,6 +287,9 @@ export function Markdown(props: MarkdownProps) {
245287

246288
onCleanup(() => {
247289
disposed = true
290+
if (renderTimer) clearTimeout(renderTimer)
291+
renderTimer = undefined
292+
pendingRenderSnapshot = undefined
248293
containerRef?.removeEventListener("click", handleClick)
249294
cleanupLanguageListener?.()
250295
cleanupLanguageListener = undefined

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

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
1-
import { Match, Show, Suspense, Switch, lazy } from "solid-js"
1+
import { Match, Show, Suspense, Switch, createMemo, createSignal, lazy } from "solid-js"
22
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
33
import { Markdown } from "./markdown"
44
import { useTheme } from "../lib/theme"
55
import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message"
6+
import { useI18n } from "../lib/i18n"
67

78
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
89

910
const LazyToolCall = lazy(() => import("./tool-call"))
1011

12+
/** Collapse text parts larger than this to avoid massive DOM trees. */
13+
const LARGE_TEXT_COLLAPSE_THRESHOLD = 12_000
14+
const LARGE_TEXT_PREVIEW_CHARS = 3_200
15+
16+
/** Module-level set tracking which part IDs the user has expanded. */
17+
const expandedLargeTextPartIds = new Set<string>()
18+
1119
interface MessagePartProps {
1220
part: ClientPart
1321
messageType?: "user" | "assistant"
@@ -22,6 +30,7 @@ interface MessagePartProps {
2230
export default function MessagePart(props: MessagePartProps) {
2331

2432
const { isDark } = useTheme()
33+
const { t } = useI18n()
2534
const partType = () => props.part?.type || ""
2635
const reasoningId = () => `reasoning-${props.part?.id || ""}`
2736
const isReasoningExpanded = () => isItemExpanded(reasoningId())
@@ -30,6 +39,33 @@ export default function MessagePart(props: MessagePartProps) {
3039
const markdownContainerClass = () => "message-text message-text-assistant"
3140
const textContainerRole = () => props.messageType || "assistant"
3241

42+
// --- Large text collapse ---
43+
const partStableId = () => {
44+
const id = (props.part as any)?.id
45+
return typeof id === "string" && id.length > 0 ? id : ""
46+
}
47+
const isPersistedExpanded = () => {
48+
const id = partStableId()
49+
return Boolean(id && expandedLargeTextPartIds.has(id))
50+
}
51+
const [localExpanded, setLocalExpanded] = createSignal(isPersistedExpanded())
52+
53+
const shouldCollapseLongText = createMemo(() => {
54+
if (props.part?.type !== "text") return false
55+
const text = typeof (props.part as any).text === "string" ? (props.part as any).text : ""
56+
return text.length >= LARGE_TEXT_COLLAPSE_THRESHOLD
57+
})
58+
59+
const isTextCollapsed = createMemo(() =>
60+
shouldCollapseLongText() && !localExpanded() && !isPersistedExpanded(),
61+
)
62+
63+
const handleExpandLongText = () => {
64+
setLocalExpanded(true)
65+
const id = partStableId()
66+
if (id) expandedLargeTextPartIds.add(id)
67+
}
68+
3369
const shouldHideTextPart = () => {
3470
const part = props.part
3571
if (!part || part.type !== "text") return false
@@ -98,20 +134,34 @@ export default function MessagePart(props: MessagePartProps) {
98134

99135
const createTextPartForMarkdown = (): TextPart => {
100136
const part = props.part
137+
const collapsed = isTextCollapsed()
138+
101139
if (part.type === "text" && typeof part.text === "string") {
140+
if (collapsed && part.text.length > LARGE_TEXT_PREVIEW_CHARS) {
141+
return {
142+
...part as unknown as TextPart,
143+
text: part.text.slice(0, LARGE_TEXT_PREVIEW_CHARS) + "\n\n\u2026",
144+
// Clear renderCache since we changed the text
145+
renderCache: undefined,
146+
}
147+
}
102148
// Pass through the original part so `renderCache` updates persist.
103149
return part as unknown as TextPart
104150
}
105151

106152
if (part.type === "reasoning" && typeof (part as any).text === "string") {
107153
// Reasoning parts render as markdown in some views; normalize to TextPart.
154+
const rawText = (part as any).text as string
155+
const text = collapsed && rawText.length > LARGE_TEXT_PREVIEW_CHARS
156+
? rawText.slice(0, LARGE_TEXT_PREVIEW_CHARS) + "\n\n\u2026"
157+
: rawText
108158
return {
109159
id: part.id,
110160
type: "text",
111-
text: (part as any).text,
161+
text,
112162
synthetic: false,
113163
version: (part as { version?: number }).version,
114-
renderCache: (part as any).renderCache,
164+
renderCache: collapsed ? undefined : (part as any).renderCache,
115165
}
116166
}
117167

@@ -139,7 +189,10 @@ export default function MessagePart(props: MessagePartProps) {
139189
data-part-type="text"
140190
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
141191
>
142-
<Show when={canRenderMarkdown()} fallback={<span class="text-primary" dir="auto">{plainTextContent()}</span>}>
192+
<Show
193+
when={canRenderMarkdown()}
194+
fallback={<span class="message-streaming-plain-text" dir="auto">{plainTextContent()}</span>}
195+
>
143196
<Markdown
144197
part={createTextPartForMarkdown()}
145198
instanceId={props.instanceId}
@@ -150,6 +203,15 @@ export default function MessagePart(props: MessagePartProps) {
150203
onRendered={props.onRendered}
151204
/>
152205
</Show>
206+
<Show when={isTextCollapsed()}>
207+
<button
208+
type="button"
209+
class="large-text-expand-button"
210+
onClick={handleExpandLongText}
211+
>
212+
{t("messagePart.largeText.showFull")}
213+
</button>
214+
</Show>
153215
</div>
154216
</Show>
155217
</Match>

packages/ui/src/components/prompt-input.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export default function PromptInput(props: PromptInputProps) {
8181
prompt,
8282
setPrompt,
8383
clearPrompt,
84+
flushPromptDraft,
8485
draftLoadedNonce,
8586
history,
8687
historyIndex,
@@ -564,7 +565,10 @@ export default function PromptInput(props: PromptInputProps) {
564565
onKeyDown={handleKeyDown}
565566
onPaste={handlePaste}
566567
onFocus={() => setIsFocused(true)}
567-
onBlur={() => setIsFocused(false)}
568+
onBlur={() => {
569+
setIsFocused(false)
570+
flushPromptDraft()
571+
}}
568572
disabled={props.disabled}
569573
rows={expandState() === "expanded" ? (props.compactLayout ? 10 : 15) : 3}
570574
spellcheck={false}

0 commit comments

Comments
 (0)