From 018e3b6a8c17daabbb58b20685cee91f8a618eca Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 11 Jan 2026 00:10:26 +0700 Subject: [PATCH] feat(app): extract tool call details for inline/sheet display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create ToolCallDetailsContent component for shared content rendering - Add useToolCallDetails hook for parsing tool call data - Desktop (md+): expand tool call details inline below badge - Mobile (xs/sm): open bottom sheet on tap (unchanged behavior) - Remove unused props from ToolCall component 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../app/src/components/agent-stream-view.tsx | 3 - packages/app/src/components/message.tsx | 72 ++-- .../app/src/components/tool-call-details.tsx | 318 ++++++++++++++++++ .../app/src/components/tool-call-sheet.tsx | 289 +--------------- 4 files changed, 368 insertions(+), 314 deletions(-) create mode 100644 packages/app/src/components/tool-call-details.tsx diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 570f31f23..a5cfc0d4c 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -280,9 +280,6 @@ export function AgentStreamView({ result={data.result} error={data.error} status={data.status as "executing" | "completed" | "failed"} - parsedEditEntries={data.parsedEdits} - parsedReadEntries={data.parsedReads} - parsedCommandDetails={data.parsedCommand ?? null} cwd={agent.cwd} /> ); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 6e12a0faa..d58deda56 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -22,16 +22,16 @@ import { Search, Brain, } from "lucide-react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles"; import { baseColors, theme } from "@/styles/theme"; import { createMarkdownStyles, createCompactMarkdownStyles } from "@/styles/markdown-styles"; import { Colors, Fonts } from "@/constants/theme"; import * as Clipboard from "expo-clipboard"; import type { TodoEntry, ThoughtStatus } from "@/types/stream"; -import type { CommandDetails, EditEntry, ReadEntry } from "@/utils/tool-call-parsers"; import { extractPrincipalParam } from "@/utils/tool-call-parsers"; import { resolveToolCallPreview } from "./tool-call-preview"; import { useToolCallSheet } from "./tool-call-sheet"; +import { ToolCallDetailsContent, useToolCallDetails } from "./tool-call-details"; interface UserMessageProps { message: string; @@ -845,7 +845,7 @@ interface ExpandableBadgeProps { secondaryLabel?: string; icon?: ComponentType<{ size?: number; color?: string }>; isExpanded: boolean; - onToggle: () => void; + onToggle?: () => void; renderDetails?: () => ReactNode; isLoading?: boolean; isError?: boolean; @@ -1110,9 +1110,6 @@ interface ToolCallProps { result?: any; error?: any; status: "executing" | "completed" | "failed"; - parsedEditEntries?: EditEntry[]; - parsedReadEntries?: ReadEntry[]; - parsedCommandDetails?: CommandDetails | null; cwd?: string; } @@ -1141,12 +1138,14 @@ export const ToolCall = memo(function ToolCall({ result, error, status, - parsedEditEntries, - parsedReadEntries, - parsedCommandDetails, cwd, }: ToolCallProps) { const { openToolCall } = useToolCallSheet(); + const [isExpanded, setIsExpanded] = useState(false); + + // Check if we're on mobile (use bottom sheet) or desktop (inline expand) + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const kind = getToolKindFromName(toolName); const IconComponent = toolKindIcons[kind] || Wrench; @@ -1157,33 +1156,56 @@ export const ToolCall = memo(function ToolCall({ [args, cwd] ); - // Check if there's any content to display in the sheet + // Check if there's any content to display const hasDetails = args !== undefined || result !== undefined || error !== undefined; - const handleOpenSheet = useCallback(() => { - openToolCall({ - toolName, - kind, - status, - args, - result, - error, - }); - }, [openToolCall, toolName, kind, status, args, result, error]); + // Parse tool call details for inline rendering + const { display, errorText } = useToolCallDetails({ args, result, error }); - // Dummy renderDetails to make badge tappable - actual content is rendered in the sheet - const dummyRenderDetails = useCallback(() => null, []); + const handleToggle = useCallback(() => { + if (isMobile) { + // Mobile: open bottom sheet + openToolCall({ + toolName, + kind, + status, + args, + result, + error, + }); + } else { + // Desktop: toggle inline expansion + setIsExpanded((prev) => !prev); + } + }, [isMobile, openToolCall, toolName, kind, status, args, result, error]); + + // Render inline details for desktop + const renderDetails = useCallback(() => { + if (isMobile) return null; + return ( + + + + ); + }, [isMobile, display, errorText]); return ( null : undefined)} isLoading={status === "executing"} isError={status === "failed"} /> ); }); + +const toolCallInlineStyles = StyleSheet.create((theme) => ({ + detailsContainer: { + paddingTop: theme.spacing[3], + paddingBottom: theme.spacing[2], + }, +})); diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx new file mode 100644 index 000000000..c8b294fb2 --- /dev/null +++ b/packages/app/src/components/tool-call-details.tsx @@ -0,0 +1,318 @@ +import React, { useMemo, ReactNode } from "react"; +import { View, Text } from "react-native"; +import { ScrollView } from "react-native-gesture-handler"; +import { StyleSheet } from "react-native-unistyles"; +import { Fonts } from "@/constants/theme"; +import { + parseToolCallDisplay, + buildLineDiff, + type ToolCallDisplay, +} from "@/utils/tool-call-parsers"; +import { DiffViewer } from "./diff-viewer"; + +// ---- Types ---- + +export interface ToolCallDetailsData { + args?: unknown; + result?: unknown; + error?: unknown; +} + +// ---- Helper ---- + +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); + } +} + +// ---- Content Component ---- + +interface ToolCallDetailsContentProps { + display: ToolCallDisplay; + errorText?: string; + maxHeight?: number; +} + +export function ToolCallDetailsContent({ + display, + errorText, + maxHeight = 300, +}: ToolCallDetailsContentProps) { + // Compute diff lines for edit type + const diffLines = useMemo(() => { + if (display.type !== "edit") return undefined; + return buildLineDiff(display.oldString, display.newString); + }, [display]); + + const sections: ReactNode[] = []; + + if (display.type === "shell") { + sections.push( + + Command + + {display.command} + + {display.output ? ( + + + {display.output} + + + ) : null} + + ); + } else if (display.type === "edit") { + sections.push( + + File + + {display.filePath} + + {diffLines && diffLines.length > 0 ? ( + + + + ) : null} + + ); + } else if (display.type === "read") { + sections.push( + + File + + {display.filePath} + + {(display.offset !== undefined || display.limit !== undefined) ? ( + + {display.offset !== undefined ? `Offset: ${display.offset}` : ""} + {display.offset !== undefined && display.limit !== undefined ? " • " : ""} + {display.limit !== undefined ? `Limit: ${display.limit}` : ""} + + ) : null} + {display.content ? ( + + + {display.content} + + + ) : null} + + ); + } else { + // Generic tool: show input/output as key-value pairs + if (display.input.length > 0) { + sections.push( + + Input + + ); + display.input.forEach((pair, index) => { + sections.push( + + {pair.key} + + {pair.value} + + + ); + }); + } + + if (display.output.length > 0) { + sections.push( + + Output + + ); + display.output.forEach((pair, index) => { + sections.push( + + {pair.key} + + {pair.value} + + + ); + }); + } + } + + // Always show errors if available + if (errorText) { + sections.push( + + Error + + + {errorText} + + + + ); + } + + if (sections.length === 0) { + return ( + No additional details available + ); + } + + return {sections}; +} + +// ---- Hook for parsing tool call data ---- + +export function useToolCallDetails(data: ToolCallDetailsData) { + const { args, result, error } = data; + + return useMemo(() => { + const display = parseToolCallDisplay(args, result); + const errorText = error !== undefined ? formatValue(error) : undefined; + return { display, errorText }; + }, [args, result, error]); +} + +// ---- Styles ---- + +const styles = StyleSheet.create((theme) => ({ + container: { + gap: theme.spacing[4], + }, + groupHeader: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingBottom: theme.spacing[1], + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.border, + }, + groupHeaderText: { + color: theme.colors.primary, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.bold, + textTransform: "uppercase", + letterSpacing: 1, + }, + 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: Fonts.mono, + fontSize: theme.fontSize.xs, + }, + rangeText: { + color: theme.colors.mutedForeground, + 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, + backgroundColor: theme.colors.card, + }, + scrollContent: { + padding: theme.spacing[2], + }, + scrollText: { + fontFamily: Fonts.mono, + fontSize: theme.fontSize.xs, + color: theme.colors.foreground, + lineHeight: 18, + }, + jsonScroll: { + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.base, + backgroundColor: theme.colors.card, + }, + 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", + }, +})); diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx index 2a3de9613..e67996612 100644 --- a/packages/app/src/components/tool-call-sheet.tsx +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -7,17 +7,14 @@ import React, { ReactNode, } from "react"; import { View, Text, Pressable } from "react-native"; -import { ScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; -import { Fonts } from "@/constants/theme"; import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop, } from "@gorhom/bottom-sheet"; import { Pencil, Eye, SquareTerminal, Search, Wrench, X } from "lucide-react-native"; -import { parseToolCallDisplay, buildLineDiff, type DiffLine } from "@/utils/tool-call-parsers"; -import { DiffViewer } from "./diff-viewer"; +import { ToolCallDetailsContent, useToolCallDetails } from "./tool-call-details"; // ----- Types ----- @@ -56,22 +53,6 @@ const toolKindIcons: Record (error !== undefined ? formatValue(error) : ""), - [error] - ); - - // Parse tool call display using discriminated union - const toolCallDisplay = useMemo( - () => parseToolCallDisplay(args, result), - [args, result] - ); - - // Compute diff lines for edit type - const editDiffLines = useMemo((): DiffLine[] => { - if (toolCallDisplay.type !== "edit") return []; - return buildLineDiff(toolCallDisplay.oldString, toolCallDisplay.newString); - }, [toolCallDisplay]); - - // Render content sections - const renderSections = useCallback(() => { - const sections: ReactNode[] = []; - - if (toolCallDisplay.type === "shell") { - // Shell tool: show command and output as single block - sections.push( - - Command - - {toolCallDisplay.command} - - {toolCallDisplay.output ? ( - - - {toolCallDisplay.output} - - - ) : null} - - ); - } else if (toolCallDisplay.type === "edit") { - // Edit tool: show file path and diff - sections.push( - - File - - {toolCallDisplay.filePath} - - {editDiffLines.length > 0 ? ( - - - - ) : null} - - ); - } else if (toolCallDisplay.type === "read") { - // Read tool: show file path and content - sections.push( - - File - - {toolCallDisplay.filePath} - - {(toolCallDisplay.offset !== undefined || toolCallDisplay.limit !== undefined) ? ( - - {toolCallDisplay.offset !== undefined ? `Offset: ${toolCallDisplay.offset}` : ""} - {toolCallDisplay.offset !== undefined && toolCallDisplay.limit !== undefined ? " • " : ""} - {toolCallDisplay.limit !== undefined ? `Limit: ${toolCallDisplay.limit}` : ""} - - ) : null} - {toolCallDisplay.content ? ( - - - {toolCallDisplay.content} - - - ) : null} - - ); - } else { - // Generic tool: show input/output as key-value pairs - if (toolCallDisplay.input.length > 0) { - sections.push( - - Input - - ); - toolCallDisplay.input.forEach((pair, index) => { - sections.push( - - {pair.key} - - {pair.value} - - - ); - }); - } - - if (toolCallDisplay.output.length > 0) { - sections.push( - - Output - - ); - toolCallDisplay.output.forEach((pair, index) => { - sections.push( - - {pair.key} - - {pair.value} - - - ); - }); - } - } - - // Always show errors if available - if (error !== undefined) { - sections.push( - - Error - - {serializedError} - - - ); - } - - if (sections.length === 0) { - return ( - No additional details available - ); - } - - return sections; - }, [toolCallDisplay, editDiffLines, error, serializedError]); + const { display, errorText } = useToolCallDetails({ args, result, error }); return ( @@ -349,7 +153,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { style={styles.content} contentContainerStyle={styles.contentContainer} > - {renderSections()} + ); @@ -406,92 +210,5 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[4], paddingTop: theme.spacing[4], paddingBottom: theme.spacing[8], - gap: theme.spacing[6], - }, - groupHeader: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingBottom: theme.spacing[1], - borderBottomWidth: theme.borderWidth[1], - borderBottomColor: theme.colors.border, - }, - groupHeaderText: { - color: theme.colors.primary, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.bold, - textTransform: "uppercase", - letterSpacing: 1, - }, - 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: Fonts.mono, - fontSize: theme.fontSize.xs, - }, - rangeText: { - color: theme.colors.mutedForeground, - 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, - backgroundColor: theme.colors.card, - }, - scrollContent: { - padding: theme.spacing[2], - }, - scrollText: { - fontFamily: Fonts.mono, - fontSize: theme.fontSize.xs, - color: theme.colors.foreground, - lineHeight: 18, - }, - jsonScroll: { - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - borderRadius: theme.borderRadius.base, - backgroundColor: theme.colors.card, - }, - 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", }, }));