Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/app/src/context/global-sync/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
15 changes: 5 additions & 10 deletions packages/app/src/context/global-sync/event-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 2 additions & 6 deletions packages/app/src/pages/directory-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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))

Expand All @@ -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 (
<DataProvider
Expand Down
52 changes: 27 additions & 25 deletions packages/app/src/pages/session/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ function TimelineDiffView(props: { diff: SummaryDiff }) {

return (
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic component={fileComponent} mode="diff" virtualize={false} fileDiff={view.fileDiff} />
<Dynamic component={fileComponent} mode="diff" virtualize={true} fileDiff={view.fileDiff} />
</div>
)
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 = () => {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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}
/>
)}
</Show>
Expand Down
9 changes: 7 additions & 2 deletions packages/ui/src/components/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pre><code> 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
}),
)
Expand Down
44 changes: 27 additions & 17 deletions packages/ui/src/context/marked.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -468,19 +468,25 @@ export type NativeMarkdownParser = (markdown: string) => Promise<string>
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 `<a href="${href}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`
},
},
const linkRenderer = {
link({ href, title, text }: { href: string; title?: string; text: string }) {
const titleAttr = title ? ` title="${title}"` : ""
return `<a href="${href}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`
},
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 <pre><code> 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({
Expand All @@ -506,14 +512,18 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext(
if (props.nativeParser) {
const nativeParser = props.nativeParser
return {
async parse(markdown: string): Promise<string> {
const html = await nativeParser(markdown)
const withMath = renderMathExpressions(html)
return highlightCodeBlocks(withMath)
parse(markdown: string, streaming?: boolean): Promise<string> {
if (streaming) return lightParser.parse(markdown)
return nativeParser(markdown).then((html) => highlightCodeBlocks(renderMathExpressions(html)))
},
}
}

return jsParser
return {
parse(markdown: string, streaming?: boolean): Promise<string> {
if (streaming) return lightParser.parse(markdown)
return fullParser.parse(markdown)
},
}
},
})
Loading