fix(app): timeline jank

This commit is contained in:
Adam
2026-03-03 05:35:07 -06:00
parent 5e8742f431
commit e4af1bb422
3 changed files with 62 additions and 54 deletions

View File

@@ -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 { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button" 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="w-full px-4 md:px-5 pb-2">
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar"> <div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
<div class="flex w-max min-w-full justify-end gap-2"> <div class="flex w-max min-w-full justify-end gap-2">
<For each={comments()}> <Index each={comments()}>
{(comment) => ( {(commentAccessor: () => MessageComment) => {
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2"> const comment = createMemo(() => commentAccessor())
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong"> return (
<FileIcon node={{ path: comment.path, type: "file" }} class="size-3.5 shrink-0" /> <div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
<span class="truncate">{getFilename(comment.path)}</span> <div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
<Show when={comment.selection}> <FileIcon
{(selection) => ( node={{ path: comment().path, type: "file" }}
<span class="shrink-0 text-text-weak"> class="size-3.5 shrink-0"
{selection().startLine === selection().endLine />
? `:${selection().startLine}` <span class="truncate">{getFilename(comment().path)}</span>
: `:${selection().startLine}-${selection().endLine}`} <Show when={comment().selection}>
</span> {(selection) => (
)} <span class="shrink-0 text-text-weak">
</Show> {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>
<div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words"> )
{comment.comment} }}
</div> </Index>
</div>
)}
</For>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -762,10 +762,12 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
</Collapsible.Trigger> </Collapsible.Trigger>
<Collapsible.Content> <Collapsible.Content>
<div data-component="context-tool-group-list"> <div data-component="context-tool-group-list">
<For each={props.parts}> <Index each={props.parts}>
{(part) => { {(partAccessor) => {
const trigger = contextToolTrigger(part, i18n) const trigger = createMemo(() => contextToolTrigger(partAccessor(), i18n))
const running = part.state.status === "pending" || part.state.status === "running" const running = createMemo(
() => partAccessor().state.status === "pending" || partAccessor().state.status === "running",
)
return ( return (
<div data-slot="context-tool-group-item"> <div data-slot="context-tool-group-item">
<div data-component="tool-trigger"> <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-structured">
<div data-slot="basic-tool-tool-info-main"> <div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title"> <span data-slot="basic-tool-tool-title">
<TextShimmer text={trigger.title} active={running} /> <TextShimmer text={trigger().title} active={running()} />
</span> </span>
<Show when={!running && trigger.subtitle}> <Show when={!running() && trigger().subtitle}>
<span data-slot="basic-tool-tool-subtitle">{trigger.subtitle}</span> <span data-slot="basic-tool-tool-subtitle">{trigger().subtitle}</span>
</Show> </Show>
<Show when={!running && trigger.args?.length}> <Show when={!running() && trigger().args?.length}>
<For each={trigger.args}> <For each={trigger().args}>
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>} {(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
</For> </For>
</Show> </Show>
@@ -792,7 +794,7 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
</div> </div>
) )
}} }}
</For> </Index>
</div> </div>
</Collapsible.Content> </Collapsible.Content>
</Collapsible> </Collapsible>
@@ -1096,30 +1098,30 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre
PART_MAPPING["tool"] = function ToolPartDisplay(props) { PART_MAPPING["tool"] = function ToolPartDisplay(props) {
const i18n = useI18n() const i18n = useI18n()
const part = props.part as ToolPart const part = () => props.part as ToolPart
if (part.tool === "todowrite" || part.tool === "todoread") return null if (part().tool === "todowrite" || part().tool === "todoread") return null
const hideQuestion = createMemo( 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 emptyInput: Record<string, any> = {}
const emptyMetadata: Record<string, any> = {} const emptyMetadata: Record<string, any> = {}
const input = () => part.state?.input ?? emptyInput const input = () => part().state?.input ?? emptyInput
// @ts-expect-error // @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 ( return (
<Show when={!hideQuestion()}> <Show when={!hideQuestion()}>
<div data-component="tool-part-wrapper"> <div data-component="tool-part-wrapper">
<Switch> <Switch>
<Match when={part.state.status === "error" && part.state.error}> <Match when={part().state.status === "error" && (part().state as any).error}>
{(error) => { {(error) => {
const cleaned = error().replace("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 ( return (
<div style="width: 100%; display: flex; justify-content: flex-end;"> <div style="width: 100%; display: flex; justify-content: flex-end;">
<span class="text-13-regular text-text-weak cursor-default"> <span class="text-13-regular text-text-weak cursor-default">
@@ -1151,13 +1153,13 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
</Match> </Match>
<Match when={true}> <Match when={true}>
<Dynamic <Dynamic
component={render} component={render()}
input={input()} input={input()}
tool={part.tool} tool={part().tool}
metadata={partMetadata()} metadata={partMetadata()}
// @ts-expect-error // @ts-expect-error
output={part.state.output} output={part().state.output}
status={part.state.status} status={part().state.status}
hideDetails={props.hideDetails} hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen} defaultOpen={props.defaultOpen}
/> />
@@ -1186,7 +1188,7 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() {
PART_MAPPING["text"] = function TextPartDisplay(props) { PART_MAPPING["text"] = function TextPartDisplay(props) {
const data = useData() const data = useData()
const i18n = useI18n() const i18n = useI18n()
const part = props.part as TextPart const part = () => props.part as TextPart
const interrupted = createMemo( const interrupted = createMemo(
() => () =>
props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError", 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 ") return items.filter((x) => !!x).join(" \u00B7 ")
}) })
const displayText = () => (part.text ?? "").trim() const displayText = () => (part().text ?? "").trim()
const throttledText = createThrottledValue(displayText) const throttledText = createThrottledValue(displayText)
const isLastTextPart = createMemo(() => { const isLastTextPart = createMemo(() => {
const last = (data.store.part?.[props.message.id] ?? []) const last = (data.store.part?.[props.message.id] ?? [])
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim()) .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
.at(-1) .at(-1)
return last?.id === part.id return last?.id === part().id
}) })
const showCopy = createMemo(() => { const showCopy = createMemo(() => {
if (props.message.role !== "assistant") return isLastTextPart() if (props.message.role !== "assistant") return isLastTextPart()
if (props.showAssistantCopyPartID === null) return false 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() return isLastTextPart()
}) })
const [copied, setCopied] = createSignal(false) const [copied, setCopied] = createSignal(false)
@@ -1257,7 +1259,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
<Show when={throttledText()}> <Show when={throttledText()}>
<div data-component="text-part"> <div data-component="text-part">
<div data-slot="text-part-body"> <div data-slot="text-part-body">
<Markdown text={throttledText()} cacheKey={part.id} /> <Markdown text={throttledText()} cacheKey={part().id} />
</div> </div>
<Show when={showCopy()}> <Show when={showCopy()}>
<div data-slot="text-part-copy-wrapper" data-interrupted={interrupted() ? "" : undefined}> <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) { PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
const part = props.part as ReasoningPart const part = () => props.part as ReasoningPart
const text = () => part.text.trim() const text = () => part().text.trim()
const throttledText = createThrottledValue(text) const throttledText = createThrottledValue(text)
return ( return (
<Show when={throttledText()}> <Show when={throttledText()}>
<div data-component="reasoning-part"> <div data-component="reasoning-part">
<Markdown text={throttledText()} cacheKey={part.id} /> <Markdown text={throttledText()} cacheKey={part().id} />
</div> </div>
</Show> </Show>
) )

View File

@@ -48,14 +48,14 @@ export function createAutoScroll(options: AutoScrollOptions) {
autoTimer = setTimeout(() => { autoTimer = setTimeout(() => {
auto = undefined auto = undefined
autoTimer = undefined autoTimer = undefined
}, 250) }, 1500)
} }
const isAuto = (el: HTMLElement) => { const isAuto = (el: HTMLElement) => {
const a = auto const a = auto
if (!a) return false if (!a) return false
if (Date.now() - a.time > 250) { if (Date.now() - a.time > 1500) {
auto = undefined auto = undefined
return false return false
} }