feat(app): extract tool call details for inline/sheet display

- 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)
This commit is contained in:
Mohamed Boudra
2026-01-11 00:10:26 +07:00
parent a0abf4a79f
commit 018e3b6a8c
4 changed files with 368 additions and 314 deletions

View File

@@ -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}
/>
);

View File

@@ -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 (
<View style={toolCallInlineStyles.detailsContainer}>
<ToolCallDetailsContent display={display} errorText={errorText} maxHeight={400} />
</View>
);
}, [isMobile, display, errorText]);
return (
<ExpandableBadge
label={toolName}
secondaryLabel={principalParam}
icon={IconComponent}
isExpanded={false}
onToggle={handleOpenSheet}
renderDetails={hasDetails ? dummyRenderDetails : undefined}
isExpanded={!isMobile && isExpanded}
onToggle={hasDetails ? handleToggle : undefined}
renderDetails={hasDetails && !isMobile ? renderDetails : (hasDetails ? () => null : undefined)}
isLoading={status === "executing"}
isError={status === "failed"}
/>
);
});
const toolCallInlineStyles = StyleSheet.create((theme) => ({
detailsContainer: {
paddingTop: theme.spacing[3],
paddingBottom: theme.spacing[2],
},
}));

View File

@@ -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(
<View key="shell" style={styles.section}>
<Text style={styles.sectionTitle}>Command</Text>
<ScrollView
horizontal
nestedScrollEnabled
style={styles.jsonScroll}
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text style={styles.scrollText}>{display.command}</Text>
</ScrollView>
{display.output ? (
<ScrollView
style={[styles.scrollArea, { maxHeight }]}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator={true}
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator={true}
>
<Text style={styles.scrollText}>{display.output}</Text>
</ScrollView>
</ScrollView>
) : null}
</View>
);
} else if (display.type === "edit") {
sections.push(
<View key="edit" style={styles.section}>
<Text style={styles.sectionTitle}>File</Text>
<View style={styles.fileBadge}>
<Text style={styles.fileBadgeText}>{display.filePath}</Text>
</View>
{diffLines && diffLines.length > 0 ? (
<View style={styles.diffContainer}>
<DiffViewer diffLines={diffLines} maxHeight={maxHeight} />
</View>
) : null}
</View>
);
} else if (display.type === "read") {
sections.push(
<View key="read" style={styles.section}>
<Text style={styles.sectionTitle}>File</Text>
<View style={styles.fileBadge}>
<Text style={styles.fileBadgeText}>{display.filePath}</Text>
</View>
{(display.offset !== undefined || display.limit !== undefined) ? (
<Text style={styles.rangeText}>
{display.offset !== undefined ? `Offset: ${display.offset}` : ""}
{display.offset !== undefined && display.limit !== undefined ? " • " : ""}
{display.limit !== undefined ? `Limit: ${display.limit}` : ""}
</Text>
) : null}
{display.content ? (
<ScrollView
style={[styles.scrollArea, { maxHeight }]}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator={true}
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator={true}
>
<Text style={styles.scrollText}>{display.content}</Text>
</ScrollView>
</ScrollView>
) : null}
</View>
);
} else {
// Generic tool: show input/output as key-value pairs
if (display.input.length > 0) {
sections.push(
<View key="input-header" style={styles.groupHeader}>
<Text style={styles.groupHeaderText}>Input</Text>
</View>
);
display.input.forEach((pair, index) => {
sections.push(
<View key={`input-${index}-${pair.key}`} style={styles.section}>
<Text style={styles.sectionTitle}>{pair.key}</Text>
<ScrollView
horizontal
nestedScrollEnabled
style={styles.jsonScroll}
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text style={styles.scrollText}>{pair.value}</Text>
</ScrollView>
</View>
);
});
}
if (display.output.length > 0) {
sections.push(
<View key="output-header" style={styles.groupHeader}>
<Text style={styles.groupHeaderText}>Output</Text>
</View>
);
display.output.forEach((pair, index) => {
sections.push(
<View key={`output-${index}-${pair.key}`} style={styles.section}>
<Text style={styles.sectionTitle}>{pair.key}</Text>
<ScrollView
horizontal
nestedScrollEnabled
style={styles.jsonScroll}
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text style={styles.scrollText}>{pair.value}</Text>
</ScrollView>
</View>
);
});
}
}
// Always show errors if available
if (errorText) {
sections.push(
<View key="error" style={styles.section}>
<Text style={[styles.sectionTitle, styles.errorText]}>Error</Text>
<ScrollView
horizontal
nestedScrollEnabled
style={[styles.jsonScroll, styles.jsonScrollError]}
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text style={[styles.scrollText, styles.errorText]}>
{errorText}
</Text>
</ScrollView>
</View>
);
}
if (sections.length === 0) {
return (
<Text style={styles.emptyStateText}>No additional details available</Text>
);
}
return <View style={styles.container}>{sections}</View>;
}
// ---- 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",
},
}));

View File

@@ -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<string, React.ComponentType<{ size?: number; color?:
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);
}
}
// ----- Provider Component -----
interface ToolCallSheetProviderProps {
@@ -150,184 +131,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
? toolKindIcons[kind.toLowerCase()] || Wrench
: Wrench;
const serializedError = useMemo(
() => (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(
<View key="shell" style={styles.section}>
<Text style={styles.sectionTitle}>Command</Text>
<ScrollView
horizontal
nestedScrollEnabled
style={styles.jsonScroll}
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text style={styles.scrollText}>{toolCallDisplay.command}</Text>
</ScrollView>
{toolCallDisplay.output ? (
<ScrollView
style={styles.scrollArea}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator={true}
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator={true}
>
<Text style={styles.scrollText}>{toolCallDisplay.output}</Text>
</ScrollView>
</ScrollView>
) : null}
</View>
);
} else if (toolCallDisplay.type === "edit") {
// Edit tool: show file path and diff
sections.push(
<View key="edit" style={styles.section}>
<Text style={styles.sectionTitle}>File</Text>
<View style={styles.fileBadge}>
<Text style={styles.fileBadgeText}>{toolCallDisplay.filePath}</Text>
</View>
{editDiffLines.length > 0 ? (
<View style={styles.diffContainer}>
<DiffViewer diffLines={editDiffLines} maxHeight={300} />
</View>
) : null}
</View>
);
} else if (toolCallDisplay.type === "read") {
// Read tool: show file path and content
sections.push(
<View key="read" style={styles.section}>
<Text style={styles.sectionTitle}>File</Text>
<View style={styles.fileBadge}>
<Text style={styles.fileBadgeText}>{toolCallDisplay.filePath}</Text>
</View>
{(toolCallDisplay.offset !== undefined || toolCallDisplay.limit !== undefined) ? (
<Text style={styles.rangeText}>
{toolCallDisplay.offset !== undefined ? `Offset: ${toolCallDisplay.offset}` : ""}
{toolCallDisplay.offset !== undefined && toolCallDisplay.limit !== undefined ? " • " : ""}
{toolCallDisplay.limit !== undefined ? `Limit: ${toolCallDisplay.limit}` : ""}
</Text>
) : null}
{toolCallDisplay.content ? (
<ScrollView
style={styles.scrollArea}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator={true}
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator={true}
>
<Text style={styles.scrollText}>{toolCallDisplay.content}</Text>
</ScrollView>
</ScrollView>
) : null}
</View>
);
} else {
// Generic tool: show input/output as key-value pairs
if (toolCallDisplay.input.length > 0) {
sections.push(
<View key="input-header" style={styles.groupHeader}>
<Text style={styles.groupHeaderText}>Input</Text>
</View>
);
toolCallDisplay.input.forEach((pair, index) => {
sections.push(
<View key={`input-${index}-${pair.key}`} style={styles.section}>
<Text style={styles.sectionTitle}>{pair.key}</Text>
<ScrollView
horizontal
nestedScrollEnabled
style={styles.jsonScroll}
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text style={styles.scrollText}>{pair.value}</Text>
</ScrollView>
</View>
);
});
}
if (toolCallDisplay.output.length > 0) {
sections.push(
<View key="output-header" style={styles.groupHeader}>
<Text style={styles.groupHeaderText}>Output</Text>
</View>
);
toolCallDisplay.output.forEach((pair, index) => {
sections.push(
<View key={`output-${index}-${pair.key}`} style={styles.section}>
<Text style={styles.sectionTitle}>{pair.key}</Text>
<ScrollView
horizontal
nestedScrollEnabled
style={styles.jsonScroll}
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text style={styles.scrollText}>{pair.value}</Text>
</ScrollView>
</View>
);
});
}
}
// Always show errors if available
if (error !== undefined) {
sections.push(
<View key="error" style={styles.section}>
<Text style={[styles.sectionTitle, styles.errorText]}>Error</Text>
<ScrollView
horizontal
nestedScrollEnabled
style={[styles.jsonScroll, styles.jsonScrollError]}
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text style={[styles.scrollText, styles.errorText]}>{serializedError}</Text>
</ScrollView>
</View>
);
}
if (sections.length === 0) {
return (
<Text style={styles.emptyStateText}>No additional details available</Text>
);
}
return sections;
}, [toolCallDisplay, editDiffLines, error, serializedError]);
const { display, errorText } = useToolCallDetails({ args, result, error });
return (
<View style={styles.container}>
@@ -349,7 +153,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
style={styles.content}
contentContainerStyle={styles.contentContainer}
>
{renderSections()}
<ToolCallDetailsContent display={display} errorText={errorText} />
</BottomSheetScrollView>
</View>
);
@@ -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",
},
}));