feat(app): add shared desktop web overlay scroll handles

This commit is contained in:
Mohamed Boudra
2026-02-18 15:10:20 +07:00
parent b1955cf74d
commit 5079ca386b
11 changed files with 639 additions and 47 deletions
+16
View File
@@ -20,6 +20,22 @@ const webEcosystemStyles = /* css */ `
-webkit-user-select: text; -webkit-user-select: text;
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() { function WebRespectfulStyleReset() {
@@ -50,6 +50,10 @@ import type { DaemonClient } from "@server/client/daemon-client";
import { ToolCallDetailsContent } from "./tool-call-details"; import { ToolCallDetailsContent } from "./tool-call-details";
import { QuestionFormCard } from "./question-form-card"; import { QuestionFormCard } from "./question-form-card";
import { ToolCallSheetProvider } from "./tool-call-sheet"; import { ToolCallSheetProvider } from "./tool-call-sheet";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "./web-desktop-scrollbar";
import { createMarkdownStyles } from "@/styles/markdown-styles"; import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf"; import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
@@ -95,12 +99,16 @@ export function AgentStreamView({
}: AgentStreamViewProps) { }: AgentStreamViewProps) {
const flatListRef = useRef<FlatList<StreamItem>>(null); const flatListRef = useRef<FlatList<StreamItem>>(null);
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [isNearBottom, setIsNearBottom] = useState(true); const [isNearBottom, setIsNearBottom] = useState(true);
const hasScrolledInitially = useRef(false); const hasScrolledInitially = useRef(false);
const hasAutoScrolledOnce = useRef(false); const hasAutoScrolledOnce = useRef(false);
const isNearBottomRef = useRef(true); const isNearBottomRef = useRef(true);
const streamItemCountRef = useRef(0); const streamItemCountRef = useRef(0);
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set()); const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
const openFileExplorer = usePanelStore((state) => state.openFileExplorer); const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
@@ -184,8 +192,12 @@ export function AgentStreamView({
isNearBottomRef.current = nearBottom; isNearBottomRef.current = nearBottom;
setIsNearBottom(nearBottom); setIsNearBottom(nearBottom);
} }
if (showDesktopWebScrollbar) {
streamScrollbarMetrics.onScroll(event);
}
}, },
[insets.bottom] [insets.bottom, showDesktopWebScrollbar, streamScrollbarMetrics]
); );
const scrollToBottomInternal = useCallback( const scrollToBottomInternal = useCallback(
@@ -647,14 +659,25 @@ export function AgentStreamView({
data={flatListData} data={flatListData}
renderItem={renderStreamItem} renderItem={renderStreamItem}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
testID="agent-chat-scroll"
ListHeaderComponentStyle={headerGapStyle} ListHeaderComponentStyle={headerGapStyle}
contentContainerStyle={{ contentContainerStyle={{
paddingVertical: 0, paddingVertical: 0,
flexGrow: 1, flexGrow: 1,
}} }}
style={stylesheet.list} style={stylesheet.list}
onLayout={
showDesktopWebScrollbar
? streamScrollbarMetrics.onLayout
: undefined
}
onScroll={handleScroll} onScroll={handleScroll}
scrollEventThrottle={16} scrollEventThrottle={16}
onContentSizeChange={
showDesktopWebScrollbar
? streamScrollbarMetrics.onContentSizeChange
: undefined
}
ListEmptyComponent={listEmptyComponent} ListEmptyComponent={listEmptyComponent}
ListHeaderComponent={listHeaderComponent} ListHeaderComponent={listHeaderComponent}
extraData={flatListExtraData} extraData={flatListExtraData}
@@ -667,9 +690,22 @@ export function AgentStreamView({
initialNumToRender={12} initialNumToRender={12}
windowSize={10} windowSize={10}
scrollEnabled={Platform.OS !== "web" || expandedInlineToolCallIds.size === 0} scrollEnabled={Platform.OS !== "web" || expandedInlineToolCallIds.size === 0}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
inverted inverted
/> />
</MessageOuterSpacingProvider> </MessageOuterSpacingProvider>
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar}
metrics={streamScrollbarMetrics}
inverted
onScrollToOffset={(nextOffset) => {
flatListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
streamScrollbarMetrics.setOffset(nextOffset);
}}
/>
{/* Scroll to bottom button */} {/* Scroll to bottom button */}
{!isNearBottom && ( {!isNearBottom && (
@@ -18,10 +18,12 @@ export function DraggableList<T>({
onDragEnd, onDragEnd,
style, style,
contentContainerStyle, contentContainerStyle,
testID,
ListFooterComponent, ListFooterComponent,
ListHeaderComponent, ListHeaderComponent,
ListEmptyComponent, ListEmptyComponent,
showsVerticalScrollIndicator = true, showsVerticalScrollIndicator = true,
enableDesktopWebScrollbar: _enableDesktopWebScrollbar = false,
refreshing, refreshing,
onRefresh, onRefresh,
simultaneousGestureRef, simultaneousGestureRef,
@@ -69,6 +71,7 @@ export function DraggableList<T>({
return ( return (
<DraggableFlatList <DraggableFlatList
testID={testID}
data={data} data={data}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
renderItem={handleRenderItem} renderItem={handleRenderItem}
@@ -16,10 +16,12 @@ export interface DraggableListProps<T> {
onDragEnd: (data: T[]) => void; onDragEnd: (data: T[]) => void;
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>; contentContainerStyle?: StyleProp<ViewStyle>;
testID?: string;
ListFooterComponent?: ReactElement | null; ListFooterComponent?: ReactElement | null;
ListHeaderComponent?: ReactElement | null; ListHeaderComponent?: ReactElement | null;
ListEmptyComponent?: ReactElement | null; ListEmptyComponent?: ReactElement | null;
showsVerticalScrollIndicator?: boolean; showsVerticalScrollIndicator?: boolean;
enableDesktopWebScrollbar?: boolean;
refreshing?: boolean; refreshing?: boolean;
onRefresh?: () => void; onRefresh?: () => void;
/** Fill remaining space when content is smaller than container */ /** Fill remaining space when content is smaller than container */
@@ -1,5 +1,5 @@
import { useCallback, useState, useRef, type ReactElement } from "react"; import { useCallback, useRef, useState, type ReactElement } from "react";
import { View, ScrollView } from "react-native"; import { ScrollView, View } from "react-native";
import { import {
DndContext, DndContext,
closestCenter, closestCenter,
@@ -23,6 +23,10 @@ import type {
DraggableListProps, DraggableListProps,
DraggableRenderItemInfo, DraggableRenderItemInfo,
} from "./draggable-list.types"; } from "./draggable-list.types";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "./web-desktop-scrollbar";
export type { DraggableListProps, DraggableRenderItemInfo }; export type { DraggableListProps, DraggableRenderItemInfo };
@@ -99,14 +103,18 @@ export function DraggableList<T>({
onDragEnd, onDragEnd,
style, style,
contentContainerStyle, contentContainerStyle,
testID,
ListFooterComponent, ListFooterComponent,
ListHeaderComponent, ListHeaderComponent,
ListEmptyComponent, ListEmptyComponent,
showsVerticalScrollIndicator = true, showsVerticalScrollIndicator = true,
enableDesktopWebScrollbar = false,
// simultaneousGestureRef is native-only, ignored on web // simultaneousGestureRef is native-only, ignored on web
}: DraggableListProps<T>) { }: DraggableListProps<T>) {
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [items, setItems] = useState(data); const [items, setItems] = useState(data);
const scrollViewRef = useRef<ScrollView>(null);
const scrollbarMetrics = useWebDesktopScrollbarMetrics();
// Sync items with data prop // Sync items with data prop
if (data !== items && !activeId) { if (data !== items && !activeId) {
@@ -151,39 +159,60 @@ export function DraggableList<T>({
); );
const ids = items.map((item, index) => keyExtractor(item, index)); const ids = items.map((item, index) => keyExtractor(item, index));
const showCustomScrollbar = enableDesktopWebScrollbar;
return ( return (
<ScrollView <View style={{ flex: 1, minHeight: 0, position: "relative" }}>
style={style} <ScrollView
contentContainerStyle={contentContainerStyle} ref={scrollViewRef}
showsVerticalScrollIndicator={showsVerticalScrollIndicator} testID={testID}
> style={style}
{ListHeaderComponent} contentContainerStyle={contentContainerStyle}
{items.length === 0 && ListEmptyComponent} showsVerticalScrollIndicator={
<DndContext showCustomScrollbar ? false : showsVerticalScrollIndicator
sensors={sensors} }
collisionDetection={closestCenter} onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
modifiers={[restrictToVerticalAxis]} onContentSizeChange={
onDragStart={handleDragStart} showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
onDragEnd={handleDragEnd} }
onScroll={showCustomScrollbar ? scrollbarMetrics.onScroll : undefined}
scrollEventThrottle={showCustomScrollbar ? 16 : undefined}
> >
<SortableContext items={ids} strategy={verticalListSortingStrategy}> {ListHeaderComponent}
{items.map((item, index) => { {items.length === 0 && ListEmptyComponent}
const id = keyExtractor(item, index); <DndContext
return ( sensors={sensors}
<SortableItem collisionDetection={closestCenter}
key={id} modifiers={[restrictToVerticalAxis]}
id={id} onDragStart={handleDragStart}
item={item} onDragEnd={handleDragEnd}
index={index} >
renderItem={renderItem} <SortableContext items={ids} strategy={verticalListSortingStrategy}>
activeId={activeId} {items.map((item, index) => {
/> const id = keyExtractor(item, index);
); return (
})} <SortableItem
</SortableContext> key={id}
</DndContext> id={id}
{ListFooterComponent} item={item}
</ScrollView> index={index}
renderItem={renderItem}
activeId={activeId}
/>
);
})}
</SortableContext>
</DndContext>
{ListFooterComponent}
</ScrollView>
<WebDesktopScrollbarOverlay
enabled={showCustomScrollbar}
metrics={scrollbarMetrics}
onScrollToOffset={(nextOffset) => {
scrollViewRef.current?.scrollTo({ y: nextOffset, animated: false });
scrollbarMetrics.setOffset(nextOffset);
}}
/>
</View>
); );
} }
@@ -5,6 +5,9 @@ import {
FlatList, FlatList,
Image as RNImage, Image as RNImage,
ListRenderItemInfo, ListRenderItemInfo,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
Pressable, Pressable,
ScrollView as RNScrollView, ScrollView as RNScrollView,
Text, Text,
@@ -63,6 +66,10 @@ import {
type SortOption, type SortOption,
} from "@/stores/panel-store"; } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
const SORT_OPTIONS: { value: SortOption; label: string }[] = [ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "name", label: "Name" }, { value: "name", label: "Name" },
@@ -86,6 +93,7 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const { connectionStates } = useDaemonConnections(); const { connectionStates } = useDaemonConnections();
const daemonProfile = connectionStates.get(serverId)?.daemon; const daemonProfile = connectionStates.get(serverId)?.daemon;
@@ -136,6 +144,8 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."])); const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
const [containerWidth, setContainerWidth] = useState(0); const [containerWidth, setContainerWidth] = useState(0);
const wasInlinePreviewVisibleRef = useRef(false); const wasInlinePreviewVisibleRef = useRef(false);
const treeListRef = useRef<FlatList<TreeRow>>(null);
const treeScrollbarMetrics = useWebDesktopScrollbarMetrics();
// Bottom sheet for file preview (mobile) // Bottom sheet for file preview (mobile)
const previewSheetRef = useRef<BottomSheetModal>(null); const previewSheetRef = useRef<BottomSheetModal>(null);
@@ -592,6 +602,24 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
); );
} }
const handleTreeListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (showDesktopWebScrollbar) {
treeScrollbarMetrics.onScroll(event);
}
},
[showDesktopWebScrollbar, treeScrollbarMetrics]
);
const handleTreeListLayout = useCallback(
(event: LayoutChangeEvent) => {
if (showDesktopWebScrollbar) {
treeScrollbarMetrics.onLayout(event);
}
},
[showDesktopWebScrollbar, treeScrollbarMetrics]
);
return ( return (
<View <View
style={styles.container} style={styles.container}
@@ -704,15 +732,41 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
</View> </View>
</View> </View>
<FlatList <FlatList
ref={treeListRef}
style={styles.treeList} style={styles.treeList}
data={treeRows} data={treeRows}
renderItem={renderTreeRow} renderItem={renderTreeRow}
keyExtractor={(row) => row.entry.path} keyExtractor={(row) => row.entry.path}
testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent} contentContainerStyle={styles.entriesContent}
onLayout={
showDesktopWebScrollbar ? handleTreeListLayout : undefined
}
onScroll={
showDesktopWebScrollbar ? handleTreeListScroll : undefined
}
onContentSizeChange={
showDesktopWebScrollbar
? treeScrollbarMetrics.onContentSizeChange
: undefined
}
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
initialNumToRender={24} initialNumToRender={24}
maxToRenderPerBatch={40} maxToRenderPerBatch={40}
windowSize={12} windowSize={12}
/> />
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar}
metrics={treeScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
treeListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
treeScrollbarMetrics.setOffset(nextOffset);
}}
/>
</Animated.View> </Animated.View>
) : ( ) : (
<View style={[styles.treePane, styles.treePaneFill]}> <View style={[styles.treePane, styles.treePaneFill]}>
@@ -741,15 +795,41 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
</View> </View>
</View> </View>
<FlatList <FlatList
ref={treeListRef}
style={styles.treeList} style={styles.treeList}
data={treeRows} data={treeRows}
renderItem={renderTreeRow} renderItem={renderTreeRow}
keyExtractor={(row) => row.entry.path} keyExtractor={(row) => row.entry.path}
testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent} contentContainerStyle={styles.entriesContent}
onLayout={
showDesktopWebScrollbar ? handleTreeListLayout : undefined
}
onScroll={
showDesktopWebScrollbar ? handleTreeListScroll : undefined
}
onContentSizeChange={
showDesktopWebScrollbar
? treeScrollbarMetrics.onContentSizeChange
: undefined
}
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
initialNumToRender={24} initialNumToRender={24}
maxToRenderPerBatch={40} maxToRenderPerBatch={40}
windowSize={12} windowSize={12}
/> />
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar}
metrics={treeScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
treeListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
treeScrollbarMetrics.setOffset(nextOffset);
}}
/>
</View> </View>
)} )}
</View> </View>
+52 -12
View File
@@ -12,7 +12,7 @@ import {
type NativeScrollEvent, type NativeScrollEvent,
} from "react-native"; } from "react-native";
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler"; 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 AsyncStorage from "@react-native-async-storage/async-storage";
import { import {
Archive, Archive,
@@ -50,6 +50,10 @@ import {
type ActionStatus, type ActionStatus,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { GitHubIcon } from "@/components/icons/github-icon"; import { GitHubIcon } from "@/components/icons/github-icon";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
import { buildHostAgentDraftRoute } from "@/utils/host-routes"; import { buildHostAgentDraftRoute } from "@/utils/host-routes";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
@@ -469,6 +473,9 @@ type DiffFlatItem =
export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const router = useRouter(); const router = useRouter();
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
@@ -517,6 +524,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const [isManualRefresh, setIsManualRefresh] = useState(false); const [isManualRefresh, setIsManualRefresh] = useState(false);
const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({}); const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({});
const diffListRef = useRef<FlatList<DiffFlatItem>>(null); const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
const diffScrollbarMetrics = useWebDesktopScrollbarMetrics();
const diffListScrollOffsetRef = useRef(0); const diffListScrollOffsetRef = useRef(0);
const diffListViewportHeightRef = useRef(0); const diffListViewportHeightRef = useRef(0);
const headerHeightByPathRef = useRef<Record<string, number>>({}); const headerHeightByPathRef = useRef<Record<string, number>>({});
@@ -623,17 +631,29 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
bodyHeightByPathRef.current[path] = height; bodyHeightByPathRef.current[path] = height;
}, []); }, []);
const handleDiffListScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => { const handleDiffListScroll = useCallback(
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; (event: NativeSyntheticEvent<NativeScrollEvent>) => {
}, []); diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
if (showDesktopWebScrollbar) {
diffScrollbarMetrics.onScroll(event);
}
},
[diffScrollbarMetrics, showDesktopWebScrollbar]
);
const handleDiffListLayout = useCallback((event: LayoutChangeEvent) => { const handleDiffListLayout = useCallback(
const height = event.nativeEvent.layout.height; (event: LayoutChangeEvent) => {
if (!Number.isFinite(height) || height <= 0) { const height = event.nativeEvent.layout.height;
return; if (!Number.isFinite(height) || height <= 0) {
} return;
diffListViewportHeightRef.current = height; }
}, []); diffListViewportHeightRef.current = height;
if (showDesktopWebScrollbar) {
diffScrollbarMetrics.onLayout(event);
}
},
[diffScrollbarMetrics, showDesktopWebScrollbar]
);
const computeHeaderOffset = useCallback( const computeHeaderOffset = useCallback(
(path: string): number => { (path: string): number => {
@@ -956,7 +976,13 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
testID="git-diff-scroll" testID="git-diff-scroll"
onLayout={handleDiffListLayout} onLayout={handleDiffListLayout}
onScroll={handleDiffListScroll} onScroll={handleDiffListScroll}
onContentSizeChange={
showDesktopWebScrollbar
? diffScrollbarMetrics.onContentSizeChange
: undefined
}
scrollEventThrottle={16} scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
onRefresh={handleRefresh} onRefresh={handleRefresh}
refreshing={isManualRefresh && isDiffFetching} refreshing={isManualRefresh && isDiffFetching}
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts. // Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
@@ -1331,7 +1357,20 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
<Text style={styles.actionErrorText}>{prErrorMessage}</Text> <Text style={styles.actionErrorText}>{prErrorMessage}</Text>
) : null} ) : null}
<View style={styles.diffContainer}>{bodyContent}</View> <View style={styles.diffContainer}>
{bodyContent}
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar && hasChanges}
metrics={diffScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
diffListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
diffScrollbarMetrics.setOffset(nextOffset);
}}
/>
</View>
</View> </View>
); );
} }
@@ -1531,6 +1570,7 @@ const styles = StyleSheet.create((theme) => ({
diffContainer: { diffContainer: {
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
position: "relative",
}, },
scrollView: { scrollView: {
flex: 1, flex: 1,
@@ -16,7 +16,11 @@ import {
type MutableRefObject, type MutableRefObject,
} from "react"; } from "react";
import { router, usePathname } from "expo-router"; 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 { type GestureType } from "react-native-gesture-handler";
import { Archive, Check, ChevronDown } from "lucide-react-native"; import { Archive, Check, ChevronDown } from "lucide-react-native";
import { import {
@@ -434,6 +438,9 @@ export function SidebarAgentList({
}: SidebarAgentListProps) { }: SidebarAgentListProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const pathname = usePathname(); const pathname = usePathname();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const [isProjectFilterOpen, setIsProjectFilterOpen] = useState(false); const [isProjectFilterOpen, setIsProjectFilterOpen] = useState(false);
const projectFilterAnchorRef = useRef<View>(null); const projectFilterAnchorRef = useRef<View>(null);
@@ -820,10 +827,12 @@ export function SidebarAgentList({
styles.listContent, styles.listContent,
isSelectionMode ? styles.listContentSelectionMode : null, isSelectionMode ? styles.listContentSelectionMode : null,
]} ]}
testID="sidebar-agent-list-scroll"
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
renderItem={renderRow} renderItem={renderRow}
onDragEnd={() => {}} onDragEnd={() => {}}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
enableDesktopWebScrollbar={showDesktopWebScrollbar}
ListFooterComponent={listFooterComponent} ListFooterComponent={listFooterComponent}
refreshing={isRefreshing} refreshing={isRefreshing}
onRefresh={onRefresh} onRefresh={onRefresh}
@@ -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);
}
@@ -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);
});
});
@@ -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<ScrollbarMetrics>({
offset: 0,
viewportSize: 0,
contentSize: 0,
});
const setMetricsIfChanged = useCallback((next: ScrollbarMetrics) => {
setMetrics((previous) => (areMetricsEqual(previous, next) ? previous : next));
}, []);
const onScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
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 (
<View style={styles.overlay} pointerEvents="box-none">
<Pressable
style={[
styles.handle,
{
top: geometry.handleOffset,
height: geometry.handleSize,
backgroundColor: handleColor,
opacity: handleOpacity,
},
Platform.OS === "web" &&
({ cursor: "grab", touchAction: "none", userSelect: "none" } as any),
]}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
{...panResponder.panHandlers}
/>
</View>
);
}
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,
},
}));