summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-04 04:24:32 -0600
committerAdam <[email protected]>2026-01-04 04:24:37 -0600
commit7ce0520f8da74a26f92fd7516d3f8c57dc3ac4c5 (patch)
tree3d61246fb5a99aee7f6de0682454d8802f907cb6
parent4486174e4319b9523d134fda863677f35e740105 (diff)
downloadopencode-7ce0520f8da74a26f92fd7516d3f8c57dc3ac4c5.tar.gz
opencode-7ce0520f8da74a26f92fd7516d3f8c57dc3ac4c5.zip
fix(app): auto-scroll behaviors
-rw-r--r--packages/app/src/context/platform.tsx2
-rw-r--r--packages/app/src/pages/session.tsx282
-rw-r--r--packages/desktop/src/index.tsx2
-rw-r--r--packages/ui/src/hooks/create-auto-scroll.tsx253
4 files changed, 131 insertions, 408 deletions
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx
index b41d6da1e..7fcbb620a 100644
--- a/packages/app/src/context/platform.tsx
+++ b/packages/app/src/context/platform.tsx
@@ -3,7 +3,7 @@ import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
export type Platform = {
/** Platform discriminator */
- platform: "web" | "tauri"
+ platform: "web" | "desktop"
/** App version */
version?: string
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 97780241f..221831605 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -1,4 +1,4 @@
-import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on, createRenderEffect, batch } from "solid-js"
+import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on, batch } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { Dynamic } from "solid-js/web"
import { useLocal } from "@/context/local"
@@ -47,6 +47,7 @@ import {
SortableTerminalTab,
NewSessionView,
} from "@/components/session"
+import { usePlatform } from "@/context/platform"
function same<T>(a: readonly T[], b: readonly T[]) {
if (a === b) return true
@@ -147,6 +148,7 @@ export default function Page() {
const dialog = useDialog()
const codeComponent = useCodeComponent()
const command = useCommand()
+ const platform = usePlatform()
const params = useParams()
const navigate = useNavigate()
const sdk = useSDK()
@@ -241,13 +243,9 @@ export default function Page() {
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
- userInteracted: false,
- stepsExpanded: true,
- mobileStepsExpanded: {} as Record<string, boolean>,
+ expanded: {} as Record<string, boolean>,
messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "review",
- ignoreScrollSpy: false,
- initialScrollDone: !params.id,
newSessionWorktree: "main",
})
@@ -309,47 +307,24 @@ export default function Page() {
),
)
- createEffect(
- on(
- () => params.id,
- (id) => {
- const status = sync.data.session_status[id ?? ""] ?? idle
- batch(() => {
- setStore("userInteracted", false)
- setStore("stepsExpanded", status.type !== "idle")
- })
- },
- ),
- )
-
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
createEffect(
on(
- () => status().type,
- (type) => {
- if (type !== "idle") return
- batch(() => {
- setStore("userInteracted", false)
- setStore("stepsExpanded", false)
- })
+ () => params.id,
+ () => {
+ setStore("messageId", undefined)
+ setStore("expanded", {})
},
{ defer: true },
),
)
- const working = createMemo(() => status().type !== "idle" && activeMessage()?.id === lastUserMessage()?.id)
-
- createRenderEffect((prev) => {
- const isWorking = working()
- if (!prev && isWorking) {
- setStore("stepsExpanded", true)
- }
- if (prev && !isWorking && !store.userInteracted) {
- setStore("stepsExpanded", false)
- }
- return isWorking
- }, working())
+ createEffect(() => {
+ const id = lastUserMessage()?.id
+ if (!id) return
+ setStore("expanded", id, status().type !== "idle")
+ })
command.register(() => [
{
@@ -398,12 +373,16 @@ export default function Page() {
{
id: "steps.toggle",
title: "Toggle steps",
- description: "Show or hide the steps",
+ description: "Show or hide steps for the current message",
category: "View",
keybind: "mod+e",
slash: "steps",
disabled: !params.id,
- onSelect: () => setStore("stepsExpanded", (x) => !x),
+ onSelect: () => {
+ const msg = activeMessage()
+ if (!msg) return
+ setStore("expanded", msg.id, (open: boolean | undefined) => !open)
+ },
},
{
id: "message.previous",
@@ -673,204 +652,76 @@ export default function Page() {
const isWorking = createMemo(() => status().type !== "idle")
const autoScroll = createAutoScroll({
working: isWorking,
- onUserInteracted: () => setStore("userInteracted", true),
})
- let scrollContainer: HTMLDivElement | undefined
- let initialScrollFrame: number | undefined
- let initialScrollTarget: string | undefined
-
- const cancelInitialScroll = () => {
- if (initialScrollFrame === undefined) return
- cancelAnimationFrame(initialScrollFrame)
- initialScrollFrame = undefined
- }
+ let scrollSpyFrame: number | undefined
+ let scrollSpyTarget: HTMLDivElement | undefined
- const ensureInitialScroll = () => {
- cancelInitialScroll()
- initialScrollFrame = requestAnimationFrame(() => {
- initialScrollFrame = undefined
- if (!params.id) {
- initialScrollTarget = undefined
- setStore("initialScrollDone", true)
- return
- }
- const msgs = visibleUserMessages()
- if (msgs.length === 0) {
- if (!messagesReady()) {
- ensureInitialScroll()
- return
- }
- initialScrollTarget = undefined
- setStore("initialScrollDone", true)
- return
- }
- const last = msgs[msgs.length - 1]
- const el = messageRefs.get(last.id)
- if (!el || !scrollContainer) {
- ensureInitialScroll()
- return
- }
- scrollToMessage(last, "auto")
- initialScrollTarget = last.id
- setStore("initialScrollDone", true)
- })
- }
+ const anchor = (id: string) => `message-${id}`
const setScrollRef = (el: HTMLDivElement | undefined) => {
- scrollContainer = el
autoScroll.scrollRef(el)
}
- const messageRefs = new Map<string, HTMLDivElement>()
- let scrollTimer: number | undefined
-
- createEffect(() => {
- const msgs = visibleUserMessages()
- if (msgs.length === 0) {
- messageRefs.clear()
- return
- }
- const ids = new Set(msgs.map((m) => m.id))
- for (const id of messageRefs.keys()) {
- if (ids.has(id)) continue
- messageRefs.delete(id)
- }
- })
-
- let scrollSpyIndex = 0
+ const updateHash = (id: string) => {
+ window.history.replaceState(null, "", `#${anchor(id)}`)
+ }
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
- setStore("ignoreScrollSpy", true)
setActiveMessage(message)
- const msgs = visibleUserMessages()
- const idx = msgs.findIndex((m) => m.id === message.id)
- if (idx >= 0) scrollSpyIndex = idx
+ const el = document.getElementById(anchor(message.id))
+ if (el) el.scrollIntoView({ behavior, block: "start" })
+ updateHash(message.id)
+ }
- const el = messageRefs.get(message.id)
- if (el) {
- el.scrollIntoView({ behavior, block: "start" })
+ const getActiveMessageId = (container: HTMLDivElement) => {
+ const cutoff = container.scrollTop + 100
+ const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
+ let id: string | undefined
+
+ for (const node of nodes) {
+ const next = node.dataset.messageId
+ if (!next) continue
+ if (node.offsetTop > cutoff) break
+ id = next
}
- if (scrollTimer !== undefined) window.clearTimeout(scrollTimer)
- scrollTimer = window.setTimeout(() => setStore("ignoreScrollSpy", false), 1000)
+ return id
}
- let scrollSpyFrame: number | undefined
- let scrollSpyTarget: HTMLDivElement | undefined
-
const scheduleScrollSpy = (container: HTMLDivElement) => {
- if (store.ignoreScrollSpy) return
scrollSpyTarget = container
if (scrollSpyFrame !== undefined) return
scrollSpyFrame = requestAnimationFrame(() => {
scrollSpyFrame = undefined
+
const target = scrollSpyTarget
scrollSpyTarget = undefined
if (!target) return
- if (store.ignoreScrollSpy) return
-
- const msgs = visibleUserMessages()
- const scrollTop = target.scrollTop
- const threshold = 100
- const cutoff = scrollTop + threshold
-
- if (msgs.length === 0) return
-
- if (scrollSpyIndex >= msgs.length) scrollSpyIndex = msgs.length - 1
- if (scrollSpyIndex < 0) scrollSpyIndex = 0
-
- while (scrollSpyIndex + 1 < msgs.length) {
- const next = msgs[scrollSpyIndex + 1]
- if (!next) break
-
- const el = messageRefs.get(next.id)
- if (!el) break
- if (el.offsetTop <= cutoff) {
- scrollSpyIndex += 1
- continue
- }
- break
- }
-
- while (scrollSpyIndex > 0) {
- const cur = msgs[scrollSpyIndex]
- if (!cur) break
-
- const el = messageRefs.get(cur.id)
- if (!el) break
- if (el.offsetTop > cutoff) {
- scrollSpyIndex -= 1
- continue
- }
- break
- }
- const msg = msgs[scrollSpyIndex]
- if (!msg) return
- if (msg.id === activeMessage()?.id) return
+ const id = getActiveMessageId(target)
+ if (!id) return
+ if (id === store.messageId) return
- setActiveMessage(msg)
+ setStore("messageId", id)
})
}
- createEffect(
- on(
- () => params.id,
- (id) => {
- cancelInitialScroll()
- if (scrollTimer !== undefined) window.clearTimeout(scrollTimer)
- scrollTimer = undefined
- if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
- scrollSpyFrame = undefined
- scrollSpyTarget = undefined
- messageRefs.clear()
- scrollSpyIndex = 0
- initialScrollTarget = undefined
- setStore("initialScrollDone", !id)
- },
- { defer: true },
- ),
- )
-
createEffect(() => {
- const msgs = visibleUserMessages()
- const target = msgs.at(-1)?.id
+ const sessionID = params.id
const ready = messagesReady()
+ if (!sessionID || !ready) return
- if (!params.id) {
- setStore("initialScrollDone", true)
- initialScrollTarget = undefined
- return
- }
-
- if (!ready) {
- setStore("initialScrollDone", false)
- ensureInitialScroll()
- return
- }
-
- if (!store.initialScrollDone) {
- ensureInitialScroll()
- return
- }
-
- if (!initialScrollTarget && target) {
- setStore("initialScrollDone", false)
- ensureInitialScroll()
- }
- })
-
- createEffect(() => {
- const msgs = visibleUserMessages()
- if (msgs.length === 0) return
requestAnimationFrame(() => {
- if (!scrollContainer) return
- if (!isDesktop()) return
- // Manually trigger spy once to set initial active message based on scroll position
- scheduleScrollSpy(scrollContainer)
+ const id = window.location.hash.slice(1)
+ const hashTarget = id ? document.getElementById(id) : undefined
+ if (hashTarget) {
+ hashTarget.scrollIntoView({ behavior: "auto", block: "start" })
+ return
+ }
+ autoScroll.forceScrollToBottom()
})
})
@@ -880,8 +731,6 @@ export default function Page() {
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
- cancelInitialScroll()
- if (scrollTimer !== undefined) window.clearTimeout(scrollTimer)
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
})
@@ -962,13 +811,10 @@ export default function Page() {
}}
onClick={autoScroll.handleInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar"
- classList={{
- "opacity-0 pointer-events-none": !store.initialScrollDone,
- }}
>
<div
ref={autoScroll.contentRef}
- class="flex flex-col gap-45 items-start justify-start pb-32 md:pb-40 transition-[margin]"
+ class="flex flex-col gap-32 items-start justify-start pb-32 md:pb-40 transition-[margin]"
classList={{
"mt-0.5": !showTabs(),
"mt-0": showTabs(),
@@ -977,16 +823,24 @@ export default function Page() {
<For each={visibleUserMessages()}>
{(message) => (
<div
- ref={(el) => messageRefs.set(message.id, el)}
- class="min-w-0 w-full max-w-full last:min-h-[80vh]"
+ id={anchor(message.id)}
+ data-message-id={message.id}
+ classList={{
+ "min-w-0 w-full max-w-full": true,
+ "last:min-h-[calc(100vh-13.5rem)] md:last:min-h-[calc(100vh-14.5rem)]":
+ platform.platform !== "desktop",
+ "last:min-h-[calc(100vh-15rem)] md:last:min-h-[calc(100vh-16rem)]":
+ platform.platform === "desktop",
+ }}
>
<SessionTurn
sessionID={params.id!}
messageID={message.id}
lastUserMessageID={lastUserMessage()?.id}
- stepsExpanded={store.mobileStepsExpanded[message.id] ?? false}
- onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)}
- onUserInteracted={() => setStore("userInteracted", true)}
+ stepsExpanded={store.expanded[message.id] ?? false}
+ onStepsExpandedToggle={() =>
+ setStore("expanded", message.id, (open: boolean | undefined) => !open)
+ }
classes={{
root: "min-w-0 w-full relative",
content:
diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx
index dbef0d7a4..1b822e265 100644
--- a/packages/desktop/src/index.tsx
+++ b/packages/desktop/src/index.tsx
@@ -27,7 +27,7 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
let update: Update | null = null
const platform: Platform = {
- platform: "tauri",
+ platform: "desktop",
version: pkg.version,
async openDirectoryPickerDialog(opts) {
diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx
index 1fb2e0c81..6dacc2795 100644
--- a/packages/ui/src/hooks/create-auto-scroll.tsx
+++ b/packages/ui/src/hooks/create-auto-scroll.tsx
@@ -1,4 +1,4 @@
-import { createEffect, onCleanup } from "solid-js"
+import { createEffect, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
@@ -8,240 +8,109 @@ export interface AutoScrollOptions {
}
export function createAutoScroll(options: AutoScrollOptions) {
- let scrollRef: HTMLElement | undefined
+ let scroll: HTMLElement | undefined
+ let settling = false
+ let settleTimer: ReturnType<typeof setTimeout> | undefined
+
const [store, setStore] = createStore({
contentRef: undefined as HTMLElement | undefined,
userScrolled: false,
})
- let lastScrollTop = 0
- let isAutoScrolling = false
- let autoScrollTimeout: ReturnType<typeof setTimeout> | undefined
- let isMouseDown = false
- let cleanupListeners: (() => void) | undefined
- let scheduledScroll = false
- let scheduledForce = false
-
- function distanceFromBottom() {
- if (!scrollRef) return 0
- return scrollRef.scrollHeight - scrollRef.clientHeight - scrollRef.scrollTop
- }
+ const active = () => options.working() || settling
- function startAutoScroll() {
- isAutoScrolling = true
- if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
- autoScrollTimeout = setTimeout(() => {
- isAutoScrolling = false
- }, 1000)
+ const distanceFromBottom = () => {
+ const el = scroll
+ if (!el) return 0
+ return el.scrollHeight - el.clientHeight - el.scrollTop
}
- 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,
- })
+ const scrollToBottomNow = (behavior: ScrollBehavior) => {
+ const el = scroll
+ if (!el) return
+ el.scrollTo({ top: el.scrollHeight, behavior })
}
- function forceScrollToBottomNow() {
- if (!scrollRef) return
+ const scrollToBottom = (force: boolean) => {
+ if (!force && !active()) return
+ if (!scroll) return
- if (store.userScrolled) setStore("userScrolled", false)
+ if (!force && store.userScrolled) return
+ if (force && store.userScrolled) setStore("userScrolled", false)
const distance = distanceFromBottom()
if (distance < 2) return
- startAutoScroll()
- scrollRef.scrollTo({
- top: scrollRef.scrollHeight,
- behavior: "auto",
- })
+ const behavior: ScrollBehavior = force || distance > 96 ? "auto" : "smooth"
+ scrollToBottomNow(behavior)
}
- 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
- }
+ const handleScroll = () => {
+ if (!options.working()) return
+ if (!scroll) return
- scrollToBottomNow()
- })
- }
-
- function scrollToBottom() {
- scheduleScrollToBottom(false)
- }
-
- function forceScrollToBottom() {
- scheduleScrollToBottom(true)
- }
-
- function handleScroll() {
- if (!scrollRef) return
-
- const { scrollTop, scrollHeight, clientHeight } = scrollRef
- const atBottom = Math.abs(scrollHeight - clientHeight - scrollTop) < 10
-
- if (isAutoScrolling) {
- if (atBottom) {
- isAutoScrolling = false
- if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
- }
- lastScrollTop = scrollTop
- return
- }
-
- if (atBottom) {
- if (store.userScrolled) {
- setStore("userScrolled", false)
- }
- lastScrollTop = scrollTop
+ if (distanceFromBottom() < 10) {
+ if (store.userScrolled) setStore("userScrolled", false)
return
}
- const delta = scrollTop - lastScrollTop
- if (delta < 0) {
- if (isMouseDown && !store.userScrolled && options.working()) {
- setStore("userScrolled", true)
- options.onUserInteracted?.()
- }
- }
-
- lastScrollTop = scrollTop
- }
+ if (store.userScrolled) return
- function handleInteraction() {
- if (options.working()) {
- setStore("userScrolled", true)
- options.onUserInteracted?.()
- }
+ setStore("userScrolled", true)
+ options.onUserInteracted?.()
}
- function handleWheel(e: WheelEvent) {
- if (e.deltaY < 0 && !store.userScrolled && options.working()) {
- setStore("userScrolled", true)
- options.onUserInteracted?.()
- }
- }
+ const handleInteraction = () => {
+ if (!options.working()) return
+ if (store.userScrolled) return
- function handleTouchStart() {
- if (!store.userScrolled && options.working()) {
- setStore("userScrolled", true)
- options.onUserInteracted?.()
- }
- }
-
- function handleKeyDown(e: KeyboardEvent) {
- if (["ArrowUp", "PageUp", "Home"].includes(e.key)) {
- if (!store.userScrolled && options.working()) {
- setStore("userScrolled", true)
- options.onUserInteracted?.()
- }
- }
+ setStore("userScrolled", true)
+ options.onUserInteracted?.()
}
- function handleMouseDown() {
- isMouseDown = true
- window.addEventListener("mouseup", handleMouseUp)
- }
+ createResizeObserver(
+ () => store.contentRef,
+ () => {
+ if (!active()) return
+ if (store.userScrolled) return
+ scrollToBottom(false)
+ },
+ )
- function handleMouseUp() {
- isMouseDown = false
- window.removeEventListener("mouseup", handleMouseUp)
- }
+ createEffect(
+ on(options.working, (working) => {
+ settling = false
+ if (settleTimer) clearTimeout(settleTimer)
+ settleTimer = undefined
- // Reset userScrolled when work completes
- createEffect(() => {
- if (!options.working()) {
setStore("userScrolled", false)
- }
- })
-
- // 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,
- () => {
- if (options.working() && !store.userScrolled) {
- scrollToBottom()
+ if (working) {
+ scrollToBottom(true)
+ return
}
- },
+
+ settling = true
+ settleTimer = setTimeout(() => {
+ settling = false
+ }, 300)
+ }),
)
onCleanup(() => {
- if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
- if (cleanupListeners) cleanupListeners()
+ if (settleTimer) clearTimeout(settleTimer)
})
return {
scrollRef: (el: HTMLElement | undefined) => {
- if (cleanupListeners) {
- cleanupListeners()
- cleanupListeners = undefined
- }
-
- scrollRef = el
- if (el) {
- lastScrollTop = el.scrollTop
- el.style.overflowAnchor = "none"
-
- el.addEventListener("wheel", handleWheel, { passive: true })
- el.addEventListener("touchstart", handleTouchStart, { passive: true })
- el.addEventListener("keydown", handleKeyDown)
- el.addEventListener("mousedown", handleMouseDown)
-
- cleanupListeners = () => {
- el.removeEventListener("wheel", handleWheel)
- el.removeEventListener("touchstart", handleTouchStart)
- el.removeEventListener("keydown", handleKeyDown)
- el.removeEventListener("mousedown", handleMouseDown)
- window.removeEventListener("mouseup", handleMouseUp)
- }
- }
+ scroll = el
+ if (el) el.style.overflowAnchor = "none"
},
contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
handleScroll,
handleInteraction,
- scrollToBottom,
- forceScrollToBottom,
+ scrollToBottom: () => scrollToBottom(false),
+ forceScrollToBottom: () => scrollToBottom(true),
userScrolled: () => store.userScrolled,
}
}