Skip to content

Commit 94888c4

Browse files
fix(cloud): show cloud run file content from session events instead of local file system (#1686)
1 parent c7e3dd9 commit 94888c4

7 files changed

Lines changed: 368 additions & 15 deletions

File tree

apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { PanelMessage } from "@components/ui/PanelMessage";
22
import { Tooltip } from "@components/ui/Tooltip";
33
import { CodeMirrorEditor } from "@features/code-editor/components/CodeMirrorEditor";
4+
import { useCloudFileContent } from "@features/code-editor/hooks/useCloudFileContent";
45
import { useMarkdownViewerStore } from "@features/code-editor/stores/markdownViewerStore";
56
import { getImageMimeType } from "@features/code-editor/utils/imageUtils";
67
import { isMarkdownFile } from "@features/code-editor/utils/markdownUtils";
@@ -9,6 +10,7 @@ import { isImageFile } from "@features/message-editor/utils/imageUtils";
910
import { usePanelLayoutStore } from "@features/panels";
1011
import { useFileTreeStore } from "@features/right-sidebar/stores/fileTreeStore";
1112
import { useCwd } from "@features/sidebar/hooks/useCwd";
13+
import { useIsWorkspaceCloudRun } from "@features/workspace/hooks/useWorkspace";
1214
import { Code, Eye } from "@phosphor-icons/react";
1315
import { Box, Flex, IconButton, Text } from "@radix-ui/themes";
1416
import { trpcClient, useTRPC } from "@renderer/trpc/client";
@@ -82,34 +84,50 @@ export function CodeEditorPanel({
8284
[handleMarkdownLinkClick],
8385
);
8486

87+
const isCloudRun = useIsWorkspaceCloudRun(taskId);
88+
const cloudFile = useCloudFileContent(
89+
taskId,
90+
filePath,
91+
isCloudRun && !isImage,
92+
);
93+
8594
const repoQuery = useQuery(
8695
trpcReact.fs.readRepoFile.queryOptions(
8796
{ repoPath: repoPath ?? "", filePath },
88-
{ enabled: isInsideRepo && !isImage, staleTime: Infinity },
97+
{ enabled: isInsideRepo && !isImage && !isCloudRun, staleTime: Infinity },
8998
),
9099
);
91100

92101
const absoluteQuery = useQuery(
93102
trpcReact.fs.readAbsoluteFile.queryOptions(
94103
{ filePath: absolutePath },
95-
{ enabled: !isInsideRepo && !isImage, staleTime: Infinity },
104+
{
105+
enabled: !isInsideRepo && !isImage && !isCloudRun,
106+
staleTime: Infinity,
107+
},
96108
),
97109
);
98110

99111
const imageQuery = useQuery(
100112
trpcReact.fs.readFileAsBase64.queryOptions(
101113
{ filePath: absolutePath },
102-
{ enabled: isImage, staleTime: Infinity },
114+
{ enabled: isImage && !isCloudRun, staleTime: Infinity },
103115
),
104116
);
105117

106-
const {
107-
data: fileContent,
108-
isLoading,
109-
error,
110-
} = isInsideRepo ? repoQuery : absoluteQuery;
118+
const localQuery = isInsideRepo ? repoQuery : absoluteQuery;
119+
const fileContent = isCloudRun ? cloudFile.content : localQuery.data;
120+
const isLoading = isCloudRun ? cloudFile.isLoading : localQuery.isLoading;
121+
const error = isCloudRun ? null : localQuery.error;
111122

112123
if (isImage) {
124+
if (isCloudRun) {
125+
return (
126+
<PanelMessage detail={filePath}>
127+
Images not available for cloud runs
128+
</PanelMessage>
129+
);
130+
}
113131
if (imageQuery.isLoading) {
114132
return <PanelMessage>Loading image...</PanelMessage>;
115133
}
@@ -140,6 +158,22 @@ export function CodeEditorPanel({
140158
return <PanelMessage>Loading file...</PanelMessage>;
141159
}
142160

161+
if (isCloudRun && !cloudFile.touched) {
162+
return (
163+
<PanelMessage detail={filePath}>
164+
File content not available — the agent did not read or write this file
165+
</PanelMessage>
166+
);
167+
}
168+
169+
if (isCloudRun && cloudFile.touched && cloudFile.content == null) {
170+
return (
171+
<PanelMessage detail={filePath}>
172+
This file was deleted by the agent
173+
</PanelMessage>
174+
);
175+
}
176+
143177
if (error || fileContent == null) {
144178
return (
145179
<PanelMessage detail={absolutePath}>Failed to load file</PanelMessage>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { useCloudEventSummary } from "@features/task-detail/hooks/useCloudEventSummary";
2+
import {
3+
type CloudFileContent,
4+
extractCloudFileContent,
5+
} from "@features/task-detail/utils/cloudToolChanges";
6+
import { useMemo } from "react";
7+
8+
export type CloudFileResult = CloudFileContent & { isLoading: boolean };
9+
10+
export function useCloudFileContent(
11+
taskId: string,
12+
filePath: string,
13+
enabled: boolean,
14+
): CloudFileResult {
15+
const summary = useCloudEventSummary(taskId, enabled);
16+
const isLoading = enabled && summary.toolCalls.size === 0;
17+
18+
return useMemo(() => {
19+
if (!enabled) {
20+
return { content: null, touched: false, isLoading: false };
21+
}
22+
const result = extractCloudFileContent(summary.toolCalls, filePath);
23+
return { ...result, isLoading };
24+
}, [enabled, summary, filePath, isLoading]);
25+
}

apps/code/src/renderer/features/panels/hooks/usePanelLayoutHooks.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,11 @@ export function useTabInjection(
8787
let updatedData = tab.data;
8888
if (tab.data.type === "file") {
8989
const rp = tab.data.relativePath;
90-
const absolutePath = isAbsolutePath(rp) ? rp : `${repoPath}/${rp}`;
90+
const absolutePath = isAbsolutePath(rp)
91+
? rp
92+
: repoPath
93+
? `${repoPath}/${rp}`
94+
: rp;
9195
updatedData = {
9296
...tab.data,
9397
absolutePath,
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { useSessionForTask } from "@features/sessions/hooks/useSession";
2+
import {
3+
buildCloudEventSummary,
4+
type CloudEventSummary,
5+
} from "@features/task-detail/utils/cloudToolChanges";
6+
import { useMemo } from "react";
7+
8+
const EMPTY_SUMMARY: CloudEventSummary = {
9+
toolCalls: new Map(),
10+
treeSnapshotFiles: [],
11+
};
12+
13+
export function useCloudEventSummary(
14+
taskId: string,
15+
enabled = true,
16+
): CloudEventSummary {
17+
const session = useSessionForTask(enabled ? taskId : undefined);
18+
const events = session?.events;
19+
return useMemo(
20+
() => (events ? buildCloudEventSummary(events) : EMPTY_SUMMARY),
21+
[events],
22+
);
23+
}

apps/code/src/renderer/features/task-detail/hooks/useCloudRunState.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { useSessionForTask } from "@features/sessions/hooks/useSession";
2-
import {
3-
buildCloudEventSummary,
4-
extractCloudToolChangedFiles,
5-
} from "@features/task-detail/utils/cloudToolChanges";
2+
import { useCloudEventSummary } from "@features/task-detail/hooks/useCloudEventSummary";
3+
import { extractCloudToolChangedFiles } from "@features/task-detail/utils/cloudToolChanges";
64
import { useTasks } from "@features/tasks/hooks/useTasks";
75
import type { ChangedFile, Task } from "@shared/types";
86
import { useMemo } from "react";
@@ -30,8 +28,7 @@ export function useCloudRunState(taskId: string, task: Task) {
3028
cloudStatus === "in_progress" ||
3129
(cloudStatus === null && session != null);
3230

33-
const events = session?.events;
34-
const summary = useMemo(() => buildCloudEventSummary(events ?? []), [events]);
31+
const summary = useCloudEventSummary(taskId);
3532
const toolCallFiles = useMemo(
3633
() => extractCloudToolChangedFiles(summary.toolCalls),
3734
[summary],
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import {
4+
extractCloudFileContent,
5+
type ParsedToolCall,
6+
} from "./cloudToolChanges";
7+
8+
function toolCall(overrides: Partial<ParsedToolCall>): ParsedToolCall {
9+
return {
10+
toolCallId: overrides.toolCallId ?? "tc-1",
11+
kind: overrides.kind ?? null,
12+
title: overrides.title,
13+
status: overrides.status ?? "completed",
14+
locations: overrides.locations,
15+
content: overrides.content,
16+
};
17+
}
18+
19+
function textContent(text: string): ParsedToolCall["content"] {
20+
return [{ type: "content", content: { type: "text", text } }];
21+
}
22+
23+
function diffContent(
24+
path: string,
25+
newText: string,
26+
oldText?: string,
27+
): ParsedToolCall["content"] {
28+
return [{ type: "diff", path, newText, oldText: oldText ?? null }];
29+
}
30+
31+
function makeToolCalls(
32+
...calls: ParsedToolCall[]
33+
): Map<string, ParsedToolCall> {
34+
return new Map(calls.map((tc, i) => [tc.toolCallId || `tc-${i}`, tc]));
35+
}
36+
37+
describe("extractCloudFileContent", () => {
38+
it("returns untouched for an empty tool calls map", () => {
39+
const result = extractCloudFileContent(new Map(), "src/app.ts");
40+
expect(result).toEqual({ content: null, touched: false });
41+
});
42+
43+
it("returns untouched when no tool call matches the file", () => {
44+
const calls = makeToolCalls(
45+
toolCall({
46+
kind: "read",
47+
locations: [{ path: "src/other.ts" }],
48+
content: textContent("other content"),
49+
}),
50+
);
51+
const result = extractCloudFileContent(calls, "src/app.ts");
52+
expect(result).toEqual({ content: null, touched: false });
53+
});
54+
55+
it("extracts content from a read tool call", () => {
56+
const calls = makeToolCalls(
57+
toolCall({
58+
kind: "read",
59+
locations: [{ path: "src/app.ts" }],
60+
content: textContent("file content"),
61+
}),
62+
);
63+
const result = extractCloudFileContent(calls, "src/app.ts");
64+
expect(result).toEqual({ content: "file content", touched: true });
65+
});
66+
67+
it("extracts content from a write tool call", () => {
68+
const calls = makeToolCalls(
69+
toolCall({
70+
kind: "write",
71+
locations: [{ path: "src/app.ts" }],
72+
content: diffContent("src/app.ts", "new content"),
73+
}),
74+
);
75+
const result = extractCloudFileContent(calls, "src/app.ts");
76+
expect(result).toEqual({ content: "new content", touched: true });
77+
});
78+
79+
it("extracts content from an edit tool call", () => {
80+
const calls = makeToolCalls(
81+
toolCall({
82+
kind: "edit",
83+
locations: [{ path: "src/app.ts" }],
84+
content: diffContent("src/app.ts", "edited content", "old content"),
85+
}),
86+
);
87+
const result = extractCloudFileContent(calls, "src/app.ts");
88+
expect(result).toEqual({ content: "edited content", touched: true });
89+
});
90+
91+
it("marks deleted files as touched with null content", () => {
92+
const calls = makeToolCalls(
93+
toolCall({
94+
toolCallId: "tc-read",
95+
kind: "read",
96+
locations: [{ path: "src/app.ts" }],
97+
content: textContent("original"),
98+
}),
99+
toolCall({
100+
toolCallId: "tc-delete",
101+
kind: "delete",
102+
locations: [{ path: "src/app.ts" }],
103+
}),
104+
);
105+
const result = extractCloudFileContent(calls, "src/app.ts");
106+
expect(result).toEqual({ content: null, touched: true });
107+
});
108+
109+
it("uses the latest content when multiple tool calls touch the same file", () => {
110+
const calls = makeToolCalls(
111+
toolCall({
112+
toolCallId: "tc-read",
113+
kind: "read",
114+
locations: [{ path: "src/app.ts" }],
115+
content: textContent("v1"),
116+
}),
117+
toolCall({
118+
toolCallId: "tc-edit",
119+
kind: "edit",
120+
locations: [{ path: "src/app.ts" }],
121+
content: diffContent("src/app.ts", "v2", "v1"),
122+
}),
123+
);
124+
const result = extractCloudFileContent(calls, "src/app.ts");
125+
expect(result).toEqual({ content: "v2", touched: true });
126+
});
127+
128+
it("skips failed tool calls", () => {
129+
const calls = makeToolCalls(
130+
toolCall({
131+
kind: "write",
132+
status: "failed",
133+
locations: [{ path: "src/app.ts" }],
134+
content: diffContent("src/app.ts", "bad content"),
135+
}),
136+
);
137+
const result = extractCloudFileContent(calls, "src/app.ts");
138+
expect(result).toEqual({ content: null, touched: false });
139+
});
140+
141+
it("matches absolute paths against relative paths", () => {
142+
const calls = makeToolCalls(
143+
toolCall({
144+
kind: "read",
145+
locations: [{ path: "/home/user/project/src/app.ts" }],
146+
content: textContent("absolute match"),
147+
}),
148+
);
149+
const result = extractCloudFileContent(calls, "src/app.ts");
150+
expect(result).toEqual({ content: "absolute match", touched: true });
151+
});
152+
153+
it("infers kind from title when kind is not set", () => {
154+
const calls = makeToolCalls(
155+
toolCall({
156+
kind: null,
157+
title: "Write src/app.ts",
158+
locations: [{ path: "src/app.ts" }],
159+
content: diffContent("src/app.ts", "inferred write"),
160+
}),
161+
);
162+
const result = extractCloudFileContent(calls, "src/app.ts");
163+
expect(result).toEqual({ content: "inferred write", touched: true });
164+
});
165+
166+
describe("move operations", () => {
167+
it("marks file as touched when looking up the source path", () => {
168+
const calls = makeToolCalls(
169+
toolCall({
170+
kind: "move",
171+
locations: [{ path: "src/old.ts" }, { path: "src/new.ts" }],
172+
}),
173+
);
174+
const result = extractCloudFileContent(calls, "src/old.ts");
175+
expect(result).toEqual({ content: null, touched: true });
176+
});
177+
178+
it("marks file as touched when looking up the destination path", () => {
179+
const calls = makeToolCalls(
180+
toolCall({
181+
kind: "move",
182+
locations: [{ path: "src/old.ts" }, { path: "src/new.ts" }],
183+
}),
184+
);
185+
const result = extractCloudFileContent(calls, "src/new.ts");
186+
expect(result).toEqual({ content: null, touched: true });
187+
});
188+
189+
it("extracts content from move with diff", () => {
190+
const calls = makeToolCalls(
191+
toolCall({
192+
kind: "move",
193+
locations: [{ path: "src/old.ts" }, { path: "src/new.ts" }],
194+
content: diffContent("src/new.ts", "moved content"),
195+
}),
196+
);
197+
const result = extractCloudFileContent(calls, "src/new.ts");
198+
expect(result).toEqual({ content: "moved content", touched: true });
199+
});
200+
201+
it("does not match unrelated paths for move", () => {
202+
const calls = makeToolCalls(
203+
toolCall({
204+
kind: "move",
205+
locations: [{ path: "src/old.ts" }, { path: "src/new.ts" }],
206+
}),
207+
);
208+
const result = extractCloudFileContent(calls, "src/other.ts");
209+
expect(result).toEqual({ content: null, touched: false });
210+
});
211+
});
212+
});

0 commit comments

Comments
 (0)