diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 9cddda707..b53cb2f01 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -66,13 +66,8 @@ import * as Clipboard from "expo-clipboard"; import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream"; import type { AgentAttachment } from "@server/shared/messages"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; -import { buildToolCallDisplayModel } from "@/utils/tool-call-display"; +import { buildToolCallPresentation } from "@/tool-calls/presentation"; import { resolveToolCallIcon } from "@/utils/tool-call-icon"; -import { extractToolCallFilePath } from "@/utils/extract-tool-call-file-path"; -import { - hasMeaningfulToolCallDetail, - isPendingToolCallDetail, -} from "@/utils/tool-call-detail-state"; import { parseAssistantFileLink, parseInlinePathToken, @@ -2858,7 +2853,6 @@ export const ToolCall = memo(function ToolCall({ const { openToolCall } = useToolCallSheet(); const [isExpanded, setIsExpanded] = useState(false); - // Check if we're on mobile (use bottom sheet) or desktop (inline expand) const isMobile = useIsCompactFormFactor(); const effectiveDetail = useMemo(() => { @@ -2875,63 +2869,36 @@ export const ToolCall = memo(function ToolCall({ return undefined; }, [detail, args, result]); - const displayDetail = useMemo( + const presentation = useMemo( () => - effectiveDetail ?? { - type: "unknown", - input: null, - output: null, - }, - [effectiveDetail], - ); - - const displayModel = useMemo( - () => - buildToolCallDisplayModel({ - name: toolName, - status: status === "executing" ? "running" : status, + buildToolCallPresentation({ + toolName, + status, error: error ?? null, - detail: displayDetail, + detail: effectiveDetail, metadata, cwd, + resolveIcon: resolveToolCallIcon, }), - [toolName, status, error, displayDetail, metadata, cwd], - ); - const displayName = displayModel.displayName; - const summary = displayModel.summary; - const errorText = displayModel.errorText; - const IconComponent = resolveToolCallIcon(toolName, effectiveDetail); - const isLoadingDetails = isPendingToolCallDetail({ - detail: effectiveDetail, - status, - error, - }); - const secondaryLabel = summary; - - // Check if there's any content to display - const hasDetails = Boolean(error) || hasMeaningfulToolCallDetail(effectiveDetail); - const canOpenDetails = hasDetails || isLoadingDetails; - - const extractedFilePath = useMemo( - () => extractToolCallFilePath(effectiveDetail), - [effectiveDetail], + [toolName, status, error, effectiveDetail, metadata, cwd], ); const handleOpenFile = useMemo(() => { - if (!extractedFilePath || !onOpenFilePath) { + const openFilePath = presentation.openFilePath; + if (!openFilePath || !onOpenFilePath) { return undefined; } - return () => onOpenFilePath(extractedFilePath); - }, [extractedFilePath, onOpenFilePath]); + return () => onOpenFilePath(openFilePath); + }, [presentation.openFilePath, onOpenFilePath]); const handleToggle = useCallback(() => { if (isMobile) { openToolCall({ - toolName, - displayName, - summary: secondaryLabel, + displayName: presentation.displayName, + summary: presentation.summary, detail: effectiveDetail, - errorText, - showLoadingSkeleton: isLoadingDetails, + errorText: presentation.errorText, + icon: presentation.icon, + showLoadingSkeleton: presentation.isLoadingDetails, }); } else { setIsExpanded((prev) => !prev); @@ -2939,12 +2906,12 @@ export const ToolCall = memo(function ToolCall({ }, [ isMobile, openToolCall, - toolName, - displayName, - secondaryLabel, + presentation.displayName, + presentation.summary, + presentation.errorText, + presentation.icon, + presentation.isLoadingDetails, effectiveDetail, - errorText, - isLoadingDetails, ]); useEffect(() => { @@ -2980,14 +2947,14 @@ export const ToolCall = memo(function ToolCall({ return ( ); - }, [isMobile, effectiveDetail, errorText, isLoadingDetails]); + }, [isMobile, effectiveDetail, presentation.errorText, presentation.isLoadingDetails]); - if (effectiveDetail?.type === "plan") { + if (presentation.isPlan && effectiveDetail?.type === "plan") { return ( diff --git a/packages/app/src/tool-calls/presentation.test.ts b/packages/app/src/tool-calls/presentation.test.ts new file mode 100644 index 000000000..041a0b9d0 --- /dev/null +++ b/packages/app/src/tool-calls/presentation.test.ts @@ -0,0 +1,94 @@ +import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; +import { describe, expect, it } from "vitest"; + +import { buildToolCallPresentation, type ToolCallPresentationIcon } from "./presentation"; + +const fakeIcons = { + brain: (() => null) as ToolCallPresentationIcon, + eye: (() => null) as ToolCallPresentationIcon, + wrench: (() => null) as ToolCallPresentationIcon, +}; + +function fakeResolveIcon( + toolName: string, + detail: ToolCallDetail | undefined, +): ToolCallPresentationIcon { + if (detail?.type === "plan") { + return fakeIcons.brain; + } + if (detail?.type === "read") { + return fakeIcons.eye; + } + if (toolName === "exec_command") { + return fakeIcons.wrench; + } + return fakeIcons.wrench; +} + +describe("tool-call presentation", () => { + it("builds badge, detail, icon, and file-open policy in one model", () => { + const presentation = buildToolCallPresentation({ + toolName: "read_file", + status: "completed", + error: null, + cwd: "/tmp/repo", + detail: { + type: "read", + filePath: "/tmp/repo/src/index.ts", + content: "console.log('hi');", + }, + resolveIcon: fakeResolveIcon, + }); + + expect(presentation).toMatchObject({ + displayName: "Read", + summary: "src/index.ts", + icon: fakeIcons.eye, + isLoadingDetails: false, + hasDetails: true, + canOpenDetails: true, + openFilePath: "/tmp/repo/src/index.ts", + isPlan: false, + }); + }); + + it("marks running calls without meaningful detail as loading details", () => { + const presentation = buildToolCallPresentation({ + toolName: "exec_command", + status: "running", + error: null, + detail: { + type: "unknown", + input: {}, + output: null, + }, + resolveIcon: fakeResolveIcon, + }); + + expect(presentation).toMatchObject({ + displayName: "Exec Command", + icon: fakeIcons.wrench, + isLoadingDetails: true, + hasDetails: false, + canOpenDetails: true, + openFilePath: null, + isPlan: false, + }); + }); + + it("keeps plan calls out of the expandable badge path", () => { + const presentation = buildToolCallPresentation({ + toolName: "ExitPlanMode", + status: "completed", + error: null, + detail: { + type: "plan", + text: "1. Do the thing", + }, + resolveIcon: fakeResolveIcon, + }); + + expect(presentation.isPlan).toBe(true); + expect(presentation.icon).toBe(fakeIcons.brain); + }); +}); diff --git a/packages/app/src/tool-calls/presentation.ts b/packages/app/src/tool-calls/presentation.ts new file mode 100644 index 000000000..8ea655d58 --- /dev/null +++ b/packages/app/src/tool-calls/presentation.ts @@ -0,0 +1,79 @@ +import type { ComponentType } from "react"; +import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; +import type { ToolCallDisplayInput } from "@/utils/tool-call-display"; +import { buildToolCallDisplayModel } from "@/utils/tool-call-display"; +import { extractToolCallFilePath } from "@/utils/extract-tool-call-file-path"; +import { + hasMeaningfulToolCallDetail, + isPendingToolCallDetail, +} from "@/utils/tool-call-detail-state"; + +type ToolCallStatus = "executing" | "running" | "completed" | "failed" | "canceled"; +export type ToolCallPresentationIcon = ComponentType<{ size?: number; color?: string }>; + +interface BuildToolCallPresentationInput { + toolName: string; + status: ToolCallStatus; + error: unknown; + detail?: ToolCallDetail; + cwd?: string; + metadata?: Record; + resolveIcon: ToolCallIconResolver; +} + +export interface ToolCallPresentation { + displayName: string; + summary?: string; + errorText?: string; + icon: ToolCallPresentationIcon; + isLoadingDetails: boolean; + hasDetails: boolean; + canOpenDetails: boolean; + openFilePath: string | null; + isPlan: boolean; +} + +export type ToolCallIconResolver = ( + toolName: string, + detail: ToolCallDetail | undefined, +) => ToolCallPresentationIcon; + +function displayStatus(status: ToolCallStatus): ToolCallDisplayInput["status"] { + return status === "executing" ? "running" : status; +} + +function displayDetail(detail: ToolCallDetail | undefined): ToolCallDetail { + return detail ?? { type: "unknown", input: null, output: null }; +} + +export function buildToolCallPresentation( + input: BuildToolCallPresentationInput, +): ToolCallPresentation { + const detailForDisplay = displayDetail(input.detail); + const displayModel = buildToolCallDisplayModel({ + name: input.toolName, + status: displayStatus(input.status), + error: input.error ?? null, + detail: detailForDisplay, + metadata: input.metadata, + cwd: input.cwd, + }); + const isLoadingDetails = isPendingToolCallDetail({ + detail: input.detail, + status: input.status, + error: input.error, + }); + const hasDetails = Boolean(input.error) || hasMeaningfulToolCallDetail(input.detail); + + return { + displayName: displayModel.displayName, + summary: displayModel.summary, + errorText: displayModel.errorText, + icon: input.resolveIcon(input.toolName, input.detail), + isLoadingDetails, + hasDetails, + canOpenDetails: hasDetails || isLoadingDetails, + openFilePath: extractToolCallFilePath(input.detail), + isPlan: input.detail?.type === "plan", + }; +}