fix: themed scrollbar on message input and reusable scrollbar hooks

Add useWebElementScrollbar (for DOM elements) and useWebScrollViewScrollbar
(for RN ScrollView/FlatList) hooks that return renderable overlays, replacing
manual WebDesktopScrollbarOverlay wiring across all consumers. Apply the
themed scrollbar to the message input textarea. Tint the dark-mode scrollbar
handle to match the teal-tinted dark theme.

Closes #174
This commit is contained in:
Mohamed Boudra
2026-04-02 10:52:14 +07:00
parent 7b4db04a81
commit a91f79053c
9 changed files with 274 additions and 240 deletions
@@ -20,7 +20,7 @@ import {
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types"; import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } from "./web-desktop-scrollbar"; import { useWebScrollViewScrollbar } from "./use-web-scrollbar";
export type { DraggableListProps, DraggableRenderItemInfo }; export type { DraggableListProps, DraggableRenderItemInfo };
@@ -133,8 +133,11 @@ export function DraggableList<T>({
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [dragItems, setDragItems] = useState<T[] | null>(null); const [dragItems, setDragItems] = useState<T[] | null>(null);
const items = dragItems ?? data; const items = dragItems ?? data;
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
const scrollViewRef = useRef<ScrollView>(null); const scrollViewRef = useRef<ScrollView>(null);
const scrollbarMetrics = useWebDesktopScrollbarMetrics(); const scrollbar = useWebScrollViewScrollbar(scrollViewRef, {
enabled: showCustomScrollbar,
});
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@@ -177,7 +180,6 @@ 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 && scrollEnabled;
const wrapperStyle = [ const wrapperStyle = [
{ position: "relative" as const }, { position: "relative" as const },
scrollEnabled ? { flex: 1, minHeight: 0 } : null, scrollEnabled ? { flex: 1, minHeight: 0 } : null,
@@ -193,12 +195,10 @@ export function DraggableList<T>({
style={style} style={style}
contentContainerStyle={contentContainerStyle} contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator} showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined} onLayout={scrollbar.onLayout}
onContentSizeChange={ onContentSizeChange={scrollbar.onContentSizeChange}
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined onScroll={scrollbar.onScroll}
} scrollEventThrottle={16}
onScroll={showCustomScrollbar ? scrollbarMetrics.onScroll : undefined}
scrollEventThrottle={showCustomScrollbar ? 16 : undefined}
> >
{ListHeaderComponent} {ListHeaderComponent}
{items.length === 0 && ListEmptyComponent} {items.length === 0 && ListEmptyComponent}
@@ -259,13 +259,7 @@ export function DraggableList<T>({
{ListFooterComponent} {ListFooterComponent}
</> </>
)} )}
<WebDesktopScrollbarOverlay {scrollbar.overlay}
enabled={showCustomScrollbar}
metrics={scrollbarMetrics}
onScrollToOffset={(nextOffset) => {
scrollViewRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
</View> </View>
); );
} }
@@ -4,9 +4,6 @@ import {
ActivityIndicator, ActivityIndicator,
FlatList, FlatList,
ListRenderItemInfo, ListRenderItemInfo,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
Pressable, Pressable,
Text, Text,
View, View,
@@ -53,10 +50,7 @@ import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-action
import { usePanelStore, type SortOption } from "@/stores/panel-store"; import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
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" },
@@ -152,7 +146,9 @@ export function FileExplorerPane({
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."])); const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
const treeListRef = useRef<FlatList<TreeRow>>(null); const treeListRef = useRef<FlatList<TreeRow>>(null);
const treeScrollbarMetrics = useWebDesktopScrollbarMetrics(); const scrollbar = useWebScrollViewScrollbar(treeListRef, {
enabled: showDesktopWebScrollbar,
});
const hasInitializedRef = useRef(false); const hasInitializedRef = useRef(false);
@@ -502,24 +498,6 @@ export function FileExplorerPane({
}); });
}, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]); }, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]);
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],
);
if (!hasWorkspaceScope) { if (!hasWorkspaceScope) {
return ( return (
<View style={styles.centerState}> <View style={styles.centerState}>
@@ -598,27 +576,16 @@ export function FileExplorerPane({
keyExtractor={(row) => row.entry.path} keyExtractor={(row) => row.entry.path}
testID="file-explorer-tree-scroll" testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent} contentContainerStyle={styles.entriesContent}
onLayout={showDesktopWebScrollbar ? handleTreeListLayout : undefined} onLayout={scrollbar.onLayout}
onScroll={showDesktopWebScrollbar ? handleTreeListScroll : undefined} onScroll={scrollbar.onScroll}
onContentSizeChange={ onContentSizeChange={scrollbar.onContentSizeChange}
showDesktopWebScrollbar ? treeScrollbarMetrics.onContentSizeChange : undefined scrollEventThrottle={16}
}
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!showDesktopWebScrollbar} showsVerticalScrollIndicator={!showDesktopWebScrollbar}
initialNumToRender={24} initialNumToRender={24}
maxToRenderPerBatch={40} maxToRenderPerBatch={40}
windowSize={12} windowSize={12}
/> />
<WebDesktopScrollbarOverlay {scrollbar.overlay}
enabled={showDesktopWebScrollbar}
metrics={treeScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
treeListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
}}
/>
</View> </View>
)} )}
</View> </View>
+17 -56
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useRef } from "react"; import React, { useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
ActivityIndicator, ActivityIndicator,
@@ -7,17 +7,11 @@ import {
Text, Text,
View, View,
Platform, Platform,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native"; } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
import { import {
highlightCode, highlightCode,
darkHighlightColors, darkHighlightColors,
@@ -123,9 +117,10 @@ function FilePreviewBody({
const colorMap = isDark ? darkHighlightColors : lightHighlightColors; const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
const baseColor = isDark ? "#c9d1d9" : "#24292f"; const baseColor = isDark ? "#c9d1d9" : "#24292f";
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
const previewScrollRef = useRef<RNScrollView>(null); const previewScrollRef = useRef<RNScrollView>(null);
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics(); const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
enabled: showDesktopWebScrollbar,
});
const highlightedLines = useMemo(() => { const highlightedLines = useMemo(() => {
if (!preview || preview.kind !== "text") { if (!preview || preview.kind !== "text") {
@@ -140,24 +135,6 @@ function FilePreviewBody({
return lineNumberGutterWidth(highlightedLines.length); return lineNumberGutterWidth(highlightedLines.length);
}, [highlightedLines]); }, [highlightedLines]);
const handlePreviewScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onScroll(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
);
const handlePreviewLayout = useCallback(
(event: LayoutChangeEvent) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onLayout(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
);
if (isLoading && !preview) { if (isLoading && !preview) {
return ( return (
<View style={styles.centerState}> <View style={styles.centerState}>
@@ -197,13 +174,11 @@ function FilePreviewBody({
<RNScrollView <RNScrollView
ref={previewScrollRef} ref={previewScrollRef}
style={styles.previewContent} style={styles.previewContent}
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined} onLayout={scrollbar.onLayout}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined} onScroll={scrollbar.onScroll}
onContentSizeChange={ onContentSizeChange={scrollbar.onContentSizeChange}
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined scrollEventThrottle={16}
} showsVerticalScrollIndicator={!showDesktopWebScrollbar}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
> >
{isMobile ? ( {isMobile ? (
<View style={styles.previewCodeScrollContent}>{codeLines}</View> <View style={styles.previewCodeScrollContent}>{codeLines}</View>
@@ -218,13 +193,7 @@ function FilePreviewBody({
</RNScrollView> </RNScrollView>
)} )}
</RNScrollView> </RNScrollView>
<WebDesktopScrollbarOverlay {scrollbar.overlay}
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
</View> </View>
); );
} }
@@ -236,13 +205,11 @@ function FilePreviewBody({
ref={previewScrollRef} ref={previewScrollRef}
style={styles.previewContent} style={styles.previewContent}
contentContainerStyle={styles.previewImageScrollContent} contentContainerStyle={styles.previewImageScrollContent}
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined} onLayout={scrollbar.onLayout}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined} onScroll={scrollbar.onScroll}
onContentSizeChange={ onContentSizeChange={scrollbar.onContentSizeChange}
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined scrollEventThrottle={16}
} showsVerticalScrollIndicator={!showDesktopWebScrollbar}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
> >
<RNImage <RNImage
source={{ source={{
@@ -252,13 +219,7 @@ function FilePreviewBody({
resizeMode="contain" resizeMode="contain"
/> />
</RNScrollView> </RNScrollView>
<WebDesktopScrollbarOverlay {scrollbar.overlay}
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
</View> </View>
); );
} }
+10 -26
View File
@@ -62,10 +62,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip
import { GitHubIcon } from "@/components/icons/github-icon"; import { GitHubIcon } from "@/components/icons/github-icon";
import { buildGitActions, type GitActions } from "@/components/git-actions-policy"; import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
import { lineNumberGutterWidth } from "@/components/code-insets"; import { lineNumberGutterWidth } from "@/components/code-insets";
import { import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { GitActionsSplitButton } from "@/components/git-actions-split-button"; import { GitActionsSplitButton } from "@/components/git-actions-split-button";
@@ -429,7 +426,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
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 scrollbar = useWebScrollViewScrollbar(diffListRef, {
enabled: showDesktopWebScrollbar,
});
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>>({});
@@ -515,11 +514,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const handleDiffListScroll = useCallback( const handleDiffListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => { (event: NativeSyntheticEvent<NativeScrollEvent>) => {
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
if (showDesktopWebScrollbar) { scrollbar.onScroll(event);
diffScrollbarMetrics.onScroll(event);
}
}, },
[diffScrollbarMetrics, showDesktopWebScrollbar], [scrollbar.onScroll],
); );
const handleDiffListLayout = useCallback( const handleDiffListLayout = useCallback(
@@ -529,11 +526,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
return; return;
} }
diffListViewportHeightRef.current = height; diffListViewportHeightRef.current = height;
if (showDesktopWebScrollbar) { scrollbar.onLayout(event);
diffScrollbarMetrics.onLayout(event);
}
}, },
[diffScrollbarMetrics, showDesktopWebScrollbar], [scrollbar.onLayout],
); );
const computeHeaderOffset = useCallback( const computeHeaderOffset = useCallback(
@@ -844,9 +839,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
testID="git-diff-scroll" testID="git-diff-scroll"
onLayout={handleDiffListLayout} onLayout={handleDiffListLayout}
onScroll={handleDiffListScroll} onScroll={handleDiffListScroll}
onContentSizeChange={ onContentSizeChange={scrollbar.onContentSizeChange}
showDesktopWebScrollbar ? diffScrollbarMetrics.onContentSizeChange : undefined
}
scrollEventThrottle={16} scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar} showsVerticalScrollIndicator={!showDesktopWebScrollbar}
onRefresh={handleRefresh} onRefresh={handleRefresh}
@@ -1085,16 +1078,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
<View style={styles.diffContainer}> <View style={styles.diffContainer}>
{bodyContent} {bodyContent}
<WebDesktopScrollbarOverlay {hasChanges ? scrollbar.overlay : null}
enabled={showDesktopWebScrollbar && hasChanges}
metrics={diffScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
diffListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
}}
/>
</View> </View>
</View> </View>
); );
+64 -37
View File
@@ -12,7 +12,15 @@ import {
Platform, Platform,
BackHandler, BackHandler,
} from "react-native"; } from "react-native";
import { useState, useRef, useCallback, useEffect, useImperativeHandle, forwardRef } from "react"; import {
useState,
useRef,
useCallback,
useEffect,
useLayoutEffect,
useImperativeHandle,
forwardRef,
} from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, Square } from "lucide-react-native"; import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, Square } from "lucide-react-native";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated"; import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
@@ -33,6 +41,7 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur
import { focusWithRetries } from "@/utils/web-focus"; import { focusWithRetries } from "@/utils/web-focus";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { import {
@@ -570,6 +579,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
return null; return null;
}, []); }, []);
const webTextareaRef = useRef<HTMLElement | null>(null);
useLayoutEffect(() => {
if (IS_WEB) {
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
}
}, [getWebTextArea]);
const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
enabled: IS_WEB && inputHeight >= MAX_INPUT_HEIGHT,
});
const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => { const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => {
const ref = target === "root" ? rootRef.current : inputWrapperRef.current; const ref = target === "root" ? rootRef.current : inputWrapperRef.current;
if (!ref) return null; if (!ref) return null;
@@ -911,42 +932,45 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
)} )}
{/* Text input */} {/* Text input */}
<TextInput <View style={styles.textInputScrollWrapper}>
ref={textInputRef} <TextInput
value={value} ref={textInputRef}
onChangeText={handleInputChange} value={value}
placeholder={placeholder} onChangeText={handleInputChange}
placeholderTextColor={theme.colors.surface4} placeholder={placeholder}
accessibilityLabel="Message agent..." placeholderTextColor={theme.colors.surface4}
onFocus={() => { accessibilityLabel="Message agent..."
isInputFocusedRef.current = true; onFocus={() => {
onFocusChange?.(true); isInputFocusedRef.current = true;
}} onFocusChange?.(true);
onBlur={() => { }}
isInputFocusedRef.current = false; onBlur={() => {
onFocusChange?.(false); isInputFocusedRef.current = false;
}} onFocusChange?.(false);
style={[ }}
styles.textInput, style={[
IS_WEB styles.textInput,
? { IS_WEB
height: inputHeight, ? {
minHeight: MIN_INPUT_HEIGHT, height: inputHeight,
maxHeight: MAX_INPUT_HEIGHT, minHeight: MIN_INPUT_HEIGHT,
} maxHeight: MAX_INPUT_HEIGHT,
: { }
minHeight: MIN_INPUT_HEIGHT, : {
maxHeight: MAX_INPUT_HEIGHT, minHeight: MIN_INPUT_HEIGHT,
}, maxHeight: MAX_INPUT_HEIGHT,
]} },
multiline ]}
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true} multiline
onContentSizeChange={handleContentSizeChange} scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} onContentSizeChange={handleContentSizeChange}
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
onSelectionChange={handleSelectionChange} onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
autoFocus={IS_WEB && autoFocus} onSelectionChange={handleSelectionChange}
/> autoFocus={IS_WEB && autoFocus}
/>
{inputScrollbar}
</View>
{/* Button row */} {/* Button row */}
<View style={styles.buttonRow}> <View style={styles.buttonRow}>
@@ -1187,6 +1211,9 @@ const styles = StyleSheet.create(((theme: any) => ({
removeImageButtonVisible: { removeImageButtonVisible: {
opacity: 1, opacity: 1,
}, },
textInputScrollWrapper: {
position: "relative",
},
textInput: { textInput: {
width: "100%", width: "100%",
color: theme.colors.foreground, color: theme.colors.foreground,
@@ -23,21 +23,7 @@ const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
const USER_SCROLL_DELTA_EPSILON = 1; const USER_SCROLL_DELTA_EPSILON = 1;
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64; const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1; const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
const WEB_STREAM_SCROLLBAR_STYLE_ID = "web-stream-viewport-scrollbar-style"; import { useWebElementScrollbar } from "./use-web-scrollbar";
const WEB_STREAM_SCROLLBAR_STYLE = `
#agent-chat-scroll-web-dom-scroll,
#agent-chat-scroll-web-dom-virtualized {
scrollbar-width: none;
-ms-overflow-style: none;
}
#agent-chat-scroll-web-dom-scroll::-webkit-scrollbar,
#agent-chat-scroll-web-dom-virtualized::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
`;
function logWebStickyBottom(_event: string, _details: Record<string, unknown>): void { function logWebStickyBottom(_event: string, _details: Record<string, unknown>): void {
// Intentionally disabled: this path is too noisy during voice debugging. // Intentionally disabled: this path is too noisy during voice debugging.
@@ -119,8 +105,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollEnabled, scrollEnabled,
isMobileBreakpoint, isMobileBreakpoint,
} = props; } = props;
const { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } =
require("./web-desktop-scrollbar") as typeof import("./web-desktop-scrollbar");
const scrollContainerRef = useRef<HTMLElement | null>(null); const scrollContainerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLElement | null>(null); const contentRef = useRef<HTMLElement | null>(null);
const [followOutput, setFollowOutputr] = useState(true); const [followOutput, setFollowOutputr] = useState(true);
@@ -142,8 +126,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const lastTouchClientYRef = useRef<number | null>(null); const lastTouchClientYRef = useRef<number | null>(null);
const pendingAutoScrollFrameRef = useRef<number | null>(null); const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null); const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
const showDesktopWebScrollbar = !isMobileBreakpoint; const showDesktopWebScrollbar = !isMobileBreakpoint;
const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, {
enabled: showDesktopWebScrollbar,
contentRef,
});
const shouldUseVirtualizer = segments.historyVirtualized.length > 0; const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
const { const {
renderHistoryVirtualizedRow, renderHistoryVirtualizedRow,
@@ -271,33 +258,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
onNearBottomChange(true); onNearBottomChange(true);
return; return;
} }
streamScrollbarMetrics.onContentSizeChange(
scrollContainer.clientWidth,
scrollContainer.scrollHeight,
);
streamScrollbarMetrics.onLayout({
nativeEvent: {
layout: {
width: scrollContainer.clientWidth,
height: scrollContainer.clientHeight,
x: 0,
y: 0,
},
},
} as never);
streamScrollbarMetrics.onScroll({
nativeEvent: {
contentOffset: { x: 0, y: scrollContainer.scrollTop },
contentSize: {
width: scrollContainer.clientWidth,
height: scrollContainer.scrollHeight,
},
layoutMeasurement: {
width: scrollContainer.clientWidth,
height: scrollContainer.clientHeight,
},
},
} as never);
syncNearBottom(scrollContainer, onNearBottomChange); syncNearBottom(scrollContainer, onNearBottomChange);
const currentMetrics = { const currentMetrics = {
scrollTop: scrollContainer.scrollTop, scrollTop: scrollContainer.scrollTop,
@@ -323,7 +283,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
...currentMetrics, ...currentMetrics,
}); });
} }
}, [onNearBottomChange, props.agentId, streamScrollbarMetrics]); }, [onNearBottomChange, props.agentId]);
const handleDomScroll = useCallback(() => { const handleDomScroll = useCallback(() => {
const scrollContainer = scrollContainerRef.current; const scrollContainer = scrollContainerRef.current;
@@ -711,7 +671,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return ( return (
<> <>
<style id={WEB_STREAM_SCROLLBAR_STYLE_ID}>{WEB_STREAM_SCROLLBAR_STYLE}</style>
<div <div
ref={(node) => { ref={(node) => {
scrollContainerRef.current = node; scrollContainerRef.current = node;
@@ -759,20 +718,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
{shouldRenderEmpty ? listEmptyComponent : null} {shouldRenderEmpty ? listEmptyComponent : null}
</div> </div>
</div> </div>
<WebDesktopScrollbarOverlay {scrollbarOverlay}
enabled={showDesktopWebScrollbar}
metrics={streamScrollbarMetrics}
inverted={false}
onScrollToOffset={(nextOffset) => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
}
scrollContainer.scrollTo({ top: nextOffset, behavior: "auto" });
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
updateScrollMetrics();
}}
/>
</> </>
); );
} }
@@ -0,0 +1,155 @@
import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react";
import {
Platform,
type FlatList,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
type ScrollView,
} from "react-native";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
type ScrollbarMetrics,
} from "./web-desktop-scrollbar";
const METRICS_EPSILON = 0.5;
const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar";
function ensureHideScrollbarStyle(): void {
if (typeof document === "undefined") return;
if (document.getElementById(HIDE_SCROLLBAR_STYLE_ID)) return;
const style = document.createElement("style");
style.id = HIDE_SCROLLBAR_STYLE_ID;
style.textContent =
"[data-hide-scrollbar]::-webkit-scrollbar { display: none; width: 0; height: 0; }";
document.head.appendChild(style);
}
function metricsChanged(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
);
}
// ── DOM element scrollbar ────────────────────────────────────────────
// Fully automatic: listens to scroll/input/resize events on the element,
// hides the native scrollbar, and returns a themed overlay or null.
export function useWebElementScrollbar(
elementRef: RefObject<HTMLElement | null>,
options?: {
enabled?: boolean;
contentRef?: RefObject<HTMLElement | null>;
},
): ReactNode {
const isWeb = Platform.OS === "web";
const enabled = (options?.enabled ?? true) && isWeb;
const contentRef = options?.contentRef;
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
offset: 0,
viewportSize: 0,
contentSize: 0,
});
useEffect(() => {
if (!enabled) return;
const element = elementRef.current;
if (!element) return;
element.setAttribute("data-hide-scrollbar", "");
(element.style as any).scrollbarWidth = "none";
(element.style as any).msOverflowStyle = "none";
ensureHideScrollbarStyle();
function update() {
const el = elementRef.current;
if (!el) return;
const next: ScrollbarMetrics = {
offset: el.scrollTop,
viewportSize: el.clientHeight,
contentSize: el.scrollHeight,
};
setMetrics((prev) => (metricsChanged(prev, next) ? next : prev));
}
element.addEventListener("scroll", update, { passive: true });
element.addEventListener("input", update, { passive: true });
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(element);
const contentElement = contentRef?.current;
if (contentElement) {
resizeObserver.observe(contentElement);
}
update();
return () => {
element.removeEventListener("scroll", update);
element.removeEventListener("input", update);
resizeObserver.disconnect();
element.removeAttribute("data-hide-scrollbar");
(element.style as any).scrollbarWidth = "";
(element.style as any).msOverflowStyle = "";
};
}, [contentRef, elementRef, enabled]);
const onScrollToOffset = useCallback(
(offset: number) => {
elementRef.current?.scrollTo({ top: offset, behavior: "auto" });
},
[elementRef],
);
if (!enabled) return null;
return <WebDesktopScrollbarOverlay enabled metrics={metrics} onScrollToOffset={onScrollToOffset} />;
}
// ── RN ScrollView / FlatList scrollbar ───────────────────────────────
// Returns event handlers to wire onto your ScrollView/FlatList plus
// a renderable overlay. The overlay is null when disabled.
interface WebScrollViewScrollbar {
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
onLayout: (event: LayoutChangeEvent) => void;
onContentSizeChange: (width: number, height: number) => void;
overlay: ReactNode;
}
export function useWebScrollViewScrollbar(
scrollableRef: RefObject<ScrollView | FlatList | null>,
options?: { enabled?: boolean },
): WebScrollViewScrollbar {
const isWeb = Platform.OS === "web";
const enabled = (options?.enabled ?? true) && isWeb;
const metricsHook = useWebDesktopScrollbarMetrics();
const onScrollToOffset = useCallback(
(offset: number) => {
const scrollable = scrollableRef.current;
if (!scrollable) return;
if ("scrollToOffset" in scrollable) {
(scrollable as FlatList).scrollToOffset({ offset, animated: false });
} else {
(scrollable as ScrollView).scrollTo({ y: offset, animated: false });
}
},
[scrollableRef],
);
const overlay: ReactNode = enabled ? (
<WebDesktopScrollbarOverlay enabled metrics={metricsHook} onScrollToOffset={onScrollToOffset} />
) : null;
return {
onScroll: metricsHook.onScroll,
onLayout: metricsHook.onLayout,
onContentSizeChange: metricsHook.onContentSizeChange,
overlay,
};
}
@@ -37,7 +37,7 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
} }
type ScrollbarMetrics = { export type ScrollbarMetrics = {
offset: number; offset: number;
viewportSize: number; viewportSize: number;
contentSize: number; contentSize: number;
+1 -1
View File
@@ -193,7 +193,7 @@ const darkSemanticColors = {
foregroundMuted: "#A1A5A4", foregroundMuted: "#A1A5A4",
// Controls // Controls
scrollbarHandle: "#71717a", // zinc-500 scrollbarHandle: "#717574", // zinc-500 w/ teal tint
// Borders // Borders
border: "#252B2A", border: "#252B2A",