diff options
| author | Adam <[email protected]> | 2026-03-03 05:35:07 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-03-03 05:35:15 -0600 |
| commit | e4af1bb42284bc76adf54927f4b224224830f1b5 (patch) | |
| tree | dcb536b73884517ad5b78342440f31384559a93a | |
| parent | 5e8742f4312a8923f3da92172a7247470ef34516 (diff) | |
| download | opencode-e4af1bb42284bc76adf54927f4b224224830f1b5.tar.gz opencode-e4af1bb42284bc76adf54927f4b224224830f1b5.zip | |
fix(app): timeline jank
| -rw-r--r-- | packages/app/src/pages/session/message-timeline.tsx | 50 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.tsx | 62 | ||||
| -rw-r--r-- | packages/ui/src/hooks/create-auto-scroll.tsx | 4 |
3 files changed, 62 insertions, 54 deletions
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 19d6e09d9..fbf5ba291 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1,4 +1,4 @@ -import { For, createEffect, createMemo, on, onCleanup, Show, startTransition, type JSX } from "solid-js" +import { For, createEffect, createMemo, on, onCleanup, Show, startTransition, Index, type JSX } from "solid-js" import { createStore, produce } from "solid-js/store" import { useNavigate, useParams } from "@solidjs/router" import { Button } from "@opencode-ai/ui/button" @@ -711,28 +711,34 @@ export function MessageTimeline(props: { <div class="w-full px-4 md:px-5 pb-2"> <div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar"> <div class="flex w-max min-w-full justify-end gap-2"> - <For each={comments()}> - {(comment) => ( - <div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2"> - <div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong"> - <FileIcon node={{ path: comment.path, type: "file" }} class="size-3.5 shrink-0" /> - <span class="truncate">{getFilename(comment.path)}</span> - <Show when={comment.selection}> - {(selection) => ( - <span class="shrink-0 text-text-weak"> - {selection().startLine === selection().endLine - ? `:${selection().startLine}` - : `:${selection().startLine}-${selection().endLine}`} - </span> - )} - </Show> + <Index each={comments()}> + {(commentAccessor: () => MessageComment) => { + const comment = createMemo(() => commentAccessor()) + return ( + <div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2"> + <div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong"> + <FileIcon + node={{ path: comment().path, type: "file" }} + class="size-3.5 shrink-0" + /> + <span class="truncate">{getFilename(comment().path)}</span> + <Show when={comment().selection}> + {(selection) => ( + <span class="shrink-0 text-text-weak"> + {selection().startLine === selection().endLine + ? `:${selection().startLine}` + : `:${selection().startLine}-${selection().endLine}`} + </span> + )} + </Show> + </div> + <div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words"> + {comment().comment} + </div> </div> - <div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words"> - {comment.comment} - </div> - </div> - )} - </For> + ) + }} + </Index> </div> </div> </div> diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 2490f5c17..a97b38671 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -762,10 +762,12 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) { </Collapsible.Trigger> <Collapsible.Content> <div data-component="context-tool-group-list"> - <For each={props.parts}> - {(part) => { - const trigger = contextToolTrigger(part, i18n) - const running = part.state.status === "pending" || part.state.status === "running" + <Index each={props.parts}> + {(partAccessor) => { + const trigger = createMemo(() => contextToolTrigger(partAccessor(), i18n)) + const running = createMemo( + () => partAccessor().state.status === "pending" || partAccessor().state.status === "running", + ) return ( <div data-slot="context-tool-group-item"> <div data-component="tool-trigger"> @@ -774,13 +776,13 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) { <div data-slot="basic-tool-tool-info-structured"> <div data-slot="basic-tool-tool-info-main"> <span data-slot="basic-tool-tool-title"> - <TextShimmer text={trigger.title} active={running} /> + <TextShimmer text={trigger().title} active={running()} /> </span> - <Show when={!running && trigger.subtitle}> - <span data-slot="basic-tool-tool-subtitle">{trigger.subtitle}</span> + <Show when={!running() && trigger().subtitle}> + <span data-slot="basic-tool-tool-subtitle">{trigger().subtitle}</span> </Show> - <Show when={!running && trigger.args?.length}> - <For each={trigger.args}> + <Show when={!running() && trigger().args?.length}> + <For each={trigger().args}> {(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>} </For> </Show> @@ -792,7 +794,7 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) { </div> ) }} - </For> + </Index> </div> </Collapsible.Content> </Collapsible> @@ -1096,30 +1098,30 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre PART_MAPPING["tool"] = function ToolPartDisplay(props) { const i18n = useI18n() - const part = props.part as ToolPart - if (part.tool === "todowrite" || part.tool === "todoread") return null + const part = () => props.part as ToolPart + if (part().tool === "todowrite" || part().tool === "todoread") return null const hideQuestion = createMemo( - () => part.tool === "question" && (part.state.status === "pending" || part.state.status === "running"), + () => part().tool === "question" && (part().state.status === "pending" || part().state.status === "running"), ) const emptyInput: Record<string, any> = {} const emptyMetadata: Record<string, any> = {} - const input = () => part.state?.input ?? emptyInput + const input = () => part().state?.input ?? emptyInput // @ts-expect-error - const partMetadata = () => part.state?.metadata ?? emptyMetadata + const partMetadata = () => part().state?.metadata ?? emptyMetadata - const render = ToolRegistry.render(part.tool) ?? GenericTool + const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool) return ( <Show when={!hideQuestion()}> <div data-component="tool-part-wrapper"> <Switch> - <Match when={part.state.status === "error" && part.state.error}> + <Match when={part().state.status === "error" && (part().state as any).error}> {(error) => { const cleaned = error().replace("Error: ", "") - if (part.tool === "question" && cleaned.includes("dismissed this question")) { + if (part().tool === "question" && cleaned.includes("dismissed this question")) { return ( <div style="width: 100%; display: flex; justify-content: flex-end;"> <span class="text-13-regular text-text-weak cursor-default"> @@ -1151,13 +1153,13 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { </Match> <Match when={true}> <Dynamic - component={render} + component={render()} input={input()} - tool={part.tool} + tool={part().tool} metadata={partMetadata()} // @ts-expect-error - output={part.state.output} - status={part.state.status} + output={part().state.output} + status={part().state.status} hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} /> @@ -1186,7 +1188,7 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() { PART_MAPPING["text"] = function TextPartDisplay(props) { const data = useData() const i18n = useI18n() - const part = props.part as TextPart + const part = () => props.part as TextPart const interrupted = createMemo( () => props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError", @@ -1229,18 +1231,18 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { return items.filter((x) => !!x).join(" \u00B7 ") }) - const displayText = () => (part.text ?? "").trim() + const displayText = () => (part().text ?? "").trim() const throttledText = createThrottledValue(displayText) const isLastTextPart = createMemo(() => { const last = (data.store.part?.[props.message.id] ?? []) .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim()) .at(-1) - return last?.id === part.id + return last?.id === part().id }) const showCopy = createMemo(() => { if (props.message.role !== "assistant") return isLastTextPart() if (props.showAssistantCopyPartID === null) return false - if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part.id + if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id return isLastTextPart() }) const [copied, setCopied] = createSignal(false) @@ -1257,7 +1259,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { <Show when={throttledText()}> <div data-component="text-part"> <div data-slot="text-part-body"> - <Markdown text={throttledText()} cacheKey={part.id} /> + <Markdown text={throttledText()} cacheKey={part().id} /> </div> <Show when={showCopy()}> <div data-slot="text-part-copy-wrapper" data-interrupted={interrupted() ? "" : undefined}> @@ -1288,14 +1290,14 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { } PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { - const part = props.part as ReasoningPart - const text = () => part.text.trim() + const part = () => props.part as ReasoningPart + const text = () => part().text.trim() const throttledText = createThrottledValue(text) return ( <Show when={throttledText()}> <div data-component="reasoning-part"> - <Markdown text={throttledText()} cacheKey={part.id} /> + <Markdown text={throttledText()} cacheKey={part().id} /> </div> </Show> ) diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx index 8483915a8..d67b1f31f 100644 --- a/packages/ui/src/hooks/create-auto-scroll.tsx +++ b/packages/ui/src/hooks/create-auto-scroll.tsx @@ -48,14 +48,14 @@ export function createAutoScroll(options: AutoScrollOptions) { autoTimer = setTimeout(() => { auto = undefined autoTimer = undefined - }, 250) + }, 1500) } const isAuto = (el: HTMLElement) => { const a = auto if (!a) return false - if (Date.now() - a.time > 250) { + if (Date.now() - a.time > 1500) { auto = undefined return false } |
