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 /packages/ui/src/components | |
| parent | 1c717d62e4bfd20078cfce223cfd5152669d1c9f (diff) | |
| download | opencode-c949e5b390814348a2a86802d4c350e964864da6.tar.gz opencode-c949e5b390814348a2a86802d4c350e964864da6.zip | |
feat(app): incrementally render turns, markdown cache, lazily render diffs
Diffstat (limited to 'packages/ui/src/components')
| -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 |
3 files changed, 100 insertions, 18 deletions
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}> |
