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/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/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 ( - + ) } @@ -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} /> )} 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)
+      },
+    }
   },
 })