summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src/components
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 /packages/ui/src/components
parent1c717d62e4bfd20078cfce223cfd5152669d1c9f (diff)
downloadopencode-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.tsx38
-rw-r--r--packages/ui/src/components/message-part.tsx4
-rw-r--r--packages/ui/src/components/session-turn.tsx76
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}>