Skip to content

Commit 291b659

Browse files
committed
chore(desktop): auto scroll utility
1 parent 90f232d commit 291b659

File tree

6 files changed

+170
-198
lines changed

6 files changed

+170
-198
lines changed

packages/desktop/src/pages/session.tsx

Lines changed: 9 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
createRenderEffect,
1313
batch,
1414
} from "solid-js"
15-
import { createResizeObserver } from "@solid-primitives/resize-observer"
15+
1616
import { Dynamic } from "solid-js/web"
1717
import { useLocal, type LocalFile } from "@/context/local"
1818
import { createStore } from "solid-js/store"
@@ -27,6 +27,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
2727
import { Tabs } from "@opencode-ai/ui/tabs"
2828
import { useCodeComponent } from "@opencode-ai/ui/context/code"
2929
import { SessionTurn } from "@opencode-ai/ui/session-turn"
30+
import { createAutoScroll } from "@opencode-ai/ui/hooks"
3031
import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
3132
import { SessionReview } from "@opencode-ai/ui/session-review"
3233
import {
@@ -93,13 +94,6 @@ export default function Page() {
9394
userInteracted: false,
9495
stepsExpanded: true,
9596
mobileStepsExpanded: {} as Record<string, boolean>,
96-
mobileLastScrollTop: 0,
97-
mobileLastScrollHeight: 0,
98-
mobileAutoScrolled: false,
99-
mobileUserScrolled: false,
100-
mobileContentRef: undefined as HTMLDivElement | undefined,
101-
mobileLastContentWidth: 0,
102-
mobileReflowing: false,
10397
messageId: undefined as string | undefined,
10498
})
10599

@@ -541,90 +535,20 @@ export default function Page() {
541535

542536
const showTabs = createMemo(() => diffs().length > 0 || tabs().all().length > 0)
543537

544-
let mobileScrollRef: HTMLDivElement | undefined
545538
const mobileWorking = createMemo(() => status().type !== "idle")
546-
547-
function handleMobileScroll() {
548-
if (!mobileScrollRef || store.mobileAutoScrolled) return
549-
550-
const scrollTop = mobileScrollRef.scrollTop
551-
const scrollHeight = mobileScrollRef.scrollHeight
552-
553-
if (store.mobileReflowing) {
554-
batch(() => {
555-
setStore("mobileLastScrollTop", scrollTop)
556-
setStore("mobileLastScrollHeight", scrollHeight)
557-
})
558-
return
559-
}
560-
561-
const scrolledUp = scrollTop < store.mobileLastScrollTop - 50
562-
if (scrolledUp && mobileWorking()) {
563-
setStore("mobileUserScrolled", true)
564-
setStore("userInteracted", true)
565-
}
566-
567-
batch(() => {
568-
setStore("mobileLastScrollTop", scrollTop)
569-
setStore("mobileLastScrollHeight", scrollHeight)
570-
})
571-
}
572-
573-
function handleMobileInteraction() {
574-
if (mobileWorking()) {
575-
setStore("mobileUserScrolled", true)
576-
setStore("userInteracted", true)
577-
}
578-
}
579-
580-
function scrollMobileToBottom() {
581-
if (!mobileScrollRef || store.mobileUserScrolled || !mobileWorking()) return
582-
setStore("mobileAutoScrolled", true)
583-
requestAnimationFrame(() => {
584-
mobileScrollRef?.scrollTo({ top: mobileScrollRef.scrollHeight, behavior: "smooth" })
585-
requestAnimationFrame(() => {
586-
batch(() => {
587-
setStore("mobileLastScrollTop", mobileScrollRef?.scrollTop ?? 0)
588-
setStore("mobileLastScrollHeight", mobileScrollRef?.scrollHeight ?? 0)
589-
setStore("mobileAutoScrolled", false)
590-
})
591-
})
592-
})
593-
}
594-
595-
createEffect(() => {
596-
if (!mobileWorking()) setStore("mobileUserScrolled", false)
539+
const mobileAutoScroll = createAutoScroll({
540+
working: mobileWorking,
541+
onUserInteracted: () => setStore("userInteracted", true),
597542
})
598543

599-
createResizeObserver(
600-
() => store.mobileContentRef,
601-
({ width }) => {
602-
const widthChanged = Math.abs(width - store.mobileLastContentWidth) > 5
603-
if (widthChanged && store.mobileLastContentWidth > 0) {
604-
setStore("mobileReflowing", true)
605-
requestAnimationFrame(() => {
606-
requestAnimationFrame(() => {
607-
setStore("mobileReflowing", false)
608-
if (mobileWorking() && !store.mobileUserScrolled) {
609-
scrollMobileToBottom()
610-
}
611-
})
612-
})
613-
} else if (!store.mobileReflowing) {
614-
scrollMobileToBottom()
615-
}
616-
setStore("mobileLastContentWidth", width)
617-
},
618-
)
619-
620544
const MobileTurns = () => (
621545
<div
622-
ref={mobileScrollRef}
623-
onScroll={handleMobileScroll}
624-
onClick={handleMobileInteraction}
546+
ref={mobileAutoScroll.scrollRef}
547+
onScroll={mobileAutoScroll.handleScroll}
548+
onClick={mobileAutoScroll.handleInteraction}
625549
class="relative mt-2 min-w-0 w-full h-full overflow-y-auto no-scrollbar pb-12"
626550
>
627-
<div ref={(el) => setStore("mobileContentRef", el)} class="flex flex-col gap-45 items-start justify-start mt-4">
551+
<div ref={mobileAutoScroll.contentRef} class="flex flex-col gap-45 items-start justify-start mt-4">
628552
<For each={visibleUserMessages()}>
629553
{(message) => (
630554
<SessionTurn

packages/ui/src/components/message-part.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { DiffChanges } from "./diff-changes"
2323
import { Markdown } from "./markdown"
2424
import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
2525
import { checksum } from "@opencode-ai/util/encode"
26+
import { createAutoScroll } from "../hooks"
2627

2728
interface Diagnostic {
2829
range: {
@@ -330,6 +331,7 @@ export interface ToolProps {
330331
metadata: Record<string, any>
331332
tool: string
332333
output?: string
334+
status?: string
333335
hideDetails?: boolean
334336
defaultOpen?: boolean
335337
}
@@ -398,6 +400,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
398400
tool={part.tool}
399401
metadata={metadata}
400402
output={part.state.status === "completed" ? part.state.output : undefined}
403+
status={part.state.status}
401404
hideDetails={props.hideDetails}
402405
defaultOpen={props.defaultOpen}
403406
/>
@@ -561,6 +564,10 @@ ToolRegistry.register({
561564
const summary = () =>
562565
(props.metadata.summary ?? []) as { id: string; tool: string; state: { status: string; title?: string } }[]
563566

567+
const autoScroll = createAutoScroll({
568+
working: () => true,
569+
})
570+
564571
return (
565572
<BasicTool
566573
icon="task"
@@ -571,8 +578,8 @@ ToolRegistry.register({
571578
subtitle: props.input.description,
572579
}}
573580
>
574-
<div data-component="tool-output" data-scrollable>
575-
<div data-component="task-tools">
581+
<div ref={autoScroll.scrollRef} onScroll={autoScroll.handleScroll} data-component="tool-output" data-scrollable>
582+
<div ref={autoScroll.contentRef} data-component="task-tools">
576583
<For each={summary()}>
577584
{(item) => {
578585
const info = getToolInfo(item.tool)

packages/ui/src/components/session-message-rail.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
display: none;
1818
}
1919

20-
@container (min-width: 72rem) {
20+
@container (min-width: 88rem) {
2121
[data-slot="session-message-rail-compact"] {
2222
display: none;
2323
}

packages/ui/src/components/session-turn.tsx

Lines changed: 15 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useData } from "../context"
33
import { useDiffComponent } from "../context/diff"
44
import { getDirectory, getFilename } from "@opencode-ai/util/path"
55
import { checksum } from "@opencode-ai/util/encode"
6-
import { batch, createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js"
6+
import { createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js"
77
import { createResizeObserver } from "@solid-primitives/resize-observer"
88
import { DiffChanges } from "./diff-changes"
99
import { Typewriter } from "./typewriter"
@@ -19,6 +19,7 @@ import { Button } from "./button"
1919
import { Spinner } from "./spinner"
2020
import { createStore } from "solid-js/store"
2121
import { DateTime, DurationUnit, Interval } from "luxon"
22+
import { createAutoScroll } from "../hooks"
2223

2324
function computeStatusFromPart(part: PartType | undefined): string | undefined {
2425
if (!part) return undefined
@@ -233,17 +234,14 @@ export function SessionTurn(
233234
})
234235
}
235236

236-
let scrollRef: HTMLDivElement | undefined
237+
const autoScroll = createAutoScroll({
238+
working,
239+
onUserInteracted: props.onUserInteracted,
240+
})
241+
237242
const [store, setStore] = createStore({
238-
contentRef: undefined as HTMLDivElement | undefined,
239243
stickyTitleRef: undefined as HTMLDivElement | undefined,
240244
stickyTriggerRef: undefined as HTMLDivElement | undefined,
241-
lastScrollTop: 0,
242-
lastScrollHeight: 0,
243-
lastContainerWidth: 0,
244-
autoScrolled: false,
245-
userScrolled: false,
246-
reflowing: false,
247245
stickyHeaderHeight: 0,
248246
retrySeconds: 0,
249247
status: rawStatus(),
@@ -265,104 +263,6 @@ export function SessionTurn(
265263
onCleanup(() => clearInterval(timer))
266264
})
267265

268-
function handleScroll() {
269-
if (!scrollRef || store.autoScrolled) return
270-
271-
const scrollTop = scrollRef.scrollTop
272-
const scrollHeight = scrollRef.scrollHeight
273-
274-
if (store.reflowing) {
275-
batch(() => {
276-
setStore("lastScrollTop", scrollTop)
277-
setStore("lastScrollHeight", scrollHeight)
278-
})
279-
return
280-
}
281-
282-
const scrollHeightChanged = Math.abs(scrollHeight - store.lastScrollHeight) > 10
283-
const scrollTopDelta = scrollTop - store.lastScrollTop
284-
285-
if (scrollHeightChanged && scrollTopDelta < 0) {
286-
const heightRatio = store.lastScrollHeight > 0 ? scrollHeight / store.lastScrollHeight : 1
287-
const expectedScrollTop = store.lastScrollTop * heightRatio
288-
if (Math.abs(scrollTop - expectedScrollTop) < 100) {
289-
batch(() => {
290-
setStore("lastScrollTop", scrollTop)
291-
setStore("lastScrollHeight", scrollHeight)
292-
})
293-
return
294-
}
295-
}
296-
297-
const reset = scrollTop <= 0 && store.lastScrollTop > 0 && working() && !store.userScrolled
298-
if (reset) {
299-
batch(() => {
300-
setStore("lastScrollTop", scrollTop)
301-
setStore("lastScrollHeight", scrollHeight)
302-
})
303-
requestAnimationFrame(scrollToBottom)
304-
return
305-
}
306-
307-
const scrolledUp = scrollTop < store.lastScrollTop - 50 && !scrollHeightChanged
308-
if (scrolledUp && working()) {
309-
setStore("userScrolled", true)
310-
props.onUserInteracted?.()
311-
}
312-
313-
batch(() => {
314-
setStore("lastScrollTop", scrollTop)
315-
setStore("lastScrollHeight", scrollHeight)
316-
})
317-
}
318-
319-
function handleInteraction() {
320-
if (working()) {
321-
setStore("userScrolled", true)
322-
props.onUserInteracted?.()
323-
}
324-
}
325-
326-
function scrollToBottom() {
327-
if (!scrollRef || store.userScrolled || !working()) return
328-
setStore("autoScrolled", true)
329-
requestAnimationFrame(() => {
330-
scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "smooth" })
331-
requestAnimationFrame(() => {
332-
batch(() => {
333-
setStore("lastScrollTop", scrollRef?.scrollTop ?? 0)
334-
setStore("lastScrollHeight", scrollRef?.scrollHeight ?? 0)
335-
setStore("autoScrolled", false)
336-
})
337-
})
338-
})
339-
}
340-
341-
createResizeObserver(
342-
() => store.contentRef,
343-
({ width }) => {
344-
const widthChanged = Math.abs(width - store.lastContainerWidth) > 5
345-
if (widthChanged && store.lastContainerWidth > 0) {
346-
setStore("reflowing", true)
347-
requestAnimationFrame(() => {
348-
requestAnimationFrame(() => {
349-
setStore("reflowing", false)
350-
if (working() && !store.userScrolled) {
351-
scrollToBottom()
352-
}
353-
})
354-
})
355-
} else if (!store.reflowing) {
356-
scrollToBottom()
357-
}
358-
setStore("lastContainerWidth", width)
359-
},
360-
)
361-
362-
createEffect(() => {
363-
if (!working()) setStore("userScrolled", false)
364-
})
365-
366266
createResizeObserver(
367267
() => store.stickyTitleRef,
368268
({ height }) => {
@@ -412,12 +312,17 @@ export function SessionTurn(
412312

413313
return (
414314
<div data-component="session-turn" class={props.classes?.root}>
415-
<div ref={scrollRef} onScroll={handleScroll} data-slot="session-turn-content" class={props.classes?.content}>
416-
<div onClick={handleInteraction}>
315+
<div
316+
ref={autoScroll.scrollRef}
317+
onScroll={autoScroll.handleScroll}
318+
data-slot="session-turn-content"
319+
class={props.classes?.content}
320+
>
321+
<div onClick={autoScroll.handleInteraction}>
417322
<Show when={message()}>
418323
{(msg) => (
419324
<div
420-
ref={(el) => setStore("contentRef", el)}
325+
ref={autoScroll.contentRef}
421326
data-message={msg().id}
422327
data-slot="session-turn-message-container"
423328
class={props.classes?.container}

0 commit comments

Comments
 (0)