summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-03-03 05:35:07 -0600
committerAdam <[email protected]>2026-03-03 05:35:15 -0600
commite4af1bb42284bc76adf54927f4b224224830f1b5 (patch)
treedcb536b73884517ad5b78342440f31384559a93a
parent5e8742f4312a8923f3da92172a7247470ef34516 (diff)
downloadopencode-e4af1bb42284bc76adf54927f4b224224830f1b5.tar.gz
opencode-e4af1bb42284bc76adf54927f4b224224830f1b5.zip
fix(app): timeline jank
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx50
-rw-r--r--packages/ui/src/components/message-part.tsx62
-rw-r--r--packages/ui/src/hooks/create-auto-scroll.tsx4
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
}