From 1b8af2e58ee872014f5940071a924b24e25c65f3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Jul 2026 21:16:29 +0200 Subject: [PATCH] Refine tool call summaries and scrolling (#2069) * feat(app): refine tool call summaries and scrolling Keep summary groups stable from the first tool call, preserve live activity feedback, and scroll expanded groups to the latest entry. Consolidate web scrollbar styling around the native browser mechanism. * refactor(app): reuse tool call height constant * fix(app): preserve tool call summary state * fix(app): refresh web tool groups on expand * fix(app): preserve grouped tool errors --- .../app/src/agent-stream/strategy-native.tsx | 60 ++- .../src/agent-stream/strategy-web.test.tsx | 57 +- .../app/src/agent-stream/strategy-web.tsx | 83 ++- packages/app/src/agent-stream/strategy.ts | 8 + packages/app/src/agent-stream/view.tsx | 88 ++- packages/app/src/app/_layout.tsx | 3 + .../src/components/adaptive-modal-sheet.tsx | 19 +- .../app/src/components/diff-scroll.web.tsx | 7 +- packages/app/src/components/diff-viewer.tsx | 6 +- .../src/components/draggable-list.native.tsx | 1 - .../src/components/draggable-list.types.ts | 1 - .../app/src/components/draggable-list.web.tsx | 15 +- .../app/src/components/file-explorer-pane.tsx | 21 +- packages/app/src/components/file-pane.tsx | 33 +- packages/app/src/components/message.tsx | 65 ++- .../schedules/schedule-form-sheet.tsx | 1 - .../app/src/components/terminal-emulator.tsx | 356 ------------- .../app/src/components/tool-call-details.tsx | 47 +- .../app/src/components/tool-call-group.tsx | 341 ------------ .../app/src/components/ui/context-menu.tsx | 3 - .../app/src/components/ui/dropdown-menu.tsx | 6 +- .../app/src/components/use-web-scrollbar.tsx | 182 ------- .../components/web-desktop-scrollbar.math.ts | 73 --- .../components/web-desktop-scrollbar.test.ts | 82 --- .../src/components/web-desktop-scrollbar.tsx | 464 ---------------- packages/app/src/composer/input/input.tsx | 6 - packages/app/src/git/diff-pane.tsx | 47 +- .../src/hooks/use-settings/storage.test.ts | 6 +- .../app/src/hooks/use-settings/storage.ts | 23 +- .../src/hooks/use-web-scrollbar-style.d.ts | 1 - .../hooks/use-web-scrollbar-style.native.ts | 5 - .../src/hooks/use-web-scrollbar-style.web.ts | 21 - packages/app/src/i18n/resources/ar.ts | 13 +- packages/app/src/i18n/resources/en.ts | 13 +- packages/app/src/i18n/resources/es.ts | 11 +- packages/app/src/i18n/resources/fr.ts | 13 +- packages/app/src/i18n/resources/ja.ts | 13 +- packages/app/src/i18n/resources/pt-BR.ts | 13 +- packages/app/src/i18n/resources/ru.ts | 13 +- packages/app/src/i18n/resources/zh-CN.ts | 13 +- packages/app/src/screens/settings-screen.tsx | 12 +- .../appearance/appearance-section.tsx | 3 +- .../app/src/screens/startup-splash-screen.tsx | 14 +- .../styles/install-web-scrollbar-styles.ts | 3 + .../install-web-scrollbar-styles.web.ts | 43 ++ packages/app/src/styles/web-scrollbar.ts | 10 + .../src/tool-calls/detail-level/grouping.ts | 228 ++++++++ .../tool-calls/detail-level/overview/model.ts | 92 ++++ .../tool-calls/detail-level/overview/view.tsx | 173 ++++++ .../detail-level/projection.test.ts | 501 ++++++++++++++++++ .../src/tool-calls/detail-level/projection.ts | 60 +++ packages/app/src/tool-calls/grouping.test.ts | 264 --------- packages/app/src/tool-calls/grouping.ts | 325 ------------ .../app/src/types/react-native-flat-list.d.ts | 10 + packages/app/src/utils/to-xterm-theme.ts | 1 - 55 files changed, 1484 insertions(+), 2488 deletions(-) delete mode 100644 packages/app/src/components/tool-call-group.tsx delete mode 100644 packages/app/src/components/use-web-scrollbar.tsx delete mode 100644 packages/app/src/components/web-desktop-scrollbar.math.ts delete mode 100644 packages/app/src/components/web-desktop-scrollbar.test.ts delete mode 100644 packages/app/src/components/web-desktop-scrollbar.tsx delete mode 100644 packages/app/src/hooks/use-web-scrollbar-style.d.ts delete mode 100644 packages/app/src/hooks/use-web-scrollbar-style.native.ts delete mode 100644 packages/app/src/hooks/use-web-scrollbar-style.web.ts create mode 100644 packages/app/src/styles/install-web-scrollbar-styles.ts create mode 100644 packages/app/src/styles/install-web-scrollbar-styles.web.ts create mode 100644 packages/app/src/styles/web-scrollbar.ts create mode 100644 packages/app/src/tool-calls/detail-level/grouping.ts create mode 100644 packages/app/src/tool-calls/detail-level/overview/model.ts create mode 100644 packages/app/src/tool-calls/detail-level/overview/view.tsx create mode 100644 packages/app/src/tool-calls/detail-level/projection.test.ts create mode 100644 packages/app/src/tool-calls/detail-level/projection.ts delete mode 100644 packages/app/src/tool-calls/grouping.test.ts delete mode 100644 packages/app/src/tool-calls/grouping.ts create mode 100644 packages/app/src/types/react-native-flat-list.d.ts 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,