From 676125a732ec1156981d7dbeee379aead7bc7129 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 21 Dec 2025 18:18:10 +0000 Subject: [PATCH] feat: add ToolCallSheet component with bottom sheet and context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create a new component for displaying tool call details in a bottom sheet on mobile devices. Includes: - ToolCallSheetProvider and useToolCallSheet hook for state management - BottomSheetModal with snap points at 50% and 90% - Header with tool icon, name, and status badge - Content sections for args, commands, file diffs, file reads, and errors - Reuses rendering logic from existing ToolCall component 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../app/src/components/tool-call-sheet.tsx | 730 ++++++++++++++++++ plan.md | 3 +- 2 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/components/tool-call-sheet.tsx diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx new file mode 100644 index 000000000..f41503ecb --- /dev/null +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -0,0 +1,730 @@ +import React, { + createContext, + useContext, + useCallback, + useMemo, + useRef, + ReactNode, +} from "react"; +import { View, Text, Pressable } from "react-native"; +import { ScrollView } from "react-native-gesture-handler"; +import { StyleSheet } from "react-native-unistyles"; +import { + BottomSheetModal, + BottomSheetScrollView, + BottomSheetBackdrop, +} from "@gorhom/bottom-sheet"; +import { Pencil, Eye, SquareTerminal, Search, Wrench, X } from "lucide-react-native"; +import { DiffViewer } from "./diff-viewer"; +import type { EditEntry, ReadEntry, CommandDetails, DiffLine } from "@/utils/tool-call-parsers"; + +// ----- Types ----- + +export interface ToolCallSheetData { + toolName: string; + kind?: string; + status?: "executing" | "completed" | "failed"; + args?: unknown; + result?: unknown; + error?: unknown; + parsedEditEntries?: EditEntry[]; + parsedReadEntries?: ReadEntry[]; + parsedCommandDetails?: CommandDetails | null; +} + +interface ToolCallSheetContextValue { + openToolCall: (data: ToolCallSheetData) => void; + closeToolCall: () => void; +} + +// ----- Context ----- + +const ToolCallSheetContext = createContext(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> = { + edit: Pencil, + read: Eye, + execute: SquareTerminal, + search: Search, +}; + +// ----- Helper Functions ----- + +function formatValue(value: unknown): string { + if (value === undefined) { + return ""; + } + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +// Type guard for structured tool results +type StructuredToolResult = { + type: "command" | "file_write" | "file_edit" | "file_read" | "generic"; + [key: string]: unknown; +}; + +function isStructuredToolResult(result: unknown): result is StructuredToolResult { + return ( + typeof result === "object" && + result !== null && + "type" in result && + typeof result.type === "string" && + ["command", "file_write", "file_edit", "file_read", "generic"].includes(result.type) + ); +} + +// Build diff lines from before/after strings +function buildLineDiffFromStrings(originalText: string, updatedText: string): DiffLine[] { + const splitIntoLines = (text: string): string[] => { + if (!text) return []; + return text.replace(/\r\n/g, "\n").split("\n"); + }; + + const originalLines = splitIntoLines(originalText); + const updatedLines = splitIntoLines(updatedText); + + const hasAnyContent = originalLines.length > 0 || updatedLines.length > 0; + if (!hasAnyContent) return []; + + const m = originalLines.length; + const n = updatedLines.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); + + for (let i = m - 1; i >= 0; i -= 1) { + for (let j = n - 1; j >= 0; j -= 1) { + if (originalLines[i] === updatedLines[j]) { + dp[i][j] = dp[i + 1][j + 1] + 1; + } else { + dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + } + + const diff: DiffLine[] = []; + let i = 0; + let j = 0; + + while (i < m && j < n) { + if (originalLines[i] === updatedLines[j]) { + diff.push({ type: "context", content: ` ${originalLines[i]}` }); + i += 1; + j += 1; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + diff.push({ type: "remove", content: `-${originalLines[i]}` }); + i += 1; + } else { + diff.push({ type: "add", content: `+${updatedLines[j]}` }); + j += 1; + } + } + + while (i < m) { + diff.push({ type: "remove", content: `-${originalLines[i]}` }); + i += 1; + } + + while (j < n) { + diff.push({ type: "add", content: `+${updatedLines[j]}` }); + j += 1; + } + + return diff; +} + +// ----- Provider Component ----- + +interface ToolCallSheetProviderProps { + children: ReactNode; +} + +export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) { + const bottomSheetRef = useRef(null); + const [sheetData, setSheetData] = React.useState(null); + + const snapPoints = useMemo(() => ["50%", "90%"], []); + + 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) => ( + + ), + [] + ); + + const contextValue = useMemo( + () => ({ openToolCall, closeToolCall }), + [openToolCall, closeToolCall] + ); + + return ( + + {children} + + {sheetData && } + + + ); +} + +// ----- Sheet Content Component ----- + +interface ToolCallSheetContentProps { + data: ToolCallSheetData; + onClose: () => void; +} + +function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { + const { + toolName, + kind, + status, + args, + result, + error, + parsedEditEntries, + parsedReadEntries, + parsedCommandDetails, + } = data; + + const IconComponent = kind + ? toolKindIcons[kind.toLowerCase()] || Wrench + : Wrench; + + const serializedArgs = useMemo( + () => (args !== undefined ? formatValue(args) : ""), + [args] + ); + const serializedResult = useMemo( + () => (result !== undefined ? formatValue(result) : ""), + [result] + ); + const serializedError = useMemo( + () => (error !== undefined ? formatValue(error) : ""), + [error] + ); + + // Check if result has a type field for structured rendering + const structuredResult = useMemo( + () => (isStructuredToolResult(result) ? result : null), + [result] + ); + + // Extract functions for structured results + const extractCommandFromStructured = useCallback( + (structured: StructuredToolResult): CommandDetails | null => { + if (structured.type !== "command") return null; + + const cmd: CommandDetails = {}; + if (typeof structured.command === "string") cmd.command = structured.command; + if (typeof structured.cwd === "string") cmd.cwd = structured.cwd; + if (typeof structured.output === "string") cmd.output = structured.output; + if (typeof structured.exitCode === "number") cmd.exitCode = structured.exitCode; + + return cmd.command || cmd.output ? cmd : null; + }, + [] + ); + + const extractDiffFromStructured = useCallback( + (structured: StructuredToolResult): EditEntry[] => { + if (structured.type !== "file_write" && structured.type !== "file_edit") { + return []; + } + + const filePath = typeof structured.filePath === "string" ? structured.filePath : undefined; + + if (structured.type === "file_write") { + const oldContent = typeof structured.oldContent === "string" ? structured.oldContent : ""; + const newContent = typeof structured.newContent === "string" ? structured.newContent : ""; + const diffLines = buildLineDiffFromStrings(oldContent, newContent); + if (diffLines.length > 0) { + return [{ filePath, diffLines }]; + } + } + + if (structured.type === "file_edit") { + if (Array.isArray(structured.diffLines)) { + return [{ filePath, diffLines: structured.diffLines as DiffLine[] }]; + } + const oldContent = typeof structured.oldContent === "string" ? structured.oldContent : ""; + const newContent = typeof structured.newContent === "string" ? structured.newContent : ""; + const diffLines = buildLineDiffFromStrings(oldContent, newContent); + if (diffLines.length > 0) { + return [{ filePath, diffLines }]; + } + } + + return []; + }, + [] + ); + + const extractReadFromStructured = useCallback( + (structured: StructuredToolResult): ReadEntry[] => { + if (structured.type !== "file_read") return []; + + const filePath = typeof structured.filePath === "string" ? structured.filePath : undefined; + const content = typeof structured.content === "string" ? structured.content : ""; + + if (content) { + return [{ filePath, content }]; + } + + return []; + }, + [] + ); + + // Render content sections + const renderSections = useCallback(() => { + const sections: ReactNode[] = []; + + // Always show args first if available + if (args !== undefined) { + sections.push( + + Arguments + + {serializedArgs} + + + ); + } + + // Render based on structured result type or raw data + if (structuredResult) { + switch (structuredResult.type) { + case "command": { + const cmd = parsedCommandDetails ?? extractCommandFromStructured(structuredResult); + if (cmd) { + sections.push( + + Command + {cmd.command ? ( + + {cmd.command} + + ) : null} + {cmd.cwd ? ( + + Directory + {cmd.cwd} + + ) : null} + {cmd.exitCode !== undefined ? ( + + Exit Code + + {cmd.exitCode === null ? "Unknown" : cmd.exitCode} + + + ) : null} + {cmd.output ? ( + + {cmd.output} + + ) : null} + + ); + } + break; + } + + case "file_write": + case "file_edit": { + const diffs = parsedEditEntries?.length + ? parsedEditEntries + : extractDiffFromStructured(structuredResult); + diffs.forEach((entry, index) => { + sections.push( + + Diff + {entry.filePath ? ( + + {entry.filePath} + + ) : null} + + + + + ); + }); + break; + } + + case "file_read": { + const reads = parsedReadEntries?.length + ? parsedReadEntries + : extractReadFromStructured(structuredResult); + reads.forEach((entry, index) => { + sections.push( + + Read Result + {entry.filePath ? ( + + {entry.filePath} + + ) : null} + + {entry.content} + + + ); + }); + break; + } + + case "generic": + default: { + if (result !== undefined && sections.length === 1) { + // Only args shown, add result + sections.push( + + Result + + {serializedResult} + + + ); + } + break; + } + } + } else if (result !== undefined) { + // No structured result - show raw result + sections.push( + + Result + + {serializedResult} + + + ); + } + + // Always show errors if available + if (error !== undefined) { + sections.push( + + Error + + {serializedError} + + + ); + } + + if (sections.length === 0) { + return ( + No additional details available + ); + } + + return sections; + }, [ + args, + result, + error, + serializedArgs, + serializedResult, + serializedError, + structuredResult, + parsedEditEntries, + parsedReadEntries, + parsedCommandDetails, + extractCommandFromStructured, + extractDiffFromStructured, + extractReadFromStructured, + ]); + + return ( + + {/* Header */} + + + + + {toolName} + + {status && ( + + + {status === "executing" ? "Running" : status === "completed" ? "Done" : "Failed"} + + + )} + + + + + + + {/* Content */} + + {renderSections()} + + + ); +} + +// ----- Styles ----- + +const styles = StyleSheet.create((theme) => ({ + sheetBackground: { + backgroundColor: theme.colors.background, + }, + handleIndicator: { + backgroundColor: theme.colors.mutedForeground, + }, + container: { + flex: 1, + }, + 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, + }, + headerIcon: { + color: theme.colors.foreground, + }, + headerTitle: { + fontSize: theme.fontSize.lg, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.foreground, + flex: 1, + }, + statusBadge: { + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + borderRadius: theme.borderRadius.full, + }, + statusExecuting: { + backgroundColor: theme.colors.palette.blue[900], + }, + statusCompleted: { + backgroundColor: theme.colors.palette.green[900], + }, + statusFailed: { + backgroundColor: theme.colors.palette.red[900], + }, + statusText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.semibold, + }, + statusTextExecuting: { + color: theme.colors.palette.blue[200], + }, + statusTextCompleted: { + color: theme.colors.palette.green[200], + }, + statusTextFailed: { + color: theme.colors.palette.red[200], + }, + closeButton: { + padding: theme.spacing[2], + }, + closeIcon: { + color: theme.colors.mutedForeground, + }, + content: { + flex: 1, + }, + contentContainer: { + padding: theme.spacing[4], + gap: theme.spacing[4], + }, + section: { + gap: theme.spacing[2], + }, + sectionTitle: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.semibold, + textTransform: "uppercase", + letterSpacing: 0.5, + }, + fileBadge: { + alignSelf: "flex-start", + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + borderRadius: theme.borderRadius.base, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + backgroundColor: theme.colors.card, + }, + fileBadgeText: { + color: theme.colors.foreground, + fontFamily: "monospace", + fontSize: theme.fontSize.xs, + }, + diffContainer: { + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.base, + overflow: "hidden", + backgroundColor: theme.colors.card, + }, + scrollArea: { + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.base, + maxHeight: 260, + }, + scrollContent: { + padding: theme.spacing[2], + }, + scrollText: { + fontFamily: "monospace", + fontSize: theme.fontSize.xs, + color: theme.colors.foreground, + lineHeight: 18, + }, + jsonScroll: { + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.base, + }, + jsonScrollError: { + borderColor: theme.colors.destructive, + }, + jsonContent: { + padding: theme.spacing[2], + }, + errorText: { + color: theme.colors.destructive, + }, + emptyStateText: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.sm, + fontStyle: "italic", + }, + metaRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + metaLabel: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, + textTransform: "uppercase", + letterSpacing: 0.5, + }, + metaValue: { + color: theme.colors.foreground, + fontFamily: "monospace", + fontSize: theme.fontSize.xs, + flex: 1, + }, +})); diff --git a/plan.md b/plan.md index 1c0623bb4..35df927eb 100644 --- a/plan.md +++ b/plan.md @@ -526,7 +526,7 @@ interface ToolCallSheetData { --- -- [ ] **Implement**: Create ToolCallSheet component with bottom sheet and context. +- [x] **Implement**: Create ToolCallSheet component with bottom sheet and context. - Create `tool-call-sheet.tsx` in `packages/app/src/components/` - Implement `ToolCallSheetContext` with `openToolCall()` and `closeToolCall()` @@ -535,6 +535,7 @@ interface ToolCallSheetData { - Add header with tool name, kind icon, status - Snap points: ["50%", "90%"] - Run typecheck after changes. + - **Done (2025-12-21 19:15)**: Created `tool-call-sheet.tsx` with `ToolCallSheetProvider` and `useToolCallSheet` hook. Component uses `@gorhom/bottom-sheet` BottomSheetModal with snap points ["50%", "90%"]. Includes header with tool icon, name, and status badge. Content reuses same rendering logic from `ToolCall.renderDetails()` for commands, file edits, file reads, and generic results. Typecheck passes. - [ ] **Implement**: Update ToolCall component to open bottom sheet on tap.