From 9cd790efa77dea35feb59b75f788a1b0f6bc4e82 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 9 Jun 2026 13:20:58 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(app):=20reduce=20streaming=20CPU=20?= =?UTF-8?q?=E2=80=94=20skip=20Shiki=20during=20streaming,=20single=20store?= =?UTF-8?q?=20mutation=20per=20delta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split marked parser into light (no Shiki) and full parser. During streaming, use the light parser to avoid WASM syntax highlighting on the main thread. Code blocks render as plain
 during streaming and get highlighted
  when the stream ends.
- Remove the redundant produce() mutation on the part store during
  message.part.delta events. Only the lightweight part_text_accum_delta store
  is updated per delta now — readPartText() prefers accum over part.text, so
  the UI sees latest text without a second store write per delta.
---
 .../src/context/global-sync/event-reducer.ts  | 15 +++----
 packages/ui/src/components/markdown.tsx       |  9 +++-
 packages/ui/src/context/marked.tsx            | 44 ++++++++++++-------
 3 files changed, 39 insertions(+), 29 deletions(-)

diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts
index f398aa995ebd..48df38c07c3e 100644
--- a/packages/app/src/context/global-sync/event-reducer.ts
+++ b/packages/app/src/context/global-sync/event-reducer.ts
@@ -282,17 +282,12 @@ export function applyDirectoryEvent(input: {
       if (!parts) break
       const result = Binary.search(parts, props.partID, (p) => p.id)
       if (!result.found) break
+      // Only update the lightweight accum store during streaming. The part store
+      // text is synced when the full `message.part.updated` event arrives, which
+      // already writes the complete part object. readPartText() prefers accum over
+      // part.text, so the UI sees the latest text without a second store mutation
+      // per delta — halving reactive propagation on the hottest path.
       input.setStore("part_text_accum_delta", props.partID, (existing) => (existing ?? "") + props.delta)
-      input.setStore(
-        "part",
-        props.messageID,
-        produce((draft) => {
-          const part = draft[result.index]
-          const field = props.field as keyof typeof part
-          const existing = part[field] as string | undefined
-          ;(part[field] as string) = (existing ?? "") + props.delta
-        }),
-      )
       break
     }
     case "vcs.branch.updated": {
diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx
index e12b661dba63..f02ce8a0f102 100644
--- a/packages/ui/src/components/markdown.tsx
+++ b/packages/ui/src/components/markdown.tsx
@@ -275,9 +275,14 @@ export function Markdown(
             }
           }
 
-          const next = await Promise.resolve(marked.parse(block.src))
+          // During streaming, pass the flag to skip Shiki WASM highlighting.
+          // Code blocks render as plain 
 and get syntax-highlighted
+          // when streaming ends and a final non-streaming parse runs.
+          const next = await marked.parse(block.src, src.streaming)
           const safe = sanitize(next)
-          if (key && hash) touch(key, { hash, html: safe })
+          // Only cache non-streaming results (streaming blocks change rapidly
+          // and haven't been syntax-highlighted yet)
+          if (key && hash && !src.streaming) touch(key, { hash, html: safe })
           return safe
         }),
       )
diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx
index 46f4993babde..c39978aef05d 100644
--- a/packages/ui/src/context/marked.tsx
+++ b/packages/ui/src/context/marked.tsx
@@ -468,19 +468,25 @@ export type NativeMarkdownParser = (markdown: string) => Promise
 export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({
   name: "Marked",
   init: (props: { nativeParser?: NativeMarkdownParser }) => {
-    const jsParser = marked.use(
-      {
-        renderer: {
-          link({ href, title, text }) {
-            const titleAttr = title ? ` title="${title}"` : ""
-            return `${text}`
-          },
-        },
+    const linkRenderer = {
+      link({ href, title, text }: { href: string; title?: string; text: string }) {
+        const titleAttr = title ? ` title="${title}"` : ""
+        return `${text}`
       },
-      markedKatex({
-        throwOnError: false,
-        nonStandard: true,
-      }),
+    }
+    const katexExt = markedKatex({
+      throwOnError: false,
+      nonStandard: true,
+    })
+
+    // Lightweight parser without Shiki for streaming — avoids main-thread WASM.
+    // Code blocks render as plain 
 during streaming, then get
+    // syntax-highlighted when streaming ends via the full parser.
+    const lightParser = marked.use({ renderer: linkRenderer }, katexExt)
+
+    const fullParser = marked.use(
+      { renderer: linkRenderer },
+      katexExt,
       markedShiki({
         async highlight(code, lang) {
           const highlighter = await getSharedHighlighter({
@@ -506,14 +512,18 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext(
     if (props.nativeParser) {
       const nativeParser = props.nativeParser
       return {
-        async parse(markdown: string): Promise {
-          const html = await nativeParser(markdown)
-          const withMath = renderMathExpressions(html)
-          return highlightCodeBlocks(withMath)
+        parse(markdown: string, streaming?: boolean): Promise {
+          if (streaming) return lightParser.parse(markdown)
+          return nativeParser(markdown).then((html) => highlightCodeBlocks(renderMathExpressions(html)))
         },
       }
     }
 
-    return jsParser
+    return {
+      parse(markdown: string, streaming?: boolean): Promise {
+        if (streaming) return lightParser.parse(markdown)
+        return fullParser.parse(markdown)
+      },
+    }
   },
 })

From 3a29b7750a2f5b0f164ffd01d504429b9da66dfc Mon Sep 17 00:00:00 2001
From: Burak Yigit Kaya 
Date: Tue, 9 Jun 2026 13:21:14 +0000
Subject: [PATCH 2/3] fix(app): reduce scroll anchor thrashing, enable diff
 virtualization, lightweight content version

- Replace activeAssistantContentVersion string fingerprint with a lightweight
  counter. Previously concatenated all active assistant message content into a
  massive string on every SSE delta; now increments a number.
- Reduce scroll anchor rAF loop from 90 frames (~1.5s) to 12 (~200ms), and
  the active streaming cap from 12 to 6. Reduces layout thrashing from
  repeated scrollHeight reads and scrollTop writes.
- Enable diff virtualization (virtualize={true}, virtualizeDiff={true}) so
  large file diffs in tool results don't render thousands of DOM nodes inline.
---
 .../src/pages/session/message-timeline.tsx    | 52 ++++++++++---------
 1 file changed, 27 insertions(+), 25 deletions(-)

diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index e4ae4a23e364..47a172b27015 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -258,7 +258,7 @@ function TimelineDiffView(props: { diff: SummaryDiff }) {
 
   return (
     
- +
) } @@ -460,27 +460,29 @@ export function MessageTimeline(props: { if (!id) return emptyAssistantMessages return assistantMessagesByParent().get(id) ?? emptyAssistantMessages }) - const activeAssistantContentVersion = createMemo(() => - activeAssistantMessages() - .flatMap((message) => [ - `${message.id}:${message.time.completed ?? ""}:${message.error?.name ?? ""}`, - ...getMsgParts(message.id).map((part) => { - if (part.type === "text" || part.type === "reasoning") return `${part.id}:${part.type}:${part.text.length}` - if (part.type === "tool") { - const metadata = "metadata" in part.state ? part.state.metadata : undefined - const output = - "output" in part.state && typeof part.state.output === "string" ? part.state.output.length : 0 - const metadataOutput = - metadata && typeof metadata === "object" && "output" in metadata && typeof metadata.output === "string" - ? metadata.output.length - : 0 - return `${part.id}:${part.tool}:${part.state.status}:${output}:${metadataOutput}` - } - return `${part.id}:${part.type}` - }), - ]) - .join("|"), - ) + // Track content changes with a lightweight counter instead of building a massive + // concatenated string fingerprint on every delta. The counter increments whenever + // any active assistant message's parts change, which is sufficient to trigger + // scroll-to-bottom effects without O(n) string allocation per SSE delta. + let contentVersionCounter = 0 + const activeAssistantContentVersion = createMemo(() => { + const messages = activeAssistantMessages() + // Subscribe to part arrays and key fields that affect layout + for (const message of messages) { + message.time.completed + message.error?.name + const parts = getMsgParts(message.id) + for (const part of parts) { + if (part.type === "text" || part.type === "reasoning") { + part.text.length + } else if (part.type === "tool") { + part.state.status + } + part.id + } + } + return ++contentVersionCounter + }) createEffect( on( @@ -597,7 +599,7 @@ export function MessageTimeline(props: { // Workaround for virtua issue #301: virtua does not expose a synchronous item-resize hook for // "stay at bottom if already at bottom". Tool rows can briefly outgrow the measured virtual // height, so keep the scroll container bottom-locked for a few frames while measurement settles. - bottomAnchorFrames = 90 + bottomAnchorFrames = 12 if (bottomAnchorFrame !== undefined) return const tick = () => { @@ -607,7 +609,7 @@ export function MessageTimeline(props: { return } - bottomAnchorFrames = working() ? 12 : bottomAnchorFrames - 1 + bottomAnchorFrames = working() ? 6 : bottomAnchorFrames - 1 if (bottomAnchorFrames <= 0) return bottomAnchorFrame = requestAnimationFrame(tick) } @@ -1063,7 +1065,7 @@ export function MessageTimeline(props: { toolOpen={toolOpen[part().id] ?? defaultOpen()} onToolOpenChange={(open) => setToolOpen(part().id, open)} deferToolContent={false} - virtualizeDiff={false} + virtualizeDiff={true} /> )} From bcc7ee1ec2efb9d57db7f7157fc63ad1aa315376 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 9 Jun 2026 13:21:27 +0000 Subject: [PATCH 3/3] fix(app): remove duplicate session loading calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove duplicate loadSessions() call in bootstrapDirectory — sessions were loaded at both the start and end of the bootstrap array. - Remove duplicate createResource in directory-layout.tsx that called sync.session.sync(id) on every params.id change. Session sync is already handled by session.tsx; the duplicate added unnecessary reactive tracking overhead and Promise creation on every session switch. --- packages/app/src/context/global-sync/bootstrap.ts | 1 - packages/app/src/pages/directory-layout.tsx | 8 ++------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index bac9c69280d0..990af4ff4fe1 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -308,7 +308,6 @@ export async function bootstrapDirectory(input: { ) }), ), - () => Promise.resolve(input.loadSessions(input.directory)), input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))), () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => { diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 2d0dfd81dc7b..83c62a3d48d7 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -2,7 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@/utils/toast" import { base64Encode } from "@opencode-ai/core/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" -import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js" +import { createEffect, createMemo, type ParentProps, Show } from "solid-js" import { useLanguage } from "@/context/language" import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" @@ -13,7 +13,6 @@ import { Schema } from "effect" function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { const location = useLocation() const navigate = useNavigate() - const params = useParams() const sync = useSync() const slug = createMemo(() => base64Encode(props.directory)) @@ -24,10 +23,7 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) }) - createResource( - () => params.id, - (id) => sync.session.sync(id).catch(() => {}), - ) + // Session sync is handled by session.tsx — no duplicate createResource needed here. return (