diff options
| author | Adam <[email protected]> | 2026-01-07 19:12:48 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-08 17:48:15 -0600 |
| commit | c949e5b390814348a2a86802d4c350e964864da6 (patch) | |
| tree | 2f074b41d6e293b4ef1f03dd71ba55308b0149f1 | |
| parent | 1c717d62e4bfd20078cfce223cfd5152669d1c9f (diff) | |
| download | opencode-c949e5b390814348a2a86802d4c350e964864da6.tar.gz opencode-c949e5b390814348a2a86802d4c350e964864da6.zip | |
feat(app): incrementally render turns, markdown cache, lazily render diffs
| -rw-r--r-- | packages/app/src/pages/session.tsx | 321 | ||||
| -rw-r--r-- | packages/ui/src/components/markdown.tsx | 38 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.tsx | 4 | ||||
| -rw-r--r-- | packages/ui/src/components/session-turn.tsx | 76 |
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}> |
