diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 5632c9129..0f9f04b8c 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -33,6 +33,24 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({ }); const HISTORY_START_THRESHOLD_PX = 96; +interface HistoryRowDisplayVariants { + regular?: StreamItem; + compact?: StreamItem; +} + +const historyRowDisplayVariants = new WeakMap(); + +function getHistoryRowDisplayVariant(item: StreamItem, compact: boolean): StreamItem { + let variants = historyRowDisplayVariants.get(item); + if (!variants) { + variants = {}; + historyRowDisplayVariants.set(item, variants); + } + const key = compact ? "compact" : "regular"; + variants[key] ??= { ...item }; + return variants[key]; +} + function keyExtractor(item: { id: string }): string { return item.id; } @@ -41,6 +59,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const { agentId, segments, + historyRowRevision, + liveHeadRowRevision, boundary, renderers, listEmptyComponent, @@ -73,12 +93,33 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const nativeViewportSettlingFrameIdRef = useRef(null); const historyStartReadyRef = useRef(false); - const historyRows = useMemo(() => { + const historyItems = useMemo(() => { if (segments.historyVirtualized.length === 0) { return segments.historyMounted; } return [...segments.historyVirtualized, ...segments.historyMounted]; }, [segments.historyMounted, segments.historyVirtualized]); + // Keep unchanged item identities intact so live updates only rerender rows + // whose projected content or local display state actually changed. A rare + // breakpoint change intentionally refreshes the whole history window. + const globallyRevisedHistoryRows = useMemo(() => { + const globalDisplayState = historyRowRevision?.globalDisplayState ?? false; + return historyItems.map((item) => getHistoryRowDisplayVariant(item, globalDisplayState)); + }, [historyItems, historyRowRevision?.globalDisplayState]); + const displayStateHistoryRows = useMemo( + () => + globallyRevisedHistoryRows.map((item) => + historyRowRevision?.displayStateById.has(item.id) ? { ...item } : item, + ), + [globallyRevisedHistoryRows, historyRowRevision?.displayStateById], + ); + const historyRows = useMemo( + () => + displayStateHistoryRows.map((item) => + historyRowRevision?.contentById.has(item.id) ? { ...item } : item, + ), + [displayStateHistoryRows, historyRowRevision?.contentById], + ); const clearNativeViewportSettling = useCallback(() => { if (nativeViewportSettlingFrameIdRef.current !== null) { @@ -307,12 +348,15 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const renderItem = useStableEvent( ({ item, index }: ListRenderItemInfo): ReactElement | null => { - const rendered = renderHistoryMountedRow(item, index, historyRows); + const rendered = renderHistoryMountedRow(item, index, historyItems); return (rendered ?? null) as ReactElement | null; }, ); const liveHeaderContent = useMemo(() => { + // Stable render events read the latest expansion state; this revision makes + // the memo invoke them again when that state changes. + void liveHeadRowRevision; const liveHeadRows = segments.liveHead.map((item, index) => ( {renderLiveHeadRow(item, index, segments.liveHead)} )); @@ -331,7 +375,14 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat {liveAuxiliary} ); - }, [boundary, listEmptyComponent, renderLiveAuxiliary, renderLiveHeadRow, segments.liveHead]); + }, [ + boundary, + listEmptyComponent, + liveHeadRowRevision, + renderLiveAuxiliary, + renderLiveHeadRow, + segments.liveHead, + ]); const historyFooterContent = useMemo(() => { if (!isLoadingOlderHistory) { @@ -344,12 +395,15 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat ); }, [isLoadingOlderHistory]); + // RN's FlatList strictMode keeps its internal renderItem wrapper stable when + // data or the live header changes, preserving the row identities above. return ( { @@ -25,8 +25,6 @@ vi.hoisted(() => { }); }); -vi.mock("@/components/use-web-scrollbar", () => ({ useWebElementScrollbar: () => null })); - function userMessage(index: number): StreamItem { return { kind: "user_message", @@ -148,6 +146,59 @@ describe("createWebStreamStrategy", () => { expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length); }); + it("rerenders a stable live-head row when its revision changes", () => { + const strategy = createWebStreamStrategy({ isMobileBreakpoint: false }); + const viewportRef = React.createRef(); + const liveHead = [userMessage(1)]; + let label = "collapsed"; + const renderLiveHeadRow = vi.fn(() =>
{label}
); + const renderInput: StreamRenderInput = { + agentId: "agent", + segments: { + historyVirtualized: [], + historyMounted: [], + liveHead, + }, + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: false, + hasLiveHead: true, + }, + renderers: { + ...createRenderers(vi.fn()), + renderLiveHeadRow, + }, + listEmptyComponent: null, + viewportRef, + routeBottomAnchorRequest: null, + isAuthoritativeHistoryReady: true, + onNearBottomChange: vi.fn(), + onNearHistoryStart: vi.fn(), + isLoadingOlderHistory: false, + hasOlderHistory: false, + scrollEnabled: true, + listStyle: null, + baseListContentContainerStyle: null, + forwardListContentContainerStyle: null, + }; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render(strategy.render({ ...renderInput, liveHeadRowRevision: 0 })); + }); + expect(container.textContent).toContain("collapsed"); + + label = "expanded"; + act(() => { + root?.render(strategy.render({ ...renderInput, liveHeadRowRevision: 1 })); + }); + + expect(container.textContent).toContain("expanded"); + expect(renderLiveHeadRow).toHaveBeenCalledTimes(2); + }); + it("fires near-history-start when the user scrolls near the top", async () => { const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); const viewportRef = React.createRef(); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 60a0ad961..bc5c11604 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -25,7 +25,6 @@ const USER_SCROLL_DELTA_EPSILON = 1; const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64; const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1; const HISTORY_START_THRESHOLD_PX = 96; -import { useWebElementScrollbar } from "@/components/use-web-scrollbar"; const historyStartSlotStyle: CSSProperties = { display: "flex", @@ -95,6 +94,7 @@ function isScrollContainerOverscrolledPastBottom( function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) { const { segments, + liveHeadRowRevision, boundary, renderers, listEmptyComponent, @@ -131,11 +131,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const pendingAutoScrollTimeoutRef = useRef(null); const pendingVirtualRowMeasureFramesRef = useRef(new Map()); const historyStartReadyRef = useRef(false); - const showDesktopWebScrollbar = !isMobileBreakpoint; - const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, { - enabled: showDesktopWebScrollbar, - contentRef, - }); const shouldUseVirtualizer = segments.historyVirtualized.length > 0; const { renderHistoryVirtualizedRow, @@ -540,10 +535,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool )); }, [renderHistoryMountedRow, segments.historyMounted]); const liveHeadRows = useMemo(() => { + void liveHeadRowRevision; return segments.liveHead.map((item, index) => ( {renderLiveHeadRow(item, index, segments.liveHead)} )); - }, [renderLiveHeadRow, segments.liveHead]); + }, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]); const liveAuxiliary = useMemo(() => { return renderLiveAuxiliary(); }, [renderLiveAuxiliary]); @@ -564,47 +560,40 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool !liveAuxiliary; return ( - <> -
-
- {historyStartSlot} - {shouldUseVirtualizer ? ( -
- {virtualRows.map((virtualRow) => { - const item = segments.historyVirtualized[virtualRow.index]; - if (!item) { - return null; - } - return ( -
- {renderHistoryVirtualizedRow( - item, - virtualRow.index, - segments.historyVirtualized, - )} -
- ); - })} -
- ) : null} - {mountedHistoryRows} - {liveHeadRows} - {liveAuxiliary} - {shouldRenderEmpty ? listEmptyComponent : null} -
+
+
+ {historyStartSlot} + {shouldUseVirtualizer ? ( +
+ {virtualRows.map((virtualRow) => { + const item = segments.historyVirtualized[virtualRow.index]; + if (!item) { + return null; + } + return ( +
+ {renderHistoryVirtualizedRow(item, virtualRow.index, segments.historyVirtualized)} +
+ ); + })} +
+ ) : null} + {mountedHistoryRows} + {liveHeadRows} + {liveAuxiliary} + {shouldRenderEmpty ? listEmptyComponent : null}
- {scrollbarOverlay} - +
); } diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index f8c272d28..dd9a798ed 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -51,9 +51,17 @@ export interface StreamSegmentRenderers { renderLiveAuxiliary: () => ReactNode; } +export interface StreamHistoryRowRevision { + contentById: { has(id: string): boolean }; + displayStateById: { has(id: string): boolean }; + globalDisplayState: boolean; +} + export interface StreamRenderInput { agentId: string; segments: StreamRenderSegments; + historyRowRevision?: StreamHistoryRowRevision; + liveHeadRowRevision?: unknown; boundary: StreamHistoryBoundary; renderers: StreamSegmentRenderers; listEmptyComponent: ReactNode; diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 8250461f1..4736bd5c0 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -57,8 +57,11 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { ToolCallDetailsContent } from "@/components/tool-call-details"; import { QuestionFormCard } from "@/components/question-form-card"; import { ToolCallSheetProvider } from "@/components/tool-call-sheet"; -import { ToolCallGroup } from "@/components/tool-call-group"; -import { compactToolCallRuns } from "@/tool-calls/grouping"; +import { + prepareToolCallHistory, + projectToolCallDetailLevel, +} from "@/tool-calls/detail-level/projection"; +import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view"; import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model"; import { resolveStreamRenderStrategy } from "./strategy-resolver"; import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy"; @@ -259,6 +262,7 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [ ]; const EMPTY_STREAM_HEAD: StreamItem[] = []; +const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200; function buildChatHistoryAttachment(input: { draftId: string; @@ -544,25 +548,38 @@ const AgentStreamViewComponent = forwardRef prepareToolCallHistory(toolCallDetailLevel, effectiveStreamItems), + [effectiveStreamItems, toolCallDetailLevel], + ); + const projectedToolCalls = useMemo( () => - compactToolCallRuns({ + projectToolCallDetailLevel({ + level: toolCallDetailLevel, tail: effectiveStreamItems, head: effectiveStreamHead ?? EMPTY_STREAM_HEAD, - enabled: toolCallDetailLevel !== "detailed", + preparedHistory: preparedToolCallHistory, + isTurnActive: context.status === "running", }), - [effectiveStreamHead, effectiveStreamItems, toolCallDetailLevel], + [ + context.status, + effectiveStreamHead, + effectiveStreamItems, + preparedToolCallHistory, + toolCallDetailLevel, + ], ); const baseRenderModel = useMemo(() => { return buildAgentStreamRenderModel({ agentStatus: context.status, - tail: compactedToolCalls.tail, - head: compactedToolCalls.head, + tail: projectedToolCalls.tail, + head: projectedToolCalls.head, platform: isWeb ? "web" : "native", isMobileBreakpoint: isMobile, }); - }, [context.status, isMobile, compactedToolCalls.head, compactedToolCalls.tail]); + }, [context.status, isMobile, projectedToolCalls.head, projectedToolCalls.tail]); const streamLayout = useMemo( () => layoutStream({ @@ -691,7 +708,11 @@ const AgentStreamViewComponent = forwardRef, isLastInSequence: boolean) => { + ( + item: Extract, + isLastInSequence: boolean, + maxDetailHeight?: number, + ) => { const { payload } = item; if (payload.source === "agent") { @@ -720,6 +741,7 @@ const AgentStreamViewComponent = forwardRef ); } @@ -735,6 +757,7 @@ const AgentStreamViewComponent = forwardRef ); }, @@ -743,32 +766,43 @@ const AgentStreamViewComponent = forwardRef) => { - const group = compactedToolCalls.groupsByHostId.get(item.id); + const group = projectedToolCalls.groupsByHostId.get(item.id); if (!group) { return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence); } - const expanded = expandedToolCallGroupIds.has(group.id); + const expanded = expandedToolCallGroupIds.has(group.run.id); return ( - - {group.calls.map((call, index) => ( - - {renderSingleToolCallItem(call, index === group.calls.length - 1)} - - ))} - + {expanded + ? group.run.calls.map((call, index) => ( + + {renderSingleToolCallItem( + call, + index === group.run.calls.length - 1, + GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT, + )} + + )) + : null} + ); }, [ - compactedToolCalls.groupsByHostId, + projectedToolCalls.groupsByHostId, + context.cwd, expandedToolCallGroupIds, + handleToolCallOpenFile, + isMobile, renderSingleToolCallItem, setToolCallGroupExpanded, - toolCallDetailLevel, ], ); @@ -960,6 +994,14 @@ const AgentStreamViewComponent = forwardRef ({ + contentById: projectedToolCalls.historyGroupUpdatesByHostId, + displayStateById: expandedToolCallGroupIds, + globalDisplayState: isMobile, + }), + [expandedToolCallGroupIds, isMobile, projectedToolCalls.historyGroupUpdatesByHostId], + ); return ( @@ -968,6 +1010,8 @@ const AgentStreamViewComponent = forwardRef installWebScrollbarStyles(), []); + return ( diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 57e424cba..68814e579 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -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(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 ? ( {children} - {desktopScrollbar.overlay} ) : ( {children} diff --git a/packages/app/src/components/diff-scroll.web.tsx b/packages/app/src/components/diff-scroll.web.tsx index 8af11ee0e..4caa3ac40 100644 --- a/packages/app/src/components/diff-scroll.web.tsx +++ b/packages/app/src/components/diff-scroll.web.tsx @@ -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} > diff --git a/packages/app/src/components/diff-viewer.tsx b/packages/app/src/components/diff-viewer.tsx index 26f22c6b8..2362363b9 100644 --- a/packages/app/src/components/diff-viewer.tsx +++ b/packages/app/src/components/diff-viewer.tsx @@ -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} > diff --git a/packages/app/src/components/draggable-list.native.tsx b/packages/app/src/components/draggable-list.native.tsx index 2de511ca8..554dcafa3 100644 --- a/packages/app/src/components/draggable-list.native.tsx +++ b/packages/app/src/components/draggable-list.native.tsx @@ -24,7 +24,6 @@ export function DraggableList({ ListHeaderComponent, ListEmptyComponent, showsVerticalScrollIndicator = true, - enableDesktopWebScrollbar: _enableDesktopWebScrollbar = false, scrollEnabled = true, useDragHandle: _useDragHandle = false, refreshing, diff --git a/packages/app/src/components/draggable-list.types.ts b/packages/app/src/components/draggable-list.types.ts index 01b15c640..74b365ac6 100644 --- a/packages/app/src/components/draggable-list.types.ts +++ b/packages/app/src/components/draggable-list.types.ts @@ -34,7 +34,6 @@ export interface DraggableListProps { ListHeaderComponent?: ReactElement | null; ListEmptyComponent?: ReactElement | null; showsVerticalScrollIndicator?: boolean; - enableDesktopWebScrollbar?: boolean; /** When false, disables internal scrolling (use outer list to scroll). */ scrollEnabled?: boolean; /** diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx index 971575255..3f16fc072 100644 --- a/packages/app/src/components/draggable-list.web.tsx +++ b/packages/app/src/components/draggable-list.web.tsx @@ -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({ ListHeaderComponent, ListEmptyComponent, showsVerticalScrollIndicator = true, - enableDesktopWebScrollbar = false, scrollEnabled = true, extraData: _extraData, useDragHandle = false, @@ -147,11 +145,6 @@ export function DraggableList({ onDragEnd, onDragBegin, }); - const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled; - const scrollViewRef = useRef(null); - const scrollbar = useWebScrollViewScrollbar(scrollViewRef, { - enabled: showCustomScrollbar, - }); const pointerActivationConstraint = getPointerActivationConstraint( useDragHandle, POINTER_ACTIVATION_CONFIG, @@ -183,15 +176,10 @@ export function DraggableList({ {scrollEnabled ? ( {ListHeaderComponent} {items.length === 0 && ListEmptyComponent} @@ -254,7 +242,6 @@ export function DraggableList({ {ListFooterComponent} )} - {scrollbar.overlay} ); } diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index aee2cbf07..6707b417c 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -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>(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 | null>; - scrollbar: ReturnType; renderTreeRow: (info: ListRenderItemInfo) => 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} ); } diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index 8163b8c03..e8cde4141 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -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(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 > - {scrollbar.overlay} ); } @@ -318,11 +304,7 @@ function FilePreviewBody({ {isMobile ? ( {codeLines} @@ -331,14 +313,12 @@ function FilePreviewBody({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator - style={webScrollbarStyle} contentContainerStyle={styles.previewCodeScrollContent} > {codeLines} )} - {scrollbar.overlay} ); } @@ -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 > - {scrollbar.overlay} ); } @@ -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({ ({ }, 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 ( - + + + ); } 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 ? ( @@ -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({ ); - }, [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; } diff --git a/packages/app/src/components/schedules/schedule-form-sheet.tsx b/packages/app/src/components/schedules/schedule-form-sheet.tsx index 1a7b1295d..721ceb225 100644 --- a/packages/app/src/components/schedules/schedule-form-sheet.tsx +++ b/packages/app/src/components/schedules/schedule-form-sheet.tsx @@ -432,7 +432,6 @@ function OpenScheduleFormSheet({ onClose={onClose} onDismiss={onDismiss} footer={footer} - webScrollbar testID="schedule-form-sheet" > 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 = [ 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(null); - const dragStartOffsetRef = useRef(0); - const dragStartClientYRef = useRef(0); - const scrollVisibilityTimeoutRef = useRef | null>(null); - const scrollActiveTimeoutRef = useRef | null>(null); - const lastObservedOffsetRef = useRef(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({ - 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 | 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(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(".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(".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( - () => ({ - 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( - () => ({ - 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 (
- {scrollbarGeometry.isVisible ? ( -
-
-
-
-
- ) : null}
); } diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index a77156478..b3e6cd463 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -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; fullBleedContainerStyle: StyleProp; loadingContainerStyle: StyleProp; - webScrollbarStyle: StyleProp; 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} > @@ -224,7 +210,6 @@ function WorktreeSetupDetailSection({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator - style={ds.webScrollbarStyle} contentContainerStyle={styles.codeHorizontalContent} > @@ -389,7 +374,6 @@ function SubAgentDetailSection({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator - style={ds.webScrollbarStyle} contentContainerStyle={styles.codeHorizontalContent} > @@ -466,12 +450,7 @@ function ScrollableTextSection({ nestedScrollEnabled showsVerticalScrollIndicator={true} > - + {keyedLines ? ( ) : ( @@ -501,12 +480,7 @@ function FetchDetailSection({ url, result, ds }: FetchDetailProps) { nestedScrollEnabled showsVerticalScrollIndicator > - + {result ? `${url}\n\n${result}` : url} @@ -545,12 +519,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[ nestedScrollEnabled showsVerticalScrollIndicator > - + {detail.content} diff --git a/packages/app/src/components/tool-call-group.tsx b/packages/app/src/components/tool-call-group.tsx deleted file mode 100644 index ef44e3f7a..000000000 --- a/packages/app/src/components/tool-call-group.tsx +++ /dev/null @@ -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 ( - - - - {t("toolCallGroup.failed", { count: category.failedCount })} - - - ); - } - if (category.runningCount > 0) { - return ; - } - 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 ( - - - - - ×{category.callCount} - {category.label} - - {resourceText ? ( - - {resourceText} - - ) : null} - - ); -} - -function GroupHeaderIcon({ - group, - compact, -}: { - group: CompactToolCallGroupModel; - compact: boolean; -}) { - const size = compact ? 11 : 12; - if (group.failedCount > 0) { - return ; - } - if (group.isRunning) { - return ; - } - return ; -} - -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 ( - - - - - - {isOverview ? ( - - {summary} - - ) : ( - <> - {t("toolCallGroup.title")} - ×{group.callCount} - - )} - {group.failedCount > 0 ? ( - - {t("toolCallGroup.failed", { count: group.failedCount })} - - ) : null} - - - - {expanded ? {children} : null} - {!expanded && !isOverview ? ( - - {group.categories.map((category) => ( - - ))} - - ) : null} - - ); -}); - -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, - }, -})); diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index ea6ba574f..1869d8568 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -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({ {children} diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index 8bceebc34..cccafc03c 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -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(null); const [contentSize, setContentSize] = useState(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; diff --git a/packages/app/src/components/use-web-scrollbar.tsx b/packages/app/src/components/use-web-scrollbar.tsx deleted file mode 100644 index ab6e13e6e..000000000 --- a/packages/app/src/components/use-web-scrollbar.tsx +++ /dev/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, - options?: { - enabled?: boolean; - contentRef?: RefObject; - }, -): ReactNode { - const enabled = (options?.enabled ?? true) && platformIsWeb; - const contentRef = options?.contentRef; - - const [metrics, setMetrics] = useState({ - 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 ( - - ); -} - -// ── 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) => void; - onLayout: (event: LayoutChangeEvent) => void; - onContentSizeChange: (width: number, height: number) => void; - overlay: ReactNode; -} - -export function useWebScrollViewScrollbar( - scrollableRef: RefObject, - 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 ? ( - - ) : null; - - return { - onScroll: metricsHook.onScroll, - onLayout: metricsHook.onLayout, - onContentSizeChange: metricsHook.onContentSizeChange, - overlay, - }; -} diff --git a/packages/app/src/components/web-desktop-scrollbar.math.ts b/packages/app/src/components/web-desktop-scrollbar.math.ts deleted file mode 100644 index cdf155d7a..000000000 --- a/packages/app/src/components/web-desktop-scrollbar.math.ts +++ /dev/null @@ -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); -} diff --git a/packages/app/src/components/web-desktop-scrollbar.test.ts b/packages/app/src/components/web-desktop-scrollbar.test.ts deleted file mode 100644 index 91bcafbab..000000000 --- a/packages/app/src/components/web-desktop-scrollbar.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/app/src/components/web-desktop-scrollbar.tsx b/packages/app/src/components/web-desktop-scrollbar.tsx deleted file mode 100644 index caff521bc..000000000 --- a/packages/app/src/components/web-desktop-scrollbar.tsx +++ /dev/null @@ -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({ - offset: 0, - viewportSize: 0, - contentSize: 0, - }); - - const setMetricsIfChanged = useCallback((next: ScrollbarMetrics) => { - setMetrics((previous) => (areMetricsEqual(previous, next) ? previous : next)); - }, []); - - const onScroll = useCallback( - (event: NativeSyntheticEvent) => { - const { contentOffset, layoutMeasurement, contentSize } = event.nativeEvent; - setMetricsIfChanged({ - offset: Math.max(0, contentOffset.y), - viewportSize: Math.max(0, layoutMeasurement.height), - contentSize: Math.max(0, contentSize.height), - }); - }, - [setMetricsIfChanged], - ); - - const onLayout = useCallback((event: LayoutChangeEvent) => { - const viewportSize = Math.max(0, event.nativeEvent.layout.height); - setMetrics((previous) => { - const next = { ...previous, viewportSize }; - return areMetricsEqual(previous, next) ? previous : next; - }); - }, []); - - const onContentSizeChange = useCallback((_width: number, height: number) => { - const contentSize = Math.max(0, height); - setMetrics((previous) => { - const next = { ...previous, contentSize }; - return areMetricsEqual(previous, next) ? previous : next; - }); - }, []); - - const setOffset = useCallback((offset: number) => { - const clampedOffset = Math.max(0, offset); - setMetrics((previous) => { - const next = { ...previous, offset: clampedOffset }; - return areMetricsEqual(previous, next) ? previous : next; - }); - }, []); - - return { - ...metrics, - onScroll, - onLayout, - onContentSizeChange, - setOffset, - }; -} - -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 | null>(null); - const scrollActiveTimeoutRef = useRef | null>(null); - const lastObservedOffsetRef = useRef(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 ( - - - - - - ); -} - -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, - }, -})); diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 73dd7b1a9..4c68a2c40 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -51,7 +51,6 @@ import { } from "@/components/ui/dropdown-menu"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss"; -import { useWebElementScrollbar } from "@/components/use-web-scrollbar"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useIosHardwareKeyboardSubmit } from "@/hooks/use-ios-hardware-keyboard-submit"; import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut"; @@ -1616,10 +1615,6 @@ export const MessageInput = forwardRef( } }, [getWebTextArea]); - const inputScrollbar = useWebElementScrollbar(webTextareaRef, { - enabled: isWeb, - }); - usePasteImagesEffect({ getWebTextArea, isConnected, @@ -1842,7 +1837,6 @@ export const MessageInput = forwardRef( onSelectionChange={handleSelectionChange} autoFocus={isWeb && autoFocus} /> - {inputScrollbar} | null>; handleDiffListLayout: (event: LayoutChangeEvent) => void; handleDiffListScroll: (event: NativeSyntheticEvent) => void; - onContentSizeChange: (width: number, height: number) => void; - showDesktopWebScrollbar: boolean; checkingRepositoryLabel: string; notRepositoryLabel: string; } @@ -1559,8 +1556,6 @@ function DiffBodyContent({ diffListRef, handleDiffListLayout, handleDiffListScroll, - onContentSizeChange, - showDesktopWebScrollbar, checkingRepositoryLabel, notRepositoryLabel, }: DiffBodyContentProps) { @@ -1621,9 +1616,8 @@ function DiffBodyContent({ testID="git-diff-scroll" onLayout={handleDiffListLayout} onScroll={handleDiffListScroll} - onContentSizeChange={onContentSizeChange} scrollEventThrottle={16} - showsVerticalScrollIndicator={!showDesktopWebScrollbar} + showsVerticalScrollIndicator // Mixed-height rows (header + potentially very large body) are prone to clipping artifacts. // Keep a larger render window and disable clipping to avoid bodies disappearing mid-scroll. removeClippedSubviews={false} @@ -1737,7 +1731,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane const { settings: appSettings } = useAppSettings(); const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); - const showDesktopWebScrollbar = isWeb && !isMobile; const canUseSplitLayout = isWeb && !isMobile; const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } = useChangesPreferences(); @@ -1963,9 +1956,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane } void updateChangesPreferences({ viewMode: nextViewMode }); }, [setDiffCollapsedFoldersForWorkspace, updateChangesPreferences, viewMode, workspaceStateKey]); - const scrollbar = useWebScrollViewScrollbar(diffListRef, { - enabled: showDesktopWebScrollbar, - }); const diffListScrollOffsetRef = useRef(0); const diffListViewportHeightRef = useRef(0); const headerHeightByPathRef = useRef>({}); @@ -2089,25 +2079,17 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane [getBodyHeightKey], ); - const handleDiffListScroll = useCallback( - (event: NativeSyntheticEvent) => { - diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; - scrollbar.onScroll(event); - }, - [scrollbar], - ); + const handleDiffListScroll = useCallback((event: NativeSyntheticEvent) => { + diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; + }, []); - const handleDiffListLayout = useCallback( - (event: LayoutChangeEvent) => { - const height = event.nativeEvent.layout.height; - if (!Number.isFinite(height) || height <= 0) { - return; - } - diffListViewportHeightRef.current = height; - scrollbar.onLayout(event); - }, - [scrollbar], - ); + const handleDiffListLayout = useCallback((event: LayoutChangeEvent) => { + const height = event.nativeEvent.layout.height; + if (!Number.isFinite(height) || height <= 0) { + return; + } + diffListViewportHeightRef.current = height; + }, []); // Offset of the first item matching `predicate`, walking the SAME flatItems // list getFlatItemLayout uses so folder rows are counted (single source of @@ -2379,8 +2361,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane diffListRef={diffListRef} handleDiffListLayout={handleDiffListLayout} handleDiffListScroll={handleDiffListScroll} - onContentSizeChange={scrollbar.onContentSizeChange} - showDesktopWebScrollbar={showDesktopWebScrollbar} checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")} notRepositoryLabel={t("workspace.git.diff.notRepository")} /> @@ -2479,10 +2459,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane {prErrorMessage ? {prErrorMessage} : null} - - {bodyContent} - {hasChanges ? scrollbar.overlay : null} - + {bodyContent} ); } diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index 38b3f84da..a864afc76 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -341,14 +341,14 @@ describe("appearance settings", () => { expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview"); }); - it("loads an explicit tool call detail level", async () => { + it("maps an unrecognized tool call detail level to overview", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ - [APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "concise" }), + [APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "unknown" }), }), }); - expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("concise"); + expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview"); }); it("clamps the UI font size into range and rejects non-numeric values", async () => { diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index dc982aef7..808665f3e 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -12,16 +12,12 @@ export type SendBehavior = "interrupt" | "queue"; export type ReleaseChannel = "stable" | "beta"; export type ServiceUrlBehavior = "ask" | "in-app" | "external"; export type WorkspaceTitleSource = "title" | "branch"; -export type ToolCallDetailLevel = "overview" | "concise" | "detailed"; +export type ToolCallDetailLevel = "overview" | "detailed"; const VALID_THEMES = new Set([...Object.keys(THEME_TO_UNISTYLES), "auto"]); const VALID_SERVICE_URL_BEHAVIORS = new Set(["ask", "in-app", "external"]); const VALID_WORKSPACE_TITLE_SOURCES = new Set(["title", "branch"]); -const VALID_TOOL_CALL_DETAIL_LEVELS = new Set([ - "overview", - "concise", - "detailed", -]); +const VALID_TOOL_CALL_DETAIL_LEVELS = new Set(["overview", "detailed"]); export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000; export const MIN_TERMINAL_SCROLLBACK_LINES = 0; export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000; @@ -172,11 +168,16 @@ export function normalizeAppSettings(value: unknown): AppSettings { } function parseToolCallDetailLevel(stored: StoredAppSettings): ToolCallDetailLevel | null { - if ( - typeof stored.toolCallDetailLevel === "string" && - VALID_TOOL_CALL_DETAIL_LEVELS.has(stored.toolCallDetailLevel) - ) { - return stored.toolCallDetailLevel; + if (stored.toolCallDetailLevel !== undefined) { + if ( + typeof stored.toolCallDetailLevel === "string" && + VALID_TOOL_CALL_DETAIL_LEVELS.has(stored.toolCallDetailLevel) + ) { + return stored.toolCallDetailLevel; + } + // COMPAT(toolCallDetailLevelConcise): removed in v0.1.107; legacy "concise" values + // deliberately follow the unknown-value fallback. Remove after 2027-01-14. + return "overview"; } if (typeof stored.compactToolCalls === "boolean") { // COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12. diff --git a/packages/app/src/hooks/use-web-scrollbar-style.d.ts b/packages/app/src/hooks/use-web-scrollbar-style.d.ts deleted file mode 100644 index afafdb944..000000000 --- a/packages/app/src/hooks/use-web-scrollbar-style.d.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./use-web-scrollbar-style.web"; diff --git a/packages/app/src/hooks/use-web-scrollbar-style.native.ts b/packages/app/src/hooks/use-web-scrollbar-style.native.ts deleted file mode 100644 index d2f336ed5..000000000 --- a/packages/app/src/hooks/use-web-scrollbar-style.native.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { ViewStyle } from "react-native"; - -export function useWebScrollbarStyle(): ViewStyle | undefined { - return undefined; -} diff --git a/packages/app/src/hooks/use-web-scrollbar-style.web.ts b/packages/app/src/hooks/use-web-scrollbar-style.web.ts deleted file mode 100644 index 38d22c910..000000000 --- a/packages/app/src/hooks/use-web-scrollbar-style.web.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useMemo } from "react"; -import type { ViewStyle } from "react-native"; -import { useUnistyles } from "react-native-unistyles"; - -// CSS scrollbar properties are supported by React Native Web at runtime -// but are not included in React Native's ViewStyle type definition. -interface WebScrollbarStyle extends ViewStyle { - scrollbarColor: string; - scrollbarWidth: string; -} - -export function useWebScrollbarStyle(): WebScrollbarStyle { - const { theme } = useUnistyles(); - return useMemo( - (): WebScrollbarStyle => ({ - scrollbarColor: `${theme.colors.scrollbarHandle} transparent`, - scrollbarWidth: "thin", - }), - [theme.colors.scrollbarHandle], - ); -} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 82e47079c..52920c2bc 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1393,8 +1393,6 @@ export const ar: TranslationResources = { output: "الإخراج", }, toolCallGroup: { - title: "الأدوات", - accessibilityLabel: "الأدوات، {{count}} استدعاءات", editedFiles: { one: "حرّر {{count}} ملفًا", other: "حرّر {{count}} ملفات", @@ -1520,13 +1518,12 @@ export const ar: TranslationResources = { description: "إظهار تفكير الوكيل وخطوات الاستدلال بشكل كامل بشكل افتراضي", }, toolCallDetail: { - label: "تفاصيل استدعاءات الأدوات", - description: "كيفية ظهور نشاط الأدوات في الخط الزمني للوكيل", - accessibilityLabel: "حدد مستوى تفاصيل الأدوات ({{value}})", + label: "عرض استدعاءات الأدوات", + description: "كيفية ظهور استدعاءات الأدوات في المخطط الزمني", + accessibilityLabel: "حدد عرض استدعاءات الأدوات ({{value}})", options: { - overview: "نظرة عامة", - concise: "موجز", - detailed: "مفصل", + overview: "ملخص", + detailed: "التفاصيل الكاملة", }, }, language: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 7f39945c1..cfe4612cb 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1401,8 +1401,6 @@ export const en = { output: "Output", }, toolCallGroup: { - title: "Tools", - accessibilityLabel: "Tools, {{count}} calls", editedFiles: { one: "edited {{count}} file", other: "edited {{count}} files", @@ -1527,13 +1525,12 @@ export const en = { description: "Show agent thinking and chain-of-thought blocks fully expanded by default", }, toolCallDetail: { - label: "Tool call detail", - description: "How tool activity appears in agent timelines", - accessibilityLabel: "Select tool call detail ({{value}})", + label: "Tool call display", + description: "How tool calls appear in the timeline", + accessibilityLabel: "Select tool call display ({{value}})", options: { - overview: "Overview", - concise: "Concise", - detailed: "Detailed", + overview: "Summary", + detailed: "Full detail", }, }, language: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 159fce658..1172b96ca 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1432,8 +1432,6 @@ export const es: TranslationResources = { output: "Producción", }, toolCallGroup: { - title: "Herramientas", - accessibilityLabel: "Herramientas, {{count}} llamadas", editedFiles: { one: "editó {{count}} archivo", other: "editó {{count}} archivos", @@ -1561,13 +1559,12 @@ export const es: TranslationResources = { "Mostrar los bloques de pensamiento y razonamiento del agente totalmente expandidos de forma predeterminada", }, toolCallDetail: { - label: "Detalle de llamadas a herramientas", - description: "Cómo aparece la actividad de herramientas en las cronologías del agente", - accessibilityLabel: "Seleccionar detalle de herramientas ({{value}})", + label: "Visualización de llamadas a herramientas", + description: "Cómo aparecen las llamadas a herramientas en la cronología", + accessibilityLabel: "Seleccionar visualización de llamadas a herramientas ({{value}})", options: { overview: "Resumen", - concise: "Conciso", - detailed: "Detallado", + detailed: "Detalle completo", }, }, language: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index cbbbbe05d..9a8b32c4e 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1435,8 +1435,6 @@ export const fr: TranslationResources = { output: "Sortir", }, toolCallGroup: { - title: "Outils", - accessibilityLabel: "Outils, {{count}} appels", editedFiles: { one: "a modifié {{count}} fichier", other: "a modifié {{count}} fichiers", @@ -1563,13 +1561,12 @@ export const fr: TranslationResources = { description: "Afficher le raisonnement de l'agent entièrement développé par défaut", }, toolCallDetail: { - label: "Détail des appels d’outils", - description: "Affichage de l’activité des outils dans la chronologie de l’agent", - accessibilityLabel: "Sélectionner le détail des outils ({{value}})", + label: "Affichage des appels d’outils", + description: "Comment les appels d’outils apparaissent dans la chronologie", + accessibilityLabel: "Sélectionner l’affichage des appels d’outils ({{value}})", options: { - overview: "Vue d’ensemble", - concise: "Concis", - detailed: "Détaillé", + overview: "Résumé", + detailed: "Détails complets", }, }, language: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 823f7d0e8..035970583 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1410,8 +1410,6 @@ export const ja: TranslationResources = { output: "出力", }, toolCallGroup: { - title: "ツール", - accessibilityLabel: "ツール、{{count}}件の呼び出し", editedFiles: { one: "{{count}}個のファイルを編集", other: "{{count}}個のファイルを編集", @@ -1536,13 +1534,12 @@ export const ja: TranslationResources = { description: "デフォルトでAIのエージェント思考・推論ブロックを完全に展開して表示します", }, toolCallDetail: { - label: "ツール呼び出しの詳細", - description: "エージェントのタイムラインでのツール活動の表示方法", - accessibilityLabel: "ツール詳細を選択({{value}})", + label: "ツール呼び出しの表示", + description: "タイムラインでのツール呼び出しの表示方法", + accessibilityLabel: "ツール呼び出しの表示を選択({{value}})", options: { - overview: "概要", - concise: "簡潔", - detailed: "詳細", + overview: "要約", + detailed: "すべての詳細", }, }, language: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 995896fc7..8966fe0cc 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1418,8 +1418,6 @@ export const ptBR: TranslationResources = { output: "Saída", }, toolCallGroup: { - title: "Ferramentas", - accessibilityLabel: "Ferramentas, {{count}} chamadas", editedFiles: { one: "editou {{count}} arquivo", other: "editou {{count}} arquivos", @@ -1546,13 +1544,12 @@ export const ptBR: TranslationResources = { "Mostrar os blocos de pensamento e raciocínio do agente totalmente expandidos por padrão", }, toolCallDetail: { - label: "Detalhe das chamadas de ferramentas", - description: "Como a atividade das ferramentas aparece na linha do tempo do agente", - accessibilityLabel: "Selecionar detalhe das ferramentas ({{value}})", + label: "Exibição de chamadas de ferramentas", + description: "Como as chamadas de ferramentas aparecem na linha do tempo", + accessibilityLabel: "Selecionar exibição de chamadas de ferramentas ({{value}})", options: { - overview: "Visão geral", - concise: "Conciso", - detailed: "Detalhado", + overview: "Resumo", + detailed: "Detalhes completos", }, }, language: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index fb6affc64..bd1ea0172 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1424,8 +1424,6 @@ export const ru: TranslationResources = { output: "Выход", }, toolCallGroup: { - title: "Инструменты", - accessibilityLabel: "Инструменты, вызовов: {{count}}", editedFiles: { one: "изменён {{count}} файл", other: "изменено {{count}} файлов", @@ -1551,13 +1549,12 @@ export const ru: TranslationResources = { "По умолчанию показывать блоки размышлений и логики агента полностью развернутыми", }, toolCallDetail: { - label: "Детализация вызовов инструментов", - description: "Отображение активности инструментов в хронологии агента", - accessibilityLabel: "Выбрать детализацию инструментов ({{value}})", + label: "Отображение вызовов инструментов", + description: "Как вызовы инструментов отображаются на временной шкале", + accessibilityLabel: "Выбрать отображение вызовов инструментов ({{value}})", options: { - overview: "Обзор", - concise: "Кратко", - detailed: "Подробно", + overview: "Сводка", + detailed: "Полная детализация", }, }, language: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 6dd094490..067b10304 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1377,8 +1377,6 @@ export const zhCN: TranslationResources = { output: "输出", }, toolCallGroup: { - title: "工具", - accessibilityLabel: "工具,{{count}} 次调用", editedFiles: { one: "编辑了 {{count}} 个文件", other: "编辑了 {{count}} 个文件", @@ -1503,13 +1501,12 @@ export const zhCN: TranslationResources = { description: "默认情况下完全展开 AI 的思考和推理过程", }, toolCallDetail: { - label: "工具调用详情", - description: "工具活动在智能体时间线中的显示方式", - accessibilityLabel: "选择工具调用详情({{value}})", + label: "工具调用显示", + description: "工具调用在时间线中的显示方式", + accessibilityLabel: "选择工具调用显示方式({{value}})", options: { - overview: "概览", - concise: "简洁", - detailed: "详细", + overview: "摘要", + detailed: "完整详情", }, }, language: { diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 33347130c..b5e2bf16e 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -103,7 +103,6 @@ import { type EnableBuiltInDaemonOption, useEnableBuiltInDaemonOption, } from "@/desktop/hooks/use-enable-built-in-daemon-option"; -import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { buildOpenProjectRoute, buildProjectsSettingsRoute, @@ -1134,11 +1133,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti const isCompactLayout = useIsCompactFormFactor(); const insets = useSafeAreaInsets(); const insetBottomStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]); - const webScrollbarStyle = useWebScrollbarStyle(); - const scrollViewStyle = useMemo( - () => [styles.scrollView, webScrollbarStyle], - [webScrollbarStyle], - ); const hosts = useHosts(); const localServerId = useLocalDaemonServerId(); const sortedHosts = useSortedHosts(hosts, localServerId); @@ -1469,7 +1463,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti return ( - + - + {content} {addHostModals} @@ -1547,7 +1541,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti } leftStyle={desktopStyles.detailLeft} /> - + {content} diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx index 9a0162406..4ae7b2886 100644 --- a/packages/app/src/screens/settings/appearance/appearance-section.tsx +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -214,9 +214,8 @@ function AutoExpandReasoningRow({ value, onChange }: AutoExpandReasoningRowProps const TOOL_CALL_DETAIL_ROW_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [ - "overview", - "concise", "detailed", + "overview", ]; function getToolCallDetailLevelLabel( diff --git a/packages/app/src/screens/startup-splash-screen.tsx b/packages/app/src/screens/startup-splash-screen.tsx index fdb5d6ce9..abf73797f 100644 --- a/packages/app/src/screens/startup-splash-screen.tsx +++ b/packages/app/src/screens/startup-splash-screen.tsx @@ -20,7 +20,6 @@ import { Button } from "@/components/ui/button"; import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { isNative, isWeb } from "@/constants/platform"; -import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; interface StartupSplashScreenProps { @@ -300,15 +299,6 @@ const styles = StyleSheet.create((theme) => ({ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) { const { t } = useTranslation(); const { theme } = useUnistyles(); - const webScrollbarStyle = useWebScrollbarStyle(); - const errorScrollViewStyle = useMemo( - () => [styles.errorScrollView, webScrollbarStyle], - [webScrollbarStyle], - ); - const logsScrollStyle = useMemo( - () => [styles.logsScroll, webScrollbarStyle], - [webScrollbarStyle], - ); const [daemonLogs, setDaemonLogs] = useState(null); const [logsError, setLogsError] = useState(null); const [isLoadingLogs, setIsLoadingLogs] = useState(false); @@ -404,7 +394,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps @@ -424,7 +414,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps diff --git a/packages/app/src/styles/install-web-scrollbar-styles.ts b/packages/app/src/styles/install-web-scrollbar-styles.ts new file mode 100644 index 000000000..fe7cf9289 --- /dev/null +++ b/packages/app/src/styles/install-web-scrollbar-styles.ts @@ -0,0 +1,3 @@ +export function installWebScrollbarStyles(): () => void { + return () => {}; +} diff --git a/packages/app/src/styles/install-web-scrollbar-styles.web.ts b/packages/app/src/styles/install-web-scrollbar-styles.web.ts new file mode 100644 index 000000000..18652c7f4 --- /dev/null +++ b/packages/app/src/styles/install-web-scrollbar-styles.web.ts @@ -0,0 +1,43 @@ +import { + WEB_SCROLLBAR_SIZE_PX, + webScrollbarColor, + webScrollbarThumbColor, + WEB_SCROLLBAR_WIDTH, +} from "@/styles/web-scrollbar"; + +const STYLE_ID = "paseo-web-scrollbar-styles"; + +export function installWebScrollbarStyles(): () => void { + const existingStyle = document.getElementById(STYLE_ID); + if (existingStyle) return () => {}; + + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` +* { + scrollbar-color: ${webScrollbarColor("var(--colors-scrollbar-handle)")}; + scrollbar-width: ${WEB_SCROLLBAR_WIDTH}; +} + +*::-webkit-scrollbar { + width: ${WEB_SCROLLBAR_SIZE_PX}px; + height: ${WEB_SCROLLBAR_SIZE_PX}px; + background: transparent; +} + +*::-webkit-scrollbar-track, +*::-webkit-scrollbar-corner { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: 999px; + background: ${webScrollbarThumbColor("var(--colors-scrollbar-handle)")}; + background-clip: content-box; +} +`; + document.head.append(style); + + return () => style.remove(); +} diff --git a/packages/app/src/styles/web-scrollbar.ts b/packages/app/src/styles/web-scrollbar.ts new file mode 100644 index 000000000..1fd4fbd80 --- /dev/null +++ b/packages/app/src/styles/web-scrollbar.ts @@ -0,0 +1,10 @@ +export const WEB_SCROLLBAR_WIDTH = "thin"; +export const WEB_SCROLLBAR_SIZE_PX = 8; + +export function webScrollbarThumbColor(handleColor: string): string { + return `color-mix(in srgb, ${handleColor} 62%, transparent)`; +} + +export function webScrollbarColor(handleColor: string): string { + return `${webScrollbarThumbColor(handleColor)} transparent`; +} diff --git a/packages/app/src/tool-calls/detail-level/grouping.ts b/packages/app/src/tool-calls/detail-level/grouping.ts new file mode 100644 index 000000000..079158b2d --- /dev/null +++ b/packages/app/src/tool-calls/detail-level/grouping.ts @@ -0,0 +1,228 @@ +import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; +import type { StreamItem, ToolCallItem } from "@/types/stream"; + +export interface ToolCallDescriptor { + detail: ToolCallDetail; + name: string; + status: "executing" | "running" | "completed" | "failed" | "canceled"; + error: unknown; + metadata?: Record; +} + +export interface ToolCallRun { + id: string; + calls: readonly ToolCallItem[]; + latest: ToolCallItem; + isSealed: boolean; +} + +export interface GroupedHistory { + tail: StreamItem[]; + groupsByHostId: Map; + pendingCalls: readonly ToolCallItem[]; +} + +export interface GroupedToolCalls { + tail: StreamItem[]; + head: StreamItem[]; + groupsByHostId: ToolCallGroupLookup; + historyGroupUpdatesByHostId: ToolCallGroupLookup; +} + +export interface ToolCallGroupLookup { + readonly size: number; + get(id: string): TGroup | undefined; + has(id: string): boolean; +} + +const EMPTY_GROUPS = new Map(); + +export function describeToolCall(item: ToolCallItem): ToolCallDescriptor { + if (item.payload.source === "agent") { + const { data } = item.payload; + return { + detail: data.detail, + name: data.name, + status: data.status, + error: data.error, + metadata: data.metadata, + }; + } + + const { data } = item.payload; + return { + detail: { + type: "unknown", + input: data.arguments ?? null, + output: data.result ?? null, + }, + name: data.toolName, + status: data.status, + error: data.error, + }; +} + +export function isGroupableToolCall(item: StreamItem): item is ToolCallItem { + if (item.kind !== "tool_call") { + return false; + } + const descriptor = describeToolCall(item); + return descriptor.detail.type !== "plan" && descriptor.name.trim().toLowerCase() !== "speak"; +} + +function createRun(calls: readonly ToolCallItem[], isSealed: boolean): ToolCallRun { + const first = calls[0]; + const latest = calls.at(-1); + if (!first || !latest) { + throw new Error("Cannot group an empty tool call run"); + } + return { id: first.id, calls, latest, isSealed }; +} + +function createHost(run: ToolCallRun): ToolCallItem { + if (run.calls.length === 1) { + return run.latest; + } + return { ...run.latest, id: run.id }; +} + +function isRunning(call: ToolCallItem): boolean { + const status = describeToolCall(call).status; + return status === "running" || status === "executing"; +} + +function appendRun(input: { + calls: readonly ToolCallItem[]; + isSealed: boolean; + output: StreamItem[]; + groups: Map; + buildGroup: (run: ToolCallRun) => TGroup; +}): void { + if (input.calls.length === 0) { + return; + } + const run = createRun(input.calls, input.isSealed); + const host = createHost(run); + input.output.push(host); + input.groups.set(host.id, input.buildGroup(run)); +} + +export function prepareGroupedHistory(input: { + tail: StreamItem[]; + buildGroup: (run: ToolCallRun) => TGroup; +}): GroupedHistory { + const output: StreamItem[] = []; + const groups = new Map(); + let pending: ToolCallItem[] = []; + + for (const item of input.tail) { + if (isGroupableToolCall(item)) { + pending.push(item); + continue; + } + appendRun({ + calls: pending, + isSealed: true, + output, + groups, + buildGroup: input.buildGroup, + }); + pending = []; + output.push(item); + } + + appendRun({ + calls: pending, + isSealed: true, + output, + groups, + buildGroup: input.buildGroup, + }); + + return { + tail: groups.size > 0 ? output : input.tail, + groupsByHostId: groups, + pendingCalls: pending, + }; +} + +export function groupLiveToolCalls(input: { + history: GroupedHistory; + head: StreamItem[]; + isTurnActive: boolean; + buildGroup: (run: ToolCallRun) => TGroup; +}): GroupedToolCalls { + const head: StreamItem[] = []; + const liveGroups = new Map(); + let pending = [...input.history.pendingCalls]; + let hostPlacement: "history" | "head" | null = pending.length > 0 ? "history" : null; + let pendingIncludesHead = false; + + const flush = (isSealed: boolean) => { + if (pending.length === 0) { + return; + } + const run = createRun(pending, isSealed); + if (hostPlacement === "head") { + head.push(createHost(run)); + } + if (hostPlacement === "head" || pendingIncludesHead || !isSealed) { + liveGroups.set(run.id, input.buildGroup(run)); + } + pending = []; + hostPlacement = null; + pendingIncludesHead = false; + }; + + for (const item of input.head) { + if (isGroupableToolCall(item)) { + if (pending.length === 0) { + hostPlacement = "head"; + } + pending.push(item); + pendingIncludesHead = true; + continue; + } + flush(true); + head.push(item); + } + // Tool calls live in retained tail rather than the streaming head. The agent + // lifecycle snapshot can still be idle while a newly received tool call is + // already running, so its direct timeline status is the authoritative start + // signal. The lifecycle state continues to keep completed calls live between + // sequential tool updates. + const trailingRunIsActive = input.isTurnActive || pending.some(isRunning); + flush(!trailingRunIsActive); + + if (liveGroups.size === 0) { + return { + tail: input.history.tail, + head: input.head, + groupsByHostId: input.history.groupsByHostId, + historyGroupUpdatesByHostId: EMPTY_GROUPS, + }; + } + if (input.history.groupsByHostId.size === 0) { + return { + tail: input.history.tail, + head, + groupsByHostId: liveGroups, + historyGroupUpdatesByHostId: EMPTY_GROUPS, + }; + } + const groupsByHostId = new Map(input.history.groupsByHostId); + let historyGroupUpdatesByHostId: Map | null = null; + for (const [id, group] of liveGroups) { + groupsByHostId.set(id, group); + if (input.history.groupsByHostId.has(id)) { + historyGroupUpdatesByHostId ??= new Map(); + historyGroupUpdatesByHostId.set(id, group); + } + } + return { + tail: input.history.tail, + head, + groupsByHostId, + historyGroupUpdatesByHostId: historyGroupUpdatesByHostId ?? EMPTY_GROUPS, + }; +} diff --git a/packages/app/src/tool-calls/detail-level/overview/model.ts b/packages/app/src/tool-calls/detail-level/overview/model.ts new file mode 100644 index 000000000..31faa23f2 --- /dev/null +++ b/packages/app/src/tool-calls/detail-level/overview/model.ts @@ -0,0 +1,92 @@ +import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization"; +import { describeToolCall, type ToolCallRun } from "../grouping"; + +const DIRECT_PASEO_TOOL_PREFIX = "paseo_"; +const DIRECT_SEARCH_TOOL_SUFFIX_PATTERN = /(?:^|[_.:/])(?:web_search|llm_context)$/; + +export interface OverviewSummary { + editedFileCount: number; + commandCount: number; + readFileCount: number; + searchCount: number; + otherToolCount: number; + paseoCallCount: number; +} + +export interface OverviewToolCallGroup { + mode: "overview"; + run: ToolCallRun; + summary: OverviewSummary; + failedCount: number; + isLoading: boolean; +} + +export type OverviewHeader = { kind: "latest"; call: ToolCallRun["latest"] } | { kind: "summary" }; + +export function resolveOverviewHeader( + group: OverviewToolCallGroup, + expanded: boolean, +): OverviewHeader { + if (group.run.calls.length === 1) { + return { kind: "latest", call: group.run.latest }; + } + if (expanded || group.run.isSealed) { + return { kind: "summary" }; + } + return { kind: "latest", call: group.run.latest }; +} + +function isPaseoCall(name: string, normalizedName: string): boolean { + return isPaseoToolName(name) || normalizedName.startsWith(DIRECT_PASEO_TOOL_PREFIX); +} + +function isSearchCall(name: string): boolean { + return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name); +} + +export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup { + const editedFiles = new Set(); + const readFiles = new Set(); + let failedCount = 0; + let isLoading = false; + let commandCount = 0; + let searchCount = 0; + let otherToolCount = 0; + let paseoCallCount = 0; + + for (const call of run.calls) { + const descriptor = describeToolCall(call); + const normalizedName = descriptor.name.trim().toLowerCase(); + failedCount += descriptor.status === "failed" ? 1 : 0; + isLoading ||= descriptor.status === "running" || descriptor.status === "executing"; + if (isPaseoCall(descriptor.name, normalizedName)) { + paseoCallCount += 1; + } else if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") { + editedFiles.add(descriptor.detail.filePath); + } else if (descriptor.detail.type === "shell") { + commandCount += 1; + } else if (descriptor.detail.type === "read") { + readFiles.add(descriptor.detail.filePath); + } else if (descriptor.detail.type === "search" || isSearchCall(normalizedName)) { + searchCount += 1; + } else { + otherToolCount += 1; + } + } + + const summary = { + editedFileCount: editedFiles.size, + commandCount, + readFileCount: readFiles.size, + searchCount, + otherToolCount, + paseoCallCount, + }; + return { + mode: "overview", + run, + failedCount, + isLoading, + summary, + }; +} diff --git a/packages/app/src/tool-calls/detail-level/overview/view.tsx b/packages/app/src/tool-calls/detail-level/overview/view.tsx new file mode 100644 index 000000000..c2cc68fc7 --- /dev/null +++ b/packages/app/src/tool-calls/detail-level/overview/view.tsx @@ -0,0 +1,173 @@ +import { memo, useCallback, useMemo, useRef, type ReactNode } from "react"; +import { ScrollView } from "react-native"; +import { useTranslation } from "react-i18next"; +import { Wrench } from "lucide-react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { ExpandableBadge } from "@/components/message"; +import { ToolCallDetailsContent } from "@/components/tool-call-details"; +import { useToolCallSheet } from "@/components/tool-call-sheet"; +import { buildToolCallPresentation } from "@/tool-calls/presentation"; +import { resolveToolCallIcon } from "@/utils/tool-call-icon"; +import { describeToolCall } from "../grouping"; +import { resolveOverviewHeader, type OverviewSummary, type OverviewToolCallGroup } from "./model"; + +interface OverviewGroupProps { + group: OverviewToolCallGroup; + expanded: boolean; + isCompact: boolean; + isLastInSequence: boolean; + onExpandedChange: (groupId: string, expanded: boolean) => void; + cwd?: string; + onOpenFilePath?: (filePath: string) => void; + children: ReactNode; +} + +const TOOL_CALL_GROUP_MAX_HEIGHT = 400; + +function joinSummaryParts(parts: string[], conjunction: string): string { + if (parts.length === 0) { + return ""; + } + let joined = parts[0] ?? ""; + if (parts.length === 2) { + joined = `${parts[0]} ${conjunction} ${parts[1]}`; + } else if (parts.length > 2) { + joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`; + } + const firstCharacter = joined[0]; + return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined; +} + +function useOverviewSummary(summary: OverviewSummary): string { + const { t } = useTranslation(); + return useMemo(() => { + const parts: string[] = []; + const entries = [ + [summary.editedFileCount, "toolCallGroup.editedFiles"], + [summary.commandCount, "toolCallGroup.commands"], + [summary.readFileCount, "toolCallGroup.readFiles"], + [summary.searchCount, "toolCallGroup.searches"], + [summary.otherToolCount, "toolCallGroup.otherTools"], + [summary.paseoCallCount, "toolCallGroup.paseoCalls"], + ] as const; + for (const [count, key] of entries) { + if (count > 0) { + parts.push(t(`${key}.${count === 1 ? "one" : "other"}`, { count })); + } + } + return joinSummaryParts(parts, t("toolCallGroup.and")); + }, [summary, t]); +} + +export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView({ + group, + expanded, + isCompact, + isLastInSequence, + onExpandedChange, + cwd, + onOpenFilePath, + children, +}: OverviewGroupProps) { + const { t } = useTranslation(); + const { openToolCall } = useToolCallSheet(); + const scrollRef = useRef(null); + const aggregateSummary = useOverviewSummary(group.summary); + const header = resolveOverviewHeader(group, expanded); + const latestCall = header.kind === "latest" ? header.call : group.run.latest; + const latest = useMemo(() => { + const descriptor = describeToolCall(latestCall); + return { + detail: descriptor.detail, + presentation: buildToolCallPresentation({ + toolName: descriptor.name, + status: descriptor.status, + error: descriptor.error, + detail: descriptor.detail, + metadata: descriptor.metadata, + cwd, + resolveIcon: resolveToolCallIcon, + }), + }; + }, [cwd, latestCall]); + const showsLatest = header.kind === "latest"; + const opensSingleCallSheet = isCompact && group.run.calls.length === 1; + const openLatestFile = useMemo(() => { + const path = latest.presentation.openFilePath; + if (!showsLatest || !path || !onOpenFilePath) { + return undefined; + } + return () => onOpenFilePath(path); + }, [showsLatest, latest.presentation.openFilePath, onOpenFilePath]); + const failedSummary = + group.failedCount > 0 ? t("toolCallGroup.failed", { count: group.failedCount }) : undefined; + const scrollToLatest = useCallback(() => { + scrollRef.current?.scrollToEnd({ animated: false }); + }, []); + const toggle = useCallback(() => { + if (opensSingleCallSheet) { + openToolCall({ + displayName: latest.presentation.displayName, + summary: latest.presentation.summary, + detail: latest.detail, + errorText: latest.presentation.errorText, + icon: latest.presentation.icon, + showLoadingSkeleton: latest.presentation.isLoadingDetails, + }); + return; + } + onExpandedChange(group.run.id, !expanded); + }, [expanded, group.run.id, latest, onExpandedChange, openToolCall, opensSingleCallSheet]); + const renderDetails = useCallback(() => { + if (group.run.calls.length === 1) { + return ( + + ); + } + return ( + + {children} + + ); + }, [children, group.run.calls.length, latest, scrollToLatest]); + const canExpand = group.run.calls.length > 1 || latest.presentation.canOpenDetails; + + return ( + 0} + isExpanded={opensSingleCallSheet ? false : expanded} + isLastInSequence={isLastInSequence} + onToggle={canExpand ? toggle : undefined} + onOpenFile={openLatestFile} + renderDetails={canExpand && !opensSingleCallSheet ? renderDetails : undefined} + borderlessWhenExpanded + /> + ); +}); + +const styles = StyleSheet.create((theme) => ({ + scroll: { + maxHeight: TOOL_CALL_GROUP_MAX_HEIGHT, + }, + content: { + paddingTop: theme.spacing[1], + paddingHorizontal: 13, + }, +})); diff --git a/packages/app/src/tool-calls/detail-level/projection.test.ts b/packages/app/src/tool-calls/detail-level/projection.test.ts new file mode 100644 index 000000000..d8b56d46b --- /dev/null +++ b/packages/app/src/tool-calls/detail-level/projection.test.ts @@ -0,0 +1,501 @@ +import { describe, expect, it } from "vitest"; +import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; +import type { StreamItem, ToolCallItem } from "@/types/stream"; +import { + prepareToolCallHistory, + projectToolCallDetailLevel, + type PreparedToolCallHistory, + type ToolCallDetailLevel, +} from "./projection"; +import { resolveOverviewHeader } from "./overview/model"; + +type AssistantMessageItem = Extract; + +function toolCall( + id: string, + detail: ToolCallDetail, + options: { + name?: string; + status?: "running" | "completed" | "failed" | "canceled"; + } = {}, +): ToolCallItem { + return { + kind: "tool_call", + id, + timestamp: new Date(`2026-01-01T00:00:${id.padStart(2, "0")}.000Z`), + payload: { + source: "agent", + data: { + provider: "claude", + callId: id, + name: options.name ?? detail.type, + status: options.status ?? "completed", + error: options.status === "failed" ? "boom" : null, + detail, + }, + }, + }; +} + +function assistant(id: string): AssistantMessageItem { + return { + kind: "assistant_message", + id, + text: id, + timestamp: new Date("2026-01-01T00:01:00.000Z"), + }; +} + +function project(input: { + level: ToolCallDetailLevel; + tail?: StreamItem[]; + head?: StreamItem[]; + isTurnActive?: boolean; + preparedHistory?: PreparedToolCallHistory | null; +}) { + const tail = input.tail ?? []; + return projectToolCallDetailLevel({ + level: input.level, + tail, + head: input.head ?? [], + preparedHistory: input.preparedHistory ?? prepareToolCallHistory(input.level, tail), + isTurnActive: input.isTurnActive ?? false, + }); +} + +describe("tool call detail-level projection", () => { + it("passes detailed timelines through without grouping work", () => { + const tail = [toolCall("1", { type: "shell", command: "one" })]; + const head = [toolCall("2", { type: "shell", command: "two" })]; + + const prepared = prepareToolCallHistory("detailed", tail); + const result = project({ level: "detailed", tail, head, preparedHistory: prepared }); + + expect(prepared).toBeNull(); + expect(result.tail).toBe(tail); + expect(result.head).toBe(head); + expect(result.groupsByHostId.size).toBe(0); + }); + + it("keeps one stable overview host as a run grows", () => { + const firstCall = toolCall("1", { type: "shell", command: "one" }); + const secondCall = toolCall("2", { type: "read", filePath: "/repo/a.ts" }); + const prepared = prepareToolCallHistory("overview", []); + + const single = project({ + level: "overview", + head: [firstCall], + isTurnActive: true, + preparedHistory: prepared, + }); + expect(single.head).toEqual([firstCall]); + expect(single.groupsByHostId.get(firstCall.id)?.run).toMatchObject({ + calls: [firstCall], + latest: firstCall, + isSealed: false, + }); + + const grouped = project({ + level: "overview", + head: [firstCall, secondCall], + isTurnActive: true, + preparedHistory: prepared, + }); + expect(grouped.head).toEqual([ + expect.objectContaining({ id: firstCall.id, timestamp: secondCall.timestamp }), + ]); + expect(grouped.groupsByHostId.get(firstCall.id)?.run).toMatchObject({ + calls: [firstCall, secondCall], + latest: secondCall, + isSealed: false, + }); + }); + + it("shows the latest call while collapsed and the summary while expanded", () => { + const calls = [ + toolCall("1", { type: "shell", command: "one" }), + toolCall("2", { type: "read", filePath: "/repo/a.ts" }, { status: "running" }), + ]; + const result = project({ + level: "overview", + head: calls, + isTurnActive: true, + }); + const group = result.groupsByHostId.get("1"); + if (!group) { + throw new Error("Expected an overview group"); + } + + expect(resolveOverviewHeader(group, false)).toEqual({ kind: "latest", call: calls[1] }); + expect(resolveOverviewHeader(group, true)).toEqual({ kind: "summary" }); + }); + + it("keeps a parallel group loading while any call is still running", () => { + const calls = [ + toolCall("1", { type: "shell", command: "slow" }, { status: "running" }), + toolCall("2", { type: "shell", command: "done" }), + ]; + const result = project({ level: "overview", head: calls, isTurnActive: true }); + + expect(result.groupsByHostId.get("1")?.isLoading).toBe(true); + }); + + it("keeps a one-call run presented as its tool call", () => { + const call = toolCall("1", { type: "shell", command: "one" }); + const result = project({ level: "overview", head: [call] }); + const group = result.groupsByHostId.get(call.id); + if (!group) { + throw new Error("Expected an overview group"); + } + + expect(resolveOverviewHeader(group, false)).toEqual({ kind: "latest", call }); + expect(resolveOverviewHeader(group, true)).toEqual({ kind: "latest", call }); + }); + + it("keeps an active overview group on its latest call until a visible boundary arrives", () => { + const calls = [ + toolCall("1", { type: "shell", command: "one" }), + toolCall("2", { type: "read", filePath: "/repo/a.ts" }), + toolCall("3", { type: "read", filePath: "/repo/b.ts" }), + toolCall("4", { type: "edit", filePath: "/repo/a.ts" }), + ]; + const prepared = prepareToolCallHistory("overview", []); + const active = project({ + level: "overview", + head: calls, + isTurnActive: true, + preparedHistory: prepared, + }); + const activeGroup = active.groupsByHostId.get("1"); + + expect(activeGroup).toMatchObject({ + mode: "overview", + run: { id: "1", latest: calls[3], isSealed: false }, + }); + expect(resolveOverviewHeader(activeGroup!, false)).toEqual({ + kind: "latest", + call: calls[3], + }); + + const boundary = assistant("answer"); + const sealed = project({ + level: "overview", + head: [...calls, boundary], + isTurnActive: true, + preparedHistory: prepared, + }); + expect(sealed.groupsByHostId.get("1")).toMatchObject({ + mode: "overview", + run: { latest: calls[3], isSealed: true }, + summary: { editedFileCount: 1, readFileCount: 2, commandCount: 1 }, + }); + expect(resolveOverviewHeader(sealed.groupsByHostId.get("1")!, false)).toEqual({ + kind: "summary", + }); + }); + + it("keeps a running overview header live before the agent lifecycle catches up", () => { + const calls = ["1", "2", "3", "4"].map((id) => + toolCall(id, { type: "shell", command: id }, { status: "running" }), + ); + + const result = project({ + level: "overview", + tail: calls, + isTurnActive: false, + }); + + expect(result.groupsByHostId.get("1")).toMatchObject({ + run: { latest: calls[3], isSealed: false }, + }); + expect(resolveOverviewHeader(result.groupsByHostId.get("1")!, false)).toEqual({ + kind: "latest", + call: calls[3], + }); + }); + + it("seals the trailing overview group only when the turn ends", () => { + const calls = ["1", "2", "3", "4"].map((id) => toolCall(id, { type: "shell", command: id })); + const prepared = prepareToolCallHistory("overview", []); + + const betweenCalls = project({ + level: "overview", + head: calls, + isTurnActive: true, + preparedHistory: prepared, + }); + const nextCall = toolCall("5", { type: "read", filePath: "/repo/a.ts" }); + const continued = project({ + level: "overview", + head: [...calls, nextCall], + isTurnActive: true, + preparedHistory: prepared, + }); + const ended = project({ + level: "overview", + head: [...calls, nextCall], + isTurnActive: false, + preparedHistory: prepared, + }); + + expect(betweenCalls.groupsByHostId.get("1")?.run.isSealed).toBe(false); + expect(resolveOverviewHeader(betweenCalls.groupsByHostId.get("1")!, false)).toEqual({ + kind: "latest", + call: calls[3], + }); + expect(continued.groupsByHostId.get("1")?.run).toMatchObject({ + latest: nextCall, + isSealed: false, + }); + expect(resolveOverviewHeader(continued.groupsByHostId.get("1")!, false)).toEqual({ + kind: "latest", + call: nextCall, + }); + expect(ended.groupsByHostId.get("1")?.run.isSealed).toBe(true); + expect(resolveOverviewHeader(ended.groupsByHostId.get("1")!, false)).toEqual({ + kind: "summary", + }); + }); + + it("builds overview summaries without category-specific presentation data", () => { + const calls = [ + toolCall("1", { type: "read", filePath: "/repo/src/a.ts" }), + toolCall("2", { type: "read", filePath: "/repo/src/b.ts" }), + toolCall("3", { type: "shell", command: "npm test" }), + toolCall("4", { type: "edit", filePath: "/repo/src/a.ts" }, { status: "failed" }), + ]; + + const overview = project({ level: "overview", head: calls }); + + expect(overview.groupsByHostId.get("1")).toEqual({ + mode: "overview", + run: expect.any(Object), + failedCount: 1, + isLoading: false, + summary: { + editedFileCount: 1, + commandCount: 1, + readFileCount: 2, + searchCount: 0, + otherToolCount: 0, + paseoCallCount: 0, + }, + }); + }); + + it("distinguishes reads, searches, and other tools in overview", () => { + const calls = [ + toolCall("1", { type: "read", filePath: "/repo/src/a.ts" }), + toolCall("2", { type: "read", filePath: "C:\\repo\\src\\beta.ts" }), + toolCall("3", { type: "fetch", url: "https://github.com/org/repo" }), + toolCall( + "4", + { type: "search", query: "paseo", toolName: "web_search" }, + { status: "failed" }, + ), + toolCall("5", { type: "fetch", url: "not a url" }), + ]; + + const result = project({ level: "overview", head: calls }); + + expect(result.groupsByHostId.get("1")).toMatchObject({ + failedCount: 1, + summary: { + editedFileCount: 0, + commandCount: 0, + readFileCount: 2, + searchCount: 1, + otherToolCount: 2, + }, + }); + }); + + it("counts unique edited files and every shell command in overview", () => { + const calls = [ + toolCall("1", { type: "edit", filePath: "/repo/a.ts" }), + toolCall("2", { type: "edit", filePath: "/repo/a.ts" }), + toolCall("3", { type: "write", filePath: "/repo/b.ts" }), + toolCall("4", { type: "shell", command: "npm test" }), + toolCall("5", { type: "shell", command: "npm run lint" }), + toolCall("6", { type: "read", filePath: "/repo/c.ts" }), + ]; + + const result = project({ level: "overview", head: calls }); + + expect(result.groupsByHostId.get("1")).toMatchObject({ + summary: { + editedFileCount: 2, + commandCount: 2, + readFileCount: 1, + otherToolCount: 0, + }, + }); + }); + + it("counts Paseo calls separately from other tools", () => { + const calls = [ + toolCall("1", { type: "unknown", input: null, output: null }, { name: "paseo.list_agents" }), + toolCall( + "2", + { type: "unknown", input: null, output: null }, + { name: "mcp__paseo__list_worktrees" }, + ), + toolCall("3", { type: "fetch", url: "https://paseo.sh" }), + toolCall("4", { type: "fetch", url: "https://github.com/getpaseo" }), + ]; + + const result = project({ level: "overview", head: calls }); + + expect(result.groupsByHostId.get("1")).toMatchObject({ + summary: { otherToolCount: 2, paseoCallCount: 2 }, + }); + }); + + it("classifies direct Brave search and Paseo runtime tool names", () => { + const unknownDetail = { type: "unknown" as const, input: null, output: null }; + const calls = [ + toolCall("1", unknownDetail, { name: "brave-search_brave_web_search" }), + toolCall("2", unknownDetail, { name: "brave-search_brave_llm_context" }), + toolCall("3", unknownDetail, { name: "paseo_list_providers" }), + toolCall("4", unknownDetail, { name: "paseo_list_worktrees" }), + toolCall("5", unknownDetail, { name: "paseo_list_worktrees" }), + toolCall("6", unknownDetail, { name: "mcp__exa__web_search" }), + ]; + + const result = project({ level: "overview", head: calls }); + + expect(result.groupsByHostId.get("1")).toMatchObject({ + summary: { searchCount: 3, otherToolCount: 0, paseoCallCount: 3 }, + }); + }); + + it("reuses prepared history and sealed group models across live-head updates", () => { + const historicalCalls = ["1", "2", "3", "4"].map((id) => + toolCall(id, { type: "shell", command: id }), + ); + const tail = [...historicalCalls, assistant("boundary")]; + const prepared = prepareToolCallHistory("overview", tail); + if (!prepared) { + throw new Error("Overview history must be prepared"); + } + expect(prepared.grouped.tail).toEqual([ + expect.objectContaining({ id: "1", timestamp: historicalCalls[3]?.timestamp }), + tail[4], + ]); + const first = project({ + level: "overview", + tail, + head: [toolCall("5", { type: "read", filePath: "/repo/a.ts" })], + isTurnActive: true, + preparedHistory: prepared, + }); + const second = project({ + level: "overview", + tail, + head: [ + toolCall("5", { type: "read", filePath: "/repo/a.ts" }), + toolCall("6", { type: "read", filePath: "/repo/b.ts" }), + ], + isTurnActive: true, + preparedHistory: prepared, + }); + + expect(first.tail).toBe(prepared.grouped.tail); + expect(second.tail).toBe(prepared.grouped.tail); + expect(first.groupsByHostId.get("1")).toBe(prepared.grouped.groupsByHostId.get("1")); + expect(second.groupsByHostId.get("1")).toBe(prepared.grouped.groupsByHostId.get("1")); + expect(first.historyGroupUpdatesByHostId.size).toBe(0); + expect(second.historyGroupUpdatesByHostId).toBe(first.historyGroupUpdatesByHostId); + expect(second.groupsByHostId.get("5")?.run.calls).toHaveLength(2); + }); + + it("preserves projected history identity during assistant-only head updates", () => { + const trailingCalls = [ + toolCall("1", { type: "shell", command: "one" }), + toolCall("2", { type: "read", filePath: "/repo/a.ts" }), + ]; + const tail = [assistant("before"), ...trailingCalls]; + const prepared = prepareToolCallHistory("overview", tail); + if (!prepared) { + throw new Error("Overview history must be prepared"); + } + + const firstHead = [assistant("answer")]; + const secondHead = [{ ...firstHead[0], text: "answer grows" }]; + const first = project({ + level: "overview", + tail, + head: firstHead, + isTurnActive: true, + preparedHistory: prepared, + }); + const second = project({ + level: "overview", + tail, + head: secondHead, + isTurnActive: true, + preparedHistory: prepared, + }); + + expect(first.tail).toBe(prepared.grouped.tail); + expect(second.tail).toBe(prepared.grouped.tail); + expect(first.groupsByHostId).toBe(prepared.grouped.groupsByHostId); + expect(second.groupsByHostId).toBe(prepared.grouped.groupsByHostId); + expect(first.historyGroupUpdatesByHostId.size).toBe(0); + expect(second.historyGroupUpdatesByHostId).toBe(first.historyGroupUpdatesByHostId); + }); + + it("forms one group across the retained-history and live-head boundary", () => { + const tail = [ + assistant("before"), + toolCall("1", { type: "shell", command: "one" }), + toolCall("2", { type: "shell", command: "two" }), + ]; + const head = [ + toolCall("3", { type: "read", filePath: "/repo/a.ts" }), + toolCall("4", { type: "edit", filePath: "/repo/a.ts" }, { status: "running" }), + ]; + + const result = project({ level: "overview", tail, head, isTurnActive: true }); + + expect(result.tail).toEqual([ + tail[0], + expect.objectContaining({ id: "1", timestamp: tail[2]?.timestamp }), + ]); + expect(result.head).toEqual([]); + expect(result.groupsByHostId.get("1")?.run).toMatchObject({ + calls: [...tail.slice(1), ...head], + latest: head[1], + isSealed: false, + }); + expect(result.historyGroupUpdatesByHostId.get("1")).toBe(result.groupsByHostId.get("1")); + }); + + it("keeps a trailing history-only group in the retained segment", () => { + const tail = ["1", "2", "3", "4"].map((id) => toolCall(id, { type: "shell", command: id })); + + const result = project({ level: "overview", tail, isTurnActive: false }); + + expect(result.tail).toEqual([ + expect.objectContaining({ id: "1", timestamp: tail[3]?.timestamp }), + ]); + expect(result.head).toEqual([]); + expect(result.groupsByHostId.get("1")?.run.isSealed).toBe(true); + }); + + it("hosts single calls while leaving plans and spoken messages ungrouped", () => { + const singleCall = toolCall("1", { type: "shell", command: "one" }); + const plan = toolCall("2", { type: "plan", text: "Plan" }); + const speak = toolCall( + "3", + { type: "unknown", input: "Hello", output: null }, + { name: "speak" }, + ); + + const result = project({ level: "overview", head: [singleCall, plan, speak] }); + + expect(result.head).toEqual([singleCall, plan, speak]); + expect(result.groupsByHostId.get(singleCall.id)?.run.calls).toEqual([singleCall]); + expect(result.groupsByHostId.size).toBe(1); + }); +}); diff --git a/packages/app/src/tool-calls/detail-level/projection.ts b/packages/app/src/tool-calls/detail-level/projection.ts new file mode 100644 index 000000000..1740e8500 --- /dev/null +++ b/packages/app/src/tool-calls/detail-level/projection.ts @@ -0,0 +1,60 @@ +import type { StreamItem } from "@/types/stream"; +import type { ToolCallDetailLevel } from "@/hooks/use-settings/storage"; +import { + groupLiveToolCalls, + prepareGroupedHistory, + type GroupedHistory, + type GroupedToolCalls, +} from "./grouping"; +import { buildOverviewGroup, type OverviewToolCallGroup } from "./overview/model"; + +export type { ToolCallDetailLevel } from "@/hooks/use-settings/storage"; +export type ToolCallDetailGroup = OverviewToolCallGroup; + +export interface PreparedToolCallHistory { + mode: "overview"; + grouped: GroupedHistory; +} + +export interface ToolCallDetailProjection extends GroupedToolCalls {} + +const EMPTY_TOOL_CALL_GROUPS = new Map(); + +export function prepareToolCallHistory( + level: ToolCallDetailLevel, + tail: StreamItem[], +): PreparedToolCallHistory | null { + if (level === "detailed") { + return null; + } + return { + mode: "overview", + grouped: prepareGroupedHistory({ tail, buildGroup: buildOverviewGroup }), + }; +} + +export function projectToolCallDetailLevel(input: { + level: ToolCallDetailLevel; + tail: StreamItem[]; + head: StreamItem[]; + preparedHistory: PreparedToolCallHistory | null; + isTurnActive: boolean; +}): ToolCallDetailProjection { + if (input.level === "detailed") { + return { + tail: input.tail, + head: input.head, + groupsByHostId: EMPTY_TOOL_CALL_GROUPS, + historyGroupUpdatesByHostId: EMPTY_TOOL_CALL_GROUPS, + }; + } + if (!input.preparedHistory || input.preparedHistory.mode !== input.level) { + throw new Error(`Missing prepared ${input.level} tool call history`); + } + return groupLiveToolCalls({ + history: input.preparedHistory.grouped, + head: input.head, + isTurnActive: input.isTurnActive, + buildGroup: buildOverviewGroup, + }); +} diff --git a/packages/app/src/tool-calls/grouping.test.ts b/packages/app/src/tool-calls/grouping.test.ts deleted file mode 100644 index e88a28edc..000000000 --- a/packages/app/src/tool-calls/grouping.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; -import type { StreamItem, ToolCallItem } from "@/types/stream"; -import { compactToolCallRuns } from "./grouping"; - -function toolCall( - id: string, - detail: ToolCallDetail, - options: { - name?: string; - status?: "running" | "completed" | "failed" | "canceled"; - } = {}, -): ToolCallItem { - return { - kind: "tool_call", - id, - timestamp: new Date(`2026-01-01T00:00:${id.padStart(2, "0")}.000Z`), - payload: { - source: "agent", - data: { - provider: "claude", - callId: id, - name: options.name ?? detail.type, - status: options.status ?? "completed", - error: options.status === "failed" ? "boom" : null, - detail, - }, - }, - }; -} - -function assistant(id: string): StreamItem { - return { - kind: "assistant_message", - id, - text: id, - timestamp: new Date("2026-01-01T00:01:00.000Z"), - }; -} - -describe("compactToolCallRuns", () => { - it("preserves the original arrays when compaction is disabled", () => { - const tail = [toolCall("1", { type: "shell", command: "one" })]; - const head = [toolCall("2", { type: "shell", command: "two" })]; - - const result = compactToolCallRuns({ tail, head, enabled: false }); - - expect(result.tail).toBe(tail); - expect(result.head).toBe(head); - expect(result.groupsByHostId.size).toBe(0); - }); - - it("replaces four contiguous calls with a stable first-call host and latest timestamp", () => { - const calls = [ - toolCall("1", { type: "shell", command: "one" }), - toolCall("2", { type: "shell", command: "two" }), - toolCall("3", { type: "read", filePath: "/repo/src/a.ts" }), - toolCall("4", { type: "edit", filePath: "/repo/src/a.ts" }), - ]; - - const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); - - expect(result.tail).toHaveLength(1); - expect(result.tail[0]).toMatchObject({ id: "1", timestamp: calls[3]?.timestamp }); - expect(result.head).toEqual([]); - expect(result.groupsByHostId.get("1")?.id).toBe("1"); - expect(result.groupsByHostId.get("1")?.calls).toEqual(calls); - expect(result.groupsByHostId.get("1")).toMatchObject({ - editedFileCount: 1, - commandCount: 2, - readFileCount: 1, - otherToolCount: 0, - categories: [ - { key: "shell", label: "Shell", callCount: 2, resources: [] }, - { key: "read", label: "Read", callCount: 1, resources: ["a.ts"] }, - { key: "edit", label: "Edit", callCount: 1, resources: ["a.ts"] }, - ], - }); - - const nextCall = toolCall("5", { type: "shell", command: "five" }, { status: "running" }); - const updated = compactToolCallRuns({ - tail: [...calls, nextCall], - head: [], - enabled: true, - }); - expect(updated.tail[0]).toMatchObject({ id: "1", timestamp: nextCall.timestamp }); - expect(updated.groupsByHostId.get("1")?.id).toBe("1"); - }); - - it("does not compact short runs and stops at visible content boundaries", () => { - const firstRun = [ - toolCall("1", { type: "shell", command: "one" }), - toolCall("2", { type: "shell", command: "two" }), - toolCall("3", { type: "shell", command: "three" }), - ]; - const boundary = assistant("assistant"); - const secondRun = [ - toolCall("4", { type: "read", filePath: "/repo/a.ts" }), - toolCall("5", { type: "read", filePath: "/repo/b.ts" }), - toolCall("6", { type: "read", filePath: "/repo/c.ts" }), - toolCall("7", { type: "read", filePath: "/repo/d.ts" }), - ]; - - const result = compactToolCallRuns({ - tail: [...firstRun, boundary, ...secondRun], - head: [], - enabled: true, - }); - - expect(result.tail.slice(0, -1)).toEqual([...firstRun, boundary]); - expect(result.tail.at(-1)).toMatchObject({ id: "4", timestamp: secondRun[3]?.timestamp }); - expect([...result.groupsByHostId.keys()]).toEqual(["4"]); - }); - - it("forms one group across the history and live-head boundary", () => { - const tail = [ - assistant("assistant"), - toolCall("1", { type: "shell", command: "one" }), - toolCall("2", { type: "shell", command: "two" }), - ]; - const head = [ - toolCall("3", { type: "read", filePath: "/repo/a.ts" }), - toolCall("4", { type: "edit", filePath: "/repo/a.ts" }, { status: "running" }), - ]; - - const result = compactToolCallRuns({ tail, head, enabled: true }); - - expect(result.tail).toEqual([tail[0]]); - expect(result.head).toHaveLength(1); - expect(result.head[0]).toMatchObject({ id: "1", timestamp: head[1]?.timestamp }); - expect(result.groupsByHostId.get("1")?.calls).toEqual([...tail.slice(1), ...head]); - expect(result.groupsByHostId.get("1")?.isRunning).toBe(true); - }); - - it("separates reads and searches from other tools", () => { - const calls = [ - toolCall("1", { type: "read", filePath: "/repo/src/a.ts" }), - toolCall("2", { type: "read", filePath: "C:\\repo\\src\\beta.ts" }), - toolCall("3", { type: "fetch", url: "https://github.com/org/repo" }), - toolCall( - "4", - { type: "search", query: "paseo", toolName: "web_search" }, - { status: "failed" }, - ), - toolCall("5", { type: "fetch", url: "not a url" }), - ]; - - const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); - const group = result.groupsByHostId.get("1"); - - expect(group).toMatchObject({ - failedCount: 1, - isRunning: false, - editedFileCount: 0, - commandCount: 0, - readFileCount: 2, - searchCount: 1, - otherToolCount: 2, - }); - }); - - it("counts unique edited files while counting each shell command", () => { - const calls = [ - toolCall("1", { type: "edit", filePath: "/repo/a.ts" }), - toolCall("2", { type: "edit", filePath: "/repo/a.ts" }), - toolCall("3", { type: "write", filePath: "/repo/b.ts" }), - toolCall("4", { type: "shell", command: "npm test" }), - toolCall("5", { type: "shell", command: "npm run lint" }), - toolCall("6", { type: "read", filePath: "/repo/c.ts" }), - ]; - - const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); - - expect(result.groupsByHostId.get("1")).toMatchObject({ - editedFileCount: 2, - commandCount: 2, - readFileCount: 1, - otherToolCount: 0, - }); - }); - - it("counts Paseo calls separately from other tools", () => { - const calls = [ - toolCall("1", { type: "unknown", input: null, output: null }, { name: "paseo.list_agents" }), - toolCall( - "2", - { type: "unknown", input: null, output: null }, - { name: "mcp__paseo__list_worktrees" }, - ), - toolCall("3", { type: "fetch", url: "https://paseo.sh" }), - toolCall("4", { type: "fetch", url: "https://github.com/getpaseo" }), - ]; - - const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); - - expect(result.groupsByHostId.get("1")).toMatchObject({ - otherToolCount: 2, - paseoCallCount: 2, - }); - }); - - it("classifies direct Brave and Paseo runtime tool names", () => { - const unknownDetail = { type: "unknown" as const, input: null, output: null }; - const calls = [ - toolCall("1", unknownDetail, { name: "brave-search_brave_web_search" }), - toolCall("2", unknownDetail, { name: "brave-search_brave_llm_context" }), - toolCall("3", unknownDetail, { name: "paseo_list_providers" }), - toolCall("4", unknownDetail, { name: "paseo_list_worktrees" }), - toolCall("5", unknownDetail, { name: "paseo_list_worktrees" }), - toolCall("6", unknownDetail, { name: "mcp__exa__web_search" }), - ]; - - const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); - - expect(result.groupsByHostId.get("1")).toMatchObject({ - searchCount: 3, - otherToolCount: 0, - paseoCallCount: 3, - }); - }); - - it("keeps plan and spoken-message calls outside compact groups", () => { - const shellCalls = ["1", "2", "3", "4"].map((id) => - toolCall(id, { type: "shell", command: id }), - ); - const plan = toolCall("5", { type: "plan", text: "Plan" }); - const speak = toolCall( - "6", - { type: "unknown", input: "Hello", output: null }, - { name: "speak" }, - ); - - const result = compactToolCallRuns({ - tail: [...shellCalls, plan, speak], - head: [], - enabled: true, - }); - - expect(result.tail[0]).toMatchObject({ id: "1", timestamp: shellCalls[3]?.timestamp }); - expect(result.tail.slice(1)).toEqual([plan, speak]); - expect(result.groupsByHostId.get("1")?.calls).toEqual(shellCalls); - }); - - it("deduplicates file resources by full path while displaying basenames", () => { - const calls = [ - toolCall("1", { type: "read", filePath: "/repo/src/index.ts" }), - toolCall("2", { type: "read", filePath: "/repo/tests/index.ts" }), - toolCall("3", { type: "read", filePath: "/repo/src/other.ts" }), - toolCall("4", { type: "read", filePath: "/repo/src/index.ts" }), - ]; - - const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); - - expect(result.groupsByHostId.get("1")).toMatchObject({ - readFileCount: 3, - categories: [ - { - key: "read", - resources: ["index.ts", "index.ts", "other.ts"], - }, - ], - }); - }); -}); diff --git a/packages/app/src/tool-calls/grouping.ts b/packages/app/src/tool-calls/grouping.ts deleted file mode 100644 index f8fce5976..000000000 --- a/packages/app/src/tool-calls/grouping.ts +++ /dev/null @@ -1,325 +0,0 @@ -import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; -import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization"; -import { getFileNameFromPath } from "@/attachments/utils"; -import type { StreamItem, ToolCallItem } from "@/types/stream"; -import { buildToolCallDisplayModel } from "@/utils/tool-call-display"; -import { resolveToolCallIconName, type ToolCallIcon } from "@/utils/tool-call-icon-name"; - -export const MIN_COMPACT_TOOL_CALLS = 4; - -const DIRECT_PASEO_TOOL_PREFIX = "paseo_"; -const DIRECT_SEARCH_TOOL_SUFFIX_PATTERN = /(?:^|[_.:/])(?:web_search|llm_context)$/; - -export interface ToolCallCategorySummary { - key: string; - label: string; - iconName: ToolCallIcon; - callCount: number; - failedCount: number; - runningCount: number; - resources: string[]; -} - -export interface CompactToolCallGroup { - id: string; - calls: ToolCallItem[]; - callCount: number; - failedCount: number; - isRunning: boolean; - editedFileCount: number; - commandCount: number; - readFileCount: number; - searchCount: number; - otherToolCount: number; - paseoCallCount: number; - categories: ToolCallCategorySummary[]; -} - -export interface CompactToolCallRunsResult { - tail: StreamItem[]; - head: StreamItem[]; - groupsByHostId: Map; -} - -interface CompactToolCallRunsInput { - tail: StreamItem[]; - head: StreamItem[]; - enabled: boolean; -} - -interface TaggedStreamItem { - segment: "tail" | "head"; - item: StreamItem; -} - -interface ToolCallDescriptor { - detail: ToolCallDetail; - name: string; - status: "running" | "completed" | "failed" | "canceled"; - error: unknown; - metadata?: Record; -} - -interface ResourceSummary { - key: string; - label: string; -} - -function describeToolCall(item: ToolCallItem): ToolCallDescriptor { - if (item.payload.source === "agent") { - const { data } = item.payload; - return { - detail: data.detail, - name: data.name, - status: data.status, - error: data.error, - metadata: data.metadata, - }; - } - - const { data } = item.payload; - return { - detail: { - type: "unknown", - input: data.arguments ?? null, - output: data.result ?? null, - }, - name: data.toolName, - status: data.status === "executing" ? "running" : data.status, - error: data.error, - }; -} - -function isCompactableToolCall(item: StreamItem): item is ToolCallItem { - if (item.kind !== "tool_call") { - return false; - } - const descriptor = describeToolCall(item); - return descriptor.detail.type !== "plan" && descriptor.name.trim().toLowerCase() !== "speak"; -} - -function isDirectPaseoToolName(name: string): boolean { - return name.startsWith(DIRECT_PASEO_TOOL_PREFIX); -} - -function isDirectSearchToolName(name: string): boolean { - return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name); -} - -function resourceForDetail(detail: ToolCallDetail): ResourceSummary | null { - if (detail.type === "read" || detail.type === "edit" || detail.type === "write") { - return { - key: detail.filePath, - label: getFileNameFromPath(detail.filePath) ?? detail.filePath, - }; - } - if (detail.type !== "fetch") { - return null; - } - try { - const hostname = new URL(detail.url).hostname || detail.url; - return { key: hostname, label: hostname }; - } catch { - return { key: detail.url, label: detail.url }; - } -} - -function categoryIdentity(input: { - descriptor: ToolCallDescriptor; - normalizedName: string; - displayName: string; -}): { key: string; label: string; iconName: ToolCallIcon } { - if (isPaseoToolName(input.descriptor.name) || isDirectPaseoToolName(input.normalizedName)) { - return { key: "paseo", label: "Paseo", iconName: "paseo" }; - } - if ( - (input.descriptor.detail.type === "search" && - input.descriptor.detail.toolName === "web_search") || - isDirectSearchToolName(input.normalizedName) - ) { - return { key: "web_search", label: "Web search", iconName: "search" }; - } - if (input.descriptor.detail.type === "fetch") { - return { key: "fetch", label: "Web fetch", iconName: "search" }; - } - if (input.descriptor.detail.type !== "unknown" && input.descriptor.detail.type !== "plain_text") { - return { - key: input.descriptor.detail.type, - label: input.displayName, - iconName: resolveToolCallIconName(input.descriptor.name, input.descriptor.detail), - }; - } - return { - key: `tool:${input.displayName.toLowerCase()}`, - label: input.displayName, - iconName: resolveToolCallIconName(input.descriptor.name, input.descriptor.detail), - }; -} - -function buildCompactToolCallGroup(calls: ToolCallItem[]) { - const editedFiles = new Set(); - const readFiles = new Set(); - const categories = new Map(); - const categoryResourceKeys = new Map>(); - let failedCount = 0; - let isRunning = false; - let commandCount = 0; - let searchCount = 0; - let otherToolCount = 0; - let paseoCallCount = 0; - - for (const call of calls) { - const descriptor = describeToolCall(call); - const isFailed = descriptor.status === "failed"; - const isCallRunning = descriptor.status === "running"; - failedCount += isFailed ? 1 : 0; - isRunning ||= isCallRunning; - const normalizedName = descriptor.name.trim().toLowerCase(); - const display = buildToolCallDisplayModel({ - name: descriptor.name, - status: descriptor.status, - error: descriptor.error, - detail: descriptor.detail, - metadata: descriptor.metadata, - }); - const identity = categoryIdentity({ - descriptor, - normalizedName, - displayName: display.displayName, - }); - let category = categories.get(identity.key); - if (!category) { - category = { - ...identity, - callCount: 0, - failedCount: 0, - runningCount: 0, - resources: [], - }; - categories.set(identity.key, category); - } - category.callCount += 1; - category.failedCount += isFailed ? 1 : 0; - category.runningCount += isCallRunning ? 1 : 0; - const resource = resourceForDetail(descriptor.detail); - if (resource) { - let resourceKeys = categoryResourceKeys.get(identity.key); - if (!resourceKeys) { - resourceKeys = new Set(); - categoryResourceKeys.set(identity.key, resourceKeys); - } - if (!resourceKeys.has(resource.key)) { - resourceKeys.add(resource.key); - category.resources.push(resource.label); - } - } - - if (isPaseoToolName(descriptor.name) || isDirectPaseoToolName(normalizedName)) { - paseoCallCount += 1; - continue; - } - if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") { - editedFiles.add(descriptor.detail.filePath); - continue; - } - if (descriptor.detail.type === "shell") { - commandCount += 1; - continue; - } - if (descriptor.detail.type === "read") { - readFiles.add(descriptor.detail.filePath); - continue; - } - if (descriptor.detail.type === "search" || isDirectSearchToolName(normalizedName)) { - searchCount += 1; - continue; - } - otherToolCount += 1; - } - - const firstCall = calls[0]; - if (!firstCall) { - throw new Error("Cannot build an empty tool call group"); - } - return { - id: firstCall.id, - calls, - callCount: calls.length, - failedCount, - isRunning, - editedFileCount: editedFiles.size, - commandCount, - readFileCount: readFiles.size, - searchCount, - otherToolCount, - paseoCallCount, - categories: [...categories.values()], - } satisfies CompactToolCallGroup; -} - -export function compactToolCallRuns(input: CompactToolCallRunsInput): CompactToolCallRunsResult { - if (!input.enabled) { - return { - tail: input.tail, - head: input.head, - groupsByHostId: new Map(), - }; - } - - const taggedItems: TaggedStreamItem[] = [ - ...input.tail.map((item) => ({ segment: "tail" as const, item })), - ...input.head.map((item) => ({ segment: "head" as const, item })), - ]; - const compactedTail: StreamItem[] = []; - const compactedHead: StreamItem[] = []; - const groupsByHostId = new Map(); - let pendingRun: TaggedStreamItem[] = []; - - const append = ({ segment, item }: TaggedStreamItem) => { - (segment === "tail" ? compactedTail : compactedHead).push(item); - }; - const flushRun = () => { - if (pendingRun.length >= MIN_COMPACT_TOOL_CALLS) { - const first = pendingRun[0]; - const latest = pendingRun.at(-1); - if (!first || !latest) { - throw new Error("Cannot compact an empty tool call run"); - } - const calls = pendingRun.map(({ item }) => item as ToolCallItem); - const stableHost: TaggedStreamItem = { - segment: latest.segment, - item: { ...latest.item, id: first.item.id }, - }; - append(stableHost); - groupsByHostId.set(stableHost.item.id, buildCompactToolCallGroup(calls)); - } else { - for (const entry of pendingRun) { - append(entry); - } - } - pendingRun = []; - }; - - for (const entry of taggedItems) { - if (isCompactableToolCall(entry.item)) { - pendingRun.push(entry); - continue; - } - flushRun(); - append(entry); - } - flushRun(); - - if (groupsByHostId.size === 0) { - return { - tail: input.tail, - head: input.head, - groupsByHostId, - }; - } - return { - tail: compactedTail, - head: compactedHead, - groupsByHostId, - }; -} diff --git a/packages/app/src/types/react-native-flat-list.d.ts b/packages/app/src/types/react-native-flat-list.d.ts new file mode 100644 index 000000000..29e82d1db --- /dev/null +++ b/packages/app/src/types/react-native-flat-list.d.ts @@ -0,0 +1,10 @@ +// React Native 0.81 implements FlatList's renderer memoization flag and ships +// it in generated types, but omits it from the legacy declarations exposed by +// the package entry point. +import "react-native"; + +declare module "react-native" { + interface FlatListProps { + strictMode?: boolean; + } +} diff --git a/packages/app/src/utils/to-xterm-theme.ts b/packages/app/src/utils/to-xterm-theme.ts index c762bab1b..7c7d38043 100644 --- a/packages/app/src/utils/to-xterm-theme.ts +++ b/packages/app/src/utils/to-xterm-theme.ts @@ -12,7 +12,6 @@ export function toXtermTheme(terminal: TerminalPalette): ITheme { cursorAccent: terminal.cursorAccent, selectionBackground: terminal.selectionBackground, selectionForeground: terminal.selectionForeground, - black: terminal.black, red: terminal.red, green: terminal.green,