Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { PanelMessage } from "@components/ui/PanelMessage";
import { Tooltip } from "@components/ui/Tooltip";
import { CodeMirrorEditor } from "@features/code-editor/components/CodeMirrorEditor";
import { useCloudFileContent } from "@features/code-editor/hooks/useCloudFileContent";
import { useMarkdownViewerStore } from "@features/code-editor/stores/markdownViewerStore";
import { getImageMimeType } from "@features/code-editor/utils/imageUtils";
import { isMarkdownFile } from "@features/code-editor/utils/markdownUtils";
Expand All @@ -9,6 +10,7 @@ import { isImageFile } from "@features/message-editor/utils/imageUtils";
import { usePanelLayoutStore } from "@features/panels";
import { useFileTreeStore } from "@features/right-sidebar/stores/fileTreeStore";
import { useCwd } from "@features/sidebar/hooks/useCwd";
import { useIsWorkspaceCloudRun } from "@features/workspace/hooks/useWorkspace";
import { Code, Eye } from "@phosphor-icons/react";
import { Box, Flex, IconButton, Text } from "@radix-ui/themes";
import { trpcClient, useTRPC } from "@renderer/trpc/client";
Expand Down Expand Up @@ -82,34 +84,50 @@ export function CodeEditorPanel({
[handleMarkdownLinkClick],
);

const isCloudRun = useIsWorkspaceCloudRun(taskId);
const cloudFile = useCloudFileContent(
taskId,
filePath,
isCloudRun && !isImage,
);

const repoQuery = useQuery(
trpcReact.fs.readRepoFile.queryOptions(
{ repoPath: repoPath ?? "", filePath },
{ enabled: isInsideRepo && !isImage, staleTime: Infinity },
{ enabled: isInsideRepo && !isImage && !isCloudRun, staleTime: Infinity },
),
);

const absoluteQuery = useQuery(
trpcReact.fs.readAbsoluteFile.queryOptions(
{ filePath: absolutePath },
{ enabled: !isInsideRepo && !isImage, staleTime: Infinity },
{
enabled: !isInsideRepo && !isImage && !isCloudRun,
staleTime: Infinity,
},
),
);

const imageQuery = useQuery(
trpcReact.fs.readFileAsBase64.queryOptions(
{ filePath: absolutePath },
{ enabled: isImage, staleTime: Infinity },
{ enabled: isImage && !isCloudRun, staleTime: Infinity },
),
);

const {
data: fileContent,
isLoading,
error,
} = isInsideRepo ? repoQuery : absoluteQuery;
const localQuery = isInsideRepo ? repoQuery : absoluteQuery;
const fileContent = isCloudRun ? cloudFile.content : localQuery.data;
const isLoading = isCloudRun ? cloudFile.isLoading : localQuery.isLoading;
const error = isCloudRun ? null : localQuery.error;

if (isImage) {
if (isCloudRun) {
return (
<PanelMessage detail={filePath}>
Images not available for cloud runs
</PanelMessage>
);
}
if (imageQuery.isLoading) {
return <PanelMessage>Loading image...</PanelMessage>;
}
Expand Down Expand Up @@ -140,6 +158,22 @@ export function CodeEditorPanel({
return <PanelMessage>Loading file...</PanelMessage>;
}

if (isCloudRun && !cloudFile.touched) {
return (
<PanelMessage detail={filePath}>
File content not available — the agent did not read or write this file
</PanelMessage>
);
}

if (isCloudRun && cloudFile.touched && cloudFile.content == null) {
return (
<PanelMessage detail={filePath}>
This file was deleted by the agent
</PanelMessage>
);
}

if (error || fileContent == null) {
return (
<PanelMessage detail={absolutePath}>Failed to load file</PanelMessage>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useCloudEventSummary } from "@features/task-detail/hooks/useCloudEventSummary";
import {
type CloudFileContent,
extractCloudFileContent,
} from "@features/task-detail/utils/cloudToolChanges";
import { useMemo } from "react";

export type CloudFileResult = CloudFileContent & { isLoading: boolean };

export function useCloudFileContent(
taskId: string,
filePath: string,
enabled: boolean,
): CloudFileResult {
const summary = useCloudEventSummary(taskId, enabled);
const isLoading = enabled && summary.toolCalls.size === 0;

return useMemo(() => {
if (!enabled) {
return { content: null, touched: false, isLoading: false };
}
const result = extractCloudFileContent(summary.toolCalls, filePath);
return { ...result, isLoading };
}, [enabled, summary, filePath, isLoading]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,11 @@ export function useTabInjection(
let updatedData = tab.data;
if (tab.data.type === "file") {
const rp = tab.data.relativePath;
const absolutePath = isAbsolutePath(rp) ? rp : `${repoPath}/${rp}`;
const absolutePath = isAbsolutePath(rp)
? rp
: repoPath
? `${repoPath}/${rp}`
: rp;
updatedData = {
...tab.data,
absolutePath,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useSessionForTask } from "@features/sessions/hooks/useSession";
import {
buildCloudEventSummary,
type CloudEventSummary,
} from "@features/task-detail/utils/cloudToolChanges";
import { useMemo } from "react";

const EMPTY_SUMMARY: CloudEventSummary = {
toolCalls: new Map(),
treeSnapshotFiles: [],
};

export function useCloudEventSummary(
taskId: string,
enabled = true,
): CloudEventSummary {
const session = useSessionForTask(enabled ? taskId : undefined);
const events = session?.events;
return useMemo(
() => (events ? buildCloudEventSummary(events) : EMPTY_SUMMARY),
[events],
);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { useSessionForTask } from "@features/sessions/hooks/useSession";
import {
buildCloudEventSummary,
extractCloudToolChangedFiles,
} from "@features/task-detail/utils/cloudToolChanges";
import { useCloudEventSummary } from "@features/task-detail/hooks/useCloudEventSummary";
import { extractCloudToolChangedFiles } from "@features/task-detail/utils/cloudToolChanges";
import { useTasks } from "@features/tasks/hooks/useTasks";
import type { ChangedFile, Task } from "@shared/types";
import { useMemo } from "react";
Expand Down Expand Up @@ -30,8 +28,7 @@ export function useCloudRunState(taskId: string, task: Task) {
cloudStatus === "in_progress" ||
(cloudStatus === null && session != null);

const events = session?.events;
const summary = useMemo(() => buildCloudEventSummary(events ?? []), [events]);
const summary = useCloudEventSummary(taskId);
const toolCallFiles = useMemo(
() => extractCloudToolChangedFiles(summary.toolCalls),
[summary],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { describe, expect, it } from "vitest";

import {
extractCloudFileContent,
type ParsedToolCall,
} from "./cloudToolChanges";

function toolCall(overrides: Partial<ParsedToolCall>): ParsedToolCall {
return {
toolCallId: overrides.toolCallId ?? "tc-1",
kind: overrides.kind ?? null,
title: overrides.title,
status: overrides.status ?? "completed",
locations: overrides.locations,
content: overrides.content,
};
}

function textContent(text: string): ParsedToolCall["content"] {
return [{ type: "content", content: { type: "text", text } }];
}

function diffContent(
path: string,
newText: string,
oldText?: string,
): ParsedToolCall["content"] {
return [{ type: "diff", path, newText, oldText: oldText ?? null }];
}

function makeToolCalls(
...calls: ParsedToolCall[]
): Map<string, ParsedToolCall> {
return new Map(calls.map((tc, i) => [tc.toolCallId || `tc-${i}`, tc]));
}

describe("extractCloudFileContent", () => {
it("returns untouched for an empty tool calls map", () => {
const result = extractCloudFileContent(new Map(), "src/app.ts");
expect(result).toEqual({ content: null, touched: false });
});

it("returns untouched when no tool call matches the file", () => {
const calls = makeToolCalls(
toolCall({
kind: "read",
locations: [{ path: "src/other.ts" }],
content: textContent("other content"),
}),
);
const result = extractCloudFileContent(calls, "src/app.ts");
expect(result).toEqual({ content: null, touched: false });
});

it("extracts content from a read tool call", () => {
const calls = makeToolCalls(
toolCall({
kind: "read",
locations: [{ path: "src/app.ts" }],
content: textContent("file content"),
}),
);
const result = extractCloudFileContent(calls, "src/app.ts");
expect(result).toEqual({ content: "file content", touched: true });
});

it("extracts content from a write tool call", () => {
const calls = makeToolCalls(
toolCall({
kind: "write",
locations: [{ path: "src/app.ts" }],
content: diffContent("src/app.ts", "new content"),
}),
);
const result = extractCloudFileContent(calls, "src/app.ts");
expect(result).toEqual({ content: "new content", touched: true });
});

it("extracts content from an edit tool call", () => {
const calls = makeToolCalls(
toolCall({
kind: "edit",
locations: [{ path: "src/app.ts" }],
content: diffContent("src/app.ts", "edited content", "old content"),
}),
);
const result = extractCloudFileContent(calls, "src/app.ts");
expect(result).toEqual({ content: "edited content", touched: true });
});

it("marks deleted files as touched with null content", () => {
const calls = makeToolCalls(
toolCall({
toolCallId: "tc-read",
kind: "read",
locations: [{ path: "src/app.ts" }],
content: textContent("original"),
}),
toolCall({
toolCallId: "tc-delete",
kind: "delete",
locations: [{ path: "src/app.ts" }],
}),
);
const result = extractCloudFileContent(calls, "src/app.ts");
expect(result).toEqual({ content: null, touched: true });
});

it("uses the latest content when multiple tool calls touch the same file", () => {
const calls = makeToolCalls(
toolCall({
toolCallId: "tc-read",
kind: "read",
locations: [{ path: "src/app.ts" }],
content: textContent("v1"),
}),
toolCall({
toolCallId: "tc-edit",
kind: "edit",
locations: [{ path: "src/app.ts" }],
content: diffContent("src/app.ts", "v2", "v1"),
}),
);
const result = extractCloudFileContent(calls, "src/app.ts");
expect(result).toEqual({ content: "v2", touched: true });
});

it("skips failed tool calls", () => {
const calls = makeToolCalls(
toolCall({
kind: "write",
status: "failed",
locations: [{ path: "src/app.ts" }],
content: diffContent("src/app.ts", "bad content"),
}),
);
const result = extractCloudFileContent(calls, "src/app.ts");
expect(result).toEqual({ content: null, touched: false });
});

it("matches absolute paths against relative paths", () => {
const calls = makeToolCalls(
toolCall({
kind: "read",
locations: [{ path: "/home/user/project/src/app.ts" }],
content: textContent("absolute match"),
}),
);
const result = extractCloudFileContent(calls, "src/app.ts");
expect(result).toEqual({ content: "absolute match", touched: true });
});

it("infers kind from title when kind is not set", () => {
const calls = makeToolCalls(
toolCall({
kind: null,
title: "Write src/app.ts",
locations: [{ path: "src/app.ts" }],
content: diffContent("src/app.ts", "inferred write"),
}),
);
const result = extractCloudFileContent(calls, "src/app.ts");
expect(result).toEqual({ content: "inferred write", touched: true });
});

describe("move operations", () => {
it("marks file as touched when looking up the source path", () => {
const calls = makeToolCalls(
toolCall({
kind: "move",
locations: [{ path: "src/old.ts" }, { path: "src/new.ts" }],
}),
);
const result = extractCloudFileContent(calls, "src/old.ts");
expect(result).toEqual({ content: null, touched: true });
});

it("marks file as touched when looking up the destination path", () => {
const calls = makeToolCalls(
toolCall({
kind: "move",
locations: [{ path: "src/old.ts" }, { path: "src/new.ts" }],
}),
);
const result = extractCloudFileContent(calls, "src/new.ts");
expect(result).toEqual({ content: null, touched: true });
});

it("extracts content from move with diff", () => {
const calls = makeToolCalls(
toolCall({
kind: "move",
locations: [{ path: "src/old.ts" }, { path: "src/new.ts" }],
content: diffContent("src/new.ts", "moved content"),
}),
);
const result = extractCloudFileContent(calls, "src/new.ts");
expect(result).toEqual({ content: "moved content", touched: true });
});

it("does not match unrelated paths for move", () => {
const calls = makeToolCalls(
toolCall({
kind: "move",
locations: [{ path: "src/old.ts" }, { path: "src/new.ts" }],
}),
);
const result = extractCloudFileContent(calls, "src/other.ts");
expect(result).toEqual({ content: null, touched: false });
});
});
});
Loading
Loading