Refine tool call summaries and scrolling (#2069)

* feat(app): refine tool call summaries and scrolling

Keep summary groups stable from the first tool call, preserve live activity feedback, and scroll expanded groups to the latest entry. Consolidate web scrollbar styling around the native browser mechanism.

* refactor(app): reuse tool call height constant

* fix(app): preserve tool call summary state

* fix(app): refresh web tool groups on expand

* fix(app): preserve grouped tool errors
This commit is contained in:
Mohamed Boudra
2026-07-14 21:16:29 +02:00
committed by GitHub
parent d781b68711
commit 1b8af2e58e
55 changed files with 1484 additions and 2488 deletions
@@ -22,7 +22,6 @@ import {
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
import { createControlGeometry } from "@/components/ui/control-geometry";
import { isNative, isWeb } from "@/constants/platform";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { useSafeAreaInsets } from "react-native-safe-area-context";
// Horizontal indent token shared by the sheet header (title, back arrow,
@@ -460,11 +459,6 @@ export interface AdaptiveModalSheetProps {
desktopMaxWidth?: number;
scrollable?: boolean;
presentation?: "push" | "replace";
/**
* Render the themed desktop-web scrollbar over the scroll area instead of the
* native browser scrollbar. No-op on native and on the mobile bottom sheet.
*/
webScrollbar?: boolean;
}
export function AdaptiveModalSheet({
@@ -479,16 +473,11 @@ export function AdaptiveModalSheet({
desktopMaxWidth,
scrollable = true,
presentation,
webScrollbar = false,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const insets = useSafeAreaInsets();
const desktopScrollRef = useRef<ScrollView>(null);
const desktopScrollbar = useWebScrollViewScrollbar(desktopScrollRef, {
enabled: webScrollbar && !isMobile,
});
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const compactSafeAreaPadding = useMemo(
() =>
@@ -651,19 +640,13 @@ export function AdaptiveModalSheet({
{scrollable ? (
<View style={styles.desktopScrollContainer}>
<ScrollView
ref={desktopScrollRef}
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
onLayout={desktopScrollbar.onLayout}
onScroll={desktopScrollbar.onScroll}
onContentSizeChange={desktopScrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!webScrollbar}
showsVerticalScrollIndicator
>
{children}
</ScrollView>
{desktopScrollbar.overlay}
</View>
) : (
<View style={styles.desktopStaticContent}>{children}</View>
@@ -1,6 +1,5 @@
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
interface DiffScrollProps {
children: React.ReactNode;
@@ -16,8 +15,6 @@ export function DiffScroll({
style,
contentContainerStyle,
}: DiffScrollProps) {
const webScrollbarStyle = useWebScrollbarStyle();
const combinedStyle = useMemo(() => [style, webScrollbarStyle], [style, webScrollbarStyle]);
const handleLayout = useCallback(
(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width),
[onScrollViewWidthChange],
@@ -28,7 +25,7 @@ export function DiffScroll({
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={combinedStyle}
style={style}
contentContainerStyle={contentContainerStyle}
onLayout={handleLayout}
>
+1 -5
View File
@@ -6,7 +6,6 @@ import { StyleSheet } from "react-native-unistyles";
import type { DiffLine } from "@/utils/tool-call-parsers";
import { diffLinePrefix } from "@/utils/diff-highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { getCodeInsets } from "./code-insets";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
@@ -127,7 +126,6 @@ export function DiffViewer({
const { t } = useTranslation();
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
const resolvedEmptyLabel = emptyLabel ?? t("diffViewer.empty");
const webScrollbarStyle = useWebScrollbarStyle();
const handleInnerLayout = React.useCallback(
(e: { nativeEvent: { layout: { width: number } } }) =>
setScrollViewWidth(e.nativeEvent.layout.width),
@@ -139,9 +137,8 @@ export function DiffViewer({
styles.verticalScroll,
maxHeight !== undefined && inlineUnistylesStyle({ maxHeight }),
fillAvailableHeight && styles.fillHeight,
webScrollbarStyle,
],
[maxHeight, fillAvailableHeight, webScrollbarStyle],
[maxHeight, fillAvailableHeight],
);
const linesContainerStyle = React.useMemo(
() => [
@@ -180,7 +177,6 @@ export function DiffViewer({
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.horizontalContent}
onLayout={handleInnerLayout}
>
@@ -24,7 +24,6 @@ export function DraggableList<T>({
ListHeaderComponent,
ListEmptyComponent,
showsVerticalScrollIndicator = true,
enableDesktopWebScrollbar: _enableDesktopWebScrollbar = false,
scrollEnabled = true,
useDragHandle: _useDragHandle = false,
refreshing,
@@ -34,7 +34,6 @@ export interface DraggableListProps<T> {
ListHeaderComponent?: ReactElement | null;
ListEmptyComponent?: ReactElement | null;
showsVerticalScrollIndicator?: boolean;
enableDesktopWebScrollbar?: boolean;
/** When false, disables internal scrolling (use outer list to scroll). */
scrollEnabled?: boolean;
/**
@@ -17,7 +17,6 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
import { useWebScrollViewScrollbar } from "./use-web-scrollbar";
import { getPointerActivationConstraint, useDragReorderState } from "./drag-reorder";
export type { DraggableListProps, DraggableRenderItemInfo };
@@ -133,7 +132,6 @@ export function DraggableList<T>({
ListHeaderComponent,
ListEmptyComponent,
showsVerticalScrollIndicator = true,
enableDesktopWebScrollbar = false,
scrollEnabled = true,
extraData: _extraData,
useDragHandle = false,
@@ -147,11 +145,6 @@ export function DraggableList<T>({
onDragEnd,
onDragBegin,
});
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
const scrollViewRef = useRef<ScrollView>(null);
const scrollbar = useWebScrollViewScrollbar(scrollViewRef, {
enabled: showCustomScrollbar,
});
const pointerActivationConstraint = getPointerActivationConstraint(
useDragHandle,
POINTER_ACTIVATION_CONFIG,
@@ -183,15 +176,10 @@ export function DraggableList<T>({
<View style={wrapperStyle}>
{scrollEnabled ? (
<ScrollView
ref={scrollViewRef}
testID={testID}
style={style}
contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
onLayout={scrollbar.onLayout}
onContentSizeChange={scrollbar.onContentSizeChange}
onScroll={scrollbar.onScroll}
scrollEventThrottle={16}
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
>
{ListHeaderComponent}
{items.length === 0 && ListEmptyComponent}
@@ -254,7 +242,6 @@ export function DraggableList<T>({
{ListFooterComponent}
</>
)}
{scrollbar.overlay}
</View>
);
}
@@ -13,7 +13,6 @@ import {
type ViewStyle,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import * as Clipboard from "expo-clipboard";
import { SvgXml } from "react-native-svg";
@@ -46,8 +45,6 @@ import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { isWeb } from "@/constants/platform";
const SORT_OPTIONS: { value: SortOption }[] = [
{ value: "name" },
@@ -224,8 +221,6 @@ export function FileExplorerPane({
onOpenFile,
}: FileExplorerPaneProps) {
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = isWeb && !isMobile;
const daemons = useHosts();
const daemonProfile = useMemo(
@@ -283,9 +278,6 @@ export function FileExplorerPane({
);
const treeListRef = useRef<FlatList<TreeRow>>(null);
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
enabled: showDesktopWebScrollbar,
});
const hasInitializedRef = useRef(false);
@@ -482,9 +474,7 @@ export function FileExplorerPane({
treeRows={treeRows}
currentSortLabel={currentSortLabel}
isRefreshFetching={isRefreshFetching}
showDesktopWebScrollbar={showDesktopWebScrollbar}
treeListRef={treeListRef}
scrollbar={scrollbar}
renderTreeRow={renderTreeRow}
handleSortCycle={handleSortCycle}
handleToggleHiddenFiles={handleToggleHiddenFiles}
@@ -505,9 +495,7 @@ interface FileExplorerPaneContentProps {
treeRows: TreeRow[];
currentSortLabel: string;
isRefreshFetching: boolean;
showDesktopWebScrollbar: boolean;
treeListRef: RefObject<FlatList<TreeRow> | null>;
scrollbar: ReturnType<typeof useWebScrollViewScrollbar>;
renderTreeRow: (info: ListRenderItemInfo<TreeRow>) => ReactElement;
handleSortCycle: () => void;
handleToggleHiddenFiles: () => void;
@@ -528,9 +516,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
treeRows,
currentSortLabel,
isRefreshFetching,
showDesktopWebScrollbar,
treeListRef,
scrollbar,
renderTreeRow,
handleSortCycle,
handleToggleHiddenFiles,
@@ -645,17 +631,12 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
keyExtractor={treeRowKeyExtractor}
testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
showsVerticalScrollIndicator
initialNumToRender={24}
maxToRenderPerBatch={40}
windowSize={12}
/>
)}
{treeRows.length > 0 ? scrollbar.overlay : null}
</View>
);
}
+3 -30
View File
@@ -13,15 +13,12 @@ import { useTranslation } from "react-i18next";
import { MarkdownRenderer } from "@/components/markdown/renderer";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import { isWeb } from "@/constants/platform";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes } from "@/attachments/service";
@@ -43,7 +40,6 @@ interface CodeLineProps {
interface FilePreviewBodyProps {
preview: ExplorerFile | null;
isLoading: boolean;
showDesktopWebScrollbar: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
imagePreviewUri: string | null;
@@ -192,7 +188,6 @@ const codeLineStyles = StyleSheet.create((theme) => ({
function FilePreviewBody({
preview,
isLoading,
showDesktopWebScrollbar,
isMobile,
location,
imagePreviewUri,
@@ -204,10 +199,6 @@ function FilePreviewBody({
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
const previewScrollRef = useRef<RNScrollView>(null);
const webScrollbarStyle = useWebScrollbarStyle();
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
enabled: showDesktopWebScrollbar,
});
const highlightedLines = useMemo(() => {
if (!preview || preview.kind !== "text" || isMarkdownFile) {
@@ -276,15 +267,10 @@ function FilePreviewBody({
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewMarkdownScrollContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
showsVerticalScrollIndicator
>
<MarkdownRenderer text={preview.content ?? ""} />
</RNScrollView>
{scrollbar.overlay}
</View>
);
}
@@ -318,11 +304,7 @@ function FilePreviewBody({
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
showsVerticalScrollIndicator
>
{isMobile ? (
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
@@ -331,14 +313,12 @@ function FilePreviewBody({
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.previewCodeScrollContent}
>
{codeLines}
</RNScrollView>
)}
</RNScrollView>
{scrollbar.overlay}
</View>
);
}
@@ -359,11 +339,7 @@ function FilePreviewBody({
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewImageScrollContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
showsVerticalScrollIndicator
>
<RNImage
source={imageSource ?? undefined}
@@ -371,7 +347,6 @@ function FilePreviewBody({
resizeMode="contain"
/>
</RNScrollView>
{scrollbar.overlay}
</View>
);
}
@@ -395,7 +370,6 @@ export function FilePane({
}) {
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = isWeb && !isMobile;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
@@ -463,7 +437,6 @@ export function FilePane({
<FilePreviewBody
preview={query.data?.file ?? null}
isLoading={query.isFetching}
showDesktopWebScrollbar={showDesktopWebScrollbar}
isMobile={isMobile}
location={location}
imagePreviewUri={imagePreviewUri}
+49 -16
View File
@@ -60,6 +60,7 @@ import Animated, {
} from "react-native-reanimated";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { MarkdownRenderer, type MarkdownStyles } from "@/components/markdown/renderer";
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
@@ -115,6 +116,7 @@ import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu";
import { useRetainedPanelActive } from "@/components/retained-panel";
export type { InlinePathTarget } from "@/assistant-file-links";
export type { AssistantForkTarget };
@@ -1273,7 +1275,6 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
},
chevron: {
flexShrink: 0,
transform: [{ scale: 1.3 }],
},
openFileButton: {
marginLeft: theme.spacing[1],
@@ -1285,9 +1286,6 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
width: 14,
height: 14,
},
chevronExpanded: {
transform: [{ scale: 1.3 }, { rotate: "90deg" }],
},
detailWrapper: {
borderBottomLeftRadius: theme.borderRadius.lg,
borderBottomRightRadius: theme.borderRadius.lg,
@@ -1302,11 +1300,16 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
...(isWeb ? { cursor: "auto" as const, userSelect: "text" as const } : {}),
},
pressableExpanded: {
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
},
pressableExpandedAttached: {
borderColor: theme.colors.border,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
},
detailWrapperBorderless: {
borderWidth: 0,
},
shimmerOverlay: {
position: "absolute",
top: 0,
@@ -1358,9 +1361,14 @@ const NativeExpandableBadgeShimmer = memo(function NativeExpandableBadgeShimmer(
durationSeconds,
gradientId,
}: NativeExpandableBadgeShimmerProps) {
const isPanelActive = useRetainedPanelActive();
const shimmerTranslateX = useSharedValue(0);
useEffect(() => {
if (!isPanelActive) {
cancelAnimation(shimmerTranslateX);
return;
}
const startPosition = -peakWidth;
const endPosition = rowWidth + peakWidth;
shimmerTranslateX.value = startPosition;
@@ -1375,7 +1383,7 @@ const NativeExpandableBadgeShimmer = memo(function NativeExpandableBadgeShimmer(
return () => {
cancelAnimation(shimmerTranslateX);
};
}, [durationSeconds, peakWidth, rowWidth, shimmerTranslateX]);
}, [durationSeconds, isPanelActive, peakWidth, rowWidth, shimmerTranslateX]);
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
transform: [{ translateX: shimmerTranslateX.value }],
@@ -2366,6 +2374,7 @@ interface ExpandableBadgeProps {
isError?: boolean;
isLastInSequence?: boolean;
disableOuterSpacing?: boolean;
borderlessWhenExpanded?: boolean;
testID?: string;
}
@@ -2604,7 +2613,9 @@ function renderExpandableBadgeIconSlot({
}): ReactNode {
if (showChevron) {
return (
<ThemedChevronRightIcon size={12} style={chevronStyle} uniProps={foregroundColorMapping} />
<View style={chevronStyle}>
<ThemedChevronRightIcon size={12} uniProps={foregroundColorMapping} />
</View>
);
}
return iconNode;
@@ -2695,7 +2706,7 @@ function buildShimmerTextStyle(input: {
offsetX: number;
}): object | null {
if (!input.isWebShimmer) return null;
return {
return inlineUnistylesStyle({
opacity: 1,
color: "transparent",
backgroundImage: SHIMMER_GRADIENT,
@@ -2707,10 +2718,10 @@ function buildShimmerTextStyle(input: {
animation: `${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} ${input.shimmerDuration}s linear infinite`,
"--paseo-shimmer-start": `${input.webShimmerTrackStart - input.offsetX}px`,
"--paseo-shimmer-end": `${input.webShimmerTrackEnd - input.offsetX}px`,
};
});
}
const ExpandableBadge = memo(function ExpandableBadge({
export const ExpandableBadge = memo(function ExpandableBadge({
label,
style,
secondaryLabel,
@@ -2724,6 +2735,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
isError = false,
isLastInSequence = false,
disableOuterSpacing,
borderlessWhenExpanded = false,
testID,
}: ExpandableBadgeProps) {
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
@@ -2894,8 +2906,17 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.pressable,
isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null,
isExpanded && expandableBadgeStylesheet.pressableExpanded,
isExpanded && !borderlessWhenExpanded && expandableBadgeStylesheet.pressableExpandedAttached,
],
[isExpanded, isInteractive, isPressed],
[borderlessWhenExpanded, isExpanded, isInteractive, isPressed],
);
const detailWrapperStyle = useMemo(
() => [
expandableBadgeStylesheet.detailWrapper,
borderlessWhenExpanded && expandableBadgeStylesheet.detailWrapperBorderless,
],
[borderlessWhenExpanded],
);
const accessibilityState = useMemo(
@@ -2944,8 +2965,10 @@ const ExpandableBadge = memo(function ExpandableBadge({
const chevronStyle = useMemo(
() => [
expandableBadgeStylesheet.chevron,
isExpanded && expandableBadgeStylesheet.chevronExpanded,
LUCIDE_CHEVRON_NUDGE_LEFT,
inlineUnistylesStyle({
transform: isExpanded ? [{ scale: 1.3 }, { rotate: "90deg" }] : [{ scale: 1.3 }],
}),
],
[isExpanded],
);
@@ -2953,7 +2976,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
const ThemedIcon = useMemo(() => (icon ? withUnistyles(icon) : null), [icon]);
const iconNode = renderExpandableBadgeIcon({ isError, isActive, ThemedIcon });
const iconSlotNode = renderExpandableBadgeIconSlot({
showChevron: isInteractive && isHovered,
showChevron: isInteractive && (isHovered || isExpanded),
chevronStyle,
iconNode,
});
@@ -3012,7 +3035,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
{detailContent ? (
<Pressable
ref={detailWrapperRef}
style={expandableBadgeStylesheet.detailWrapper}
style={detailWrapperStyle}
onHoverIn={handleDetailHoverIn}
onHoverOut={handleDetailHoverOut}
>
@@ -3033,6 +3056,7 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
if (previous.isError !== next.isError) return false;
if (previous.isLastInSequence !== next.isLastInSequence) return false;
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
if (previous.borderlessWhenExpanded !== next.borderlessWhenExpanded) return false;
if (previous.testID !== next.testID) return false;
if (previous.onToggle !== next.onToggle) return false;
if (previous.onOpenFile !== next.onOpenFile) return false;
@@ -3057,6 +3081,7 @@ interface ToolCallProps {
onOpenFilePath?: (filePath: string) => void;
defaultExpanded?: boolean;
forceInline?: boolean;
maxDetailHeight?: number;
}
export const ToolCall = memo(function ToolCall({
@@ -3075,6 +3100,7 @@ export const ToolCall = memo(function ToolCall({
onOpenFilePath,
defaultExpanded,
forceInline = false,
maxDetailHeight = 400,
}: ToolCallProps) {
const { openToolCall } = useToolCallSheet();
const [isExpanded, setIsExpanded] = useState(defaultExpanded ?? false);
@@ -3175,11 +3201,17 @@ export const ToolCall = memo(function ToolCall({
<ToolCallDetailsContent
detail={effectiveDetail}
errorText={presentation.errorText}
maxHeight={400}
maxHeight={maxDetailHeight}
showLoadingSkeleton={presentation.isLoadingDetails}
/>
);
}, [shouldRenderInline, effectiveDetail, presentation.errorText, presentation.isLoadingDetails]);
}, [
shouldRenderInline,
effectiveDetail,
presentation.errorText,
presentation.isLoadingDetails,
maxDetailHeight,
]);
if (presentation.isPlan && effectiveDetail?.type === "plan") {
return (
@@ -3224,5 +3256,6 @@ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
if (previous.onOpenFilePath !== next.onOpenFilePath) return false;
if (previous.defaultExpanded !== next.defaultExpanded) return false;
if (previous.forceInline !== next.forceInline) return false;
if (previous.maxDetailHeight !== next.maxDetailHeight) return false;
return true;
}
@@ -432,7 +432,6 @@ function OpenScheduleFormSheet({
onClose={onClose}
onDismiss={onDismiss}
footer={footer}
webScrollbar
testID="schedule-form-sheet"
>
<ScheduleFormFields
@@ -10,7 +10,6 @@ import {
type CSSProperties,
type DragEvent as ReactDragEvent,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type Ref,
} from "react";
import type { DOMProps } from "expo/dom";
@@ -31,10 +30,6 @@ import type {
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url";
import { focusWithRetries } from "../utils/web-focus";
import {
computeScrollOffsetFromDragDelta,
computeVerticalScrollbarGeometry,
} from "./web-desktop-scrollbar.math";
import {
extractTerminalDropPaths,
isTerminalDragLeaveOutside,
@@ -51,20 +46,6 @@ export interface TerminalEmulatorHandle {
blur: () => void;
}
const SCROLLBAR_HANDLE_WIDTH_IDLE = 6;
const SCROLLBAR_HANDLE_WIDTH_ACTIVE = 9;
const SCROLLBAR_HANDLE_GRAB_WIDTH = 18;
const SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING = 8;
const SCROLLBAR_HANDLE_OPACITY_VISIBLE = 0.62;
const SCROLLBAR_HANDLE_OPACITY_HOVERED = 0.78;
const SCROLLBAR_HANDLE_OPACITY_DRAGGING = 0.9;
const SCROLLBAR_HANDLE_FADE_DURATION_MS = 220;
const SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS = 240;
const SCROLLBAR_HANDLE_TRAVEL_DURATION_MS = 90;
const SCROLLBAR_HANDLE_SCROLL_VISIBILITY_MS = 1_200;
const SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS = 110;
const WEBKIT_SCROLLBAR_STYLE_ID = "terminal-emulator-webkit-scrollbar-style";
const HOST_DIV_STYLE: CSSProperties = {
flex: 1,
minHeight: 0,
@@ -79,25 +60,6 @@ const HOST_DIV_STYLE: CSSProperties = {
paddingRight: 0,
};
const SCROLLBAR_CONTAINER_STYLE: CSSProperties = {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: 12,
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
zIndex: 10,
pointerEvents: "none",
};
interface ViewportMetrics {
offset: number;
viewportSize: number;
contentSize: number;
}
function buildXtermThemeKey(theme: ITheme): string {
const values: Array<string> = [
theme.background,
@@ -169,10 +131,6 @@ declare global {
interface Window {}
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function isTerminalState(value: unknown): value is TerminalState {
return (
typeof value === "object" &&
@@ -183,30 +141,6 @@ function isTerminalState(value: unknown): value is TerminalState {
);
}
function ensureTerminalScrollbarStyle(): void {
if (typeof document === "undefined") {
return;
}
if (document.getElementById(WEBKIT_SCROLLBAR_STYLE_ID)) {
return;
}
const styleElement = document.createElement("style");
styleElement.id = WEBKIT_SCROLLBAR_STYLE_ID;
styleElement.textContent = `
[data-terminal-scrollbar-root="true"] .xterm-viewport {
scrollbar-width: none;
-ms-overflow-style: none;
}
[data-terminal-scrollbar-root="true"] .xterm-viewport::-webkit-scrollbar {
width: 0;
height: 0;
}
`;
document.head.appendChild(styleElement);
}
export default function TerminalEmulator({
ref,
streamKey,
@@ -246,13 +180,6 @@ export default function TerminalEmulator({
scrollbackLinesRef.current = scrollbackLines;
fontFamilyRef.current = fontFamily;
fontSizeRef.current = fontSize;
const viewportRef = useRef<HTMLElement | null>(null);
const dragStartOffsetRef = useRef(0);
const dragStartClientYRef = useRef(0);
const scrollVisibilityTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scrollActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastObservedOffsetRef = useRef<number | null>(null);
const lastMetricsRef = useRef({ offset: 0, viewportSize: 0, contentSize: 0 });
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
const xtermThemeRef = useRef(xtermTheme);
xtermThemeRef.current = xtermTheme;
@@ -280,30 +207,8 @@ export default function TerminalEmulator({
initialSnapshotRef.current = initialSnapshot;
const pendingModifiersRef = useRef(pendingModifiers);
pendingModifiersRef.current = pendingModifiers;
const [viewportMetrics, setViewportMetrics] = useState<ViewportMetrics>({
offset: 0,
viewportSize: 0,
contentSize: 0,
});
const [isHandleHovered, setIsHandleHovered] = useState(false);
const [isDraggingScrollbar, setIsDraggingScrollbar] = useState(false);
const [isScrollVisible, setIsScrollVisible] = useState(false);
const [isScrollActive, setIsScrollActive] = useState(false);
const [isDropActive, setIsDropActive] = useState(false);
const dropActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const updateViewportMetricsState = useCallback((metrics: ViewportMetrics) => {
const lastMetrics = lastMetricsRef.current;
if (
metrics.offset === lastMetrics.offset &&
metrics.viewportSize === lastMetrics.viewportSize &&
metrics.contentSize === lastMetrics.contentSize
) {
return;
}
lastMetricsRef.current = metrics;
setViewportMetrics(metrics);
}, []);
const domBridgeRef = useRef<DOMImperativeFactory | null>(null);
useDOMImperativeHandle(
@@ -366,10 +271,6 @@ export default function TerminalEmulator({
runtimeRef.current?.setScrollback({ lines: scrollbackLines });
}, [scrollbackLines]);
useEffect(() => {
ensureTerminalScrollbarStyle();
}, []);
useEffect(() => {
const root = rootRef.current;
if (!root || !swipeGesturesEnabled) {
@@ -580,188 +481,6 @@ export default function TerminalEmulator({
runtimeRef.current?.resize({ force: true, shouldClaim: true });
}, [resizeRequestToken]);
useEffect(() => {
const host = hostRef.current;
if (!host) {
return () => {};
}
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
if (!viewportElement) {
viewportRef.current = null;
updateViewportMetricsState({ offset: 0, viewportSize: 0, contentSize: 0 });
return () => {};
}
viewportRef.current = viewportElement;
const updateViewportMetrics = () => {
const offset = Math.max(0, viewportElement.scrollTop);
const viewportSize = Math.max(0, viewportElement.clientHeight);
const contentSize = Math.max(0, viewportElement.scrollHeight);
updateViewportMetricsState({ offset, viewportSize, contentSize });
};
updateViewportMetrics();
let scrollRafId: number | null = null;
const handleViewportScroll = () => {
if (scrollRafId !== null) {
return;
}
scrollRafId = requestAnimationFrame(() => {
scrollRafId = null;
updateViewportMetrics();
});
};
const resizeObserver = new ResizeObserver(() => {
updateViewportMetrics();
});
resizeObserver.observe(viewportElement);
const scrollAreaElement = host.querySelector<HTMLElement>(".xterm-scroll-area");
if (scrollAreaElement) {
resizeObserver.observe(scrollAreaElement);
}
viewportElement.addEventListener("scroll", handleViewportScroll, { passive: true });
return () => {
if (scrollRafId !== null) {
cancelAnimationFrame(scrollRafId);
scrollRafId = null;
}
viewportElement.removeEventListener("scroll", handleViewportScroll);
resizeObserver.disconnect();
if (viewportRef.current === viewportElement) {
viewportRef.current = null;
}
};
}, [streamKey, updateViewportMetricsState]);
useEffect(() => {
const maxScrollOffset = Math.max(0, viewportMetrics.contentSize - viewportMetrics.viewportSize);
const normalizedOffset = clamp(viewportMetrics.offset, 0, maxScrollOffset);
if (maxScrollOffset <= 0 || viewportMetrics.viewportSize <= 0) {
setIsScrollVisible(false);
setIsScrollActive(false);
lastObservedOffsetRef.current = null;
return;
}
const previousOffset = lastObservedOffsetRef.current;
lastObservedOffsetRef.current = normalizedOffset;
if (previousOffset === null || Math.abs(previousOffset - normalizedOffset) <= 0.5) {
return;
}
setIsScrollVisible(true);
if (scrollVisibilityTimeoutRef.current !== null) {
clearTimeout(scrollVisibilityTimeoutRef.current);
}
scrollVisibilityTimeoutRef.current = setTimeout(() => {
setIsScrollVisible(false);
scrollVisibilityTimeoutRef.current = null;
}, SCROLLBAR_HANDLE_SCROLL_VISIBILITY_MS);
setIsScrollActive(true);
if (scrollActiveTimeoutRef.current !== null) {
clearTimeout(scrollActiveTimeoutRef.current);
}
scrollActiveTimeoutRef.current = setTimeout(() => {
setIsScrollActive(false);
scrollActiveTimeoutRef.current = null;
}, SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS);
}, [viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize]);
useEffect(() => {
return () => {
if (scrollVisibilityTimeoutRef.current !== null) {
clearTimeout(scrollVisibilityTimeoutRef.current);
}
if (scrollActiveTimeoutRef.current !== null) {
clearTimeout(scrollActiveTimeoutRef.current);
}
};
}, []);
const scrollbarGeometry = useMemo(
() =>
computeVerticalScrollbarGeometry({
viewportSize: viewportMetrics.viewportSize,
contentSize: viewportMetrics.contentSize,
offset: viewportMetrics.offset,
}),
[viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize],
);
useEffect(() => {
if (!isDraggingScrollbar) {
return () => {};
}
const handlePointerMove = (event: PointerEvent) => {
const dragDelta = event.clientY - dragStartClientYRef.current;
const nextOffset = computeScrollOffsetFromDragDelta({
startOffset: dragStartOffsetRef.current,
dragDelta,
maxScrollOffset: scrollbarGeometry.maxScrollOffset,
maxHandleOffset: scrollbarGeometry.maxHandleOffset,
});
const viewportElement = viewportRef.current;
if (!viewportElement) {
return;
}
viewportElement.scrollTop = nextOffset;
updateViewportMetricsState({
offset: nextOffset,
viewportSize: Math.max(0, viewportElement.clientHeight),
contentSize: Math.max(0, viewportElement.scrollHeight),
});
};
const stopDragging = () => {
setIsDraggingScrollbar(false);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopDragging);
window.addEventListener("pointercancel", stopDragging);
return () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopDragging);
window.removeEventListener("pointercancel", stopDragging);
};
}, [
isDraggingScrollbar,
scrollbarGeometry.maxHandleOffset,
scrollbarGeometry.maxScrollOffset,
updateViewportMetricsState,
]);
const handleVisible =
scrollbarGeometry.isVisible && (isDraggingScrollbar || isScrollVisible || isHandleHovered);
let handleOpacity: number;
if (isDraggingScrollbar) handleOpacity = SCROLLBAR_HANDLE_OPACITY_DRAGGING;
else if (isHandleHovered) handleOpacity = SCROLLBAR_HANDLE_OPACITY_HOVERED;
else if (isScrollVisible) handleOpacity = SCROLLBAR_HANDLE_OPACITY_VISIBLE;
else handleOpacity = 0;
const handleWidth =
isDraggingScrollbar || isHandleHovered
? SCROLLBAR_HANDLE_WIDTH_ACTIVE
: SCROLLBAR_HANDLE_WIDTH_IDLE;
const thumbRegionOffset = Math.max(
0,
scrollbarGeometry.handleOffset - SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING,
);
const thumbRegionHeight = Math.min(
viewportMetrics.viewportSize - thumbRegionOffset,
scrollbarGeometry.handleSize + SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING * 2,
);
const handleInsetTop = Math.max(0, (thumbRegionHeight - scrollbarGeometry.handleSize) / 2);
const handleTravelDurationMs =
isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS;
const showTerminalContextMenu = useCallback(() => {
const showContextMenu = window.paseoDesktop?.menu?.showContextMenu;
if (typeof showContextMenu !== "function") {
@@ -788,29 +507,6 @@ export default function TerminalEmulator({
[showTerminalContextMenu],
);
const scrollbarMaxOffset = scrollbarGeometry.maxScrollOffset;
const handleScrollbarPointerDown = useCallback(
(event: ReactPointerEvent) => {
event.preventDefault();
event.stopPropagation();
dragStartOffsetRef.current = clamp(viewportMetrics.offset, 0, scrollbarMaxOffset);
dragStartClientYRef.current = event.clientY;
setIsDraggingScrollbar(true);
},
[scrollbarMaxOffset, viewportMetrics.offset],
);
const handleScrollbarPointerEnter = useCallback(() => {
if (!isScrollVisible && !isDraggingScrollbar) {
return;
}
setIsHandleHovered(true);
}, [isScrollVisible, isDraggingScrollbar]);
const handleScrollbarPointerLeave = useCallback(() => {
setIsHandleHovered(false);
}, []);
const clearDropActiveTimeout = useCallback(() => {
if (dropActiveTimeoutRef.current === null) {
return;
@@ -944,46 +640,6 @@ export default function TerminalEmulator({
}),
[isDropActive],
);
const handleContainerStyle = useMemo<CSSProperties>(
() => ({
position: "absolute",
top: 0,
right: -3,
width: SCROLLBAR_HANDLE_GRAB_WIDTH,
height: thumbRegionHeight,
transform: `translateY(${thumbRegionOffset}px)`,
cursor: isDraggingScrollbar ? "grabbing" : "grab",
touchAction: "none",
userSelect: "none",
transitionProperty: "transform",
transitionDuration: `${handleTravelDurationMs}ms`,
transitionTimingFunction: "linear",
pointerEvents: handleVisible ? "auto" : "none",
}),
[
thumbRegionHeight,
thumbRegionOffset,
isDraggingScrollbar,
handleTravelDurationMs,
handleVisible,
],
);
const handleInnerStyle = useMemo<CSSProperties>(
() => ({
marginTop: handleInsetTop,
height: scrollbarGeometry.handleSize,
width: handleWidth,
borderRadius: 999,
alignSelf: "center",
backgroundColor: "rgba(113, 113, 122, 1)",
opacity: handleOpacity,
transitionProperty: "opacity, width, background-color",
transitionDuration: `${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms, ${SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms`,
transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
}),
[handleInsetTop, scrollbarGeometry.handleSize, handleWidth, handleOpacity],
);
return (
<div
ref={rootRef}
@@ -996,18 +652,6 @@ export default function TerminalEmulator({
>
<div ref={hostRef} style={HOST_DIV_STYLE} />
<div style={dropOverlayStyle} />
{scrollbarGeometry.isVisible ? (
<div style={SCROLLBAR_CONTAINER_STYLE}>
<div
style={handleContainerStyle}
onPointerDown={handleScrollbarPointerDown}
onPointerEnter={handleScrollbarPointerEnter}
onPointerLeave={handleScrollbarPointerLeave}
>
<div style={handleInnerStyle} />
</div>
</div>
) : null}
</div>
);
}
@@ -15,7 +15,6 @@ import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
import { highlightDiffLines } from "@/utils/diff-highlight";
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { extensionFromPath, highlightToKeyedLines } from "@/utils/highlight-cache";
@@ -46,7 +45,6 @@ interface DetailStyles {
jsonScrollErrorCombined: StyleProp<ViewStyle>;
fullBleedContainerStyle: StyleProp<ViewStyle>;
loadingContainerStyle: StyleProp<ViewStyle>;
webScrollbarStyle: StyleProp<ViewStyle>;
resolvedMaxHeight: number | undefined;
shouldFill: boolean;
isFullBleed: boolean;
@@ -70,7 +68,6 @@ function useDetailStyles(
resolvedMaxHeight: number | undefined,
fillAvailableHeight: boolean,
): DetailStyles {
const webScrollbarStyle = useWebScrollbarStyle();
const isFullBleed = resolveIsFullBleed(detail);
const shouldFill = resolveShouldFill(detail, fillAvailableHeight);
const codeBlockStyle = isFullBleed ? styles.fullBleedBlock : styles.diffContainer;
@@ -88,35 +85,26 @@ function useDetailStyles(
styles.codeVerticalScroll,
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
shouldFill && styles.fillHeight,
webScrollbarStyle,
],
[resolvedMaxHeight, shouldFill, webScrollbarStyle],
[resolvedMaxHeight, shouldFill],
);
const scrollAreaFillStyle = useMemo(
() => [
styles.scrollArea,
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
shouldFill && styles.fillHeight,
webScrollbarStyle,
],
[resolvedMaxHeight, shouldFill, webScrollbarStyle],
[resolvedMaxHeight, shouldFill],
);
const scrollAreaStyle = useMemo(
() => [
styles.scrollArea,
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
webScrollbarStyle,
],
[resolvedMaxHeight, webScrollbarStyle],
);
const jsonScrollCombined = useMemo(
() => [styles.jsonScroll, webScrollbarStyle],
[webScrollbarStyle],
);
const jsonScrollErrorCombined = useMemo(
() => [styles.jsonScroll, styles.jsonScrollError, webScrollbarStyle],
[webScrollbarStyle],
[resolvedMaxHeight],
);
const jsonScrollCombined = styles.jsonScroll;
const jsonScrollErrorCombined = [styles.jsonScroll, styles.jsonScrollError];
const fullBleedContainerStyle = useMemo(
() => [
isFullBleed ? styles.fullBleedContainer : styles.paddedContainer,
@@ -139,7 +127,6 @@ function useDetailStyles(
jsonScrollErrorCombined,
fullBleedContainerStyle,
loadingContainerStyle,
webScrollbarStyle,
resolvedMaxHeight,
shouldFill,
isFullBleed,
@@ -179,7 +166,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={ds.webScrollbarStyle}
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
@@ -224,7 +210,6 @@ function WorktreeSetupDetailSection({
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={ds.webScrollbarStyle}
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
@@ -389,7 +374,6 @@ function SubAgentDetailSection({
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={ds.webScrollbarStyle}
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
@@ -466,12 +450,7 @@ function ScrollableTextSection({
nestedScrollEnabled
showsVerticalScrollIndicator={true}
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator={true}
style={ds.webScrollbarStyle}
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator={true}>
{keyedLines ? (
<HighlightedLines lines={keyedLines} startLine={startLine} />
) : (
@@ -501,12 +480,7 @@ function FetchDetailSection({ url, result, ds }: FetchDetailProps) {
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={ds.webScrollbarStyle}
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
{result ? `${url}\n\n${result}` : url}
</Text>
@@ -545,12 +519,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={ds.webScrollbarStyle}
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
{detail.content}
</Text>
@@ -1,341 +0,0 @@
import { memo, useCallback, useMemo, type ReactNode } from "react";
import {
ActivityIndicator,
Pressable,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import { useTranslation } from "react-i18next";
import { ChevronRight, TriangleAlert, Wrench } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import type {
CompactToolCallGroup as CompactToolCallGroupModel,
ToolCallCategorySummary,
} from "@/tool-calls/grouping";
import { componentForToolCallIcon } from "@/utils/tool-call-icon";
interface ToolCallGroupProps {
group: CompactToolCallGroupModel;
presentation: "overview" | "concise";
expanded: boolean;
onExpandedChange: (groupId: string, expanded: boolean) => void;
children: ReactNode;
}
function CategoryStatus({ category }: { category: ToolCallCategorySummary }) {
const { t } = useTranslation();
if (category.failedCount > 0) {
return (
<View style={styles.categoryStatus}>
<TriangleAlert size={12} color={styles.error.color} />
<Text style={styles.error}>
{t("toolCallGroup.failed", { count: category.failedCount })}
</Text>
</View>
);
}
if (category.runningCount > 0) {
return <ActivityIndicator size={12} color={styles.muted.color} />;
}
return null;
}
function CategoryRow({
category,
resourceLimit,
}: {
category: ToolCallCategorySummary;
resourceLimit: number;
}) {
const Icon = componentForToolCallIcon(category.iconName);
const visibleResources = category.resources.slice(0, resourceLimit);
const hiddenResourceCount = category.resources.length - visibleResources.length;
const resourceText = [
...visibleResources,
...(hiddenResourceCount > 0 ? [`+${hiddenResourceCount}`] : []),
].join(", ");
return (
<View style={styles.categoryRow}>
<View style={styles.categoryIcon}>
<Icon size={12} color={styles.muted.color} />
</View>
<Text style={styles.categoryCount}>×{category.callCount}</Text>
<Text style={styles.categoryLabel}>{category.label}</Text>
<CategoryStatus category={category} />
{resourceText ? (
<Text style={styles.resources} numberOfLines={1}>
{resourceText}
</Text>
) : null}
</View>
);
}
function GroupHeaderIcon({
group,
compact,
}: {
group: CompactToolCallGroupModel;
compact: boolean;
}) {
const size = compact ? 11 : 12;
if (group.failedCount > 0) {
return <TriangleAlert size={size} color={styles.error.color} />;
}
if (group.isRunning) {
return <ActivityIndicator size={size} color={styles.foreground.color} />;
}
return <Wrench size={size} color={styles.muted.color} />;
}
function joinSummaryParts(parts: string[], conjunction: string): string {
if (parts.length === 0) {
return "";
}
let joined: string;
if (parts.length === 1) {
joined = parts[0] ?? "";
} else if (parts.length === 2) {
joined = `${parts[0]} ${conjunction} ${parts[1]}`;
} else {
joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`;
}
const firstCharacter = joined[0];
return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined;
}
export const ToolCallGroup = memo(function ToolCallGroup({
group,
presentation,
expanded,
onExpandedChange,
children,
}: ToolCallGroupProps) {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const isOverview = presentation === "overview";
const resourceLimit = isCompact ? 2 : 3;
const handlePress = useCallback(
() => onExpandedChange(group.id, !expanded),
[expanded, group.id, onExpandedChange],
);
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
const headerStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.header,
!isOverview && styles.headerConcise,
(pressed || hovered || expanded) && styles.headerActive,
],
[expanded, isOverview],
);
const summary = useMemo(() => {
const parts: string[] = [];
if (group.editedFileCount > 0) {
parts.push(
t(
group.editedFileCount === 1
? "toolCallGroup.editedFiles.one"
: "toolCallGroup.editedFiles.other",
{ count: group.editedFileCount },
),
);
}
if (group.commandCount > 0) {
parts.push(
t(
group.commandCount === 1 ? "toolCallGroup.commands.one" : "toolCallGroup.commands.other",
{ count: group.commandCount },
),
);
}
if (group.readFileCount > 0) {
parts.push(
t(
group.readFileCount === 1
? "toolCallGroup.readFiles.one"
: "toolCallGroup.readFiles.other",
{ count: group.readFileCount },
),
);
}
if (group.searchCount > 0) {
parts.push(
t(group.searchCount === 1 ? "toolCallGroup.searches.one" : "toolCallGroup.searches.other", {
count: group.searchCount,
}),
);
}
if (group.otherToolCount > 0) {
parts.push(
t(
group.otherToolCount === 1
? "toolCallGroup.otherTools.one"
: "toolCallGroup.otherTools.other",
{ count: group.otherToolCount },
),
);
}
if (group.paseoCallCount > 0) {
parts.push(
t(
group.paseoCallCount === 1
? "toolCallGroup.paseoCalls.one"
: "toolCallGroup.paseoCalls.other",
{ count: group.paseoCallCount },
),
);
}
return joinSummaryParts(parts, t("toolCallGroup.and"));
}, [group, t]);
const accessibilityLabel = isOverview
? summary
: t("toolCallGroup.accessibilityLabel", { count: group.callCount });
return (
<View style={styles.container} testID="tool-call-group">
<Pressable
onPress={handlePress}
accessibilityRole="button"
accessibilityState={accessibilityState}
accessibilityLabel={accessibilityLabel}
style={headerStyle}
>
<View style={styles.headerIcon}>
<GroupHeaderIcon group={group} compact={isOverview} />
</View>
{isOverview ? (
<Text style={styles.summary} numberOfLines={1}>
{summary}
</Text>
) : (
<>
<Text style={styles.conciseTitle}>{t("toolCallGroup.title")}</Text>
<Text style={styles.conciseCallCount}>×{group.callCount}</Text>
</>
)}
{group.failedCount > 0 ? (
<Text style={styles.error}>
{t("toolCallGroup.failed", { count: group.failedCount })}
</Text>
) : null}
<ChevronRight
size={isOverview ? 12 : 14}
color={styles.muted.color}
style={expanded ? styles.chevronExpanded : undefined}
/>
</Pressable>
{expanded ? <View style={styles.expandedCalls}>{children}</View> : null}
{!expanded && !isOverview ? (
<View style={styles.categories}>
{group.categories.map((category) => (
<CategoryRow key={category.key} category={category} resourceLimit={resourceLimit} />
))}
</View>
) : null}
</View>
);
});
const styles = StyleSheet.create((theme) => ({
container: {
marginHorizontal: -theme.spacing[3],
},
header: {
minHeight: 26,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.lg,
},
headerActive: {
backgroundColor: theme.colors.surface1,
},
headerConcise: {
minHeight: 30,
},
headerIcon: {
width: 18,
height: 18,
alignItems: "center",
justifyContent: "center",
},
summary: {
flex: 1,
minWidth: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
conciseTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
conciseCallCount: {
flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
categories: {
gap: theme.spacing[1],
paddingLeft: theme.spacing[8],
paddingRight: theme.spacing[2],
paddingTop: theme.spacing[1],
},
categoryRow: {
minHeight: 20,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
categoryIcon: {
width: 14,
alignItems: "center",
},
categoryLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
minWidth: 64,
},
categoryCount: {
width: theme.spacing[6],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
textAlign: "right",
},
categoryStatus: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
resources: {
flex: 1,
minWidth: 0,
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
},
expandedCalls: {
paddingTop: theme.spacing[1],
marginHorizontal: theme.spacing[3],
},
chevronExpanded: {
transform: [{ rotate: "90deg" }],
},
foreground: {
color: theme.colors.foreground,
},
muted: {
color: theme.colors.foregroundMuted,
},
error: {
color: theme.colors.destructive,
fontSize: theme.fontSize.xs,
},
}));
@@ -40,7 +40,6 @@ import {
} from "@/components/ui/isolated-bottom-sheet-modal";
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { isWeb, isNative } from "@/constants/platform";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
// Keep parity with dropdown-menu action statuses.
export type ActionStatus = "idle" | "pending" | "success";
@@ -389,7 +388,6 @@ export function ContextMenuContent({
const { t } = useTranslation();
const context = useContextMenuContext("ContextMenuContent");
const { theme } = useUnistyles();
const webScrollbarStyle = useWebScrollbarStyle();
const isMobile = useIsCompactFormFactor();
const useMobileSheet = isMobile && mobileMode === "sheet";
const { open, setOpen, triggerRef, anchorRect } = context;
@@ -579,7 +577,6 @@ export function ContextMenuContent({
<FloatingScrollView
bounces={false}
showsVerticalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={SCROLL_CONTENT_CONTAINER_STYLE}
>
{children}
@@ -31,7 +31,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { isWeb } from "@/constants/platform";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
@@ -448,7 +447,6 @@ export function DropdownMenuContent({
useDropdownMenuContext("DropdownMenuContent");
const [modalVisible, setModalVisible] = useState(false);
const surfaceNativeID = useId();
const webScrollbarStyle = useWebScrollbarStyle();
const [closing, setClosing] = useState(false);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<Size | null>(null);
@@ -603,8 +601,8 @@ export function DropdownMenuContent({
align,
]);
const scrollViewportStyle = useMemo(
() => [webScrollbarStyle, visibleContentSize ? { height: visibleContentSize.height } : null],
[visibleContentSize, webScrollbarStyle],
() => [visibleContentSize ? { height: visibleContentSize.height } : null],
[visibleContentSize],
);
if (!modalVisible) return null;
@@ -1,182 +0,0 @@
import { useCallback, useLayoutEffect, useState, type ReactNode, type RefObject } from "react";
import {
type FlatList,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
type ScrollView,
} from "react-native";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
type ScrollbarMetrics,
} from "./web-desktop-scrollbar";
import { isWeb as platformIsWeb } from "@/constants/platform";
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] {
scrollbar-width: none;
-ms-overflow-style: none;
scrollbar-gutter: auto;
}
[data-hide-scrollbar]::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
[data-hide-scrollbar]::-webkit-scrollbar-button {
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 enabled = (options?.enabled ?? true) && platformIsWeb;
const contentRef = options?.contentRef;
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
offset: 0,
viewportSize: 0,
contentSize: 0,
});
useLayoutEffect(() => {
if (!enabled) return;
const element = elementRef.current;
if (!element) return;
type ScrollbarStyle = CSSStyleDeclaration & {
scrollbarWidth: string;
msOverflowStyle: string;
scrollbarGutter: string;
};
const style = element.style as ScrollbarStyle;
const previousScrollbarWidth = style.scrollbarWidth;
const previousMsOverflowStyle = style.msOverflowStyle;
const previousScrollbarGutter = style.scrollbarGutter;
element.setAttribute("data-hide-scrollbar", "");
style.scrollbarWidth = "none";
style.msOverflowStyle = "none";
style.scrollbarGutter = "auto";
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 });
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(element);
const contentElement = contentRef?.current;
if (contentElement) {
resizeObserver.observe(contentElement);
}
update();
return () => {
element.removeEventListener("scroll", update);
resizeObserver.disconnect();
element.removeAttribute("data-hide-scrollbar");
style.scrollbarWidth = previousScrollbarWidth;
style.msOverflowStyle = previousMsOverflowStyle;
style.scrollbarGutter = previousScrollbarGutter;
};
}, [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 enabled = (options?.enabled ?? true) && platformIsWeb;
const metricsHook = useWebDesktopScrollbarMetrics();
const onScrollToOffset = useCallback(
(offset: number) => {
const scrollable = scrollableRef.current;
if (!scrollable) return;
if ("scrollToOffset" in scrollable) {
scrollable.scrollToOffset({ offset, animated: false });
} else {
scrollable.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,
};
}
@@ -1,73 +0,0 @@
const DEFAULT_MIN_HANDLE_SIZE = 36;
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
export interface VerticalScrollbarGeometryInput {
viewportSize: number;
contentSize: number;
offset: number;
minHandleSize?: number;
}
export interface 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 interface 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);
}
@@ -1,82 +0,0 @@
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);
});
});
@@ -1,464 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
PanResponder,
type GestureResponderEvent,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
type ViewStyle,
View,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import {
computeScrollOffsetFromDragDelta,
computeVerticalScrollbarGeometry,
} from "./web-desktop-scrollbar.math";
const METRICS_EPSILON = 0.5;
const HANDLE_WIDTH_IDLE = 6;
const HANDLE_WIDTH_ACTIVE = 9;
const HANDLE_GRAB_WIDTH = 18;
const HANDLE_GRAB_VERTICAL_PADDING = 8;
const HANDLE_OPACITY_VISIBLE = 0.62;
const HANDLE_OPACITY_HOVERED = 0.78;
const HANDLE_OPACITY_DRAGGING = 0.9;
const HANDLE_TRAVEL_TRANSITION_DURATION_MS = 90;
const HANDLE_FADE_DURATION_MS = 220;
const HANDLE_WIDTH_TRANSITION_DURATION_MS = 240;
const HANDLE_SCROLL_VISIBILITY_MS = 1200;
const HANDLE_SCROLL_ACTIVE_MS = 110;
interface WebPointerStyle {
cursor?: "grab" | "grabbing";
touchAction?: "none";
userSelect?: "none";
transitionProperty?: string;
transitionDuration?: string;
transitionTimingFunction?: string;
}
interface PointerLikeEvent {
clientY?: number;
pageY?: number;
nativeEvent?: { clientY?: number; pageY?: number; preventDefault?: () => void };
preventDefault?: () => void;
stopPropagation?: () => void;
}
function readClientY(event: PointerLikeEvent): number | null {
const value =
event?.nativeEvent?.clientY ?? event?.clientY ?? event?.nativeEvent?.pageY ?? event?.pageY;
return typeof value === "number" ? value : null;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
export interface 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
);
}
interface WebDesktopScrollbarOverlayProps {
enabled: boolean;
metrics: ScrollbarMetrics;
onScrollToOffset: (offset: number) => void;
inverted?: boolean;
}
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,
};
}
export function WebDesktopScrollbarOverlay({
enabled,
metrics,
onScrollToOffset,
inverted = false,
}: WebDesktopScrollbarOverlayProps) {
const { theme } = useUnistyles();
const [isHandleHovered, setIsHandleHovered] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [isScrollVisible, setIsScrollVisible] = useState(false);
const [isScrollActive, setIsScrollActive] = useState(false);
const dragStartOffsetRef = useRef(0);
const dragStartClientYRef = useRef(0);
const scrollVisibilityTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scrollActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastObservedOffsetRef = useRef<number | null>(null);
const geometryRef = useRef({
maxHandleOffset: 0,
maxScrollOffset: 0,
});
const onScrollToOffsetRef = useRef(onScrollToOffset);
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 normalizedOffsetRef = useRef(normalizedOffset);
const geometry = useMemo(
() =>
computeVerticalScrollbarGeometry({
viewportSize: metrics.viewportSize,
contentSize: metrics.contentSize,
offset: normalizedOffset,
}),
[metrics.contentSize, metrics.viewportSize, normalizedOffset],
);
useEffect(() => {
geometryRef.current = {
maxHandleOffset: geometry.maxHandleOffset,
maxScrollOffset: geometry.maxScrollOffset,
};
}, [geometry.maxHandleOffset, geometry.maxScrollOffset]);
useEffect(() => {
onScrollToOffsetRef.current = onScrollToOffset;
}, [onScrollToOffset]);
useEffect(() => {
normalizedOffsetRef.current = normalizedOffset;
}, [normalizedOffset]);
const clearScrollVisibilityTimeout = useCallback(() => {
if (scrollVisibilityTimeoutRef.current === null) {
return;
}
clearTimeout(scrollVisibilityTimeoutRef.current);
scrollVisibilityTimeoutRef.current = null;
}, []);
const clearScrollActiveTimeout = useCallback(() => {
if (scrollActiveTimeoutRef.current === null) {
return;
}
clearTimeout(scrollActiveTimeoutRef.current);
scrollActiveTimeoutRef.current = null;
}, []);
const revealScrollbarFromScroll = useCallback(() => {
setIsScrollVisible(true);
clearScrollVisibilityTimeout();
scrollVisibilityTimeoutRef.current = setTimeout(() => {
setIsScrollVisible(false);
scrollVisibilityTimeoutRef.current = null;
}, HANDLE_SCROLL_VISIBILITY_MS);
}, [clearScrollVisibilityTimeout]);
const markScrollActivity = useCallback(() => {
setIsScrollActive(true);
clearScrollActiveTimeout();
scrollActiveTimeoutRef.current = setTimeout(() => {
setIsScrollActive(false);
scrollActiveTimeoutRef.current = null;
}, HANDLE_SCROLL_ACTIVE_MS);
}, [clearScrollActiveTimeout]);
useEffect(() => {
if (!enabled || !geometry.isVisible) {
setIsScrollVisible(false);
setIsScrollActive(false);
clearScrollVisibilityTimeout();
clearScrollActiveTimeout();
lastObservedOffsetRef.current = null;
return;
}
const previousOffset = lastObservedOffsetRef.current;
lastObservedOffsetRef.current = normalizedOffset;
if (previousOffset === null) {
return;
}
if (Math.abs(normalizedOffset - previousOffset) <= METRICS_EPSILON) {
return;
}
revealScrollbarFromScroll();
markScrollActivity();
}, [
clearScrollActiveTimeout,
clearScrollVisibilityTimeout,
enabled,
geometry.isVisible,
markScrollActivity,
normalizedOffset,
revealScrollbarFromScroll,
]);
useEffect(
() => () => {
clearScrollActiveTimeout();
clearScrollVisibilityTimeout();
},
[clearScrollActiveTimeout, clearScrollVisibilityTimeout],
);
const applyDragDelta = useCallback(
(dragDelta: number) => {
const currentGeometry = geometryRef.current;
const nextNormalizedOffset = computeScrollOffsetFromDragDelta({
startOffset: dragStartOffsetRef.current,
dragDelta,
maxScrollOffset: currentGeometry.maxScrollOffset,
maxHandleOffset: currentGeometry.maxHandleOffset,
});
const nextOffset = inverted
? currentGeometry.maxScrollOffset - nextNormalizedOffset
: nextNormalizedOffset;
onScrollToOffsetRef.current(nextOffset);
},
[inverted],
);
const panResponder = useMemo(() => {
if (platformIsWeb) {
return null;
}
return PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderTerminationRequest: () => false,
onPanResponderGrant: (event: GestureResponderEvent) => {
const clientY = readClientY(event);
dragStartOffsetRef.current = normalizedOffsetRef.current;
if (clientY !== null) {
dragStartClientYRef.current = clientY;
}
setIsDragging(true);
},
onPanResponderMove: (_event, gestureState) => {
applyDragDelta(gestureState.dy);
},
onPanResponderRelease: () => {
setIsDragging(false);
},
onPanResponderTerminate: () => {
setIsDragging(false);
},
});
}, [applyDragDelta]);
const startWebDrag = useCallback((event: PointerLikeEvent) => {
if (!platformIsWeb) {
return;
}
const clientY = readClientY(event);
if (clientY === null) {
return;
}
event?.preventDefault?.();
event?.stopPropagation?.();
event?.nativeEvent?.preventDefault?.();
dragStartOffsetRef.current = normalizedOffsetRef.current;
dragStartClientYRef.current = clientY;
setIsDragging(true);
}, []);
const handleGrabHoverIn = useCallback(() => {
if (!isScrollVisible && !isDragging) {
return;
}
setIsHandleHovered(true);
}, [isDragging, isScrollVisible]);
const handleGrabHoverOut = useCallback(() => {
setIsHandleHovered(false);
}, []);
useEffect(() => {
if (!platformIsWeb || !isDragging) {
return;
}
const handlePointerMove = (event: PointerEvent) => {
const dragDelta = event.clientY - dragStartClientYRef.current;
applyDragDelta(dragDelta);
};
const stopDragging = () => {
setIsDragging(false);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopDragging);
window.addEventListener("pointercancel", stopDragging);
return () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopDragging);
window.removeEventListener("pointercancel", stopDragging);
};
}, [applyDragDelta, isDragging]);
const handleVisible = isDragging || isScrollVisible || isHandleHovered;
let handleOpacity: number;
if (isDragging) handleOpacity = HANDLE_OPACITY_DRAGGING;
else if (isHandleHovered) handleOpacity = HANDLE_OPACITY_HOVERED;
else if (isScrollVisible) handleOpacity = HANDLE_OPACITY_VISIBLE;
else handleOpacity = 0;
const handleWidth = isDragging || isHandleHovered ? HANDLE_WIDTH_ACTIVE : HANDLE_WIDTH_IDLE;
const handleColor = theme.colors.scrollbarHandle;
const handleCursor = isDragging ? "grabbing" : "grab";
const handleTravelDurationMs =
isDragging || isScrollActive ? 0 : HANDLE_TRAVEL_TRANSITION_DURATION_MS;
const thumbRegionOffset = Math.max(0, geometry.handleOffset - HANDLE_GRAB_VERTICAL_PADDING);
const thumbRegionHeight = Math.min(
metrics.viewportSize - thumbRegionOffset,
geometry.handleSize + HANDLE_GRAB_VERTICAL_PADDING * 2,
);
const handleInsetTop = Math.max(0, (thumbRegionHeight - geometry.handleSize) / 2);
const thumbRegionStyle = useMemo(
() => [
styles.thumbRegion,
inlineUnistylesStyle({
height: thumbRegionHeight,
transform: [{ translateY: thumbRegionOffset }],
}),
platformIsWeb &&
inlineUnistylesStyle({
cursor: handleCursor,
touchAction: "none",
userSelect: "none",
transitionProperty: "transform",
transitionDuration: `${handleTravelDurationMs}ms`,
transitionTimingFunction: "linear",
} satisfies WebPointerStyle as unknown as ViewStyle),
],
[thumbRegionHeight, thumbRegionOffset, handleCursor, handleTravelDurationMs],
);
const handleStyle = useMemo(
() => [
styles.handle,
inlineUnistylesStyle({
marginTop: handleInsetTop,
height: geometry.handleSize,
width: handleWidth,
backgroundColor: handleColor,
opacity: handleOpacity,
}),
platformIsWeb &&
inlineUnistylesStyle({
transitionProperty: "opacity, width, background-color",
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,
transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
} satisfies WebPointerStyle as unknown as ViewStyle),
],
[handleInsetTop, geometry.handleSize, handleWidth, handleColor, handleOpacity],
);
if (!enabled || !geometry.isVisible) {
return null;
}
return (
<View style={styles.overlay} pointerEvents="box-none">
<View
style={thumbRegionStyle}
pointerEvents={handleVisible ? "auto" : "none"}
{...(panResponder?.panHandlers ?? {})}
{...(platformIsWeb
? ({
onPointerDown: startWebDrag,
onMouseEnter: handleGrabHoverIn,
onMouseLeave: handleGrabHoverOut,
} as object)
: null)}
>
<View style={handleStyle} pointerEvents="none" />
</View>
</View>
);
}
const styles = StyleSheet.create(() => ({
overlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: 12,
alignItems: "center",
justifyContent: "flex-start",
zIndex: 10,
},
handle: {
width: HANDLE_WIDTH_IDLE,
borderRadius: 999,
alignSelf: "center",
},
thumbRegion: {
position: "absolute",
right: -3,
width: HANDLE_GRAB_WIDTH,
top: 0,
},
}));