wip(app): custom scroll view

This commit is contained in:
Adam
2026-02-20 10:43:18 -06:00
parent 1e48d7fe82
commit fe89bedfcc
12 changed files with 346 additions and 90 deletions

View File

@@ -225,7 +225,7 @@ export async function hoverSessionItem(page: Page, sessionID: string) {
export async function openSessionMoreMenu(page: Page, sessionID: string) { export async function openSessionMoreMenu(page: Page, sessionID: string) {
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
const scroller = page.locator(".session-scroller").first() const scroller = page.locator(".scroll-view__viewport").first()
await expect(scroller).toBeVisible() await expect(scroller).toBeVisible()
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 }) await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })

View File

@@ -44,7 +44,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
const menu = await openSessionMoreMenu(page, session.id) const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i) await clickMenuItem(menu, /rename/i)
const input = page.locator(".session-scroller").locator(inlineInputSelector).first() const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
await expect(input).toBeVisible() await expect(input).toBeVisible()
await expect(input).toBeFocused() await expect(input).toBeFocused()
await input.fill(renamedTitle) await input.fill(renamedTitle)

View File

@@ -11,6 +11,7 @@ import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { Code } from "@opencode-ai/ui/code" import { Code } from "@opencode-ai/ui/code"
import { Markdown } from "@opencode-ai/ui/markdown" import { Markdown } from "@opencode-ai/ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getSessionContextMetrics } from "./session-context-metrics" import { getSessionContextMetrics } from "./session-context-metrics"
@@ -268,9 +269,9 @@ export function SessionContextTab() {
}) })
return ( return (
<div <ScrollView
class="@container h-full overflow-y-auto no-scrollbar pb-10" class="@container h-full pb-10"
ref={(el) => { viewportRef={(el) => {
scroll = el scroll = el
restoreScroll() restoreScroll()
}} }}
@@ -336,6 +337,6 @@ export function SessionContextTab() {
</Accordion> </Accordion>
</div> </div>
</div> </div>
</div> </ScrollView>
) )
} }

View File

@@ -62,7 +62,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const measure = () => { const measure = () => {
if (!root) return if (!root) return
const scroller = document.querySelector(".session-scroller") const scroller = document.querySelector(".scroll-view__viewport")
const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined
const top = const top =
head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0 head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0
@@ -95,7 +95,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
window.addEventListener("resize", update) window.addEventListener("resize", update)
const dock = root?.closest('[data-component="session-prompt-dock"]') const dock = root?.closest('[data-component="session-prompt-dock"]')
const scroller = document.querySelector(".session-scroller") const scroller = document.querySelector(".scroll-view__viewport")
const observer = new ResizeObserver(update) const observer = new ResizeObserver(update)
if (dock instanceof HTMLElement) observer.observe(dock) if (dock instanceof HTMLElement) observer.observe(dock)
if (scroller instanceof HTMLElement) observer.observe(scroller) if (scroller instanceof HTMLElement) observer.observe(scroller)

View File

@@ -9,6 +9,7 @@ import { showToast } from "@opencode-ai/ui/toast"
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment" import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
import { Mark } from "@opencode-ai/ui/logo" import { Mark } from "@opencode-ai/ui/logo"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
@@ -509,51 +510,52 @@ export function FileTabContent(props: { tab: string }) {
) )
return ( return (
<Tabs.Content <Tabs.Content value={props.tab} class="mt-3 relative h-full">
value={props.tab} <ScrollView
class="mt-3 relative" class="h-full"
ref={(el: HTMLDivElement) => { viewportRef={(el: HTMLDivElement) => {
scroll = el scroll = el
restoreScroll() restoreScroll()
}} }}
onScroll={handleScroll} onScroll={handleScroll as any}
> >
<Switch> <Switch>
<Match when={state()?.loaded && isImage()}> <Match when={state()?.loaded && isImage()}>
<div class="px-6 py-4 pb-40"> <div class="px-6 py-4 pb-40">
<img <img
src={imageDataUrl()} src={imageDataUrl()}
alt={path()} alt={path()}
class="max-w-full" class="max-w-full"
onLoad={() => requestAnimationFrame(restoreScroll)} onLoad={() => requestAnimationFrame(restoreScroll)}
/> />
</div>
</Match>
<Match when={state()?.loaded && isSvg()}>
<div class="flex flex-col gap-4 px-6 py-4">
{renderCode(svgContent() ?? "", "")}
<Show when={svgPreviewUrl()}>
<div class="flex justify-center pb-40">
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
</div>
</Show>
</div>
</Match>
<Match when={state()?.loaded && isBinary()}>
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="flex flex-col gap-2 max-w-md">
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
<div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
</div> </div>
</div> </Match>
</Match> <Match when={state()?.loaded && isSvg()}>
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match> <div class="flex flex-col gap-4 px-6 py-4">
<Match when={state()?.loading}> {renderCode(svgContent() ?? "", "")}
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div> <Show when={svgPreviewUrl()}>
</Match> <div class="flex justify-center pb-40">
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match> <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
</Switch> </div>
</Show>
</div>
</Match>
<Match when={state()?.loaded && isBinary()}>
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="flex flex-col gap-2 max-w-md">
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
<div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
</div>
</div>
</Match>
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
</ScrollView>
</Tabs.Content> </Tabs.Content>
) )
} }

View File

@@ -8,6 +8,7 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input" import { InlineInput } from "@opencode-ai/ui/inline-input"
import { SessionTurn } from "@opencode-ai/ui/session-turn" import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { UserMessage } from "@opencode-ai/sdk/v2" import type { UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
@@ -322,8 +323,8 @@ export function MessageTimeline(props: {
<Icon name="arrow-down-to-line" /> <Icon name="arrow-down-to-line" />
</button> </button>
</div> </div>
<div <ScrollView
ref={props.setScrollRef} viewportRef={props.setScrollRef}
onWheel={(e) => { onWheel={(e) => {
const root = e.currentTarget const root = e.currentTarget
const delta = normalizeWheelDelta({ const delta = normalizeWheelDelta({
@@ -367,7 +368,7 @@ export function MessageTimeline(props: {
if (props.isDesktop) props.onScrollSpyScroll() if (props.isDesktop) props.onScrollSpyScroll()
}} }}
onClick={props.onAutoScrollInteraction} onClick={props.onAutoScrollInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller" class="relative min-w-0 w-full h-full"
style={{ style={{
"--session-title-height": showHeader() ? "40px" : "0px", "--session-title-height": showHeader() ? "40px" : "0px",
"--sticky-accordion-top": showHeader() ? "48px" : "0px", "--sticky-accordion-top": showHeader() ? "48px" : "0px",
@@ -548,7 +549,7 @@ export function MessageTimeline(props: {
)} )}
</For> </For>
</div> </div>
</div> </ScrollView>
</div> </div>
</Show> </Show>
) )

View File

@@ -143,9 +143,9 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
open={props.view().review.open()} open={props.view().review.open()}
onOpenChange={props.view().review.setOpen} onOpenChange={props.view().review.setOpen}
classes={{ classes={{
root: props.classes?.root ?? "pb-6", root: props.classes?.root ?? "pb-6 pr-3",
header: props.classes?.header ?? "px-3", header: props.classes?.header ?? "px-3",
container: props.classes?.container ?? "px-3", container: props.classes?.container ?? "pl-3",
}} }}
diffs={props.diffs()} diffs={props.diffs()}
diffStyle={props.diffStyle} diffStyle={props.diffStyle}

View File

@@ -0,0 +1,61 @@
.scroll-view {
position: relative;
overflow: hidden;
}
.scroll-view__viewport {
height: 100%;
width: 100%;
overflow-y: auto;
scrollbar-width: none;
outline: none;
}
.scroll-view__viewport::-webkit-scrollbar {
display: none;
}
.scroll-view__thumb {
position: absolute;
right: 0;
top: 0;
width: 16px;
transition: opacity 200ms ease;
cursor: default;
user-select: none;
opacity: 0;
}
.scroll-view__thumb::after {
content: "";
position: absolute;
right: 4px;
top: 0;
bottom: 0;
width: 6px;
border-radius: 9999px;
background-color: var(--border-weak-base);
backdrop-filter: blur(4px);
transition: background-color 150ms ease;
}
.scroll-view__thumb:hover::after,
.scroll-view__thumb[data-dragging="true"]::after {
background-color: var(--border-strong-base);
}
.dark .scroll-view__thumb::after,
[data-theme="dark"] .scroll-view__thumb::after {
background-color: var(--border-weak-base);
}
.dark .scroll-view__thumb:hover::after,
[data-theme="dark"] .scroll-view__thumb:hover::after,
.dark .scroll-view__thumb[data-dragging="true"]::after,
[data-theme="dark"] .scroll-view__thumb[data-dragging="true"]::after {
background-color: var(--border-strong-base);
}
.scroll-view__thumb[data-visible="true"] {
opacity: 1;
}

View File

@@ -0,0 +1,217 @@
import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
export interface ScrollViewProps extends ComponentProps<"div"> {
viewportRef?: (el: HTMLDivElement) => void
orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb
}
export function ScrollView(props: ScrollViewProps) {
const merged = mergeProps({ orientation: "vertical" }, props)
const [local, events, rest] = splitProps(
merged,
["class", "children", "viewportRef", "orientation", "style"],
[
"onScroll",
"onWheel",
"onTouchStart",
"onTouchMove",
"onTouchEnd",
"onTouchCancel",
"onPointerDown",
"onClick",
"onKeyDown",
],
)
let rootRef!: HTMLDivElement
let viewportRef!: HTMLDivElement
let thumbRef!: HTMLDivElement
const [isHovered, setIsHovered] = createSignal(false)
const [isDragging, setIsDragging] = createSignal(false)
const [thumbHeight, setThumbHeight] = createSignal(0)
const [thumbTop, setThumbTop] = createSignal(0)
const [showThumb, setShowThumb] = createSignal(false)
const updateThumb = () => {
if (!viewportRef) return
const { scrollTop, scrollHeight, clientHeight } = viewportRef
if (scrollHeight <= clientHeight || scrollHeight === 0) {
setShowThumb(false)
return
}
setShowThumb(true)
const trackPadding = 8
const trackHeight = clientHeight - trackPadding * 2
const minThumbHeight = 32
// Calculate raw thumb height based on ratio
let height = (clientHeight / scrollHeight) * trackHeight
height = Math.max(height, minThumbHeight)
const maxScrollTop = scrollHeight - clientHeight
const maxThumbTop = trackHeight - height
const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0
// Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety)
const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
setThumbHeight(height)
setThumbTop(boundedTop)
}
onMount(() => {
if (local.viewportRef) {
local.viewportRef(viewportRef)
}
const observer = new ResizeObserver(() => {
updateThumb()
})
observer.observe(viewportRef)
// Also observe the first child if possible to catch content changes
if (viewportRef.firstElementChild) {
observer.observe(viewportRef.firstElementChild)
}
onCleanup(() => {
observer.disconnect()
})
updateThumb()
})
let startY = 0
let startScrollTop = 0
const onThumbPointerDown = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
startY = e.clientY
startScrollTop = viewportRef.scrollTop
thumbRef.setPointerCapture(e.pointerId)
const onPointerMove = (e: PointerEvent) => {
const deltaY = e.clientY - startY
const { scrollHeight, clientHeight } = viewportRef
const maxScrollTop = scrollHeight - clientHeight
const maxThumbTop = clientHeight - thumbHeight()
if (maxThumbTop > 0) {
const scrollDelta = deltaY * (maxScrollTop / maxThumbTop)
viewportRef.scrollTop = startScrollTop + scrollDelta
}
}
const onPointerUp = (e: PointerEvent) => {
setIsDragging(false)
thumbRef.releasePointerCapture(e.pointerId)
thumbRef.removeEventListener("pointermove", onPointerMove)
thumbRef.removeEventListener("pointerup", onPointerUp)
}
thumbRef.addEventListener("pointermove", onPointerMove)
thumbRef.addEventListener("pointerup", onPointerUp)
}
// Keybinds implementation
// We ensure the viewport has a tabindex so it can receive focus
// We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
// but native usually handles this perfectly. Let's explicitly ensure it behaves well.
const onKeyDown = (e: KeyboardEvent) => {
// If user is focused on an input inside the scroll view, don't hijack keys
if (document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) {
return
}
const scrollAmount = viewportRef.clientHeight * 0.8
const lineAmount = 40
switch (e.key) {
case "PageDown":
e.preventDefault()
viewportRef.scrollBy({ top: scrollAmount, behavior: "smooth" })
break
case "PageUp":
e.preventDefault()
viewportRef.scrollBy({ top: -scrollAmount, behavior: "smooth" })
break
case "Home":
e.preventDefault()
viewportRef.scrollTo({ top: 0, behavior: "smooth" })
break
case "End":
e.preventDefault()
viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
break
case "ArrowUp":
e.preventDefault()
viewportRef.scrollBy({ top: -lineAmount, behavior: "smooth" })
break
case "ArrowDown":
e.preventDefault()
viewportRef.scrollBy({ top: lineAmount, behavior: "smooth" })
break
}
}
return (
<div
ref={rootRef}
class={`scroll-view ${local.class || ""}`}
style={local.style}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
{...rest}
>
{/* Viewport */}
<div
ref={viewportRef}
class="scroll-view__viewport"
onScroll={(e) => {
updateThumb()
if (typeof events.onScroll === "function") events.onScroll(e as any)
}}
onWheel={events.onWheel as any}
onTouchStart={events.onTouchStart as any}
onTouchMove={events.onTouchMove as any}
onTouchEnd={events.onTouchEnd as any}
onTouchCancel={events.onTouchCancel as any}
onPointerDown={events.onPointerDown as any}
onClick={events.onClick as any}
tabIndex={0}
role="region"
aria-label="scrollable content"
onKeyDown={(e) => {
onKeyDown(e)
if (typeof events.onKeyDown === "function") events.onKeyDown(e as any)
}}
>
{local.children}
</div>
{/* Thumb Overlay */}
<Show when={showThumb()}>
<div
ref={thumbRef}
onPointerDown={onThumbPointerDown}
class="scroll-view__thumb"
data-visible={isHovered() || isDragging()}
data-dragging={isDragging()}
style={{
height: `${thumbHeight()}px`,
transform: `translateY(${thumbTop()}px)`,
"z-index": 100, // ensure it displays over content
}}
/>
</Show>
</div>
)
}

View File

@@ -7,6 +7,7 @@ import { Icon } from "./icon"
import { LineComment, LineCommentEditor } from "./line-comment" import { LineComment, LineCommentEditor } from "./line-comment"
import { StickyAccordionHeader } from "./sticky-accordion-header" import { StickyAccordionHeader } from "./sticky-accordion-header"
import { Tooltip } from "./tooltip" import { Tooltip } from "./tooltip"
import { ScrollView } from "./scroll-view"
import { useDiffComponent } from "../context/diff" import { useDiffComponent } from "../context/diff"
import { useI18n } from "../context/i18n" import { useI18n } from "../context/i18n"
import { getDirectory, getFilename } from "@opencode-ai/util/path" import { getDirectory, getFilename } from "@opencode-ai/util/path"
@@ -274,13 +275,13 @@ export const SessionReview = (props: SessionReviewProps) => {
}) })
return ( return (
<div <ScrollView
data-component="session-review" data-component="session-review"
ref={(el) => { viewportRef={(el) => {
scroll = el scroll = el
props.scrollRef?.(el) props.scrollRef?.(el)
}} }}
onScroll={props.onScroll} onScroll={props.onScroll as any}
classList={{ classList={{
...(props.classList ?? {}), ...(props.classList ?? {}),
[props.classes?.root ?? ""]: !!props.classes?.root, [props.classes?.root ?? ""]: !!props.classes?.root,
@@ -709,6 +710,6 @@ export const SessionReview = (props: SessionReviewProps) => {
</Accordion> </Accordion>
</Show> </Show>
</div> </div>
</div> </ScrollView>
) )
} }

View File

@@ -44,6 +44,7 @@
@import "../components/select.css" layer(components); @import "../components/select.css" layer(components);
@import "../components/spinner.css" layer(components); @import "../components/spinner.css" layer(components);
@import "../components/switch.css" layer(components); @import "../components/switch.css" layer(components);
@import "../components/scroll-view.css" layer(components);
@import "../components/session-review.css" layer(components); @import "../components/session-review.css" layer(components);
@import "../components/session-turn.css" layer(components); @import "../components/session-turn.css" layer(components);
@import "../components/sticky-accordion-header.css" layer(components); @import "../components/sticky-accordion-header.css" layer(components);

View File

@@ -8,34 +8,6 @@
} }
} }
@utility session-scroller {
&::-webkit-scrollbar {
width: 10px;
height: 10px;
}
&::-webkit-scrollbar-track {
background: transparent;
border-radius: 5px;
}
&::-webkit-scrollbar-thumb {
background: var(--border-weak-base);
border-radius: 5px;
border: 3px solid transparent;
background-clip: padding-box;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--border-weak-base);
}
& {
scrollbar-width: thin;
scrollbar-color: var(--border-weak-base) transparent;
}
}
@utility badge-mask { @utility badge-mask {
-webkit-mask-image: radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px); -webkit-mask-image: radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px);
mask-image: radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px); mask-image: radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px);