summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-29 14:23:16 -0600
committerAdam <[email protected]>2025-12-29 14:23:41 -0600
commit68b4038196d6275cc274dc776d89002e2592b55b (patch)
tree33b610d7cefee4dfd4bf94960fb9174fa899fd5c /packages/ui/src
parent31092149002eb69111f4b94a16f875e101c61432 (diff)
downloadopencode-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.tsx50
-rw-r--r--packages/ui/src/components/session-turn.tsx96
-rw-r--r--packages/ui/src/hooks/create-auto-scroll.tsx86
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,