summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-22 11:27:23 -0600
committerAdam <[email protected]>2025-12-22 11:27:27 -0600
commit291b65977c0de428f86886acd05f79c46051be90 (patch)
treec71809481733577a59e90d2ce049e26d33388a28
parent90f232d7f15a8bf09ebeed24297ceb5f4ea4dbad (diff)
downloadopencode-291b65977c0de428f86886acd05f79c46051be90.tar.gz
opencode-291b65977c0de428f86886acd05f79c46051be90.zip
chore(desktop): auto scroll utility
-rw-r--r--packages/desktop/src/pages/session.tsx94
-rw-r--r--packages/ui/src/components/message-part.tsx11
-rw-r--r--packages/ui/src/components/session-message-rail.css2
-rw-r--r--packages/ui/src/components/session-turn.tsx125
-rw-r--r--packages/ui/src/hooks/create-auto-scroll.tsx135
-rw-r--r--packages/ui/src/hooks/index.ts1
6 files changed, 170 insertions, 198 deletions
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index 1f38da3c7..b6f5ccca1 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/packages/desktop/src/pages/session.tsx
@@ -12,7 +12,7 @@ import {
createRenderEffect,
batch,
} from "solid-js"
-import { createResizeObserver } from "@solid-primitives/resize-observer"
+
import { Dynamic } from "solid-js/web"
import { useLocal, type LocalFile } from "@/context/local"
import { createStore } from "solid-js/store"
@@ -27,6 +27,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
+import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
import { SessionReview } from "@opencode-ai/ui/session-review"
import {
@@ -93,13 +94,6 @@ export default function Page() {
userInteracted: false,
stepsExpanded: true,
mobileStepsExpanded: {} as Record<string, boolean>,
- mobileLastScrollTop: 0,
- mobileLastScrollHeight: 0,
- mobileAutoScrolled: false,
- mobileUserScrolled: false,
- mobileContentRef: undefined as HTMLDivElement | undefined,
- mobileLastContentWidth: 0,
- mobileReflowing: false,
messageId: undefined as string | undefined,
})
@@ -541,90 +535,20 @@ export default function Page() {
const showTabs = createMemo(() => diffs().length > 0 || tabs().all().length > 0)
- let mobileScrollRef: HTMLDivElement | undefined
const mobileWorking = createMemo(() => status().type !== "idle")
-
- function handleMobileScroll() {
- if (!mobileScrollRef || store.mobileAutoScrolled) return
-
- const scrollTop = mobileScrollRef.scrollTop
- const scrollHeight = mobileScrollRef.scrollHeight
-
- if (store.mobileReflowing) {
- batch(() => {
- setStore("mobileLastScrollTop", scrollTop)
- setStore("mobileLastScrollHeight", scrollHeight)
- })
- return
- }
-
- const scrolledUp = scrollTop < store.mobileLastScrollTop - 50
- if (scrolledUp && mobileWorking()) {
- setStore("mobileUserScrolled", true)
- setStore("userInteracted", true)
- }
-
- batch(() => {
- setStore("mobileLastScrollTop", scrollTop)
- setStore("mobileLastScrollHeight", scrollHeight)
- })
- }
-
- function handleMobileInteraction() {
- if (mobileWorking()) {
- setStore("mobileUserScrolled", true)
- setStore("userInteracted", true)
- }
- }
-
- function scrollMobileToBottom() {
- if (!mobileScrollRef || store.mobileUserScrolled || !mobileWorking()) return
- setStore("mobileAutoScrolled", true)
- requestAnimationFrame(() => {
- mobileScrollRef?.scrollTo({ top: mobileScrollRef.scrollHeight, behavior: "smooth" })
- requestAnimationFrame(() => {
- batch(() => {
- setStore("mobileLastScrollTop", mobileScrollRef?.scrollTop ?? 0)
- setStore("mobileLastScrollHeight", mobileScrollRef?.scrollHeight ?? 0)
- setStore("mobileAutoScrolled", false)
- })
- })
- })
- }
-
- createEffect(() => {
- if (!mobileWorking()) setStore("mobileUserScrolled", false)
+ const mobileAutoScroll = createAutoScroll({
+ working: mobileWorking,
+ onUserInteracted: () => setStore("userInteracted", true),
})
- createResizeObserver(
- () => store.mobileContentRef,
- ({ width }) => {
- const widthChanged = Math.abs(width - store.mobileLastContentWidth) > 5
- if (widthChanged && store.mobileLastContentWidth > 0) {
- setStore("mobileReflowing", true)
- requestAnimationFrame(() => {
- requestAnimationFrame(() => {
- setStore("mobileReflowing", false)
- if (mobileWorking() && !store.mobileUserScrolled) {
- scrollMobileToBottom()
- }
- })
- })
- } else if (!store.mobileReflowing) {
- scrollMobileToBottom()
- }
- setStore("mobileLastContentWidth", width)
- },
- )
-
const MobileTurns = () => (
<div
- ref={mobileScrollRef}
- onScroll={handleMobileScroll}
- onClick={handleMobileInteraction}
+ ref={mobileAutoScroll.scrollRef}
+ onScroll={mobileAutoScroll.handleScroll}
+ onClick={mobileAutoScroll.handleInteraction}
class="relative mt-2 min-w-0 w-full h-full overflow-y-auto no-scrollbar pb-12"
>
- <div ref={(el) => setStore("mobileContentRef", el)} class="flex flex-col gap-45 items-start justify-start mt-4">
+ <div ref={mobileAutoScroll.contentRef} class="flex flex-col gap-45 items-start justify-start mt-4">
<For each={visibleUserMessages()}>
{(message) => (
<SessionTurn
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 4f0645bf8..973a06230 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -23,6 +23,7 @@ import { DiffChanges } from "./diff-changes"
import { Markdown } from "./markdown"
import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
import { checksum } from "@opencode-ai/util/encode"
+import { createAutoScroll } from "../hooks"
interface Diagnostic {
range: {
@@ -330,6 +331,7 @@ export interface ToolProps {
metadata: Record<string, any>
tool: string
output?: string
+ status?: string
hideDetails?: boolean
defaultOpen?: boolean
}
@@ -398,6 +400,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
tool={part.tool}
metadata={metadata}
output={part.state.status === "completed" ? part.state.output : undefined}
+ status={part.state.status}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
/>
@@ -561,6 +564,10 @@ ToolRegistry.register({
const summary = () =>
(props.metadata.summary ?? []) as { id: string; tool: string; state: { status: string; title?: string } }[]
+ const autoScroll = createAutoScroll({
+ working: () => true,
+ })
+
return (
<BasicTool
icon="task"
@@ -571,8 +578,8 @@ ToolRegistry.register({
subtitle: props.input.description,
}}
>
- <div data-component="tool-output" data-scrollable>
- <div data-component="task-tools">
+ <div ref={autoScroll.scrollRef} onScroll={autoScroll.handleScroll} data-component="tool-output" data-scrollable>
+ <div ref={autoScroll.contentRef} data-component="task-tools">
<For each={summary()}>
{(item) => {
const info = getToolInfo(item.tool)
diff --git a/packages/ui/src/components/session-message-rail.css b/packages/ui/src/components/session-message-rail.css
index dc2352c22..5729e4920 100644
--- a/packages/ui/src/components/session-message-rail.css
+++ b/packages/ui/src/components/session-message-rail.css
@@ -17,7 +17,7 @@
display: none;
}
-@container (min-width: 72rem) {
+@container (min-width: 88rem) {
[data-slot="session-message-rail-compact"] {
display: none;
}
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index 71e6689f6..e49e70864 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -3,7 +3,7 @@ import { useData } from "../context"
import { useDiffComponent } from "../context/diff"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { checksum } from "@opencode-ai/util/encode"
-import { batch, createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js"
+import { createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DiffChanges } from "./diff-changes"
import { Typewriter } from "./typewriter"
@@ -19,6 +19,7 @@ import { Button } from "./button"
import { Spinner } from "./spinner"
import { createStore } from "solid-js/store"
import { DateTime, DurationUnit, Interval } from "luxon"
+import { createAutoScroll } from "../hooks"
function computeStatusFromPart(part: PartType | undefined): string | undefined {
if (!part) return undefined
@@ -233,17 +234,14 @@ export function SessionTurn(
})
}
- let scrollRef: HTMLDivElement | undefined
+ const autoScroll = createAutoScroll({
+ working,
+ onUserInteracted: props.onUserInteracted,
+ })
+
const [store, setStore] = createStore({
- contentRef: undefined as HTMLDivElement | undefined,
stickyTitleRef: undefined as HTMLDivElement | undefined,
stickyTriggerRef: undefined as HTMLDivElement | undefined,
- lastScrollTop: 0,
- lastScrollHeight: 0,
- lastContainerWidth: 0,
- autoScrolled: false,
- userScrolled: false,
- reflowing: false,
stickyHeaderHeight: 0,
retrySeconds: 0,
status: rawStatus(),
@@ -265,104 +263,6 @@ export function SessionTurn(
onCleanup(() => clearInterval(timer))
})
- function handleScroll() {
- if (!scrollRef || store.autoScrolled) return
-
- const scrollTop = scrollRef.scrollTop
- const scrollHeight = scrollRef.scrollHeight
-
- if (store.reflowing) {
- batch(() => {
- setStore("lastScrollTop", scrollTop)
- setStore("lastScrollHeight", scrollHeight)
- })
- return
- }
-
- const scrollHeightChanged = Math.abs(scrollHeight - store.lastScrollHeight) > 10
- const scrollTopDelta = scrollTop - store.lastScrollTop
-
- if (scrollHeightChanged && scrollTopDelta < 0) {
- const heightRatio = store.lastScrollHeight > 0 ? scrollHeight / store.lastScrollHeight : 1
- const expectedScrollTop = store.lastScrollTop * heightRatio
- if (Math.abs(scrollTop - expectedScrollTop) < 100) {
- batch(() => {
- setStore("lastScrollTop", scrollTop)
- setStore("lastScrollHeight", scrollHeight)
- })
- return
- }
- }
-
- const reset = scrollTop <= 0 && store.lastScrollTop > 0 && working() && !store.userScrolled
- if (reset) {
- batch(() => {
- setStore("lastScrollTop", scrollTop)
- setStore("lastScrollHeight", scrollHeight)
- })
- requestAnimationFrame(scrollToBottom)
- return
- }
-
- const scrolledUp = scrollTop < store.lastScrollTop - 50 && !scrollHeightChanged
- if (scrolledUp && working()) {
- setStore("userScrolled", true)
- props.onUserInteracted?.()
- }
-
- batch(() => {
- setStore("lastScrollTop", scrollTop)
- setStore("lastScrollHeight", scrollHeight)
- })
- }
-
- function handleInteraction() {
- if (working()) {
- setStore("userScrolled", true)
- props.onUserInteracted?.()
- }
- }
-
- function scrollToBottom() {
- if (!scrollRef || store.userScrolled || !working()) return
- setStore("autoScrolled", true)
- requestAnimationFrame(() => {
- scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "smooth" })
- requestAnimationFrame(() => {
- batch(() => {
- setStore("lastScrollTop", scrollRef?.scrollTop ?? 0)
- setStore("lastScrollHeight", scrollRef?.scrollHeight ?? 0)
- setStore("autoScrolled", false)
- })
- })
- })
- }
-
- createResizeObserver(
- () => store.contentRef,
- ({ width }) => {
- const widthChanged = Math.abs(width - store.lastContainerWidth) > 5
- if (widthChanged && store.lastContainerWidth > 0) {
- setStore("reflowing", true)
- requestAnimationFrame(() => {
- requestAnimationFrame(() => {
- setStore("reflowing", false)
- if (working() && !store.userScrolled) {
- scrollToBottom()
- }
- })
- })
- } else if (!store.reflowing) {
- scrollToBottom()
- }
- setStore("lastContainerWidth", width)
- },
- )
-
- createEffect(() => {
- if (!working()) setStore("userScrolled", false)
- })
-
createResizeObserver(
() => store.stickyTitleRef,
({ height }) => {
@@ -412,12 +312,17 @@ export function SessionTurn(
return (
<div data-component="session-turn" class={props.classes?.root}>
- <div ref={scrollRef} onScroll={handleScroll} data-slot="session-turn-content" class={props.classes?.content}>
- <div onClick={handleInteraction}>
+ <div
+ ref={autoScroll.scrollRef}
+ onScroll={autoScroll.handleScroll}
+ data-slot="session-turn-content"
+ class={props.classes?.content}
+ >
+ <div onClick={autoScroll.handleInteraction}>
<Show when={message()}>
{(msg) => (
<div
- ref={(el) => setStore("contentRef", el)}
+ ref={autoScroll.contentRef}
data-message={msg().id}
data-slot="session-turn-message-container"
class={props.classes?.container}
diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx
new file mode 100644
index 000000000..d201fe36b
--- /dev/null
+++ b/packages/ui/src/hooks/create-auto-scroll.tsx
@@ -0,0 +1,135 @@
+import { batch, createEffect } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createResizeObserver } from "@solid-primitives/resize-observer"
+
+export interface AutoScrollOptions {
+ working: () => boolean
+ onUserInteracted?: () => void
+}
+
+export function createAutoScroll(options: AutoScrollOptions) {
+ let scrollRef: HTMLElement | undefined
+ const [store, setStore] = createStore({
+ contentRef: undefined as HTMLElement | undefined,
+ lastScrollTop: 0,
+ lastScrollHeight: 0,
+ lastContentWidth: 0,
+ autoScrolled: false,
+ userScrolled: false,
+ reflowing: false,
+ })
+
+ function scrollToBottom() {
+ if (!scrollRef || store.userScrolled || !options.working()) return
+ setStore("autoScrolled", true)
+ requestAnimationFrame(() => {
+ scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "smooth" })
+ requestAnimationFrame(() => {
+ batch(() => {
+ setStore("lastScrollTop", scrollRef?.scrollTop ?? 0)
+ setStore("lastScrollHeight", scrollRef?.scrollHeight ?? 0)
+ setStore("autoScrolled", false)
+ })
+ })
+ })
+ }
+
+ function handleScroll() {
+ if (!scrollRef || store.autoScrolled) return
+
+ const scrollTop = scrollRef.scrollTop
+ const scrollHeight = scrollRef.scrollHeight
+
+ if (store.reflowing) {
+ batch(() => {
+ setStore("lastScrollTop", scrollTop)
+ setStore("lastScrollHeight", scrollHeight)
+ })
+ return
+ }
+
+ const scrollHeightChanged = Math.abs(scrollHeight - store.lastScrollHeight) > 10
+ const scrollTopDelta = scrollTop - store.lastScrollTop
+
+ // Handle reflow-caused scroll position changes
+ if (scrollHeightChanged && scrollTopDelta < 0) {
+ const heightRatio = store.lastScrollHeight > 0 ? scrollHeight / store.lastScrollHeight : 1
+ const expectedScrollTop = store.lastScrollTop * heightRatio
+ if (Math.abs(scrollTop - expectedScrollTop) < 100) {
+ batch(() => {
+ setStore("lastScrollTop", scrollTop)
+ setStore("lastScrollHeight", scrollHeight)
+ })
+ return
+ }
+ }
+
+ // Handle reset to top while working
+ const reset = scrollTop <= 0 && store.lastScrollTop > 0 && options.working() && !store.userScrolled
+ if (reset) {
+ batch(() => {
+ setStore("lastScrollTop", scrollTop)
+ setStore("lastScrollHeight", scrollHeight)
+ })
+ requestAnimationFrame(scrollToBottom)
+ return
+ }
+
+ // Detect intentional scroll up
+ const scrolledUp = scrollTop < store.lastScrollTop - 50 && !scrollHeightChanged
+ if (scrolledUp && options.working()) {
+ setStore("userScrolled", true)
+ options.onUserInteracted?.()
+ }
+
+ batch(() => {
+ setStore("lastScrollTop", scrollTop)
+ setStore("lastScrollHeight", scrollHeight)
+ })
+ }
+
+ function handleInteraction() {
+ if (options.working()) {
+ setStore("userScrolled", true)
+ options.onUserInteracted?.()
+ }
+ }
+
+ // Reset userScrolled when work completes
+ createEffect(() => {
+ if (!options.working()) setStore("userScrolled", false)
+ })
+
+ // Handle content resize
+ createResizeObserver(
+ () => store.contentRef,
+ ({ width }) => {
+ const widthChanged = Math.abs(width - store.lastContentWidth) > 5
+ if (widthChanged && store.lastContentWidth > 0) {
+ setStore("reflowing", true)
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ setStore("reflowing", false)
+ if (options.working() && !store.userScrolled) {
+ scrollToBottom()
+ }
+ })
+ })
+ } else if (!store.reflowing) {
+ scrollToBottom()
+ }
+ setStore("lastContentWidth", width)
+ },
+ )
+
+ return {
+ scrollRef: (el: HTMLElement | undefined) => {
+ scrollRef = el
+ },
+ contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
+ handleScroll,
+ handleInteraction,
+ scrollToBottom,
+ userScrolled: () => store.userScrolled,
+ }
+}
diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts
index 7eef78091..1c90a2e49 100644
--- a/packages/ui/src/hooks/index.ts
+++ b/packages/ui/src/hooks/index.ts
@@ -1 +1,2 @@
export * from "./use-filtered-list"
+export * from "./create-auto-scroll"