refactor(app): centralize tool call presentation (#853)

* refactor(app): centralize tool call presentation

* test(app): inject tool call presentation icons
This commit is contained in:
Mohamed Boudra
2026-05-10 12:07:54 +08:00
committed by GitHub
parent 6db5200890
commit 73d27bd5fe
4 changed files with 207 additions and 69 deletions

View File

@@ -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<ToolCallDetail | undefined>(() => {
@@ -2875,63 +2869,36 @@ export const ToolCall = memo(function ToolCall({
return undefined;
}, [detail, args, result]);
const displayDetail = useMemo<ToolCallDetail>(
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 (
<ToolCallDetailsContent
detail={effectiveDetail}
errorText={errorText}
errorText={presentation.errorText}
maxHeight={400}
showLoadingSkeleton={isLoadingDetails}
showLoadingSkeleton={presentation.isLoadingDetails}
/>
);
}, [isMobile, effectiveDetail, errorText, isLoadingDetails]);
}, [isMobile, effectiveDetail, presentation.errorText, presentation.isLoadingDetails]);
if (effectiveDetail?.type === "plan") {
if (presentation.isPlan && effectiveDetail?.type === "plan") {
return (
<PlanCard
title="Plan"
@@ -3001,13 +2968,13 @@ export const ToolCall = memo(function ToolCall({
return (
<ExpandableBadge
testID="tool-call-badge"
label={displayName}
secondaryLabel={secondaryLabel}
icon={IconComponent}
label={presentation.displayName}
secondaryLabel={presentation.summary}
icon={presentation.icon}
isExpanded={!isMobile && isExpanded}
onToggle={canOpenDetails ? handleToggle : undefined}
onToggle={presentation.canOpenDetails ? handleToggle : undefined}
onOpenFile={handleOpenFile}
renderDetails={canOpenDetails && !isMobile ? renderDetails : undefined}
renderDetails={presentation.canOpenDetails && !isMobile ? renderDetails : undefined}
isLoading={status === "running" || status === "executing"}
isError={status === "failed"}
isLastInSequence={isLastInSequence}

View File

@@ -14,17 +14,17 @@ import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import type { ToolCallIconComponent } from "@/utils/tool-call-icon";
import { ToolCallDetailsContent } from "./tool-call-details";
// ----- Types -----
export interface ToolCallSheetData {
toolName: string;
displayName: string;
summary?: string;
detail?: ToolCallDetail;
errorText?: string;
icon: ToolCallIconComponent;
showLoadingSkeleton?: boolean;
}
@@ -139,9 +139,7 @@ interface ToolCallSheetContentProps {
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
const { theme } = useUnistyles();
const { toolName, displayName, detail, errorText, showLoadingSkeleton } = data;
const IconComponent = resolveToolCallIcon(toolName, detail);
const { displayName, detail, errorText, icon: IconComponent, showLoadingSkeleton } = data;
return (
<View style={styles.container}>

View File

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

View File

@@ -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<string, unknown>;
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",
};
}