diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index d294bb3d7..4b464adc6 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1,4 +1,4 @@ -import { View, Text, Pressable, Animated, ScrollView } from "react-native"; +import { View, Text, Pressable, Animated } from "react-native"; import { useState, useEffect, useRef, memo, useMemo, useCallback } from "react"; import type { ReactNode, ComponentType } from "react"; import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; @@ -27,9 +27,9 @@ import { baseColors, theme } from "@/styles/theme"; import { Colors } from "@/constants/theme"; import * as Clipboard from "expo-clipboard"; import type { TodoEntry, ThoughtStatus } from "@/types/stream"; -import type { CommandDetails, EditEntry, ReadEntry, DiffLine } from "@/utils/tool-call-parsers"; -import { DiffViewer } from "./diff-viewer"; +import type { CommandDetails, EditEntry, ReadEntry } from "@/utils/tool-call-parsers"; import { resolveToolCallPreview } from "./tool-call-preview"; +import { useToolCallSheet } from "./tool-call-sheet"; interface UserMessageProps { message: string; @@ -1094,191 +1094,14 @@ interface ToolCallProps { parsedCommandDetails?: CommandDetails | null; } -const toolCallStylesheet = StyleSheet.create((theme) => ({ - detailContent: { - gap: theme.spacing[3], - }, - section: { - gap: theme.spacing[1], - }, - 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, - }, -})); - // Icon mapping for tool kinds const toolKindIcons: Record = { edit: Pencil, read: Eye, execute: SquareTerminal, search: Search, - // Add more mappings as needed }; -function formatFullValue(value: unknown): string { - if (value === undefined) { - return ""; - } - if (typeof value === "string") { - return value; - } - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -} - -// Helper function to build diff lines (mirrors logic from tool-call-parsers) -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; -} - -// 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) - ); -} - export const ToolCall = memo(function ToolCall({ toolName, kind, @@ -1290,379 +1113,39 @@ export const ToolCall = memo(function ToolCall({ parsedReadEntries, parsedCommandDetails, }: ToolCallProps) { - const [isExpanded, setIsExpanded] = useState(false); - - // Check if result has a type field for structured rendering - const structuredResult = useMemo( - () => (isStructuredToolResult(result) ? result : null), - [result] - ); + const { openToolCall } = useToolCallSheet(); const IconComponent = kind ? toolKindIcons[kind.toLowerCase()] || Wrench : Wrench; - const serializedArgs = useMemo( - () => (args !== undefined ? formatFullValue(args) : ""), - [args] - ); - const serializedResult = useMemo( - () => (result !== undefined ? formatFullValue(result) : ""), - [result] - ); - const serializedError = useMemo( - () => (error !== undefined ? formatFullValue(error) : ""), - [error] - ); + // Check if there's any content to display in the sheet + const hasDetails = args !== undefined || result !== undefined || error !== undefined; - const handleToggle = useCallback(() => { - setIsExpanded((prev) => !prev); - }, []); + const handleOpenSheet = useCallback(() => { + openToolCall({ + toolName, + kind, + status, + args, + result, + error, + parsedEditEntries, + parsedReadEntries, + parsedCommandDetails, + }); + }, [openToolCall, toolName, kind, status, args, result, error, parsedEditEntries, parsedReadEntries, parsedCommandDetails]); - // Helper functions to extract data from 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; - - // For file_write, create a diff from oldContent -> newContent - if (structured.type === "file_write") { - const oldContent = typeof structured.oldContent === "string" ? structured.oldContent : ""; - const newContent = typeof structured.newContent === "string" ? structured.newContent : ""; - - // Use the same diff building logic from tool-call-parsers - const diffLines = buildLineDiffFromStrings(oldContent, newContent); - if (diffLines.length > 0) { - return [{ filePath, diffLines }]; - } - } - - // For file_edit, it might already have diffLines or we need to construct them - if (structured.type === "file_edit") { - // Check if diffLines are provided directly - if (Array.isArray(structured.diffLines)) { - return [{ filePath, diffLines: structured.diffLines as DiffLine[] }]; - } - - // Otherwise try to build from old/new content - 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 []; - }, - [] - ); - - const renderDetails = useCallback(() => { - // If we have a structured result, use type-based rendering - if (structuredResult) { - const sections: ReactNode[] = []; - - // Render based on type - switch (structuredResult.type) { - case "command": { - // Use pre-parsed command details if available, otherwise extract from structured result - const cmd = parsedCommandDetails ?? extractCommandFromStructured(structuredResult); - if (cmd) { - // Reuse the command section rendering logic - 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": { - // Use pre-parsed entries if available, otherwise extract from structured result - const diffs = parsedEditEntries?.length - ? parsedEditEntries - : extractDiffFromStructured(structuredResult); - diffs.forEach((entry, index) => { - sections.push( - - Diff - {entry.filePath ? ( - - {entry.filePath} - - ) : null} - - - - - ); - }); - break; - } - - case "file_read": { - // Use pre-parsed entries if available, otherwise extract from structured result - 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: { - // Show raw JSON for generic type - if (result !== undefined) { - sections.push( - - Result - - {serializedResult} - - - ); - } - break; - } - } - - // Always show args if available - if (args !== undefined) { - sections.unshift( - - Arguments - - {serializedArgs} - - - ); - } - - // Always show errors if available - if (error !== undefined) { - sections.push( - - Error - - - {serializedError} - - - - ); - } - - if (sections.length === 0) { - return ( - - No additional details available - - ); - } - - return {sections}; - } - - // No structured result - show raw JSON - const sections: ReactNode[] = []; - - if (args !== undefined) { - sections.push( - - Arguments - - {serializedArgs} - - - ); - } - - if (result !== undefined) { - sections.push( - - Result - - {serializedResult} - - - ); - } - - if (error !== undefined) { - sections.push( - - Error - - - {serializedError} - - - - ); - } - - if (sections.length === 0) { - return ( - - No additional details available - - ); - } - - return {sections}; - }, [ - structuredResult, - extractCommandFromStructured, - extractDiffFromStructured, - extractReadFromStructured, - serializedArgs, - serializedResult, - serializedError, - args, - result, - error, - ]); + // Dummy renderDetails to make badge tappable - actual content is rendered in the sheet + const dummyRenderDetails = useCallback(() => null, []); return ( diff --git a/plan.md b/plan.md index 35df927eb..67e2d94bc 100644 --- a/plan.md +++ b/plan.md @@ -537,13 +537,14 @@ interface ToolCallSheetData { - 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. +- [x] **Implement**: Update ToolCall component to open bottom sheet on tap. - Import `useToolCallSheet` from new context - Replace inline expansion with `openToolCall(data)` on tap - Keep badge display unchanged (icon, label, loading state) - Remove `isExpanded` state and `renderDetails` prop from ExpandableBadge usage - Run typecheck after changes. + - **Done (2025-12-21 19:25)**: Updated `ToolCall` in `message.tsx` to use `useToolCallSheet` hook. On tap, calls `openToolCall()` with tool data instead of expanding inline. Removed ~300 lines of unused code including `toolCallStylesheet`, `formatFullValue`, `buildLineDiffFromStrings`, `isStructuredToolResult`, and inline rendering logic (all now in `tool-call-sheet.tsx`). Cleaned up imports (`ScrollView`, `DiffViewer`, `DiffLine` no longer needed). Typecheck passes. - [ ] **Implement**: Integrate ToolCallSheetProvider in agent stream view.