From fe89bedfcc6d97fdd4b8066c2c3d8eac92b531ea Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:43:18 -0600 Subject: [PATCH] wip(app): custom scroll view --- packages/app/e2e/actions.ts | 2 +- packages/app/e2e/session/session.spec.ts | 2 +- .../session/session-context-tab.tsx | 9 +- .../composer/session-question-dock.tsx | 4 +- packages/app/src/pages/session/file-tabs.tsx | 90 ++++---- .../src/pages/session/message-timeline.tsx | 9 +- packages/app/src/pages/session/review-tab.tsx | 4 +- packages/ui/src/components/scroll-view.css | 61 +++++ packages/ui/src/components/scroll-view.tsx | 217 ++++++++++++++++++ packages/ui/src/components/session-review.tsx | 9 +- packages/ui/src/styles/index.css | 1 + packages/ui/src/styles/tailwind/utilities.css | 28 --- 12 files changed, 346 insertions(+), 90 deletions(-) create mode 100644 packages/ui/src/components/scroll-view.css create mode 100644 packages/ui/src/components/scroll-view.tsx diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index d42c0fceb..a7ccba617 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -225,7 +225,7 @@ export async function hoverSessionItem(page: Page, sessionID: string) { export async function openSessionMoreMenu(page: Page, sessionID: string) { 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.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 }) diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts index 93eaee5cb..68d992949 100644 --- a/packages/app/e2e/session/session.spec.ts +++ b/packages/app/e2e/session/session.spec.ts @@ -44,7 +44,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession } const menu = await openSessionMoreMenu(page, session.id) 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).toBeFocused() await input.fill(renamedTitle) diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 162e016c6..1ea97c395 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -11,6 +11,7 @@ import { Accordion } from "@opencode-ai/ui/accordion" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { Code } from "@opencode-ai/ui/code" 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 { useLanguage } from "@/context/language" import { getSessionContextMetrics } from "./session-context-metrics" @@ -268,9 +269,9 @@ export function SessionContextTab() { }) return ( -
{ + { scroll = el restoreScroll() }} @@ -336,6 +337,6 @@ export function SessionContextTab() {
- + ) } diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 1ccac937c..fd2ced3dc 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -62,7 +62,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const measure = () => { 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 top = 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) 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) if (dock instanceof HTMLElement) observer.observe(dock) if (scroller instanceof HTMLElement) observer.observe(scroller) diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index ebc1f5922..032756cab 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -9,6 +9,7 @@ import { showToast } from "@opencode-ai/ui/toast" import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment" import { Mark } from "@opencode-ai/ui/logo" import { Tabs } from "@opencode-ai/ui/tabs" +import { ScrollView } from "@opencode-ai/ui/scroll-view" import { useLayout } from "@/context/layout" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { useComments } from "@/context/comments" @@ -509,51 +510,52 @@ export function FileTabContent(props: { tab: string }) { ) return ( - { - scroll = el - restoreScroll() - }} - onScroll={handleScroll} - > - - -
- {path()} requestAnimationFrame(restoreScroll)} - /> -
-
- -
- {renderCode(svgContent() ?? "", "")} - -
- {path()} -
-
-
-
- -
- -
-
{path()?.split("/").pop()}
-
{language.t("session.files.binaryContent")}
+ + { + scroll = el + restoreScroll() + }} + onScroll={handleScroll as any} + > + + +
+ {path()} requestAnimationFrame(restoreScroll)} + />
-
- - {renderCode(contents(), "pb-40")} - -
{language.t("common.loading")}...
-
- {(err) =>
{err()}
}
- + + +
+ {renderCode(svgContent() ?? "", "")} + +
+ {path()} +
+
+
+
+ +
+ +
+
{path()?.split("/").pop()}
+
{language.t("session.files.binaryContent")}
+
+
+
+ {renderCode(contents(), "pb-40")} + +
{language.t("common.loading")}...
+
+ {(err) =>
{err()}
}
+ + ) } diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 6ac89a3a7..b13ccb474 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -8,6 +8,7 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Dialog } from "@opencode-ai/ui/dialog" import { InlineInput } from "@opencode-ai/ui/inline-input" 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 { showToast } from "@opencode-ai/ui/toast" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" @@ -322,8 +323,8 @@ export function MessageTimeline(props: {
-
{ const root = e.currentTarget const delta = normalizeWheelDelta({ @@ -367,7 +368,7 @@ export function MessageTimeline(props: { if (props.isDesktop) props.onScrollSpyScroll() }} 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={{ "--session-title-height": showHeader() ? "40px" : "0px", "--sticky-accordion-top": showHeader() ? "48px" : "0px", @@ -548,7 +549,7 @@ export function MessageTimeline(props: { )}
- + ) diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index 3a9f63949..9349e9937 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -143,9 +143,9 @@ export function SessionReviewTab(props: SessionReviewTabProps) { open={props.view().review.open()} onOpenChange={props.view().review.setOpen} classes={{ - root: props.classes?.root ?? "pb-6", + root: props.classes?.root ?? "pb-6 pr-3", header: props.classes?.header ?? "px-3", - container: props.classes?.container ?? "px-3", + container: props.classes?.container ?? "pl-3", }} diffs={props.diffs()} diffStyle={props.diffStyle} diff --git a/packages/ui/src/components/scroll-view.css b/packages/ui/src/components/scroll-view.css new file mode 100644 index 000000000..f81ae2976 --- /dev/null +++ b/packages/ui/src/components/scroll-view.css @@ -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; +} diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx new file mode 100644 index 000000000..acc54c8c3 --- /dev/null +++ b/packages/ui/src/components/scroll-view.tsx @@ -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 ( +
setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} + {...rest} + > + {/* Viewport */} +
{ + 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} +
+ + {/* Thumb Overlay */} + +
+ +
+ ) +} diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index fd85fb485..15464d3ba 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -7,6 +7,7 @@ import { Icon } from "./icon" import { LineComment, LineCommentEditor } from "./line-comment" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Tooltip } from "./tooltip" +import { ScrollView } from "./scroll-view" import { useDiffComponent } from "../context/diff" import { useI18n } from "../context/i18n" import { getDirectory, getFilename } from "@opencode-ai/util/path" @@ -274,13 +275,13 @@ export const SessionReview = (props: SessionReviewProps) => { }) return ( -
{ + viewportRef={(el) => { scroll = el props.scrollRef?.(el) }} - onScroll={props.onScroll} + onScroll={props.onScroll as any} classList={{ ...(props.classList ?? {}), [props.classes?.root ?? ""]: !!props.classes?.root, @@ -709,6 +710,6 @@ export const SessionReview = (props: SessionReviewProps) => {
-
+ ) } diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index efe00e5f1..c0af0ac9b 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -44,6 +44,7 @@ @import "../components/select.css" layer(components); @import "../components/spinner.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-turn.css" layer(components); @import "../components/sticky-accordion-header.css" layer(components); diff --git a/packages/ui/src/styles/tailwind/utilities.css b/packages/ui/src/styles/tailwind/utilities.css index be305b4cb..4318b9ec1 100644 --- a/packages/ui/src/styles/tailwind/utilities.css +++ b/packages/ui/src/styles/tailwind/utilities.css @@ -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 { -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);