568 lines
18 KiB
TypeScript
568 lines
18 KiB
TypeScript
import React, { useMemo, ReactNode } from "react";
|
|
import { View, Text, Platform, ScrollView as RNScrollView } from "react-native";
|
|
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
|
import { StyleSheet } from "react-native-unistyles";
|
|
import { Fonts } from "@/constants/theme";
|
|
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
|
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";
|
|
|
|
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView;
|
|
|
|
// ---- Content Component ----
|
|
|
|
interface ToolCallDetailsContentProps {
|
|
detail?: ToolCallDetail;
|
|
errorText?: string;
|
|
maxHeight?: number;
|
|
fillAvailableHeight?: boolean;
|
|
showLoadingSkeleton?: boolean;
|
|
}
|
|
|
|
export function ToolCallDetailsContent({
|
|
detail,
|
|
errorText,
|
|
maxHeight,
|
|
fillAvailableHeight = false,
|
|
showLoadingSkeleton = false,
|
|
}: ToolCallDetailsContentProps) {
|
|
const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300);
|
|
|
|
// Compute diff lines for edit type
|
|
const diffLines = useMemo(() => {
|
|
if (!detail || detail.type !== "edit") return undefined;
|
|
// Use pre-computed unified diff if available (e.g., from apply_patch)
|
|
if (detail.unifiedDiff) {
|
|
return parseUnifiedDiff(detail.unifiedDiff);
|
|
}
|
|
return buildLineDiff(detail.oldString ?? "", detail.newString ?? "");
|
|
}, [detail]);
|
|
|
|
const sections: ReactNode[] = [];
|
|
const isFullBleed =
|
|
detail?.type === "edit" || detail?.type === "shell" || detail?.type === "write";
|
|
const shouldFill =
|
|
fillAvailableHeight &&
|
|
(detail?.type === "shell" ||
|
|
detail?.type === "edit" ||
|
|
detail?.type === "write" ||
|
|
detail?.type === "read" ||
|
|
detail?.type === "sub_agent");
|
|
const codeBlockStyle = isFullBleed ? styles.fullBleedBlock : styles.diffContainer;
|
|
|
|
if (detail?.type === "shell") {
|
|
const command = detail.command.replace(/\n+$/, "");
|
|
const commandOutput = (detail.output ?? "").replace(/^\n+/, "");
|
|
const hasOutput = commandOutput.length > 0;
|
|
sections.push(
|
|
<View key="shell" style={[styles.section, shouldFill && styles.fillHeight]}>
|
|
<View style={[codeBlockStyle, shouldFill && styles.fillHeight]}>
|
|
<ScrollView
|
|
style={[
|
|
styles.codeVerticalScroll,
|
|
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
|
shouldFill && styles.fillHeight,
|
|
]}
|
|
contentContainerStyle={styles.codeVerticalContent}
|
|
nestedScrollEnabled
|
|
showsVerticalScrollIndicator
|
|
>
|
|
<ScrollView
|
|
horizontal
|
|
nestedScrollEnabled
|
|
showsHorizontalScrollIndicator
|
|
contentContainerStyle={styles.codeHorizontalContent}
|
|
>
|
|
<View style={styles.codeLine}>
|
|
<Text selectable style={styles.scrollText}>
|
|
<Text style={styles.shellPrompt}>$ </Text>
|
|
{command}
|
|
{hasOutput ? `\n\n${commandOutput}` : ""}
|
|
</Text>
|
|
</View>
|
|
</ScrollView>
|
|
</ScrollView>
|
|
</View>
|
|
</View>,
|
|
);
|
|
} else if (detail?.type === "worktree_setup") {
|
|
const setupLog = detail.log.replace(/^\n+/, "");
|
|
const hasLog = setupLog.length > 0;
|
|
sections.push(
|
|
<View key="worktree-setup" style={[styles.section, shouldFill && styles.fillHeight]}>
|
|
<View style={[codeBlockStyle, shouldFill && styles.fillHeight]}>
|
|
<ScrollView
|
|
style={[
|
|
styles.codeVerticalScroll,
|
|
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
|
shouldFill && styles.fillHeight,
|
|
]}
|
|
contentContainerStyle={styles.codeVerticalContent}
|
|
nestedScrollEnabled
|
|
showsVerticalScrollIndicator
|
|
>
|
|
<ScrollView
|
|
horizontal
|
|
nestedScrollEnabled
|
|
showsHorizontalScrollIndicator
|
|
contentContainerStyle={styles.codeHorizontalContent}
|
|
>
|
|
<View style={styles.codeLine}>
|
|
<Text selectable style={styles.scrollText}>
|
|
{hasLog
|
|
? setupLog
|
|
: `Preparing worktree ${detail.branchName} at ${detail.worktreePath}`}
|
|
</Text>
|
|
</View>
|
|
</ScrollView>
|
|
</ScrollView>
|
|
</View>
|
|
</View>,
|
|
);
|
|
} else if (detail?.type === "sub_agent") {
|
|
const activityLog = detail.log.replace(/^\n+/, "");
|
|
const hasLog = activityLog.length > 0;
|
|
const fallbackHeader =
|
|
detail.subAgentType && detail.description
|
|
? `${detail.subAgentType}: ${detail.description}`
|
|
: (detail.subAgentType ?? detail.description ?? "Sub-agent activity");
|
|
sections.push(
|
|
<View key="sub-agent" style={[styles.section, shouldFill && styles.fillHeight]}>
|
|
<View style={[codeBlockStyle, shouldFill && styles.fillHeight]}>
|
|
<ScrollView
|
|
style={[
|
|
styles.codeVerticalScroll,
|
|
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
|
shouldFill && styles.fillHeight,
|
|
]}
|
|
contentContainerStyle={styles.codeVerticalContent}
|
|
nestedScrollEnabled
|
|
showsVerticalScrollIndicator
|
|
>
|
|
<ScrollView
|
|
horizontal
|
|
nestedScrollEnabled
|
|
showsHorizontalScrollIndicator
|
|
contentContainerStyle={styles.codeHorizontalContent}
|
|
>
|
|
<View style={styles.codeLine}>
|
|
<Text selectable style={styles.scrollText}>
|
|
{hasLog ? activityLog : fallbackHeader}
|
|
</Text>
|
|
</View>
|
|
</ScrollView>
|
|
</ScrollView>
|
|
</View>
|
|
</View>,
|
|
);
|
|
} else if (detail?.type === "edit") {
|
|
sections.push(
|
|
<View key="edit" style={[styles.section, shouldFill && styles.fillHeight]}>
|
|
{diffLines ? (
|
|
<View style={[codeBlockStyle, shouldFill && styles.fillHeight]}>
|
|
<DiffViewer
|
|
diffLines={diffLines}
|
|
maxHeight={resolvedMaxHeight}
|
|
fillAvailableHeight={shouldFill}
|
|
/>
|
|
</View>
|
|
) : null}
|
|
</View>,
|
|
);
|
|
} else if (detail?.type === "write") {
|
|
sections.push(
|
|
<View key="write" style={[styles.section, shouldFill && styles.fillHeight]}>
|
|
{detail.content ? (
|
|
<ScrollView
|
|
style={[
|
|
styles.scrollArea,
|
|
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
|
shouldFill && styles.fillHeight,
|
|
]}
|
|
contentContainerStyle={styles.scrollContent}
|
|
nestedScrollEnabled
|
|
showsVerticalScrollIndicator={true}
|
|
>
|
|
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator={true}>
|
|
<Text selectable style={styles.scrollText}>
|
|
{detail.content}
|
|
</Text>
|
|
</ScrollView>
|
|
</ScrollView>
|
|
) : null}
|
|
</View>,
|
|
);
|
|
} else if (detail?.type === "read") {
|
|
if (detail.content) {
|
|
sections.push(
|
|
<View key="read" style={[styles.section, shouldFill && styles.fillHeight]}>
|
|
<ScrollView
|
|
style={[
|
|
styles.scrollArea,
|
|
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
|
shouldFill && styles.fillHeight,
|
|
]}
|
|
contentContainerStyle={styles.scrollContent}
|
|
nestedScrollEnabled
|
|
showsVerticalScrollIndicator={true}
|
|
>
|
|
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator={true}>
|
|
<Text selectable style={styles.scrollText}>
|
|
{detail.content}
|
|
</Text>
|
|
</ScrollView>
|
|
</ScrollView>
|
|
</View>,
|
|
);
|
|
}
|
|
} else if (detail?.type === "search") {
|
|
const searchSections: ReactNode[] = [];
|
|
if (detail.query) {
|
|
searchSections.push(
|
|
<View key="search-query" style={styles.section}>
|
|
<Text selectable style={styles.scrollText}>
|
|
{detail.query}
|
|
</Text>
|
|
</View>,
|
|
);
|
|
}
|
|
if (detail.content) {
|
|
searchSections.push(
|
|
<View key="search-content" style={styles.section}>
|
|
<ScrollView
|
|
style={[
|
|
styles.scrollArea,
|
|
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
|
]}
|
|
contentContainerStyle={styles.scrollContent}
|
|
nestedScrollEnabled
|
|
showsVerticalScrollIndicator
|
|
>
|
|
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
|
<Text selectable style={styles.scrollText}>
|
|
{detail.content}
|
|
</Text>
|
|
</ScrollView>
|
|
</ScrollView>
|
|
</View>,
|
|
);
|
|
}
|
|
if (detail.filePaths && detail.filePaths.length > 0) {
|
|
searchSections.push(
|
|
<View key="search-files" style={styles.section}>
|
|
<Text selectable style={styles.scrollText}>
|
|
{detail.filePaths.join("\n")}
|
|
</Text>
|
|
</View>,
|
|
);
|
|
}
|
|
if (detail.webResults && detail.webResults.length > 0) {
|
|
searchSections.push(
|
|
<View key="search-web-results" style={styles.section}>
|
|
<Text selectable style={styles.scrollText}>
|
|
{detail.webResults.map((entry) => `${entry.title}\n${entry.url}`).join("\n\n")}
|
|
</Text>
|
|
</View>,
|
|
);
|
|
}
|
|
if (detail.annotations && detail.annotations.length > 0) {
|
|
searchSections.push(
|
|
<View key="search-annotations" style={styles.section}>
|
|
<Text selectable style={styles.scrollText}>
|
|
{detail.annotations.join("\n\n")}
|
|
</Text>
|
|
</View>,
|
|
);
|
|
}
|
|
sections.push(...searchSections);
|
|
} else if (detail?.type === "fetch") {
|
|
sections.push(
|
|
<View key="fetch" style={[styles.section, shouldFill && styles.fillHeight]}>
|
|
<ScrollView
|
|
style={[
|
|
styles.scrollArea,
|
|
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
|
shouldFill && styles.fillHeight,
|
|
]}
|
|
contentContainerStyle={styles.scrollContent}
|
|
nestedScrollEnabled
|
|
showsVerticalScrollIndicator
|
|
>
|
|
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
|
<Text selectable style={styles.scrollText}>
|
|
{detail.result ? `${detail.url}\n\n${detail.result}` : detail.url}
|
|
</Text>
|
|
</ScrollView>
|
|
</ScrollView>
|
|
</View>,
|
|
);
|
|
} else if (detail?.type === "plain_text") {
|
|
if (detail.text) {
|
|
sections.push(
|
|
<View key="plain-text" style={styles.plainTextSection}>
|
|
<Text selectable style={styles.plainText}>
|
|
{detail.text}
|
|
</Text>
|
|
</View>,
|
|
);
|
|
}
|
|
} else if (detail?.type === "unknown") {
|
|
const plainInputText =
|
|
typeof detail.input === "string" && detail.output === null ? detail.input : null;
|
|
|
|
if (plainInputText !== null) {
|
|
sections.push(
|
|
<View key="unknown-plain-text" style={styles.plainTextSection}>
|
|
<Text selectable style={styles.plainText}>
|
|
{plainInputText}
|
|
</Text>
|
|
</View>,
|
|
);
|
|
} else {
|
|
const sectionsFromTopLevel = [
|
|
{ title: "Input", value: detail.input },
|
|
{ title: "Output", value: detail.output },
|
|
].filter((entry) =>
|
|
hasMeaningfulToolCallDetail({
|
|
type: "unknown",
|
|
input: entry.value ?? null,
|
|
output: null,
|
|
}),
|
|
);
|
|
|
|
for (const section of sectionsFromTopLevel) {
|
|
let value = "";
|
|
try {
|
|
value =
|
|
typeof section.value === "string"
|
|
? section.value
|
|
: JSON.stringify(section.value, null, 2);
|
|
} catch {
|
|
value = String(section.value);
|
|
}
|
|
if (!value.length) {
|
|
continue;
|
|
}
|
|
sections.push(
|
|
<View key={`${section.title}-header`} style={styles.groupHeader}>
|
|
<Text style={styles.groupHeaderText}>{section.title}</Text>
|
|
</View>,
|
|
);
|
|
sections.push(
|
|
<View key={`${section.title}-value`} style={styles.section}>
|
|
<ScrollView
|
|
horizontal
|
|
nestedScrollEnabled
|
|
style={styles.jsonScroll}
|
|
contentContainerStyle={styles.jsonContent}
|
|
showsHorizontalScrollIndicator={true}
|
|
>
|
|
<Text selectable style={styles.scrollText}>
|
|
{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 selectable style={[styles.scrollText, styles.errorText]}>
|
|
{errorText}
|
|
</Text>
|
|
</ScrollView>
|
|
</View>,
|
|
);
|
|
}
|
|
|
|
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>;
|
|
}
|
|
|
|
return (
|
|
<View
|
|
style={[
|
|
isFullBleed ? styles.fullBleedContainer : styles.paddedContainer,
|
|
shouldFill && styles.fillHeight,
|
|
]}
|
|
>
|
|
{sections}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// ---- Styles ----
|
|
|
|
const styles = StyleSheet.create((theme) => {
|
|
const insets = getCodeInsets(theme);
|
|
|
|
return {
|
|
paddedContainer: {
|
|
gap: theme.spacing[4],
|
|
padding: 0,
|
|
},
|
|
fullBleedContainer: {
|
|
gap: theme.spacing[2],
|
|
padding: 0,
|
|
},
|
|
groupHeader: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[2],
|
|
borderBottomWidth: theme.borderWidth[1],
|
|
borderBottomColor: theme.colors.border,
|
|
},
|
|
groupHeaderText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
},
|
|
section: {
|
|
gap: theme.spacing[2],
|
|
},
|
|
fillHeight: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
},
|
|
plainTextSection: {
|
|
gap: theme.spacing[2],
|
|
padding: theme.spacing[3],
|
|
},
|
|
plainText: {
|
|
fontFamily: Fonts.sans,
|
|
fontSize: theme.fontSize.base,
|
|
color: theme.colors.foreground,
|
|
lineHeight: 22,
|
|
overflowWrap: "anywhere",
|
|
},
|
|
sectionTitle: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
fontWeight: theme.fontWeight.semibold,
|
|
textTransform: "uppercase",
|
|
letterSpacing: 0.5,
|
|
},
|
|
rangeText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
},
|
|
diffContainer: {
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.border,
|
|
borderRadius: theme.borderRadius.base,
|
|
overflow: "hidden",
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
fullBleedBlock: {
|
|
borderWidth: 0,
|
|
borderRadius: 0,
|
|
overflow: "hidden",
|
|
backgroundColor: theme.colors.surface1,
|
|
},
|
|
codeVerticalScroll: {},
|
|
codeVerticalContent: {
|
|
flexGrow: 1,
|
|
paddingBottom: insets.extraBottom,
|
|
},
|
|
codeHorizontalContent: {
|
|
paddingRight: insets.extraRight,
|
|
},
|
|
codeLine: {
|
|
minWidth: "100%",
|
|
paddingHorizontal: insets.padding,
|
|
paddingVertical: insets.padding,
|
|
},
|
|
scrollArea: {
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.border,
|
|
borderRadius: theme.borderRadius.base,
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
scrollContent: {
|
|
padding: insets.padding,
|
|
},
|
|
scrollText: {
|
|
fontFamily: Fonts.mono,
|
|
fontSize: theme.fontSize.xs,
|
|
color: theme.colors.foreground,
|
|
lineHeight: 18,
|
|
...(Platform.OS === "web"
|
|
? {
|
|
whiteSpace: "pre",
|
|
overflowWrap: "normal",
|
|
}
|
|
: null),
|
|
},
|
|
shellPrompt: {
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
jsonScroll: {
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.border,
|
|
borderRadius: theme.borderRadius.base,
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
jsonScrollError: {
|
|
borderColor: theme.colors.destructive,
|
|
},
|
|
jsonContent: {
|
|
padding: insets.padding,
|
|
},
|
|
errorText: {
|
|
color: theme.colors.destructive,
|
|
},
|
|
emptyStateText: {
|
|
color: theme.colors.foregroundMuted,
|
|
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,
|
|
},
|
|
};
|
|
});
|