Files
paseo/packages/app/src/components/tool-call-sheet.tsx
T
2026-02-04 12:58:25 +07:00

245 lines
6.4 KiB
TypeScript

import React, {
createContext,
useContext,
useCallback,
useMemo,
useRef,
ReactNode,
} from "react";
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated from "react-native-reanimated";
import {
BottomSheetModal,
BottomSheetScrollView,
BottomSheetBackdrop,
BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import { Pencil, Eye, SquareTerminal, Search, Wrench, X } from "lucide-react-native";
import { extractPrincipalParam } from "@/utils/tool-call-parsers";
import { ToolCallDetailsContent, useToolCallDetails } from "./tool-call-details";
// ----- Types -----
export interface ToolCallSheetData {
toolName: string;
kind?: string;
status?: "executing" | "completed" | "failed";
cwd?: string;
args?: unknown;
result?: unknown;
error?: unknown;
}
interface ToolCallSheetContextValue {
openToolCall: (data: ToolCallSheetData) => void;
closeToolCall: () => void;
}
// ----- Context -----
const ToolCallSheetContext = createContext<ToolCallSheetContextValue | null>(null);
export function useToolCallSheet(): ToolCallSheetContextValue {
const context = useContext(ToolCallSheetContext);
if (!context) {
throw new Error("useToolCallSheet must be used within a ToolCallSheetProvider");
}
return context;
}
// ----- Icon Mapping -----
const toolKindIcons: Record<string, React.ComponentType<{ size?: number; color?: string }>> = {
edit: Pencil,
read: Eye,
execute: SquareTerminal,
search: Search,
};
// ----- Custom Background Component -----
function CustomSheetBackground({ style }: BottomSheetBackgroundProps) {
const { theme } = useUnistyles();
const containerStyle = useMemo(
() => [style, { backgroundColor: theme.colors.surface2, borderRadius: 16 }],
[style, theme.colors.surface2]
);
return <Animated.View pointerEvents="none" style={containerStyle} />;
}
// ----- Provider Component -----
interface ToolCallSheetProviderProps {
children: ReactNode;
}
export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) {
const bottomSheetRef = useRef<BottomSheetModal>(null);
const [sheetData, setSheetData] = React.useState<ToolCallSheetData | null>(null);
const snapPoints = useMemo(() => ["60%", "95%"], []);
const openToolCall = useCallback((data: ToolCallSheetData) => {
setSheetData(data);
bottomSheetRef.current?.present();
}, []);
const closeToolCall = useCallback(() => {
bottomSheetRef.current?.dismiss();
}, []);
const handleSheetChange = useCallback((index: number) => {
if (index === -1) {
setSheetData(null);
}
}, []);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.5}
/>
),
[]
);
const contextValue = useMemo(
() => ({ openToolCall, closeToolCall }),
[openToolCall, closeToolCall]
);
return (
<ToolCallSheetContext.Provider value={contextValue}>
{children}
<BottomSheetModal
ref={bottomSheetRef}
snapPoints={snapPoints}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
backdropComponent={renderBackdrop}
enablePanDownToClose
backgroundStyle={styles.sheetBackground}
handleIndicatorStyle={styles.handleIndicator}
>
{sheetData && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />}
</BottomSheetModal>
</ToolCallSheetContext.Provider>
);
}
// ----- Sheet Content Component -----
interface ToolCallSheetContentProps {
data: ToolCallSheetData;
onClose: () => void;
}
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
const { toolName, kind, cwd, args, result, error } = data;
const IconComponent = kind
? toolKindIcons[kind.toLowerCase()] || Wrench
: Wrench;
const { display, errorText } = useToolCallDetails({ toolName, args, result, error });
const principalParam = useMemo(() => extractPrincipalParam(args, cwd), [args, cwd]);
return (
<View style={styles.container}>
{/* Header */}
<View style={styles.header}>
<View style={styles.headerLeft}>
<IconComponent size={20} color={styles.headerIcon.color} />
<View style={styles.headerTextColumn}>
<Text style={styles.headerTitle} numberOfLines={1}>
{display.toolName}
</Text>
{principalParam ? (
<Text style={styles.headerSubtitle} numberOfLines={1}>
{principalParam}
</Text>
) : null}
</View>
</View>
<Pressable onPress={onClose} style={styles.closeButton}>
<X size={20} color={styles.closeIcon.color} />
</Pressable>
</View>
{/* Content */}
<BottomSheetScrollView
style={styles.content}
contentContainerStyle={styles.contentContainer}
>
<ToolCallDetailsContent display={display} errorText={errorText} />
</BottomSheetScrollView>
</View>
);
}
// ----- Styles -----
const styles = StyleSheet.create((theme) => ({
sheetBackground: {
backgroundColor: theme.colors.surface2,
},
handleIndicator: {
backgroundColor: theme.colors.palette.zinc[600],
},
container: {
flex: 1,
backgroundColor: theme.colors.surface2,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
headerLeft: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flex: 1,
},
headerTextColumn: {
flex: 1,
minWidth: 0,
},
headerIcon: {
color: theme.colors.foreground,
},
headerTitle: {
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
flex: 1,
},
headerSubtitle: {
marginTop: theme.spacing[1],
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
closeButton: {
padding: theme.spacing[2],
},
closeIcon: {
color: theme.colors.foregroundMuted,
},
content: {
flex: 1,
backgroundColor: theme.colors.surface2,
},
contentContainer: {
padding: 0,
},
}));