diff --git a/apps/code/src/main/services/git/schemas.ts b/apps/code/src/main/services/git/schemas.ts index f25a73f69c..31829da659 100644 --- a/apps/code/src/main/services/git/schemas.ts +++ b/apps/code/src/main/services/git/schemas.ts @@ -615,6 +615,15 @@ export const getGithubPullRequestInput = getGithubIssueInput; export const getGithubPullRequestOutput = getGithubIssueOutput; +export const getGithubFileContentInput = z.object({ + owner: z.string(), + repo: z.string(), + filePath: z.string(), + ref: z.string(), +}); + +export const getGithubFileContentOutput = z.string().nullable(); + export const createPrProgressPayload = z.object({ flowId: z.string(), step: createPrStep, diff --git a/apps/code/src/main/services/git/service.ts b/apps/code/src/main/services/git/service.ts index 99ee93a957..f3800c8a0e 100644 --- a/apps/code/src/main/services/git/service.ts +++ b/apps/code/src/main/services/git/service.ts @@ -1957,6 +1957,35 @@ ${truncatedDiff || "(no diff available)"}${contextSection}`; return refs[0] ?? null; } + public async getGithubFileContent( + owner: string, + repo: string, + filePath: string, + ref: string, + ): Promise { + const encodedPath = filePath + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); + const result = await execGh([ + "api", + `/repos/${owner}/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`, + "-H", + "Accept: application/vnd.github.raw", + ]); + if (result.exitCode !== 0) { + log.info("Failed to fetch file from GitHub", { + owner, + repo, + filePath, + ref, + stderr: result.stderr, + }); + return null; + } + return result.stdout; + } + private async fetchGhRefs( args: string[], repo: string, diff --git a/apps/code/src/main/trpc/routers/git.ts b/apps/code/src/main/trpc/routers/git.ts index 21b7e65099..056d38f57c 100644 --- a/apps/code/src/main/trpc/routers/git.ts +++ b/apps/code/src/main/trpc/routers/git.ts @@ -37,6 +37,8 @@ import { getFileAtHeadOutput, getGitBusyStateInput, getGitBusyStateOutput, + getGithubFileContentInput, + getGithubFileContentOutput, getGithubIssueInput, getGithubIssueOutput, getGithubPullRequestInput, @@ -463,6 +465,18 @@ export const gitRouter = router({ getService().getGithubPullRequest(input.owner, input.repo, input.number), ), + getGithubFileContent: publicProcedure + .input(getGithubFileContentInput) + .output(getGithubFileContentOutput) + .query(({ input }) => + getService().getGithubFileContent( + input.owner, + input.repo, + input.filePath, + input.ref, + ), + ), + onCreatePrProgress: publicProcedure.subscription(async function* (opts) { const service = getService(); const iterable = service.toIterable(GitServiceEvent.CreatePrProgress, { diff --git a/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx b/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx index aa7b1f7bca..ed6a18e7af 100644 --- a/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx +++ b/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx @@ -9,6 +9,7 @@ import { isMarkdownFile } from "@features/code-editor/utils/markdownUtils"; import { getRelativePath } from "@features/code-editor/utils/pathUtils"; import { usePanelLayoutStore } from "@features/panels"; import { useFileTreeStore } from "@features/right-sidebar/stores/fileTreeStore"; +import { useSessionForTask } from "@features/sessions/hooks/useSession"; import { useCwd } from "@features/sidebar/hooks/useCwd"; import { useIsWorkspaceCloudRun } from "@features/workspace/hooks/useWorkspace"; import { Check, Copy } from "@phosphor-icons/react"; @@ -17,7 +18,7 @@ import { isRasterImageFile, parseImageDataUrl, } from "@posthog/shared"; -import { Box, Flex, IconButton, Text } from "@radix-ui/themes"; +import { Box, Button, Flex, IconButton, Text } from "@radix-ui/themes"; import { trpcClient, useTRPC } from "@renderer/trpc/client"; import type { Task } from "@shared/types"; @@ -67,9 +68,26 @@ function FilePanelImagePreview({ ); } +const CLOUD_SANDBOX_REPOS_ROOT = "/tmp/workspace/repos"; + +function toRepoRelativePath( + owner: string, + name: string, + path: string, +): string | null { + if (!path.startsWith("/")) return path; + const prefix = `${CLOUD_SANDBOX_REPOS_ROOT}/${owner}/${name}/`; + if (!path.startsWith(prefix)) return null; + return path.slice(prefix.length); +} + +function encodePathSegments(path: string): string { + return path.split("/").map(encodeURIComponent).join("/"); +} + export function CodeEditorPanel({ taskId, - task: _task, + task, absolutePath, }: CodeEditorPanelProps) { const trpcReact = useTRPC(); @@ -125,6 +143,38 @@ export function CodeEditorPanel({ filePath, isCloudRun && !isImage, ); + const cloudSession = useSessionForTask(isCloudRun ? taskId : undefined); + const cloudFileMeta = useMemo(() => { + if (!isCloudRun) return null; + const repo = task.repository ?? null; + const branch = task.latest_run?.branch ?? cloudSession?.cloudBranch ?? null; + if (!repo || !branch) return null; + const [owner, name] = repo.split("/"); + if (!owner || !name) return null; + const repoRelativePath = toRepoRelativePath(owner, name, filePath); + if (!repoRelativePath) return null; + return { + owner, + name, + branch, + repoRelativePath, + blobUrl: `https://github.com/${owner}/${name}/blob/${encodeURIComponent(branch)}/${encodePathSegments(repoRelativePath)}`, + }; + }, [isCloudRun, task, cloudSession, filePath]); + + const shouldFetchFromGithub = + isCloudRun && !isImage && !cloudFile.touched && cloudFileMeta != null; + const githubFileQuery = useQuery( + trpcReact.git.getGithubFileContent.queryOptions( + { + owner: cloudFileMeta?.owner ?? "", + repo: cloudFileMeta?.name ?? "", + filePath: cloudFileMeta?.repoRelativePath ?? "", + ref: cloudFileMeta?.branch ?? "", + }, + { enabled: shouldFetchFromGithub, staleTime: 5 * 60 * 1000 }, + ), + ); const repoQuery = useQuery( trpcReact.fs.readRepoFile.queryOptions( @@ -151,8 +201,16 @@ export function CodeEditorPanel({ ); const localQuery = isInsideRepo ? repoQuery : absoluteQuery; - const fileContent = isCloudRun ? cloudFile.content : localQuery.data; - const isLoading = isCloudRun ? cloudFile.isLoading : localQuery.isLoading; + const cloudContentQuery = shouldFetchFromGithub + ? { + content: githubFileQuery.data ?? null, + isLoading: githubFileQuery.isLoading, + } + : { content: cloudFile.content, isLoading: cloudFile.isLoading }; + const fileContent = isCloudRun ? cloudContentQuery.content : localQuery.data; + const isLoading = isCloudRun + ? cloudContentQuery.isLoading + : localQuery.isLoading; const error = isCloudRun ? null : localQuery.error; const enrichment = useFileEnrichment({ @@ -198,10 +256,42 @@ export function CodeEditorPanel({ return Loading file...; } - if (isCloudRun && !cloudFile.touched) { + if (isCloudRun && !cloudFile.touched && !shouldFetchFromGithub) { + return ( + + File content not available — the agent did not read or write this file, + and the cloud run's branch could not be resolved + + ); + } + + if ( + isCloudRun && + !cloudFile.touched && + shouldFetchFromGithub && + fileContent == null + ) { return ( - File content not available — the agent did not read or write this file + + + Couldn't load file from GitHub — the agent did not read or write + this file + + {cloudFileMeta && ( + + )} + ); }