diff options
| author | Adam <[email protected]> | 2025-12-29 14:23:16 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-29 14:23:41 -0600 |
| commit | 68b4038196d6275cc274dc776d89002e2592b55b (patch) | |
| tree | 33b610d7cefee4dfd4bf94960fb9174fa899fd5c /packages/ui/src | |
| parent | 31092149002eb69111f4b94a16f875e101c61432 (diff) | |
| download | opencode-68b4038196d6275cc274dc776d89002e2592b55b.tar.gz opencode-68b4038196d6275cc274dc776d89002e2592b55b.zip | |
fix(desktop): more performance/scrolling fixes
Diffstat (limited to 'packages/ui/src')
| -rw-r--r-- | packages/ui/src/components/message-part.tsx | 50 | ||||
| -rw-r--r-- | packages/ui/src/components/session-turn.tsx | 96 | ||||
| -rw-r--r-- | packages/ui/src/hooks/create-auto-scroll.tsx | 86 |
3 files changed, 161 insertions, 71 deletions
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 8590b31cb..be40fbd33 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -95,6 +95,12 @@ export const PART_MAPPING: Record<string, PartComponent | undefined> = {} const TEXT_RENDER_THROTTLE_MS = 250 +function same<T>(a: readonly T[], b: readonly T[]) { + if (a === b) return true + if (a.length !== b.length) return false + return a.every((x, i) => x === b[i]) +} + function createThrottledValue(getValue: () => string) { const [value, setValue] = createSignal(getValue()) let timeout: ReturnType<typeof setTimeout> | undefined @@ -257,11 +263,15 @@ export function Message(props: MessageProps) { } export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) { - const filteredParts = createMemo(() => { - return props.parts?.filter((x) => { - return x.type !== "tool" || (x as ToolPart).tool !== "todoread" - }) - }) + const emptyParts: PartType[] = [] + const filteredParts = createMemo( + () => + props.parts.filter((x) => { + return x.type !== "tool" || (x as ToolPart).tool !== "todoread" + }), + emptyParts, + { equals: same }, + ) return <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} />}</For> } @@ -425,9 +435,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const part = props.part as ToolPart const permission = createMemo(() => { - const sessionID = props.message.sessionID - const permissions = data.store.permission?.[sessionID] ?? [] - const next = permissions[0] + const next = data.store.permission?.[props.message.sessionID]?.[0] if (!next) return undefined if (next.callID !== part.callID) return undefined return next @@ -448,13 +456,17 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { }) } - const component = createMemo(() => { - const render = ToolRegistry.render(part.tool) ?? GenericTool - // @ts-expect-error - const metadata = part.state?.metadata ?? {} - const input = part.state?.input ?? {} + const emptyInput: Record<string, any> = {} + const emptyMetadata: Record<string, any> = {} - return ( + const input = () => part.state?.input ?? emptyInput + // @ts-expect-error + const metadata = () => part.state?.metadata ?? emptyMetadata + + const render = ToolRegistry.render(part.tool) ?? GenericTool + + return ( + <div data-component="tool-part-wrapper" data-permission={!!permission()}> <Switch> <Match when={part.state.status === "error" && part.state.error}> {(error) => { @@ -483,9 +495,9 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { <Match when={true}> <Dynamic component={render} - input={input} + input={input()} tool={part.tool} - metadata={metadata} + metadata={metadata()} // @ts-expect-error output={part.state.output} status={part.state.status} @@ -495,12 +507,6 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { /> </Match> </Switch> - ) - }) - - return ( - <div data-component="tool-part-wrapper" data-permission={!!permission()}> - <Show when={component()}>{component()}</Show> <Show when={permission()}> {(perm) => ( <div data-component="permission-prompt"> diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index bd75ab5e8..e2169102f 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -1,4 +1,11 @@ -import { AssistantMessage, Part as PartType, TextPart, ToolPart } from "@opencode-ai/sdk/v2/client" +import { + AssistantMessage, + Message as MessageType, + Part as PartType, + type Permission, + TextPart, + ToolPart, +} from "@opencode-ai/sdk/v2/client" import { useData } from "../context" import { useDiffComponent } from "../context/diff" import { getDirectory, getFilename } from "@opencode-ai/util/path" @@ -61,13 +68,20 @@ function computeStatusFromPart(part: PartType | undefined): string | undefined { return undefined } +function same<T>(a: readonly T[], b: readonly T[]) { + if (a === b) return true + if (a.length !== b.length) return false + return a.every((x, i) => x === b[i]) +} + function AssistantMessageItem(props: { message: AssistantMessage responsePartId: string | undefined hideResponsePart: boolean }) { const data = useData() - const msgParts = createMemo(() => data.store.part[props.message.id] ?? []) + const emptyParts: PartType[] = [] + const msgParts = createMemo(() => data.store.part[props.message.id] ?? emptyParts) const lastTextPart = createMemo(() => { const parts = msgParts() for (let i = parts.length - 1; i >= 0; i--) { @@ -109,7 +123,14 @@ export function SessionTurn( const data = useData() const diffComponent = useDiffComponent() - const allMessages = createMemo(() => data.store.message[props.sessionID] ?? []) + const emptyMessages: MessageType[] = [] + const emptyParts: PartType[] = [] + const emptyAssistant: AssistantMessage[] = [] + const emptyPermissions: Permission[] = [] + const emptyPermissionParts: { part: ToolPart; message: AssistantMessage }[] = [] + const idle = { type: "idle" as const } + + const allMessages = createMemo(() => data.store.message[props.sessionID] ?? emptyMessages) const messageIndex = createMemo(() => { const messages = allMessages() @@ -147,27 +168,31 @@ export function SessionTurn( const parts = createMemo(() => { const msg = message() - if (!msg) return [] - return data.store.part[msg.id] ?? [] + if (!msg) return emptyParts + return data.store.part[msg.id] ?? emptyParts }) - const assistantMessages = createMemo(() => { - const msg = message() - if (!msg) return [] as AssistantMessage[] - - const messages = allMessages() - const index = messageIndex() - if (index < 0) return [] as AssistantMessage[] - - const result: AssistantMessage[] = [] - for (let i = index + 1; i < messages.length; i++) { - const item = messages[i] - if (!item) continue - if (item.role === "user") break - if (item.role === "assistant" && item.parentID === msg.id) result.push(item as AssistantMessage) - } - return result - }) + const assistantMessages = createMemo( + () => { + const msg = message() + if (!msg) return emptyAssistant + + const messages = allMessages() + const index = messageIndex() + if (index < 0) return emptyAssistant + + const result: AssistantMessage[] = [] + for (let i = index + 1; i < messages.length; i++) { + const item = messages[i] + if (!item) continue + if (item.role === "user") break + if (item.role === "assistant" && item.parentID === msg.id) result.push(item as AssistantMessage) + } + return result + }, + emptyAssistant, + { equals: same }, + ) const lastAssistantMessage = createMemo(() => assistantMessages().at(-1)) @@ -176,7 +201,7 @@ export function SessionTurn( const lastTextPart = createMemo(() => { const msgs = assistantMessages() for (let mi = msgs.length - 1; mi >= 0; mi--) { - const msgParts = data.store.part[msgs[mi].id] ?? [] + const msgParts = data.store.part[msgs[mi].id] ?? emptyParts for (let pi = msgParts.length - 1; pi >= 0; pi--) { const part = msgParts[pi] if (part?.type === "text") return part as TextPart @@ -196,25 +221,25 @@ export function SessionTurn( return false }) - const permissions = createMemo(() => data.store.permission?.[props.sessionID] ?? []) + const permissions = createMemo(() => data.store.permission?.[props.sessionID] ?? emptyPermissions) const permissionCount = createMemo(() => permissions().length) const nextPermission = createMemo(() => permissions()[0]) const permissionParts = createMemo(() => { - if (props.stepsExpanded) return [] as { part: ToolPart; message: AssistantMessage }[] + if (props.stepsExpanded) return emptyPermissionParts const next = nextPermission() - if (!next) return [] as { part: ToolPart; message: AssistantMessage }[] + if (!next) return emptyPermissionParts for (const message of assistantMessages()) { - const parts = data.store.part[message.id] ?? [] + const parts = data.store.part[message.id] ?? emptyParts for (const part of parts) { if (part?.type !== "tool") continue const tool = part as ToolPart if (tool.callID === next.callID) return [{ part: tool, message }] } } - return [] as { part: ToolPart; message: AssistantMessage }[] + return emptyPermissionParts }) const shellModePart = createMemo(() => { @@ -224,7 +249,7 @@ export function SessionTurn( const msgs = assistantMessages() if (msgs.length !== 1) return - const msgParts = data.store.part[msgs[0].id] ?? [] + const msgParts = data.store.part[msgs[0].id] ?? emptyParts if (msgParts.length !== 1) return const assistantPart = msgParts[0] @@ -239,7 +264,7 @@ export function SessionTurn( let currentTask: ToolPart | undefined for (let mi = msgs.length - 1; mi >= 0; mi--) { - const msgParts = data.store.part[msgs[mi].id] ?? [] + const msgParts = data.store.part[msgs[mi].id] ?? emptyParts for (let pi = msgParts.length - 1; pi >= 0; pi--) { const part = msgParts[pi] if (!part) continue @@ -266,12 +291,12 @@ export function SessionTurn( : undefined if (taskSessionId) { - const taskMessages = data.store.message[taskSessionId] ?? [] + const taskMessages = data.store.message[taskSessionId] ?? emptyMessages for (let mi = taskMessages.length - 1; mi >= 0; mi--) { const msg = taskMessages[mi] if (!msg || msg.role !== "assistant") continue - const msgParts = data.store.part[msg.id] ?? [] + const msgParts = data.store.part[msg.id] ?? emptyParts for (let pi = msgParts.length - 1; pi >= 0; pi--) { const part = msgParts[pi] if (part) return computeStatusFromPart(part) @@ -282,12 +307,7 @@ export function SessionTurn( return computeStatusFromPart(last) }) - const status = createMemo( - () => - data.store.session_status[props.sessionID] ?? { - type: "idle", - }, - ) + const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle) const working = createMemo(() => status().type !== "idle" && isLastUserMessage()) const retry = createMemo(() => { const s = status() diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx index b780f47c6..1fb2e0c81 100644 --- a/packages/ui/src/hooks/create-auto-scroll.tsx +++ b/packages/ui/src/hooks/create-auto-scroll.tsx @@ -19,38 +19,88 @@ export function createAutoScroll(options: AutoScrollOptions) { let autoScrollTimeout: ReturnType<typeof setTimeout> | undefined let isMouseDown = false let cleanupListeners: (() => void) | undefined + let scheduledScroll = false + let scheduledForce = false - function scrollToBottom() { - if (!scrollRef || store.userScrolled || !options.working()) return + function distanceFromBottom() { + if (!scrollRef) return 0 + return scrollRef.scrollHeight - scrollRef.clientHeight - scrollRef.scrollTop + } + function startAutoScroll() { isAutoScrolling = true if (autoScrollTimeout) clearTimeout(autoScrollTimeout) autoScrollTimeout = setTimeout(() => { isAutoScrolling = false }, 1000) + } + + function scrollToBottomNow() { + if (!scrollRef || store.userScrolled || !options.working()) return + + const distance = distanceFromBottom() + if (distance < 2) return + const behavior = distance > 96 ? "auto" : "smooth" + startAutoScroll() scrollRef.scrollTo({ top: scrollRef.scrollHeight, - behavior: "smooth", + behavior, }) } - function forceScrollToBottom() { + function forceScrollToBottomNow() { if (!scrollRef) return - setStore("userScrolled", false) - isAutoScrolling = true - if (autoScrollTimeout) clearTimeout(autoScrollTimeout) - autoScrollTimeout = setTimeout(() => { - isAutoScrolling = false - }, 1000) + if (store.userScrolled) setStore("userScrolled", false) + const distance = distanceFromBottom() + if (distance < 2) return + + startAutoScroll() scrollRef.scrollTo({ top: scrollRef.scrollHeight, - behavior: "smooth", + behavior: "auto", }) } + function scheduleScrollToBottom(force = false) { + if (typeof requestAnimationFrame === "undefined") { + if (force) { + forceScrollToBottomNow() + return + } + scrollToBottomNow() + return + } + + if (force) scheduledForce = true + if (scheduledScroll) return + + scheduledScroll = true + requestAnimationFrame(() => { + scheduledScroll = false + + const shouldForce = scheduledForce + scheduledForce = false + + if (shouldForce) { + forceScrollToBottomNow() + return + } + + scrollToBottomNow() + }) + } + + function scrollToBottom() { + scheduleScrollToBottom(false) + } + + function forceScrollToBottom() { + scheduleScrollToBottom(true) + } + function handleScroll() { if (!scrollRef) return @@ -132,6 +182,20 @@ export function createAutoScroll(options: AutoScrollOptions) { } }) + // Ensure pinned-to-bottom stays pinned during heavy DOM updates + createEffect(() => { + const el = store.contentRef + if (!el) return + + const observer = new MutationObserver(() => { + if (store.userScrolled) return + if (!options.working()) return + scheduleScrollToBottom(false) + }) + observer.observe(el, { childList: true, subtree: true, characterData: true }) + onCleanup(() => observer.disconnect()) + }) + // Handle content resize createResizeObserver( () => store.contentRef, |
