diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 30ae13115..379f8a5f4 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -108,6 +108,7 @@ export function AgentStreamView({ const hasAutoScrolledOnce = useRef(false); const isNearBottomRef = useRef(true); const streamItemCountRef = useRef(0); + const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState>(new Set()); const openFileExplorer = usePanelStore((state) => state.openFileExplorer); const setExplorerTab = usePanelStore((state) => state.setExplorerTab); @@ -137,6 +138,7 @@ export function AgentStreamView({ hasScrolledInitially.current = false; hasAutoScrolledOnce.current = false; isNearBottomRef.current = true; + setExpandedInlineToolCallIds(new Set()); }, [agentId]); const handleInlinePathPress = useCallback( @@ -289,6 +291,21 @@ export function AgentStreamView({ const renderStreamItemContent = useCallback( (item: StreamItem, index: number) => { + const handleInlineDetailsExpandedChange = (expanded: boolean) => { + if (Platform.OS !== "web") { + return; + } + setExpandedInlineToolCallIds((previous) => { + const next = new Set(previous); + if (expanded) { + next.add(item.id); + } else { + next.delete(item.id); + } + return next; + }); + }; + switch (item.kind) { case "user_message": { // In inverted list: index+1 is the item above, index-1 is below. @@ -326,6 +343,7 @@ export function AgentStreamView({ args={item.text} status={item.status === "ready" ? "completed" : "executing"} isLastInSequence={isLastInSequence} + onInlineDetailsExpandedChange={handleInlineDetailsExpandedChange} /> ); } @@ -348,6 +366,7 @@ export function AgentStreamView({ status={data.status as "executing" | "completed" | "failed"} cwd={agent.cwd} isLastInSequence={isLastInSequence} + onInlineDetailsExpandedChange={handleInlineDetailsExpandedChange} /> ); } @@ -360,6 +379,7 @@ export function AgentStreamView({ result={data.result} status={data.status} isLastInSequence={isLastInSequence} + onInlineDetailsExpandedChange={handleInlineDetailsExpandedChange} /> ); } @@ -651,6 +671,7 @@ export function AgentStreamView({ } initialNumToRender={12} windowSize={10} + scrollEnabled={Platform.OS !== "web" || expandedInlineToolCallIds.size === 0} inverted /> diff --git a/packages/app/src/components/diff-viewer.tsx b/packages/app/src/components/diff-viewer.tsx index 8de5d1d67..8d34d7990 100644 --- a/packages/app/src/components/diff-viewer.tsx +++ b/packages/app/src/components/diff-viewer.tsx @@ -1,11 +1,13 @@ import React from "react"; -import { View, Text } from "react-native"; -import { ScrollView } from "react-native-gesture-handler"; +import { View, Text, Platform, ScrollView as RNScrollView } from "react-native"; +import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers"; import { getCodeInsets } from "./code-insets"; +const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView; + interface DiffViewerProps { diffLines: DiffLine[]; maxHeight?: number; diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 6dc7a19d5..5f0849cfd 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -5,11 +5,10 @@ import { Text, ActivityIndicator, Pressable, - SectionList, + FlatList, Platform, type NativeSyntheticEvent, type NativeScrollEvent, - type SectionListRenderItem, } from "react-native"; import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -18,7 +17,7 @@ import * as Linking from "expo-linking"; import { Archive, ChevronDown, - ChevronRight, + GitBranch, GitCommitHorizontal, GitMerge, @@ -221,7 +220,6 @@ const DiffFileHeader = memo(function DiffFileHeader({ onToggle, testID, }: DiffFileSectionProps) { - const { theme } = useUnistyles(); const expandStartRef = useRef(null); const { hunkCount, lineCount, tokenCount } = useMemo(() => { @@ -305,17 +303,6 @@ const DiffFileHeader = memo(function DiffFileHeader({ onPress={toggleExpanded} > - - - {file.path.split("/").pop()} {file.path.includes("/") @@ -425,13 +412,9 @@ interface GitDiffPaneProps { cwd: string; } -type GitDiffSection = { - key: string; - index: number; - file: ParsedDiffFile; - isExpanded: boolean; - data: ParsedDiffFile[]; -}; +type DiffFlatItem = + | { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean } + | { type: "body"; file: ParsedDiffFile; fileIndex: number }; export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { const { theme } = useUnistyles(); @@ -557,17 +540,19 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { })); }, []); - const diffSections = useMemo((): GitDiffSection[] => { - return files.map((file, index) => { + const { flatItems, stickyHeaderIndices } = useMemo(() => { + const items: DiffFlatItem[] = []; + const stickyIndices: number[] = []; + for (let i = 0; i < files.length; i++) { + const file = files[i]; const isExpanded = expandedByPath[file.path] ?? false; - return { - key: file.path, - index, - file, - isExpanded, - data: isExpanded ? [file] : [], - }; - }); + stickyIndices.push(items.length); + items.push({ type: "header", file, fileIndex: i, isExpanded }); + if (isExpanded) { + items.push({ type: "body", file, fileIndex: i }); + } + } + return { flatItems: items, stickyHeaderIndices: stickyIndices }; }, [files, expandedByPath]); const allExpanded = useMemo(() => { @@ -714,26 +699,29 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { }); }, [runArchiveWorktree, router, serverId, cwd, status?.cwd]); - const renderFileBody: SectionListRenderItem = useCallback( - ({ item, section }) => ( - - ), - [] - ); - - const renderSectionHeader = useCallback( - ({ section }: { section: GitDiffSection }) => ( - - ), + const renderFlatItem = useCallback( + ({ item }: { item: DiffFlatItem }) => { + if (item.type === "header") { + return ( + + ); + } + return ( + + ); + }, [handleToggleExpanded] ); - const keyExtractor = useCallback((item: ParsedDiffFile) => item.path, []); + const flatKeyExtractor = useCallback( + (item: DiffFlatItem) => `${item.type}-${item.file.path}`, + [] + ); const hasChanges = files.length > 0; const diffErrorMessage = @@ -819,20 +807,19 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { ); } else { bodyContent = ( - ); @@ -1444,11 +1431,11 @@ const styles = StyleSheet.create((theme) => ({ overflow: "hidden", backgroundColor: theme.colors.surface2, borderBottomWidth: 1, - borderBottomColor: theme.colors.borderAccent, + borderBottomColor: theme.colors.border, }, fileSectionHeaderContainer: { overflow: "hidden", - backgroundColor: theme.colors.surface2, + backgroundColor: theme.colors.surface1, }, fileSectionBodyContainer: { overflow: "hidden", @@ -1456,16 +1443,17 @@ const styles = StyleSheet.create((theme) => ({ }, fileSectionBorder: { borderBottomWidth: 1, - borderBottomColor: theme.colors.borderAccent, + borderBottomColor: theme.colors.border, }, fileHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", - paddingHorizontal: theme.spacing[2], + paddingLeft: theme.spacing[3], + paddingRight: theme.spacing[2], paddingVertical: theme.spacing[2], gap: theme.spacing[1], - backgroundColor: theme.colors.surface2, + backgroundColor: theme.colors.surface1, zIndex: 2, elevation: 2, }, @@ -1485,12 +1473,6 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[1], flexShrink: 0, }, - chevronContainer: { - transform: [{ rotate: "0deg" }], - }, - chevronExpanded: { - transform: [{ rotate: "90deg" }], - }, fileName: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 8dd629afb..a2f42b149 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -883,6 +883,7 @@ interface ExpandableBadgeProps { isExpanded: boolean; style?: StyleProp; onToggle?: () => void; + onDetailHoverChange?: (hovered: boolean) => void; renderDetails?: () => ReactNode; isLoading?: boolean; isError?: boolean; @@ -898,6 +899,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ icon, isExpanded, onToggle, + onDetailHoverChange, renderDetails, isLoading = false, isError = false, @@ -1017,9 +1019,13 @@ const ExpandableBadge = memo(function ExpandableBadge({ )} {detailContent ? ( - + onDetailHoverChange?.(true)} + onHoverOut={() => onDetailHoverChange?.(false)} + > {detailContent} - + ) : null} ); @@ -1034,6 +1040,8 @@ interface ToolCallProps { cwd?: string; isLastInSequence?: boolean; disableOuterSpacing?: boolean; + onInlineDetailsHoverChange?: (hovered: boolean) => void; + onInlineDetailsExpandedChange?: (expanded: boolean) => void; } // Icon mapping for tool kinds @@ -1070,6 +1078,8 @@ export const ToolCall = memo(function ToolCall({ cwd, isLastInSequence = false, disableOuterSpacing, + onInlineDetailsHoverChange, + onInlineDetailsExpandedChange, }: ToolCallProps) { const { openToolCall } = useToolCallSheet(); const [isExpanded, setIsExpanded] = useState(false); @@ -1144,6 +1154,33 @@ export const ToolCall = memo(function ToolCall({ } }, [isExpanded, isMobile, toolName, kind]); + useEffect(() => { + if (!onInlineDetailsHoverChange || isMobile || isExpanded) { + return; + } + onInlineDetailsHoverChange(false); + }, [isExpanded, isMobile, onInlineDetailsHoverChange]); + + useEffect(() => { + if (!onInlineDetailsExpandedChange) { + return; + } + if (isMobile) { + onInlineDetailsExpandedChange(false); + return; + } + onInlineDetailsExpandedChange(isExpanded); + }, [isExpanded, isMobile, onInlineDetailsExpandedChange]); + + useEffect(() => { + if (!onInlineDetailsExpandedChange) { + return; + } + return () => { + onInlineDetailsExpandedChange(false); + }; + }, [onInlineDetailsExpandedChange]); + // Render inline details for desktop const renderDetails = useCallback(() => { if (isMobile) return null; @@ -1169,6 +1206,7 @@ export const ToolCall = memo(function ToolCall({ isError={status === "failed"} isLastInSequence={isLastInSequence} disableOuterSpacing={disableOuterSpacing} + onDetailHoverChange={onInlineDetailsHoverChange} /> ); }); diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index 348e561e5..150223a57 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -1,6 +1,6 @@ import React, { useMemo, ReactNode } from "react"; -import { View, Text } from "react-native"; -import { ScrollView } from "react-native-gesture-handler"; +import { View, Text, Platform, ScrollView as RNScrollView } from "react-native"; +import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf"; @@ -13,6 +13,8 @@ import { import { DiffViewer } from "./diff-viewer"; import { getCodeInsets } from "./code-insets"; +const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView; + // ---- Types ---- export interface ToolCallDetailsData {