* fix: show loading skeleton for pending tool call details * fix: surface claude tool previews before full json * fix: route claude partial tool input through canonical mapping * fix: remove pending tool-call summary placeholder
This commit is contained in:
@@ -71,6 +71,10 @@ import {
|
||||
buildToolCallDisplayModel,
|
||||
} from "@/utils/tool-call-display";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import {
|
||||
hasMeaningfulToolCallDetail,
|
||||
isPendingToolCallDetail,
|
||||
} from "@/utils/tool-call-detail-state";
|
||||
import {
|
||||
parseAssistantFileLink,
|
||||
parseInlinePathToken,
|
||||
@@ -1923,31 +1927,42 @@ export const ToolCall = memo(function ToolCall({
|
||||
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) ||
|
||||
(effectiveDetail
|
||||
? effectiveDetail.type === "plain_text"
|
||||
? Boolean(effectiveDetail.text)
|
||||
: effectiveDetail.type !== "unknown" ||
|
||||
effectiveDetail.input !== null ||
|
||||
effectiveDetail.output !== null
|
||||
: false);
|
||||
hasMeaningfulToolCallDetail(effectiveDetail);
|
||||
const canOpenDetails = hasDetails || isLoadingDetails;
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (isMobile) {
|
||||
openToolCall({
|
||||
toolName,
|
||||
displayName,
|
||||
summary,
|
||||
summary: secondaryLabel,
|
||||
detail: effectiveDetail,
|
||||
errorText,
|
||||
showLoadingSkeleton: isLoadingDetails,
|
||||
});
|
||||
} else {
|
||||
setIsExpanded((prev) => !prev);
|
||||
}
|
||||
}, [isMobile, openToolCall, toolName, displayName, summary, effectiveDetail, errorText]);
|
||||
}, [
|
||||
isMobile,
|
||||
openToolCall,
|
||||
toolName,
|
||||
displayName,
|
||||
secondaryLabel,
|
||||
effectiveDetail,
|
||||
errorText,
|
||||
isLoadingDetails,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onInlineDetailsHoverChange || isMobile || isExpanded) {
|
||||
@@ -1984,19 +1999,20 @@ export const ToolCall = memo(function ToolCall({
|
||||
detail={effectiveDetail}
|
||||
errorText={errorText}
|
||||
maxHeight={400}
|
||||
showLoadingSkeleton={isLoadingDetails}
|
||||
/>
|
||||
);
|
||||
}, [isMobile, effectiveDetail, errorText]);
|
||||
}, [isMobile, effectiveDetail, errorText, isLoadingDetails]);
|
||||
|
||||
return (
|
||||
<ExpandableBadge
|
||||
testID="tool-call-badge"
|
||||
label={displayName}
|
||||
secondaryLabel={summary}
|
||||
secondaryLabel={secondaryLabel}
|
||||
icon={IconComponent}
|
||||
isExpanded={!isMobile && isExpanded}
|
||||
onToggle={hasDetails ? handleToggle : undefined}
|
||||
renderDetails={hasDetails && !isMobile ? renderDetails : undefined}
|
||||
onToggle={canOpenDetails ? handleToggle : undefined}
|
||||
renderDetails={canOpenDetails && !isMobile ? renderDetails : undefined}
|
||||
isLoading={status === "running" || status === "executing"}
|
||||
isError={status === "failed"}
|
||||
isLastInSequence={isLastInSequence}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildLineDiff,
|
||||
parseUnifiedDiff,
|
||||
} from "@/utils/tool-call-parsers";
|
||||
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
|
||||
import { DiffViewer } from "./diff-viewer";
|
||||
import { getCodeInsets } from "./code-insets";
|
||||
|
||||
@@ -20,6 +21,7 @@ interface ToolCallDetailsContentProps {
|
||||
errorText?: string;
|
||||
maxHeight?: number;
|
||||
fillAvailableHeight?: boolean;
|
||||
showLoadingSkeleton?: boolean;
|
||||
}
|
||||
|
||||
export function ToolCallDetailsContent({
|
||||
@@ -27,6 +29,7 @@ export function ToolCallDetailsContent({
|
||||
errorText,
|
||||
maxHeight,
|
||||
fillAvailableHeight = false,
|
||||
showLoadingSkeleton = false,
|
||||
}: ToolCallDetailsContentProps) {
|
||||
const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300);
|
||||
|
||||
@@ -269,7 +272,11 @@ export function ToolCallDetailsContent({
|
||||
const sectionsFromTopLevel = [
|
||||
{ title: "Input", value: detail.input },
|
||||
{ title: "Output", value: detail.output },
|
||||
].filter((entry) => entry.value !== null && entry.value !== undefined);
|
||||
].filter((entry) => hasMeaningfulToolCallDetail({
|
||||
type: "unknown",
|
||||
input: entry.value ?? null,
|
||||
output: null,
|
||||
}));
|
||||
|
||||
for (const section of sectionsFromTopLevel) {
|
||||
let value = "";
|
||||
@@ -327,6 +334,20 @@ export function ToolCallDetailsContent({
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
if (showLoadingSkeleton) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.loadingContainer,
|
||||
fillAvailableHeight && styles.fillHeight,
|
||||
]}
|
||||
>
|
||||
<View style={styles.loadingLineWide} />
|
||||
<View style={styles.loadingLineMedium} />
|
||||
<View style={styles.loadingLineShort} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text style={styles.emptyStateText}>No additional details available</Text>
|
||||
);
|
||||
@@ -471,5 +492,27 @@ const styles = StyleSheet.create((theme) => {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
loadingContainer: {
|
||||
gap: theme.spacing[2],
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
loadingLineWide: {
|
||||
height: 12,
|
||||
width: "100%",
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
loadingLineMedium: {
|
||||
height: 12,
|
||||
width: "72%",
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
loadingLineShort: {
|
||||
height: 12,
|
||||
width: "48%",
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ export type ToolCallSheetData = {
|
||||
summary?: string;
|
||||
detail?: ToolCallDetail;
|
||||
errorText?: string;
|
||||
showLoadingSkeleton?: boolean;
|
||||
};
|
||||
|
||||
interface ToolCallSheetContextValue {
|
||||
@@ -131,7 +132,7 @@ interface ToolCallSheetContentProps {
|
||||
}
|
||||
|
||||
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
const { toolName, displayName, detail, errorText } = data;
|
||||
const { toolName, displayName, detail, errorText, showLoadingSkeleton } = data;
|
||||
|
||||
const IconComponent = resolveToolCallIcon(toolName, detail);
|
||||
|
||||
@@ -159,6 +160,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
detail={detail}
|
||||
errorText={errorText}
|
||||
fillAvailableHeight
|
||||
showLoadingSkeleton={showLoadingSkeleton}
|
||||
/>
|
||||
</BottomSheetScrollView>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
hasMeaningfulToolCallDetail,
|
||||
isPendingToolCallDetail,
|
||||
} from "./tool-call-detail-state";
|
||||
|
||||
describe("tool-call detail state", () => {
|
||||
it("treats empty unknown payloads as not meaningful", () => {
|
||||
const detail: ToolCallDetail = {
|
||||
type: "unknown",
|
||||
input: {},
|
||||
output: null,
|
||||
};
|
||||
|
||||
assert.strictEqual(hasMeaningfulToolCallDetail(detail), false);
|
||||
});
|
||||
|
||||
it("treats partial unknown payloads with real values as meaningful", () => {
|
||||
const detail: ToolCallDetail = {
|
||||
type: "unknown",
|
||||
input: { path: "src/index.ts" },
|
||||
output: null,
|
||||
};
|
||||
|
||||
assert.strictEqual(hasMeaningfulToolCallDetail(detail), true);
|
||||
});
|
||||
|
||||
it("marks running calls with no meaningful detail as pending", () => {
|
||||
assert.strictEqual(
|
||||
isPendingToolCallDetail({
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: {},
|
||||
output: null,
|
||||
},
|
||||
status: "running",
|
||||
error: null,
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("does not mark completed calls as pending", () => {
|
||||
assert.strictEqual(
|
||||
isPendingToolCallDetail({
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: {},
|
||||
output: null,
|
||||
},
|
||||
status: "completed",
|
||||
error: null,
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
function hasMeaningfulUnknownValue(value: unknown): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.trim().length > 0;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(hasMeaningfulUnknownValue);
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return Object.values(value).some(hasMeaningfulUnknownValue);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function hasMeaningfulToolCallDetail(
|
||||
detail: ToolCallDetail | undefined
|
||||
): boolean {
|
||||
if (!detail) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (detail.type) {
|
||||
case "shell":
|
||||
return true;
|
||||
case "read":
|
||||
return Boolean(detail.filePath || detail.content);
|
||||
case "edit":
|
||||
return Boolean(
|
||||
detail.filePath || detail.unifiedDiff || detail.oldString || detail.newString
|
||||
);
|
||||
case "write":
|
||||
return Boolean(detail.filePath || detail.content);
|
||||
case "search":
|
||||
return detail.query.trim().length > 0;
|
||||
case "worktree_setup":
|
||||
return Boolean(detail.branchName || detail.worktreePath || detail.log);
|
||||
case "sub_agent":
|
||||
return Boolean(
|
||||
detail.subAgentType ||
|
||||
detail.description ||
|
||||
detail.log ||
|
||||
detail.actions.length > 0
|
||||
);
|
||||
case "plain_text":
|
||||
return Boolean(detail.label || detail.text);
|
||||
case "unknown":
|
||||
return (
|
||||
hasMeaningfulUnknownValue(detail.input) ||
|
||||
hasMeaningfulUnknownValue(detail.output)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function isPendingToolCallDetail(params: {
|
||||
detail: ToolCallDetail | undefined;
|
||||
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
||||
error: unknown | null | undefined;
|
||||
}): boolean {
|
||||
const isRunning =
|
||||
params.status === "running" || params.status === "executing";
|
||||
return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail);
|
||||
}
|
||||
Reference in New Issue
Block a user