summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-07 19:12:48 -0600
committerAdam <[email protected]>2026-01-08 17:48:15 -0600
commitc949e5b390814348a2a86802d4c350e964864da6 (patch)
tree2f074b41d6e293b4ef1f03dd71ba55308b0149f1
parent1c717d62e4bfd20078cfce223cfd5152669d1c9f (diff)
downloadopencode-c949e5b390814348a2a86802d4c350e964864da6.tar.gz
opencode-c949e5b390814348a2a86802d4c350e964864da6.zip
feat(app): incrementally render turns, markdown cache, lazily render diffs
-rw-r--r--packages/app/src/pages/session.tsx321
-rw-r--r--packages/ui/src/components/markdown.tsx38
-rw-r--r--packages/ui/src/components/message-part.tsx4
-rw-r--r--packages/ui/src/components/session-turn.tsx76
4 files changed, 331 insertions, 108 deletions
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 99306a2e7..e71177730 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -309,11 +309,20 @@ export default function Page() {
activeTerminalDraggable: undefined as string | undefined,
expanded: {} as Record<string, boolean>,
messageId: undefined as string | undefined,
+ turnStart: 0,
mobileTab: "session" as "session" | "review",
newSessionWorktree: "main",
promptHeight: 0,
})
+ const renderedUserMessages = createMemo(() => {
+ const msgs = visibleUserMessages()
+ const start = store.turnStart
+ if (start <= 0) return msgs
+ if (start >= msgs.length) return emptyUserMessages
+ return msgs.slice(start)
+ }, emptyUserMessages)
+
const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
const project = sync.project
@@ -758,6 +767,88 @@ export default function Page() {
autoScroll.scrollRef(el)
}
+ const turnInit = 20
+ const turnBatch = 20
+ let turnHandle: number | undefined
+ let turnIdle = false
+
+ function cancelTurnBackfill() {
+ const handle = turnHandle
+ if (handle === undefined) return
+ turnHandle = undefined
+
+ if (turnIdle && window.cancelIdleCallback) {
+ window.cancelIdleCallback(handle)
+ return
+ }
+
+ clearTimeout(handle)
+ }
+
+ function scheduleTurnBackfill() {
+ if (turnHandle !== undefined) return
+ if (store.turnStart <= 0) return
+
+ if (window.requestIdleCallback) {
+ turnIdle = true
+ turnHandle = window.requestIdleCallback(() => {
+ turnHandle = undefined
+ backfillTurns()
+ })
+ return
+ }
+
+ turnIdle = false
+ turnHandle = window.setTimeout(() => {
+ turnHandle = undefined
+ backfillTurns()
+ }, 0)
+ }
+
+ function backfillTurns() {
+ const start = store.turnStart
+ if (start <= 0) return
+
+ const next = start - turnBatch
+ const nextStart = next > 0 ? next : 0
+
+ const el = scroller
+ if (!el) {
+ setStore("turnStart", nextStart)
+ scheduleTurnBackfill()
+ return
+ }
+
+ const beforeTop = el.scrollTop
+ const beforeHeight = el.scrollHeight
+
+ setStore("turnStart", nextStart)
+
+ requestAnimationFrame(() => {
+ const delta = el.scrollHeight - beforeHeight
+ if (delta) el.scrollTop = beforeTop + delta
+ })
+
+ scheduleTurnBackfill()
+ }
+
+ createEffect(
+ on(
+ () => [params.id, messagesReady()] as const,
+ ([id, ready]) => {
+ cancelTurnBackfill()
+ setStore("turnStart", 0)
+ if (!id || !ready) return
+
+ const len = visibleUserMessages().length
+ const start = len > turnInit ? len - turnInit : 0
+ setStore("turnStart", start)
+ scheduleTurnBackfill()
+ },
+ { defer: true },
+ ),
+ )
+
createResizeObserver(
() => promptDock,
({ height }) => {
@@ -785,6 +876,21 @@ export default function Page() {
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
setActiveMessage(message)
+ const msgs = visibleUserMessages()
+ const index = msgs.findIndex((m) => m.id === message.id)
+ if (index !== -1 && index < store.turnStart) {
+ setStore("turnStart", index)
+ scheduleTurnBackfill()
+
+ requestAnimationFrame(() => {
+ const el = document.getElementById(anchor(message.id))
+ if (el) el.scrollIntoView({ behavior, block: "start" })
+ })
+
+ updateHash(message.id)
+ return
+ }
+
const el = document.getElementById(anchor(message.id))
if (el) el.scrollIntoView({ behavior, block: "start" })
updateHash(message.id)
@@ -830,12 +936,27 @@ export default function Page() {
if (!sessionID || !ready) return
requestAnimationFrame(() => {
- const id = window.location.hash.slice(1)
- const hashTarget = id ? document.getElementById(id) : undefined
+ const hash = window.location.hash.slice(1)
+ if (!hash) {
+ autoScroll.forceScrollToBottom()
+ return
+ }
+
+ const hashTarget = document.getElementById(hash)
if (hashTarget) {
hashTarget.scrollIntoView({ behavior: "auto", block: "start" })
return
}
+
+ const match = hash.match(/^message-(.+)$/)
+ if (match) {
+ const msg = visibleUserMessages().find((m) => m.id === match[1])
+ if (msg) {
+ scrollToMessage(msg, "auto")
+ return
+ }
+ }
+
autoScroll.forceScrollToBottom()
})
})
@@ -868,6 +989,7 @@ export default function Page() {
return [[path, file.selectedLines(path) ?? null] as const]
}),
)
+ cancelTurnBackfill()
document.removeEventListener("keydown", handleKeyDown)
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
})
@@ -971,6 +1093,18 @@ export default function Page() {
"mt-0": showTabs(),
}}
>
+ <Show when={store.turnStart > 0}>
+ <div class="w-full flex justify-center">
+ <Button
+ variant="ghost"
+ size="large"
+ class="text-12-medium opacity-50"
+ onClick={() => setStore("turnStart", 0)}
+ >
+ Render earlier messages
+ </Button>
+ </div>
+ </Show>
<Show when={historyMore()}>
<div class="w-full flex justify-center">
<Button
@@ -981,6 +1115,7 @@ export default function Page() {
onClick={() => {
const id = params.id
if (!id) return
+ setStore("turnStart", 0)
sync.session.history.loadMore(id)
}}
>
@@ -988,7 +1123,7 @@ export default function Page() {
</Button>
</div>
</Show>
- <For each={visibleUserMessages()}>
+ <For each={renderedUserMessages()}>
{(message) => {
if (import.meta.env.DEV) {
onMount(() => {
@@ -1173,36 +1308,40 @@ export default function Page() {
</div>
<Show when={reviewTab()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
- <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
- <Show
- when={diffsReady()}
- fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
- >
- <SessionReviewTab
- diffs={diffs}
- view={view}
- diffStyle={layout.review.diffStyle()}
- onDiffStyleChange={layout.review.setDiffStyle}
- onViewFile={(path) => {
- const value = file.tab(path)
- tabs().open(value)
- file.load(path)
- }}
- />
- </Show>
- </div>
+ <Show when={activeTab() === "review"}>
+ <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+ <Show
+ when={diffsReady()}
+ fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
+ >
+ <SessionReviewTab
+ diffs={diffs}
+ view={view}
+ diffStyle={layout.review.diffStyle()}
+ onDiffStyleChange={layout.review.setDiffStyle}
+ onViewFile={(path) => {
+ const value = file.tab(path)
+ tabs().open(value)
+ file.load(path)
+ }}
+ />
+ </Show>
+ </div>
+ </Show>
</Tabs.Content>
</Show>
<Show when={contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
- <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
- <SessionContextTab
- messages={messages}
- visibleUserMessages={visibleUserMessages}
- view={view}
- info={info}
- />
- </div>
+ <Show when={activeTab() === "context"}>
+ <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+ <SessionContextTab
+ messages={messages}
+ visibleUserMessages={visibleUserMessages}
+ view={view}
+ info={info}
+ />
+ </div>
+ </Show>
</Tabs.Content>
</Show>
<For each={openedTabs()}>
@@ -1349,37 +1488,63 @@ export default function Page() {
}}
onScroll={handleScroll}
>
- <Show when={selection()}>
- {(sel) => (
- <div class="hidden sticky top-0 z-10 px-6 py-2 _flex justify-end bg-background-base border-b border-border-weak-base">
- <button
- type="button"
- class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-strong hover:bg-surface-raised-base-hover"
- onClick={() => {
- const p = path()
- if (!p) return
- prompt.context.add({ type: "file", path: p, selection: sel() })
- }}
- >
- <Icon name="plus-small" size="small" />
- <span>Add {selectionLabel()} to context</span>
- </button>
- </div>
- )}
- </Show>
- <Switch>
- <Match when={state()?.loaded && isImage()}>
- <div class="px-6 py-4 pb-40">
- <img src={imageDataUrl()} alt={path()} class="max-w-full" />
- </div>
- </Match>
- <Match when={state()?.loaded && isSvg()}>
- <div class="flex flex-col gap-4 px-6 py-4">
+ <Show when={activeTab() === tab}>
+ <Show when={selection()}>
+ {(sel) => (
+ <div class="hidden sticky top-0 z-10 px-6 py-2 _flex justify-end bg-background-base border-b border-border-weak-base">
+ <button
+ type="button"
+ class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-strong hover:bg-surface-raised-base-hover"
+ onClick={() => {
+ const p = path()
+ if (!p) return
+ prompt.context.add({ type: "file", path: p, selection: sel() })
+ }}
+ >
+ <Icon name="plus-small" size="small" />
+ <span>Add {selectionLabel()} to context</span>
+ </button>
+ </div>
+ )}
+ </Show>
+ <Switch>
+ <Match when={state()?.loaded && isImage()}>
+ <div class="px-6 py-4 pb-40">
+ <img src={imageDataUrl()} alt={path()} class="max-w-full" />
+ </div>
+ </Match>
+ <Match when={state()?.loaded && isSvg()}>
+ <div class="flex flex-col gap-4 px-6 py-4">
+ <Dynamic
+ component={codeComponent}
+ file={{
+ name: path() ?? "",
+ contents: svgContent() ?? "",
+ cacheKey: cacheKey(),
+ }}
+ enableLineSelection
+ selectedLines={selectedLines()}
+ onLineSelected={(range: SelectedLineRange | null) => {
+ const p = path()
+ if (!p) return
+ file.setSelectedLines(p, range)
+ }}
+ overflow="scroll"
+ class="select-text"
+ />
+ <Show when={svgPreviewUrl()}>
+ <div class="flex justify-center pb-40">
+ <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
+ </div>
+ </Show>
+ </div>
+ </Match>
+ <Match when={state()?.loaded}>
<Dynamic
component={codeComponent}
file={{
name: path() ?? "",
- contents: svgContent() ?? "",
+ contents: contents(),
cacheKey: cacheKey(),
}}
enableLineSelection
@@ -1390,41 +1555,17 @@ export default function Page() {
file.setSelectedLines(p, range)
}}
overflow="scroll"
- class="select-text"
+ class="select-text pb-40"
/>
- <Show when={svgPreviewUrl()}>
- <div class="flex justify-center pb-40">
- <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
- </div>
- </Show>
- </div>
- </Match>
- <Match when={state()?.loaded}>
- <Dynamic
- component={codeComponent}
- file={{
- name: path() ?? "",
- contents: contents(),
- cacheKey: cacheKey(),
- }}
- enableLineSelection
- selectedLines={selectedLines()}
- onLineSelected={(range: SelectedLineRange | null) => {
- const p = path()
- if (!p) return
- file.setSelectedLines(p, range)
- }}
- overflow="scroll"
- class="select-text pb-40"
- />
- </Match>
- <Match when={state()?.loading}>
- <div class="px-6 py-4 text-text-weak">Loading...</div>
- </Match>
- <Match when={state()?.error}>
- {(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
- </Match>
- </Switch>
+ </Match>
+ <Match when={state()?.loading}>
+ <div class="px-6 py-4 text-text-weak">Loading...</div>
+ </Match>
+ <Match when={state()?.error}>
+ {(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
+ </Match>
+ </Switch>
+ </Show>
</Tabs.Content>
)
}}
diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx
index 6e40b700a..2b0b01874 100644
--- a/packages/ui/src/components/markdown.tsx
+++ b/packages/ui/src/components/markdown.tsx
@@ -1,19 +1,53 @@
import { useMarked } from "../context/marked"
+import { checksum } from "@opencode-ai/util/encode"
import { ComponentProps, createResource, splitProps } from "solid-js"
+type Entry = {
+ hash: string
+ html: string
+}
+
+const max = 200
+const cache = new Map<string, Entry>()
+
+function touch(key: string, value: Entry) {
+ cache.delete(key)
+ cache.set(key, value)
+
+ if (cache.size <= max) return
+
+ const first = cache.keys().next().value
+ if (!first) return
+ cache.delete(first)
+}
+
export function Markdown(
props: ComponentProps<"div"> & {
text: string
+ cacheKey?: string
class?: string
classList?: Record<string, boolean>
},
) {
- const [local, others] = splitProps(props, ["text", "class", "classList"])
+ const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"])
const marked = useMarked()
const [html] = createResource(
() => local.text,
async (markdown) => {
- return marked.parse(markdown)
+ const hash = checksum(markdown)
+ const key = local.cacheKey ?? hash
+
+ if (key && hash) {
+ const cached = cache.get(key)
+ if (cached && cached.hash === hash) {
+ touch(key, cached)
+ return cached.html
+ }
+ }
+
+ const next = await marked.parse(markdown)
+ if (key && hash) touch(key, { hash, html: next })
+ return next
},
{ initialValue: "" },
)
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 8102c2ce7..534ea8f50 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -566,7 +566,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
return (
<Show when={throttledText()}>
<div data-component="text-part">
- <Markdown text={throttledText()} />
+ <Markdown text={throttledText()} cacheKey={part.id} />
</div>
</Show>
)
@@ -580,7 +580,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
return (
<Show when={throttledText()}>
<div data-component="reasoning-part">
- <Markdown text={throttledText()} />
+ <Markdown text={throttledText()} cacheKey={part.id} />
</div>
</Show>
)
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index 005b6e5a3..f69d414be 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -350,15 +350,31 @@ export function SessionTurn(
onUserInteracted: props.onUserInteracted,
})
+ const diffInit = 20
+ const diffBatch = 20
+
const [store, setStore] = createStore({
stickyTitleRef: undefined as HTMLDivElement | undefined,
stickyTriggerRef: undefined as HTMLDivElement | undefined,
stickyHeaderHeight: 0,
retrySeconds: 0,
+ diffsOpen: [] as string[],
+ diffLimit: diffInit,
status: rawStatus(),
duration: duration(),
})
+ createEffect(
+ on(
+ () => message()?.id,
+ () => {
+ setStore("diffsOpen", [])
+ setStore("diffLimit", diffInit)
+ },
+ { defer: true },
+ ),
+ )
+
createEffect(() => {
const r = retry()
if (!r) {
@@ -542,10 +558,23 @@ export function SessionTurn(
<div data-slot="session-turn-summary-section">
<div data-slot="session-turn-summary-header">
<h2 data-slot="session-turn-summary-title">Response</h2>
- <Markdown data-slot="session-turn-markdown" data-diffs={hasDiffs()} text={response() ?? ""} />
+ <Markdown
+ data-slot="session-turn-markdown"
+ data-diffs={hasDiffs()}
+ text={response() ?? ""}
+ cacheKey={responsePartId()}
+ />
</div>
- <Accordion data-slot="session-turn-accordion" multiple>
- <For each={msg().summary?.diffs ?? []}>
+ <Accordion
+ data-slot="session-turn-accordion"
+ multiple
+ value={store.diffsOpen}
+ onChange={(value) => {
+ if (!Array.isArray(value)) return
+ setStore("diffsOpen", value)
+ }}
+ >
+ <For each={(msg().summary?.diffs ?? []).slice(0, store.diffLimit)}>
{(diff) => (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader>
@@ -573,22 +602,41 @@ export function SessionTurn(
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content data-slot="session-turn-accordion-content">
- <Dynamic
- component={diffComponent}
- before={{
- name: diff.file!,
- contents: diff.before!,
- }}
- after={{
- name: diff.file!,
- contents: diff.after!,
- }}
- />
+ <Show when={store.diffsOpen.includes(diff.file!)}>
+ <Dynamic
+ component={diffComponent}
+ before={{
+ name: diff.file!,
+ contents: diff.before!,
+ }}
+ after={{
+ name: diff.file!,
+ contents: diff.after!,
+ }}
+ />
+ </Show>
</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion>
+ <Show when={(msg().summary?.diffs?.length ?? 0) > store.diffLimit}>
+ <Button
+ data-slot="session-turn-accordion-more"
+ variant="ghost"
+ size="small"
+ onClick={() => {
+ const total = msg().summary?.diffs?.length ?? 0
+ setStore("diffLimit", (limit) => {
+ const next = limit + diffBatch
+ if (next > total) return total
+ return next
+ })
+ }}
+ >
+ Show more changes ({(msg().summary?.diffs?.length ?? 0) - store.diffLimit})
+ </Button>
+ </Show>
</div>
</Show>
<Show when={error() && !props.stepsExpanded}>