refactor(file-explorer): add deep linking, path navigation, diff preview, and Codex session persistence

This commit is contained in:
Mohamed Boudra
2025-11-14 19:02:54 +01:00
parent f766a18d7b
commit b2c89df7c7
18 changed files with 3393 additions and 285 deletions
+265 -157
View File
@@ -7,13 +7,24 @@ import {
Text,
View,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useLocalSearchParams } from "expo-router";
import * as Clipboard from "expo-clipboard";
import { Copy, Check, ArrowLeft } from "lucide-react-native";
import { BackHeader } from "@/components/headers/back-header";
import { useSession, type ExplorerEntry } from "@/contexts/session-context";
export default function FileExplorerScreen() {
const { agentId } = useLocalSearchParams<{ agentId: string }>();
const { theme } = useUnistyles();
const {
agentId,
path: pathParamRaw,
file: fileParamRaw,
} = useLocalSearchParams<{
agentId: string;
path?: string | string[];
file?: string | string[];
}>();
const {
agents,
fileExplorer,
@@ -21,6 +32,17 @@ export default function FileExplorerScreen() {
requestFilePreview,
} = useSession();
const [selectedEntryPath, setSelectedEntryPath] = useState<string | null>(null);
const pendingPathParamRef = useRef<string | null>(null);
const pendingFileParamRef = useRef<string | null>(null);
const [copiedPath, setCopiedPath] = useState<string | null>(null);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const normalizedPathParam = normalizePathParam(getFirstParam(pathParamRaw));
const normalizedFileParam = normalizeFileParam(getFirstParam(fileParamRaw));
const derivedDirectoryFromFile = normalizedFileParam
? deriveDirectoryFromFile(normalizedFileParam)
: null;
const initialTargetDirectory = normalizedPathParam ?? derivedDirectoryFromFile ?? ".";
const agent = agentId ? agents.get(agentId) : undefined;
const explorerState = agentId ? fileExplorer.get(agentId) : undefined;
@@ -34,27 +56,6 @@ export default function FileExplorerScreen() {
: null;
const shouldShowPreview = Boolean(selectedEntryPath);
const initializedAgentRef = useRef<string | null>(null);
useEffect(() => {
if (!agentId) {
initializedAgentRef.current = null;
return;
}
if (initializedAgentRef.current === agentId) {
return;
}
initializedAgentRef.current = agentId;
setSelectedEntryPath(null);
const hasDirectory = explorerState?.directories.has(currentPath) ?? false;
if (!hasDirectory) {
requestDirectoryListing(agentId, currentPath);
}
}, [agentId, currentPath, explorerState, requestDirectoryListing]);
useEffect(() => {
setSelectedEntryPath(null);
}, [currentPath]);
@@ -69,6 +70,53 @@ export default function FileExplorerScreen() {
return nextPath.length === 0 ? "." : nextPath;
}, [currentPath]);
useEffect(() => {
setCopiedPath(null);
}, [currentPath]);
useEffect(() => {
if (!agentId || !initialTargetDirectory) {
pendingPathParamRef.current = null;
return;
}
if (pendingPathParamRef.current === initialTargetDirectory) {
return;
}
pendingPathParamRef.current = initialTargetDirectory;
requestDirectoryListing(agentId, initialTargetDirectory);
}, [agentId, initialTargetDirectory, requestDirectoryListing]);
useEffect(() => {
if (!agentId || !normalizedFileParam) {
pendingFileParamRef.current = null;
return;
}
pendingFileParamRef.current = normalizedFileParam;
requestFilePreview(agentId, normalizedFileParam);
}, [agentId, normalizedFileParam, requestFilePreview]);
useEffect(() => {
if (!agentId) {
return;
}
const targetFile = pendingFileParamRef.current;
if (!targetFile) {
return;
}
const hasEntry = entries.some((entry) => entry.path === targetFile);
if (!hasEntry) {
return;
}
setSelectedEntryPath(targetFile);
pendingFileParamRef.current = null;
}, [agentId, entries]);
const handleEntryPress = useCallback(
(entry: ExplorerEntry) => {
if (!agentId) {
@@ -95,6 +143,26 @@ export default function FileExplorerScreen() {
requestDirectoryListing(agentId, parentPath);
}, [agentId, parentPath, requestDirectoryListing]);
const handleCopyPath = useCallback(async (path: string) => {
await Clipboard.setStringAsync(path);
setCopiedPath(path);
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
copyTimeoutRef.current = setTimeout(() => {
setCopiedPath(null);
copyTimeoutRef.current = null;
}, 1500);
}, []);
useEffect(() => {
return () => {
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
};
}, []);
if (!agent) {
return (
<View style={styles.container}>
@@ -108,110 +176,109 @@ export default function FileExplorerScreen() {
return (
<View style={styles.container}>
<BackHeader title="Files" />
<View style={styles.headerRow}>
<View style={styles.breadcrumbs}>
<Text style={styles.breadcrumbLabel}>Path</Text>
<ScrollView horizontal>
<Text style={styles.breadcrumbText}>{currentPath}</Text>
</ScrollView>
</View>
{parentPath && (
<Pressable style={styles.upButton} onPress={handleNavigateUp}>
<Text style={styles.upButtonText}>Up</Text>
</Pressable>
)}
</View>
<BackHeader title={selectedEntryPath ?? currentPath || "."} />
<View style={styles.content}>
{shouldShowPreview && (
<View style={styles.previewSection}>
{isLoading && !preview ? (
{shouldShowPreview ? (
<View style={styles.previewWrapper}>
<UpRow label="Back to directory" onPress={() => setSelectedEntryPath(null)} />
<View style={styles.previewSection}>
{isLoading && !preview ? (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file...</Text>
</View>
) : !preview ? (
<View style={styles.centerState}>
<Text style={styles.emptyText}>No preview available yet</Text>
</View>
) : preview.kind === "text" ? (
<ScrollView
style={styles.textPreview}
horizontal={false}
contentContainerStyle={styles.textPreviewContent}
>
<ScrollView horizontal>
<Text style={styles.codeText}>{preview.content}</Text>
</ScrollView>
</ScrollView>
) : preview.kind === "image" && preview.content ? (
<View style={styles.imagePreviewContainer}>
<Image
source={{
uri: `data:${preview.mimeType ?? "image/png"};base64,${
preview.content
}`,
}}
style={styles.image}
resizeMode="contain"
/>
</View>
) : (
<View style={styles.centerState}>
<Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.entryMeta}>
{formatFileSize({ size: preview.size })}
</Text>
</View>
)}
</View>
</View>
) : (
<View style={styles.listSection}>
{error ? (
<View style={styles.centerState}>
<Text style={styles.errorText}>{error}</Text>
</View>
) : isLoading && entries.length === 0 ? (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file...</Text>
<Text style={styles.loadingText}>Loading...</Text>
</View>
) : !preview ? (
) : entries.length === 0 ? (
<View style={styles.centerState}>
<Text style={styles.emptyText}>No preview available yet</Text>
</View>
) : preview.kind === "text" ? (
<ScrollView
style={styles.textPreview}
horizontal={false}
contentContainerStyle={styles.textPreviewContent}
>
<ScrollView horizontal>
<Text style={styles.codeText}>{preview.content}</Text>
</ScrollView>
</ScrollView>
) : preview.kind === "image" && preview.content ? (
<View style={styles.imagePreviewContainer}>
<Image
source={{
uri: `data:${preview.mimeType ?? "image/png"};base64,${
preview.content
}`,
}}
style={styles.image}
resizeMode="contain"
/>
<Text style={styles.emptyText}>Directory is empty</Text>
</View>
) : (
<View style={styles.centerState}>
<Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.entryMeta}>
{formatFileSize({ size: preview.size })}
</Text>
</View>
<ScrollView contentContainerStyle={styles.entriesContent}>
{parentPath && <UpRow label=".." onPress={handleNavigateUp} />}
{entries.map((entry) => (
<Pressable
key={entry.path}
style={[
styles.entryRow,
entry.kind === "directory"
? styles.directoryRow
: styles.fileRow,
]}
onPress={() => handleEntryPress(entry)}
>
<View style={styles.entryTextContainer}>
<Text style={styles.entryName}>{entry.name}</Text>
<Text style={styles.entryMeta}>
{entry.kind.toUpperCase()} · {formatFileSize({ size: entry.size })} · {formatModifiedTime({ value: entry.modifiedAt })}
</Text>
</View>
<Pressable
onPress={(event) => {
event.stopPropagation();
handleCopyPath(entry.path);
}}
hitSlop={8}
style={styles.copyButton}
>
{copiedPath === entry.path ? (
<Check size={16} color={theme.colors.primary} />
) : (
<Copy size={16} color={theme.colors.foreground} />
)}
</Pressable>
</Pressable>
))}
</ScrollView>
)}
</View>
)}
<View style={styles.listSection}>
{error ? (
<View style={styles.centerState}>
<Text style={styles.errorText}>{error}</Text>
</View>
) : isLoading && entries.length === 0 ? (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading...</Text>
</View>
) : entries.length === 0 ? (
<View style={styles.centerState}>
<Text style={styles.emptyText}>Directory is empty</Text>
</View>
) : (
<ScrollView>
{entries.map((entry) => (
<Pressable
key={entry.path}
style={[
styles.entryRow,
entry.kind === "directory"
? styles.directoryRow
: styles.fileRow,
selectedEntryPath === entry.path && styles.selectedRow,
]}
onPress={() => handleEntryPress(entry)}
>
<View style={styles.entryTextContainer}>
<Text style={styles.entryName}>{entry.name}</Text>
<Text style={styles.entryMeta}>
{entry.kind.toUpperCase()} ·{" "}
{formatFileSize({ size: entry.size })} ·{" "}
{formatModifiedTime({ value: entry.modifiedAt })}
</Text>
</View>
<Text style={styles.entryAction}>
{entry.kind === "directory" ? "Open" : "Preview"}
</Text>
</Pressable>
))}
</ScrollView>
)}
</View>
</View>
</View>
);
@@ -235,54 +302,76 @@ function formatModifiedTime({ value }: { value: string }): string {
return date.toLocaleString();
}
function getFirstParam(value?: string | string[]): string | null {
if (Array.isArray(value)) {
return value[0] ?? null;
}
return value ?? null;
}
function normalizePathParam(value: string | null): string | null {
if (value === null) {
return null;
}
const trimmed = value.trim();
if (!trimmed.length) {
return ".";
}
return trimmed.replace(/\\/g, "/");
}
function normalizeFileParam(value: string | null): string | null {
if (value === null) {
return null;
}
const trimmed = value.trim();
if (!trimmed.length) {
return null;
}
return trimmed.replace(/\\/g, "/");
}
function deriveDirectoryFromFile(filePath: string): string {
const normalized = filePath.replace(/\\/g, "/");
const lastSlash = normalized.lastIndexOf("/");
if (lastSlash === -1) {
return ".";
}
const directory = normalized.slice(0, lastSlash);
return directory.length > 0 ? directory : ".";
}
function UpRow({ label, onPress }: { label: string; onPress: () => void }) {
const { theme } = useUnistyles();
return (
<Pressable style={styles.upRow} onPress={onPress}>
<ArrowLeft size={16} color={theme.colors.foreground} />
<Text style={styles.upRowText}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
headerRow: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
gap: theme.spacing[3],
},
breadcrumbs: {
flex: 1,
},
breadcrumbLabel: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
marginBottom: theme.spacing[1],
},
breadcrumbText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontFamily: "monospace",
},
upButton: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.muted,
},
upButtonText: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.semibold,
},
content: {
flex: 1,
flexDirection: "column",
paddingHorizontal: theme.spacing[4],
paddingBottom: theme.spacing[4],
gap: theme.spacing[4],
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[3],
gap: theme.spacing[3],
},
listSection: {
flex: 1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[2],
},
entriesContent: {
paddingBottom: theme.spacing[4],
},
previewWrapper: {
flex: 1,
gap: theme.spacing[2],
},
previewSection: {
flex: 1,
@@ -316,10 +405,12 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: theme.spacing[3],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
marginBottom: theme.spacing[2],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
marginBottom: theme.spacing[1],
},
directoryRow: {
backgroundColor: theme.colors.muted,
@@ -328,7 +419,6 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.card,
},
selectedRow: {
borderWidth: theme.borderWidth[2],
borderColor: theme.colors.primary,
},
entryTextContainer: {
@@ -345,7 +435,25 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
entryAction: {
copyButton: {
width: 36,
height: 36,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
upRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.background,
},
upRowText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
@@ -448,7 +448,8 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.muted,
borderRadius: theme.borderRadius.lg,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontSize: theme.fontSize.lg,
lineHeight: theme.fontSize.lg * 1.4,
},
buttonRow: {
flexDirection: "row",
@@ -14,17 +14,20 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { BottomSheetModal } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { ChevronDown } from "lucide-react-native";
import { useRouter } from "expo-router";
import {
AssistantMessage,
UserMessage,
ActivityLog,
ToolCall,
AgentThoughtMessage,
type InlinePathTarget,
} from "./message";
import { ToolCallBottomSheet } from "./tool-call-bottom-sheet";
import type { StreamItem } from "@/types/stream";
import type { SelectedToolCall, PendingPermission } from "@/types/shared";
import type { Agent } from "@/contexts/session-context";
import { useSession } from "@/contexts/session-context";
export interface AgentStreamViewProps {
agentId: string;
@@ -52,6 +55,8 @@ export function AgentStreamView({
const isProgrammaticScrollRef = useRef(false);
const isNearBottomRef = useRef(true);
const isUserScrollingRef = useRef(false);
const router = useRouter();
const { requestDirectoryListing, requestFilePreview } = useSession();
useEffect(() => {
hasScrolledInitially.current = false;
@@ -71,6 +76,40 @@ export function AgentStreamView({
setSelectedToolCall(null);
}
const handleInlinePathPress = useCallback(
(target: InlinePathTarget) => {
if (!target.path) {
return;
}
const normalized = normalizeInlinePath(target.path);
if (!normalized) {
return;
}
requestDirectoryListing(agentId, normalized.directory);
if (normalized.file) {
requestFilePreview(agentId, normalized.file);
}
router.push({
pathname: "/file-explorer",
params: {
agentId,
path: normalized.directory,
...(normalized.file ? { file: normalized.file } : {}),
...(target.lineStart !== undefined
? { lineStart: String(target.lineStart) }
: {}),
...(target.lineEnd !== undefined
? { lineEnd: String(target.lineEnd) }
: {}),
},
});
},
[agentId, requestDirectoryListing, requestFilePreview, router]
);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset } = event.nativeEvent;
@@ -179,6 +218,7 @@ export function AgentStreamView({
<AssistantMessage
message={item.text}
timestamp={item.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress}
/>
);
@@ -324,6 +364,48 @@ export function AgentStreamView({
);
}
function normalizeInlinePath(rawPath: string):
| { directory: string; file?: string }
| null {
if (!rawPath) {
return null;
}
let value = rawPath.trim();
value = value.replace(/^['"`]/, "").replace(/['"`]$/, "");
if (!value) {
return null;
}
let normalized = value.replace(/\\/g, "/");
normalized = normalized.replace(/\/{2,}/g, "/");
if (normalized.startsWith("./")) {
normalized = normalized.slice(2) || ".";
}
if (!normalized.length) {
normalized = ".";
}
if (normalized === ".") {
return { directory: "." };
}
if (normalized.endsWith("/")) {
const dir = normalized.replace(/\/+$/, "");
return { directory: dir.length > 0 ? dir : "." };
}
const lastSlash = normalized.lastIndexOf("/");
const directory = lastSlash >= 0 ? normalized.slice(0, lastSlash) : ".";
return {
directory: directory.length > 0 ? directory : ".",
file: normalized,
};
}
// Permission Request Card Component
function PermissionRequestCard({
permission,
+141 -1
View File
@@ -125,10 +125,18 @@ export const UserMessage = memo(function UserMessage({
);
});
export interface InlinePathTarget {
raw: string;
path: string;
lineStart?: number;
lineEnd?: number;
}
interface AssistantMessageProps {
message: string;
timestamp: number;
isStreaming?: boolean;
onInlinePathPress?: (target: InlinePathTarget) => void;
}
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
@@ -211,6 +219,19 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontStyle: "italic" as const,
},
pathChip: {
backgroundColor: theme.colors.secondary,
borderRadius: theme.borderRadius.full,
paddingHorizontal: theme.spacing[2],
paddingVertical: 2,
marginRight: theme.spacing[1],
marginVertical: 2,
},
pathChipText: {
color: theme.colors.secondaryForeground,
fontFamily: "monospace",
fontSize: 13,
},
}));
const markdownStyles = {
@@ -229,12 +250,129 @@ const markdownStyles = {
blockquote_text: assistantMessageStylesheet.markdownBlockquoteText,
};
function isLikelyPathToken(value: string): boolean {
if (!value || value.length > 300) {
return false;
}
if (/\s/.test(value)) {
return false;
}
const hasSlash = value.includes("/") || value.includes("\\");
const hasExtension = /\.[a-zA-Z0-9]{1,8}$/.test(value);
if (!hasSlash && !hasExtension) {
return false;
}
const looksLikeDir = value.endsWith("/") || value.startsWith("./") || value.startsWith("../");
return hasExtension || looksLikeDir || value.includes("/");
}
function normalizeInlinePathValue(value: string): string | null {
const trimmed = value.trim().replace(/^['"`]/, "").replace(/['"`]$/, "");
if (!trimmed) {
return null;
}
return trimmed.replace(/\\/g, "/");
}
function parseInlinePathToken(
value: string,
lastPathRef: React.MutableRefObject<string | null>
): InlinePathTarget | null {
const rawValue = value ?? "";
const trimmed = rawValue.trim();
if (!trimmed) {
return null;
}
const rangeOnlyMatch = trimmed.match(/^:([0-9]+)(?:-([0-9]+))?$/);
if (rangeOnlyMatch) {
const basePath = lastPathRef.current;
if (!basePath) {
return null;
}
const lineStart = parseInt(rangeOnlyMatch[1], 10);
const lineEnd = rangeOnlyMatch[2] ? parseInt(rangeOnlyMatch[2], 10) : undefined;
return {
raw: rawValue,
path: basePath,
lineStart,
lineEnd,
};
}
const pathMatch = trimmed.match(/^(.*?)(?::([0-9]+)(?:-([0-9]+))?)?$/);
if (!pathMatch) {
return null;
}
const basePath = pathMatch[1]?.trim();
if (!basePath || !isLikelyPathToken(basePath)) {
return null;
}
const normalizedPath = normalizeInlinePathValue(basePath);
if (!normalizedPath) {
return null;
}
lastPathRef.current = normalizedPath;
const lineStart = pathMatch[2] ? parseInt(pathMatch[2], 10) : undefined;
const lineEnd = pathMatch[3] ? parseInt(pathMatch[3], 10) : undefined;
return {
raw: rawValue,
path: normalizedPath,
lineStart,
lineEnd,
};
}
export const AssistantMessage = memo(function AssistantMessage({
message,
timestamp,
isStreaming = false,
onInlinePathPress,
}: AssistantMessageProps) {
const fadeAnim = useRef(new Animated.Value(0.3)).current;
const lastPathRef = useRef<string | null>(null);
const markdownRules = useMemo(() => {
if (!onInlinePathPress) {
return undefined;
}
return {
code_inline: (node: any) => {
const content = node.content ?? "";
const parsed = parseInlinePathToken(content, lastPathRef);
if (!parsed) {
return (
<Text key={node.key} style={assistantMessageStylesheet.markdownCodeInline}>
{content}
</Text>
);
}
return (
<Text
key={node.key}
onPress={() => parsed && onInlinePathPress?.(parsed)}
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
>
{content}
</Text>
);
},
};
}, [onInlinePathPress]);
useEffect(() => {
if (isStreaming) {
@@ -260,7 +398,9 @@ export const AssistantMessage = memo(function AssistantMessage({
return (
<View style={assistantMessageStylesheet.container}>
<Markdown style={markdownStyles}>{message}</Markdown>
<Markdown style={markdownStyles} rules={markdownRules}>
{message}
</Markdown>
{isStreaming && (
<Animated.View
style={[
@@ -12,6 +12,164 @@ import {
} from "@gorhom/bottom-sheet";
import type { SelectedToolCall } from "@/types/shared";
type DiffLine = {
type: "add" | "remove" | "context" | "header";
content: string;
};
type EditEntry = {
filePath?: string;
diffLines: DiffLine[];
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function splitIntoLines(text: string): string[] {
if (!text) {
return [];
}
return text.replace(/\r\n/g, "\n").split("\n");
}
function buildLineDiff(originalText: string, updatedText: string): DiffLine[] {
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;
}
function parseUnifiedDiff(diffText?: string): DiffLine[] {
if (!diffText) {
return [];
}
const lines = splitIntoLines(diffText);
const diff: DiffLine[] = [];
for (const line of lines) {
if (!line.length) {
diff.push({ type: "context", content: line });
continue;
}
if (line.startsWith("@@")) {
diff.push({ type: "header", content: line });
continue;
}
if (line.startsWith("+")) {
if (!line.startsWith("+++")) {
diff.push({ type: "add", content: line });
}
continue;
}
if (line.startsWith("-")) {
if (!line.startsWith("---")) {
diff.push({ type: "remove", content: line });
}
continue;
}
if (
line.startsWith("diff --git") ||
line.startsWith("index ") ||
line.startsWith("---") ||
line.startsWith("+++")
) {
continue;
}
if (line.startsWith("\\ No newline")) {
diff.push({ type: "header", content: line });
continue;
}
diff.push({ type: "context", content: line });
}
return diff;
}
function deriveDiffLines({
unifiedDiff,
original,
updated,
}: {
unifiedDiff?: string;
original?: string;
updated?: string;
}): DiffLine[] {
if (unifiedDiff) {
const parsed = parseUnifiedDiff(unifiedDiff);
if (parsed.length > 0) {
return parsed;
}
}
if (original !== undefined || updated !== undefined) {
return buildLineDiff(original ?? "", updated ?? "");
}
return [];
}
interface ToolCallBottomSheetProps {
bottomSheetRef: React.RefObject<BottomSheetModal | null>;
selectedToolCall: SelectedToolCall | null;
@@ -81,6 +239,110 @@ export function ToolCallBottomSheet({
}
}, [selectedToolCall]);
const editEntries = useMemo(() => {
if (!args || !isRecord(args)) {
return [] as EditEntry[];
}
const rawArgs = args as Record<string, unknown>;
const changesValue = rawArgs["changes"];
if (isRecord(changesValue)) {
const changeEntries = Object.entries(
changesValue as Record<string, unknown>
);
return changeEntries
.map<EditEntry | null>(([filePath, value]) => {
if (!isRecord(value)) {
return null;
}
let changeBlock: Record<string, unknown> | null = null;
if (isRecord(value["update"])) {
changeBlock = value["update"] as Record<string, unknown>;
} else if (isRecord(value["create"])) {
changeBlock = value["create"] as Record<string, unknown>;
} else if (isRecord(value["delete"])) {
changeBlock = value["delete"] as Record<string, unknown>;
} else {
changeBlock = value;
}
if (!changeBlock) {
return null;
}
const diffLines = deriveDiffLines({
unifiedDiff: getString(
changeBlock["unified_diff"] ??
changeBlock["diff"] ??
changeBlock["patch"] ??
changeBlock["unifiedDiff"]
),
original:
getString(
changeBlock["old_content"] ??
changeBlock["oldContent"] ??
changeBlock["old_string"] ??
changeBlock["previous_content"] ??
changeBlock["previousContent"] ??
changeBlock["base_content"] ??
changeBlock["baseContent"]
) ?? undefined,
updated:
getString(
changeBlock["new_content"] ??
changeBlock["newContent"] ??
changeBlock["new_string"] ??
changeBlock["content"] ??
changeBlock["replace_with"] ??
changeBlock["replaceWith"]
) ?? undefined,
});
return {
filePath,
diffLines,
};
})
.filter((entry): entry is EditEntry => Boolean(entry));
}
const filePath =
getString(
rawArgs["file_path"] ?? rawArgs["filePath"] ?? rawArgs["path"]
) || undefined;
const diffLines = deriveDiffLines({
original:
getString(
rawArgs["old_string"] ??
rawArgs["oldString"] ??
rawArgs["old_content"] ??
rawArgs["previous_content"] ??
rawArgs["base_content"]
) ?? undefined,
updated:
getString(
rawArgs["new_string"] ??
rawArgs["newString"] ??
rawArgs["new_content"] ??
rawArgs["content"]
) ?? undefined,
});
if (!filePath && diffLines.length === 0) {
return [];
}
return [
{
filePath,
diffLines,
},
];
}, [args]);
return (
<BottomSheetModal
ref={bottomSheetRef}
@@ -102,6 +364,68 @@ export function ToolCallBottomSheet({
contentContainerStyle={styles.sheetContent}
showsVerticalScrollIndicator={true}
>
{editEntries.length > 0 &&
editEntries.map((entry, index) => (
<View key={`${entry.filePath ?? "file"}-${index}`} style={styles.section}>
<Text style={styles.sectionTitle}>File</Text>
<View style={styles.fileInfoContainer}>
<Text style={styles.fileInfoText}>
{entry.filePath ?? "Unknown file"}
</Text>
</View>
<Text style={styles.sectionTitle}>Diff</Text>
<View style={styles.diffContainer}>
{entry.diffLines.length === 0 ? (
<View style={styles.diffEmptyState}>
<Text style={styles.diffEmptyText}>No changes to display</Text>
</View>
) : (
<ScrollView
style={styles.diffScrollVertical}
contentContainerStyle={styles.diffVerticalContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.diffScrollContent}
>
<View style={styles.diffLinesContainer}>
{entry.diffLines.map((line, lineIndex) => (
<View
key={`${line.type}-${lineIndex}`}
style={[
styles.diffLine,
line.type === "header" && styles.diffHeaderLine,
line.type === "add" && styles.diffAddLine,
line.type === "remove" && styles.diffRemoveLine,
line.type === "context" && styles.diffContextLine,
]}
>
<Text
style={[
styles.diffLineText,
line.type === "header" && styles.diffHeaderText,
line.type === "add" && styles.diffAddText,
line.type === "remove" && styles.diffRemoveText,
line.type === "context" && styles.diffContextText,
]}
>
{line.content}
</Text>
</View>
))}
</View>
</ScrollView>
</ScrollView>
)}
</View>
</View>
))}
{/* Content sections */}
{args !== undefined && (
<View style={styles.section}>
@@ -196,6 +520,81 @@ const styles = StyleSheet.create((theme) => ({
textTransform: "uppercase" as const,
letterSpacing: 0.5,
},
fileInfoContainer: {
backgroundColor: theme.colors.background,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
padding: theme.spacing[3],
marginBottom: theme.spacing[4],
},
fileInfoText: {
fontFamily: "monospace",
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
diffContainer: {
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.card,
overflow: "hidden",
},
diffScrollVertical: {
maxHeight: 280,
},
diffVerticalContent: {
flexGrow: 1,
},
diffScrollContent: {
flexDirection: "column" as const,
},
diffLinesContainer: {
alignSelf: "flex-start",
},
diffLine: {
minWidth: "100%",
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
},
diffLineText: {
fontFamily: "monospace",
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
},
diffHeaderLine: {
backgroundColor: theme.colors.muted,
},
diffHeaderText: {
color: theme.colors.mutedForeground,
},
diffAddLine: {
backgroundColor: theme.colors.palette.green[900],
},
diffAddText: {
color: theme.colors.palette.green[200],
},
diffRemoveLine: {
backgroundColor: theme.colors.palette.red[900],
},
diffRemoveText: {
color: theme.colors.palette.red[200],
},
diffContextLine: {
backgroundColor: theme.colors.card,
},
diffContextText: {
color: theme.colors.mutedForeground,
},
diffEmptyState: {
padding: theme.spacing[4],
alignItems: "center" as const,
justifyContent: "center" as const,
},
diffEmptyText: {
fontSize: theme.fontSize.sm,
color: theme.colors.mutedForeground,
},
jsonContainer: {
backgroundColor: theme.colors.background,
borderRadius: theme.borderRadius.lg,