diff --git a/packages/app/src/app/+html.tsx b/packages/app/src/app/+html.tsx
index a5cd89c5a..bea8c752d 100644
--- a/packages/app/src/app/+html.tsx
+++ b/packages/app/src/app/+html.tsx
@@ -20,6 +20,22 @@ const webEcosystemStyles = /* css */ `
-webkit-user-select: text;
user-select: text;
}
+
+ [data-testid="sidebar-agent-list-scroll"],
+ [data-testid="agent-chat-scroll"],
+ [data-testid="git-diff-scroll"],
+ [data-testid="file-explorer-tree-scroll"] {
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ }
+
+ [data-testid="sidebar-agent-list-scroll"]::-webkit-scrollbar,
+ [data-testid="agent-chat-scroll"]::-webkit-scrollbar,
+ [data-testid="git-diff-scroll"]::-webkit-scrollbar,
+ [data-testid="file-explorer-tree-scroll"]::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+ }
`;
function WebRespectfulStyleReset() {
diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx
index a2d093029..2bcfb6b54 100644
--- a/packages/app/src/components/agent-stream-view.tsx
+++ b/packages/app/src/components/agent-stream-view.tsx
@@ -50,6 +50,10 @@ import type { DaemonClient } from "@server/client/daemon-client";
import { ToolCallDetailsContent } from "./tool-call-details";
import { QuestionFormCard } from "./question-form-card";
import { ToolCallSheetProvider } from "./tool-call-sheet";
+import {
+ WebDesktopScrollbarOverlay,
+ useWebDesktopScrollbarMetrics,
+} from "./web-desktop-scrollbar";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
@@ -95,12 +99,16 @@ export function AgentStreamView({
}: AgentStreamViewProps) {
const flatListRef = useRef>(null);
const { theme } = useUnistyles();
+ const isMobile =
+ UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
+ const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const insets = useSafeAreaInsets();
const [isNearBottom, setIsNearBottom] = useState(true);
const hasScrolledInitially = useRef(false);
const hasAutoScrolledOnce = useRef(false);
const isNearBottomRef = useRef(true);
const streamItemCountRef = useRef(0);
+ const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState>(new Set());
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
@@ -184,8 +192,12 @@ export function AgentStreamView({
isNearBottomRef.current = nearBottom;
setIsNearBottom(nearBottom);
}
+
+ if (showDesktopWebScrollbar) {
+ streamScrollbarMetrics.onScroll(event);
+ }
},
- [insets.bottom]
+ [insets.bottom, showDesktopWebScrollbar, streamScrollbarMetrics]
);
const scrollToBottomInternal = useCallback(
@@ -647,14 +659,25 @@ export function AgentStreamView({
data={flatListData}
renderItem={renderStreamItem}
keyExtractor={(item) => item.id}
+ testID="agent-chat-scroll"
ListHeaderComponentStyle={headerGapStyle}
contentContainerStyle={{
paddingVertical: 0,
flexGrow: 1,
}}
style={stylesheet.list}
+ onLayout={
+ showDesktopWebScrollbar
+ ? streamScrollbarMetrics.onLayout
+ : undefined
+ }
onScroll={handleScroll}
scrollEventThrottle={16}
+ onContentSizeChange={
+ showDesktopWebScrollbar
+ ? streamScrollbarMetrics.onContentSizeChange
+ : undefined
+ }
ListEmptyComponent={listEmptyComponent}
ListHeaderComponent={listHeaderComponent}
extraData={flatListExtraData}
@@ -667,9 +690,22 @@ export function AgentStreamView({
initialNumToRender={12}
windowSize={10}
scrollEnabled={Platform.OS !== "web" || expandedInlineToolCallIds.size === 0}
+ showsVerticalScrollIndicator={!showDesktopWebScrollbar}
inverted
/>
+ {
+ flatListRef.current?.scrollToOffset({
+ offset: nextOffset,
+ animated: false,
+ });
+ streamScrollbarMetrics.setOffset(nextOffset);
+ }}
+ />
{/* Scroll to bottom button */}
{!isNearBottom && (
diff --git a/packages/app/src/components/draggable-list.native.tsx b/packages/app/src/components/draggable-list.native.tsx
index 99671635c..05432c039 100644
--- a/packages/app/src/components/draggable-list.native.tsx
+++ b/packages/app/src/components/draggable-list.native.tsx
@@ -18,10 +18,12 @@ export function DraggableList({
onDragEnd,
style,
contentContainerStyle,
+ testID,
ListFooterComponent,
ListHeaderComponent,
ListEmptyComponent,
showsVerticalScrollIndicator = true,
+ enableDesktopWebScrollbar: _enableDesktopWebScrollbar = false,
refreshing,
onRefresh,
simultaneousGestureRef,
@@ -69,6 +71,7 @@ export function DraggableList({
return (
{
onDragEnd: (data: T[]) => void;
style?: StyleProp;
contentContainerStyle?: StyleProp;
+ testID?: string;
ListFooterComponent?: ReactElement | null;
ListHeaderComponent?: ReactElement | null;
ListEmptyComponent?: ReactElement | null;
showsVerticalScrollIndicator?: boolean;
+ enableDesktopWebScrollbar?: boolean;
refreshing?: boolean;
onRefresh?: () => void;
/** Fill remaining space when content is smaller than container */
diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx
index 74631db43..2f545e51f 100644
--- a/packages/app/src/components/draggable-list.web.tsx
+++ b/packages/app/src/components/draggable-list.web.tsx
@@ -1,5 +1,5 @@
-import { useCallback, useState, useRef, type ReactElement } from "react";
-import { View, ScrollView } from "react-native";
+import { useCallback, useRef, useState, type ReactElement } from "react";
+import { ScrollView, View } from "react-native";
import {
DndContext,
closestCenter,
@@ -23,6 +23,10 @@ import type {
DraggableListProps,
DraggableRenderItemInfo,
} from "./draggable-list.types";
+import {
+ WebDesktopScrollbarOverlay,
+ useWebDesktopScrollbarMetrics,
+} from "./web-desktop-scrollbar";
export type { DraggableListProps, DraggableRenderItemInfo };
@@ -99,14 +103,18 @@ export function DraggableList({
onDragEnd,
style,
contentContainerStyle,
+ testID,
ListFooterComponent,
ListHeaderComponent,
ListEmptyComponent,
showsVerticalScrollIndicator = true,
+ enableDesktopWebScrollbar = false,
// simultaneousGestureRef is native-only, ignored on web
}: DraggableListProps) {
const [activeId, setActiveId] = useState(null);
const [items, setItems] = useState(data);
+ const scrollViewRef = useRef(null);
+ const scrollbarMetrics = useWebDesktopScrollbarMetrics();
// Sync items with data prop
if (data !== items && !activeId) {
@@ -151,39 +159,60 @@ export function DraggableList({
);
const ids = items.map((item, index) => keyExtractor(item, index));
+ const showCustomScrollbar = enableDesktopWebScrollbar;
return (
-
- {ListHeaderComponent}
- {items.length === 0 && ListEmptyComponent}
-
+
-
- {items.map((item, index) => {
- const id = keyExtractor(item, index);
- return (
-
- );
- })}
-
-
- {ListFooterComponent}
-
+ {ListHeaderComponent}
+ {items.length === 0 && ListEmptyComponent}
+
+
+ {items.map((item, index) => {
+ const id = keyExtractor(item, index);
+ return (
+
+ );
+ })}
+
+
+ {ListFooterComponent}
+
+ {
+ scrollViewRef.current?.scrollTo({ y: nextOffset, animated: false });
+ scrollbarMetrics.setOffset(nextOffset);
+ }}
+ />
+
);
}
diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx
index e75b6a0dd..7a87e4fc7 100644
--- a/packages/app/src/components/file-explorer-pane.tsx
+++ b/packages/app/src/components/file-explorer-pane.tsx
@@ -5,6 +5,9 @@ import {
FlatList,
Image as RNImage,
ListRenderItemInfo,
+ type LayoutChangeEvent,
+ type NativeScrollEvent,
+ type NativeSyntheticEvent,
Pressable,
ScrollView as RNScrollView,
Text,
@@ -63,6 +66,10 @@ import {
type SortOption,
} from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
+import {
+ WebDesktopScrollbarOverlay,
+ useWebDesktopScrollbarMetrics,
+} from "@/components/web-desktop-scrollbar";
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "name", label: "Name" },
@@ -86,6 +93,7 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
+ const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const { connectionStates } = useDaemonConnections();
const daemonProfile = connectionStates.get(serverId)?.daemon;
@@ -136,6 +144,8 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
const [expandedPaths, setExpandedPaths] = useState>(() => new Set(["."]));
const [containerWidth, setContainerWidth] = useState(0);
const wasInlinePreviewVisibleRef = useRef(false);
+ const treeListRef = useRef>(null);
+ const treeScrollbarMetrics = useWebDesktopScrollbarMetrics();
// Bottom sheet for file preview (mobile)
const previewSheetRef = useRef(null);
@@ -592,6 +602,24 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
);
}
+ const handleTreeListScroll = useCallback(
+ (event: NativeSyntheticEvent) => {
+ if (showDesktopWebScrollbar) {
+ treeScrollbarMetrics.onScroll(event);
+ }
+ },
+ [showDesktopWebScrollbar, treeScrollbarMetrics]
+ );
+
+ const handleTreeListLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ if (showDesktopWebScrollbar) {
+ treeScrollbarMetrics.onLayout(event);
+ }
+ },
+ [showDesktopWebScrollbar, treeScrollbarMetrics]
+ );
+
return (
row.entry.path}
+ testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent}
+ onLayout={
+ showDesktopWebScrollbar ? handleTreeListLayout : undefined
+ }
+ onScroll={
+ showDesktopWebScrollbar ? handleTreeListScroll : undefined
+ }
+ onContentSizeChange={
+ showDesktopWebScrollbar
+ ? treeScrollbarMetrics.onContentSizeChange
+ : undefined
+ }
+ scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
+ showsVerticalScrollIndicator={!showDesktopWebScrollbar}
initialNumToRender={24}
maxToRenderPerBatch={40}
windowSize={12}
/>
+ {
+ treeListRef.current?.scrollToOffset({
+ offset: nextOffset,
+ animated: false,
+ });
+ treeScrollbarMetrics.setOffset(nextOffset);
+ }}
+ />
) : (
@@ -741,15 +795,41 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
row.entry.path}
+ testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent}
+ onLayout={
+ showDesktopWebScrollbar ? handleTreeListLayout : undefined
+ }
+ onScroll={
+ showDesktopWebScrollbar ? handleTreeListScroll : undefined
+ }
+ onContentSizeChange={
+ showDesktopWebScrollbar
+ ? treeScrollbarMetrics.onContentSizeChange
+ : undefined
+ }
+ scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
+ showsVerticalScrollIndicator={!showDesktopWebScrollbar}
initialNumToRender={24}
maxToRenderPerBatch={40}
windowSize={12}
/>
+ {
+ treeListRef.current?.scrollToOffset({
+ offset: nextOffset,
+ animated: false,
+ });
+ treeScrollbarMetrics.setOffset(nextOffset);
+ }}
+ />
)}
diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx
index 0043e1747..750727258 100644
--- a/packages/app/src/components/git-diff-pane.tsx
+++ b/packages/app/src/components/git-diff-pane.tsx
@@ -12,7 +12,7 @@ import {
type NativeScrollEvent,
} from "react-native";
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
-import { StyleSheet, useUnistyles } from "react-native-unistyles";
+import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import AsyncStorage from "@react-native-async-storage/async-storage";
import {
Archive,
@@ -50,6 +50,10 @@ import {
type ActionStatus,
} from "@/components/ui/dropdown-menu";
import { GitHubIcon } from "@/components/icons/github-icon";
+import {
+ WebDesktopScrollbarOverlay,
+ useWebDesktopScrollbarMetrics,
+} from "@/components/web-desktop-scrollbar";
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
import { openExternalUrl } from "@/utils/open-external-url";
@@ -469,6 +473,9 @@ type DiffFlatItem =
export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const { theme } = useUnistyles();
+ const isMobile =
+ UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
+ const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const router = useRouter();
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
const [actionError, setActionError] = useState(null);
@@ -517,6 +524,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const [isManualRefresh, setIsManualRefresh] = useState(false);
const [expandedByPath, setExpandedByPath] = useState>({});
const diffListRef = useRef>(null);
+ const diffScrollbarMetrics = useWebDesktopScrollbarMetrics();
const diffListScrollOffsetRef = useRef(0);
const diffListViewportHeightRef = useRef(0);
const headerHeightByPathRef = useRef>({});
@@ -623,17 +631,29 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
bodyHeightByPathRef.current[path] = height;
}, []);
- const handleDiffListScroll = useCallback((event: NativeSyntheticEvent) => {
- diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
- }, []);
+ const handleDiffListScroll = useCallback(
+ (event: NativeSyntheticEvent) => {
+ diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
+ if (showDesktopWebScrollbar) {
+ diffScrollbarMetrics.onScroll(event);
+ }
+ },
+ [diffScrollbarMetrics, showDesktopWebScrollbar]
+ );
- const handleDiffListLayout = useCallback((event: LayoutChangeEvent) => {
- const height = event.nativeEvent.layout.height;
- if (!Number.isFinite(height) || height <= 0) {
- return;
- }
- diffListViewportHeightRef.current = height;
- }, []);
+ const handleDiffListLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ const height = event.nativeEvent.layout.height;
+ if (!Number.isFinite(height) || height <= 0) {
+ return;
+ }
+ diffListViewportHeightRef.current = height;
+ if (showDesktopWebScrollbar) {
+ diffScrollbarMetrics.onLayout(event);
+ }
+ },
+ [diffScrollbarMetrics, showDesktopWebScrollbar]
+ );
const computeHeaderOffset = useCallback(
(path: string): number => {
@@ -956,7 +976,13 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
testID="git-diff-scroll"
onLayout={handleDiffListLayout}
onScroll={handleDiffListScroll}
+ onContentSizeChange={
+ showDesktopWebScrollbar
+ ? diffScrollbarMetrics.onContentSizeChange
+ : undefined
+ }
scrollEventThrottle={16}
+ showsVerticalScrollIndicator={!showDesktopWebScrollbar}
onRefresh={handleRefresh}
refreshing={isManualRefresh && isDiffFetching}
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
@@ -1331,7 +1357,20 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
{prErrorMessage}
) : null}
- {bodyContent}
+
+ {bodyContent}
+ {
+ diffListRef.current?.scrollToOffset({
+ offset: nextOffset,
+ animated: false,
+ });
+ diffScrollbarMetrics.setOffset(nextOffset);
+ }}
+ />
+
);
}
@@ -1531,6 +1570,7 @@ const styles = StyleSheet.create((theme) => ({
diffContainer: {
flex: 1,
minHeight: 0,
+ position: "relative",
},
scrollView: {
flex: 1,
diff --git a/packages/app/src/components/sidebar-agent-list.tsx b/packages/app/src/components/sidebar-agent-list.tsx
index 1d5d394e0..8488d1048 100644
--- a/packages/app/src/components/sidebar-agent-list.tsx
+++ b/packages/app/src/components/sidebar-agent-list.tsx
@@ -16,7 +16,11 @@ import {
type MutableRefObject,
} from "react";
import { router, usePathname } from "expo-router";
-import { StyleSheet, useUnistyles } from "react-native-unistyles";
+import {
+ StyleSheet,
+ UnistylesRuntime,
+ useUnistyles,
+} from "react-native-unistyles";
import { type GestureType } from "react-native-gesture-handler";
import { Archive, Check, ChevronDown } from "lucide-react-native";
import {
@@ -434,6 +438,9 @@ export function SidebarAgentList({
}: SidebarAgentListProps) {
const { theme } = useUnistyles();
const pathname = usePathname();
+ const isMobile =
+ UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
+ const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const [isProjectFilterOpen, setIsProjectFilterOpen] = useState(false);
const projectFilterAnchorRef = useRef(null);
@@ -820,10 +827,12 @@ export function SidebarAgentList({
styles.listContent,
isSelectionMode ? styles.listContentSelectionMode : null,
]}
+ testID="sidebar-agent-list-scroll"
keyExtractor={keyExtractor}
renderItem={renderRow}
onDragEnd={() => {}}
showsVerticalScrollIndicator={false}
+ enableDesktopWebScrollbar={showDesktopWebScrollbar}
ListFooterComponent={listFooterComponent}
refreshing={isRefreshing}
onRefresh={onRefresh}
diff --git a/packages/app/src/components/web-desktop-scrollbar.math.ts b/packages/app/src/components/web-desktop-scrollbar.math.ts
new file mode 100644
index 000000000..fbb6d7683
--- /dev/null
+++ b/packages/app/src/components/web-desktop-scrollbar.math.ts
@@ -0,0 +1,81 @@
+const DEFAULT_MIN_HANDLE_SIZE = 36;
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, value));
+}
+
+export type VerticalScrollbarGeometryInput = {
+ viewportSize: number;
+ contentSize: number;
+ offset: number;
+ minHandleSize?: number;
+};
+
+export type VerticalScrollbarGeometry = {
+ isVisible: boolean;
+ maxScrollOffset: number;
+ handleSize: number;
+ handleOffset: number;
+ maxHandleOffset: number;
+};
+
+export function computeVerticalScrollbarGeometry(
+ input: VerticalScrollbarGeometryInput
+): VerticalScrollbarGeometry {
+ const viewportSize = Number.isFinite(input.viewportSize)
+ ? Math.max(0, input.viewportSize)
+ : 0;
+ const contentSize = Number.isFinite(input.contentSize)
+ ? Math.max(0, input.contentSize)
+ : 0;
+ const minHandleSize = Number.isFinite(input.minHandleSize)
+ ? Math.max(0, input.minHandleSize ?? DEFAULT_MIN_HANDLE_SIZE)
+ : DEFAULT_MIN_HANDLE_SIZE;
+
+ const maxScrollOffset = Math.max(0, contentSize - viewportSize);
+ if (maxScrollOffset <= 0 || viewportSize <= 0 || contentSize <= 0) {
+ return {
+ isVisible: false,
+ maxScrollOffset: 0,
+ handleSize: 0,
+ handleOffset: 0,
+ maxHandleOffset: 0,
+ };
+ }
+
+ const rawHandleSize = (viewportSize * viewportSize) / contentSize;
+ const handleSize = clamp(rawHandleSize, minHandleSize, viewportSize);
+ const maxHandleOffset = Math.max(0, viewportSize - handleSize);
+ const clampedOffset = clamp(input.offset, 0, maxScrollOffset);
+ const handleOffset =
+ maxScrollOffset > 0
+ ? (clampedOffset / maxScrollOffset) * maxHandleOffset
+ : 0;
+
+ return {
+ isVisible: true,
+ maxScrollOffset,
+ handleSize,
+ handleOffset,
+ maxHandleOffset,
+ };
+}
+
+export type ScrollOffsetFromDragDeltaInput = {
+ startOffset: number;
+ dragDelta: number;
+ maxScrollOffset: number;
+ maxHandleOffset: number;
+};
+
+export function computeScrollOffsetFromDragDelta(
+ input: ScrollOffsetFromDragDeltaInput
+): number {
+ if (input.maxScrollOffset <= 0 || input.maxHandleOffset <= 0) {
+ return clamp(input.startOffset, 0, Math.max(0, input.maxScrollOffset));
+ }
+
+ const scrollPerPixel = input.maxScrollOffset / input.maxHandleOffset;
+ const nextOffset = input.startOffset + input.dragDelta * scrollPerPixel;
+ return clamp(nextOffset, 0, input.maxScrollOffset);
+}
diff --git a/packages/app/src/components/web-desktop-scrollbar.test.ts b/packages/app/src/components/web-desktop-scrollbar.test.ts
new file mode 100644
index 000000000..91bcafbab
--- /dev/null
+++ b/packages/app/src/components/web-desktop-scrollbar.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from "vitest";
+import {
+ computeScrollOffsetFromDragDelta,
+ computeVerticalScrollbarGeometry,
+} from "./web-desktop-scrollbar.math";
+
+describe("computeVerticalScrollbarGeometry", () => {
+ it("returns hidden geometry when content does not overflow", () => {
+ const geometry = computeVerticalScrollbarGeometry({
+ viewportSize: 500,
+ contentSize: 500,
+ offset: 0,
+ minHandleSize: 36,
+ });
+
+ expect(geometry).toEqual({
+ isVisible: false,
+ maxScrollOffset: 0,
+ handleSize: 0,
+ handleOffset: 0,
+ maxHandleOffset: 0,
+ });
+ });
+
+ it("computes visible geometry when content overflows", () => {
+ const geometry = computeVerticalScrollbarGeometry({
+ viewportSize: 500,
+ contentSize: 2000,
+ offset: 375,
+ minHandleSize: 36,
+ });
+
+ expect(geometry).toEqual({
+ isVisible: true,
+ maxScrollOffset: 1500,
+ handleSize: 125,
+ handleOffset: 93.75,
+ maxHandleOffset: 375,
+ });
+ });
+
+ it("clamps handle size to min and offset to bounds", () => {
+ const geometry = computeVerticalScrollbarGeometry({
+ viewportSize: 100,
+ contentSize: 10000,
+ offset: 99999,
+ minHandleSize: 24,
+ });
+
+ expect(geometry).toEqual({
+ isVisible: true,
+ maxScrollOffset: 9900,
+ handleSize: 24,
+ handleOffset: 76,
+ maxHandleOffset: 76,
+ });
+ });
+});
+
+describe("computeScrollOffsetFromDragDelta", () => {
+ it("maps drag distance proportionally to scroll offset", () => {
+ const nextOffset = computeScrollOffsetFromDragDelta({
+ startOffset: 250,
+ dragDelta: 50,
+ maxScrollOffset: 1000,
+ maxHandleOffset: 200,
+ });
+
+ expect(nextOffset).toBe(500);
+ });
+
+ it("clamps to scroll bounds", () => {
+ const nextOffset = computeScrollOffsetFromDragDelta({
+ startOffset: 900,
+ dragDelta: 1000,
+ maxScrollOffset: 1000,
+ maxHandleOffset: 200,
+ });
+
+ expect(nextOffset).toBe(1000);
+ });
+});
diff --git a/packages/app/src/components/web-desktop-scrollbar.tsx b/packages/app/src/components/web-desktop-scrollbar.tsx
new file mode 100644
index 000000000..4f97a221b
--- /dev/null
+++ b/packages/app/src/components/web-desktop-scrollbar.tsx
@@ -0,0 +1,214 @@
+import { useCallback, useMemo, useRef, useState } from "react";
+import {
+ PanResponder,
+ Platform,
+ Pressable,
+ View,
+ type LayoutChangeEvent,
+ type NativeScrollEvent,
+ type NativeSyntheticEvent,
+} from "react-native";
+import { StyleSheet, useUnistyles } from "react-native-unistyles";
+import {
+ computeScrollOffsetFromDragDelta,
+ computeVerticalScrollbarGeometry,
+} from "./web-desktop-scrollbar.math";
+
+const METRICS_EPSILON = 0.5;
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, value));
+}
+
+type ScrollbarMetrics = {
+ offset: number;
+ viewportSize: number;
+ contentSize: number;
+};
+
+function areMetricsEqual(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean {
+ return (
+ Math.abs(a.offset - b.offset) <= METRICS_EPSILON &&
+ Math.abs(a.viewportSize - b.viewportSize) <= METRICS_EPSILON &&
+ Math.abs(a.contentSize - b.contentSize) <= METRICS_EPSILON
+ );
+}
+
+export function useWebDesktopScrollbarMetrics() {
+ const [metrics, setMetrics] = useState({
+ offset: 0,
+ viewportSize: 0,
+ contentSize: 0,
+ });
+
+ const setMetricsIfChanged = useCallback((next: ScrollbarMetrics) => {
+ setMetrics((previous) => (areMetricsEqual(previous, next) ? previous : next));
+ }, []);
+
+ const onScroll = useCallback(
+ (event: NativeSyntheticEvent) => {
+ const { contentOffset, layoutMeasurement, contentSize } = event.nativeEvent;
+ setMetricsIfChanged({
+ offset: Math.max(0, contentOffset.y),
+ viewportSize: Math.max(0, layoutMeasurement.height),
+ contentSize: Math.max(0, contentSize.height),
+ });
+ },
+ [setMetricsIfChanged]
+ );
+
+ const onLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ const viewportSize = Math.max(0, event.nativeEvent.layout.height);
+ setMetrics((previous) => {
+ const next = { ...previous, viewportSize };
+ return areMetricsEqual(previous, next) ? previous : next;
+ });
+ },
+ []
+ );
+
+ const onContentSizeChange = useCallback((_width: number, height: number) => {
+ const contentSize = Math.max(0, height);
+ setMetrics((previous) => {
+ const next = { ...previous, contentSize };
+ return areMetricsEqual(previous, next) ? previous : next;
+ });
+ }, []);
+
+ const setOffset = useCallback((offset: number) => {
+ const clampedOffset = Math.max(0, offset);
+ setMetrics((previous) => {
+ const next = { ...previous, offset: clampedOffset };
+ return areMetricsEqual(previous, next) ? previous : next;
+ });
+ }, []);
+
+ return {
+ ...metrics,
+ onScroll,
+ onLayout,
+ onContentSizeChange,
+ setOffset,
+ };
+}
+
+type WebDesktopScrollbarOverlayProps = {
+ enabled: boolean;
+ metrics: ScrollbarMetrics;
+ onScrollToOffset: (offset: number) => void;
+ inverted?: boolean;
+};
+
+export function WebDesktopScrollbarOverlay({
+ enabled,
+ metrics,
+ onScrollToOffset,
+ inverted = false,
+}: WebDesktopScrollbarOverlayProps) {
+ const { theme } = useUnistyles();
+ const [isHovered, setIsHovered] = useState(false);
+ const [isDragging, setIsDragging] = useState(false);
+ const dragStartOffsetRef = useRef(0);
+
+ const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize);
+ const normalizedOffset = inverted
+ ? Math.max(0, maxScrollOffset - clamp(metrics.offset, 0, maxScrollOffset))
+ : clamp(metrics.offset, 0, maxScrollOffset);
+
+ const geometry = useMemo(
+ () =>
+ computeVerticalScrollbarGeometry({
+ viewportSize: metrics.viewportSize,
+ contentSize: metrics.contentSize,
+ offset: normalizedOffset,
+ }),
+ [metrics.contentSize, metrics.viewportSize, normalizedOffset]
+ );
+
+ const panResponder = useMemo(
+ () =>
+ PanResponder.create({
+ onStartShouldSetPanResponder: () => true,
+ onMoveShouldSetPanResponder: () => true,
+ onPanResponderGrant: () => {
+ dragStartOffsetRef.current = normalizedOffset;
+ setIsDragging(true);
+ },
+ onPanResponderMove: (_event, gestureState) => {
+ const nextNormalizedOffset = computeScrollOffsetFromDragDelta({
+ startOffset: dragStartOffsetRef.current,
+ dragDelta: gestureState.dy,
+ maxScrollOffset: geometry.maxScrollOffset,
+ maxHandleOffset: geometry.maxHandleOffset,
+ });
+ const nextOffset = inverted
+ ? geometry.maxScrollOffset - nextNormalizedOffset
+ : nextNormalizedOffset;
+ onScrollToOffset(nextOffset);
+ },
+ onPanResponderRelease: () => {
+ setIsDragging(false);
+ },
+ onPanResponderTerminate: () => {
+ setIsDragging(false);
+ },
+ }),
+ [
+ geometry.maxHandleOffset,
+ geometry.maxScrollOffset,
+ inverted,
+ normalizedOffset,
+ onScrollToOffset,
+ ]
+ );
+
+ if (!enabled || !geometry.isVisible) {
+ return null;
+ }
+
+ const handleOpacity = isDragging ? 0.52 : isHovered ? 0.4 : 0.28;
+ const handleColor =
+ isDragging || isHovered
+ ? theme.colors.foreground
+ : theme.colors.foregroundMuted;
+
+ return (
+
+ setIsHovered(true)}
+ onHoverOut={() => setIsHovered(false)}
+ {...panResponder.panHandlers}
+ />
+
+ );
+}
+
+const styles = StyleSheet.create(() => ({
+ overlay: {
+ position: "absolute",
+ top: 0,
+ right: 0,
+ bottom: 0,
+ width: 12,
+ alignItems: "center",
+ justifyContent: "flex-start",
+ zIndex: 10,
+ },
+ handle: {
+ position: "absolute",
+ width: 6,
+ borderRadius: 999,
+ },
+}));