From a4a3aedb9f08f849418782c4760ef8900b32bded Mon Sep 17 00:00:00 2001 From: Vojta Bartos Date: Wed, 22 Apr 2026 13:56:29 +0200 Subject: [PATCH 1/2] feat(agent): add sandbox git/fs commands for remote filesystem access --- .../code-review/components/DiffStatsBadge.tsx | 74 ++++--- .../agent/src/server/agent-server.test.ts | 181 +++++++++++++++++ packages/agent/src/server/agent-server.ts | 190 +++++++++++++++++- packages/agent/src/server/schemas.test.ts | 99 +++++++++ packages/agent/src/server/schemas.ts | 62 +++++- 5 files changed, 565 insertions(+), 41 deletions(-) diff --git a/apps/code/src/renderer/features/code-review/components/DiffStatsBadge.tsx b/apps/code/src/renderer/features/code-review/components/DiffStatsBadge.tsx index a3060f858..6f51a9b30 100644 --- a/apps/code/src/renderer/features/code-review/components/DiffStatsBadge.tsx +++ b/apps/code/src/renderer/features/code-review/components/DiffStatsBadge.tsx @@ -1,9 +1,7 @@ import { Tooltip } from "@components/ui/Tooltip"; import { useGitQueries } from "@features/git-interaction/hooks/useGitQueries"; -import { computeDiffStats } from "@features/git-interaction/utils/diffStats"; import { useCwd } from "@features/sidebar/hooks/useCwd"; -import { useCloudChangedFiles } from "@features/task-detail/hooks/useCloudChangedFiles"; -import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; +import { useIsWorkspaceCloudRun } from "@features/workspace/hooks/useWorkspace"; import { GitDiff } from "@phosphor-icons/react"; import { Button } from "@posthog/quill"; import { Flex, Text } from "@radix-ui/themes"; @@ -11,54 +9,66 @@ import { formatHotkey, SHORTCUTS, } from "@renderer/constants/keyboard-shortcuts"; +import { useSandboxDiffStats } from "@renderer/features/code-review/hooks/useSandboxGit"; import { useReviewNavigationStore } from "@renderer/features/code-review/stores/reviewNavigationStore"; import type { Task } from "@shared/types"; -import { useMemo } from "react"; interface DiffStatsBadgeProps { task: Task; } -function useChangedFileStats(task: Task) { - const taskId = task.id; - const workspace = useWorkspace(taskId); - const isCloud = - workspace?.mode === "cloud" || task.latest_run?.environment === "cloud"; - const repoPath = useCwd(taskId); +export function DiffStatsBadge({ task }: DiffStatsBadgeProps) { + const isCloud = useIsWorkspaceCloudRun(task.id); + if (isCloud) { + return ; + } + return ; +} - const { diffStats: localDiffStats } = useGitQueries( - isCloud ? undefined : repoPath, +function LocalDiffStatsBadge({ task }: DiffStatsBadgeProps) { + const repoPath = useCwd(task.id); + const { diffStats } = useGitQueries(repoPath); + return ( + ); +} - const { reviewFiles } = useCloudChangedFiles(taskId, task); - - return useMemo(() => { - if (isCloud) { - const stats = computeDiffStats(reviewFiles); - return { - filesChanged: stats.filesChanged, - linesAdded: stats.linesAdded, - linesRemoved: stats.linesRemoved, - }; - } - return { - filesChanged: localDiffStats.filesChanged, - linesAdded: localDiffStats.linesAdded, - linesRemoved: localDiffStats.linesRemoved, - }; - }, [isCloud, reviewFiles, localDiffStats]); +function CloudDiffStatsBadge({ task }: DiffStatsBadgeProps) { + const { data: stats } = useSandboxDiffStats(task.id, { + refetchInterval: 10_000, + }); + return ( + + ); } -export function DiffStatsBadge({ task }: DiffStatsBadgeProps) { +function DiffStatsBadgeView({ + task, + filesChanged, + linesAdded, + linesRemoved, +}: DiffStatsBadgeProps & { + filesChanged: number; + linesAdded: number; + linesRemoved: number; +}) { const taskId = task.id; - const { filesChanged, linesAdded, linesRemoved } = useChangedFileStats(task); const reviewMode = useReviewNavigationStore( (s) => s.reviewModes[taskId] ?? "closed", ); const setReviewMode = useReviewNavigationStore((s) => s.setReviewMode); const hasChanges = filesChanged > 0; - const isOpen = reviewMode !== "closed"; const handleClick = () => { diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index 93566798f..b57fcf51a 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -332,6 +332,187 @@ describe("AgentServer HTTP Mode", () => { }, 20000); }); + describe("sandbox commands", () => { + const sendSandboxCommand = async ( + method: string, + params: Record = {}, + ) => { + const token = createToken(); + return fetch(`http://localhost:${port}/command`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "test-1", + method, + params, + }), + }); + }; + + it("returns changed files from sandbox git state", async () => { + await createServer().start(); + // Create an unstaged change + await repo.writeFile("new-file.txt", "hello world"); + + const response = await sendSandboxCommand("git/changed_files"); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.result).toBeDefined(); + expect(body.result.files).toBeInstanceOf(Array); + + const newFile = body.result.files.find( + (f: { path: string }) => f.path === "new-file.txt", + ); + expect(newFile).toBeDefined(); + expect(newFile.status).toBe("untracked"); + }, 30000); + + it("returns staged diff", async () => { + await createServer().start(); + await repo.writeFile("staged.txt", "staged content"); + await repo.git(["add", "staged.txt"]); + + const response = await sendSandboxCommand("git/diff_cached"); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.result.diff).toContain("staged content"); + }, 30000); + + it("returns unstaged diff", async () => { + await createServer().start(); + // Modify a tracked file + await repo.writeFile("README.md", "# Modified Readme"); + + const response = await sendSandboxCommand("git/diff_unstaged"); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.result.diff).toContain("Modified Readme"); + }, 30000); + + it("returns diff stats", async () => { + await createServer().start(); + await repo.writeFile("README.md", "# Modified\nNew line"); + + const response = await sendSandboxCommand("git/diff_stats"); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.result).toHaveProperty("filesChanged"); + expect(body.result).toHaveProperty("linesAdded"); + expect(body.result).toHaveProperty("linesRemoved"); + expect(body.result.filesChanged).toBeGreaterThan(0); + }, 30000); + + it("returns current branch", async () => { + await createServer().start(); + + const response = await sendSandboxCommand("git/current_branch"); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(typeof body.result.branch).toBe("string"); + }, 30000); + + it("returns file content at HEAD", async () => { + await createServer().start(); + + const response = await sendSandboxCommand("git/file_at_head", { + filePath: "README.md", + }); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.result.content).toBe("# Test Repo"); + }, 30000); + + it("stages and unstages files", async () => { + await createServer().start(); + await repo.writeFile("to-stage.txt", "content"); + + // Stage + const stageResponse = await sendSandboxCommand("git/stage_files", { + paths: ["to-stage.txt"], + }); + expect(stageResponse.status).toBe(200); + const stageBody = await stageResponse.json(); + expect(stageBody.result.changedFiles).toBeInstanceOf(Array); + + const stagedFile = stageBody.result.changedFiles.find( + (f: { path: string }) => f.path === "to-stage.txt", + ); + expect(stagedFile).toBeDefined(); + expect(stagedFile.staged).toBe(true); + + // Unstage + const unstageResponse = await sendSandboxCommand("git/unstage_files", { + paths: ["to-stage.txt"], + }); + expect(unstageResponse.status).toBe(200); + const unstageBody = await unstageResponse.json(); + + const unstagedFile = unstageBody.result.changedFiles.find( + (f: { path: string }) => f.path === "to-stage.txt", + ); + expect(unstagedFile).toBeDefined(); + expect(unstagedFile.staged).toBeFalsy(); + }, 30000); + + it("reads a file from the sandbox filesystem", async () => { + await createServer().start(); + await repo.writeFile("test-read.txt", "file contents here"); + + const response = await sendSandboxCommand("fs/read_file", { + filePath: "test-read.txt", + }); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.result.content).toBe("file contents here"); + }, 30000); + + it("returns error when repository path is not configured", async () => { + await createServer({ repositoryPath: undefined }).start(); + + const response = await sendSandboxCommand("git/changed_files"); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.error).toBeDefined(); + expect(body.error.message).toContain("No repository configured"); + }, 30000); + + it("does not require an active agent session", async () => { + // Sandbox commands with a different run_id should still work (no session guard) + await createServer().start(); + const token = createToken({ run_id: "different-run-id" }); + + const response = await fetch(`http://localhost:${port}/command`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "test-1", + method: "git/current_branch", + }), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.result).toBeDefined(); + expect(typeof body.result.branch).toBe("string"); + }, 30000); + }); + describe("404 handling", () => { it("returns 404 for unknown routes", async () => { await createServer().start(); diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 17c19a974..a46c5070f 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -1,5 +1,5 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { basename, join } from "node:path"; +import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import type { ContentBlock, @@ -12,7 +12,18 @@ import { PROTOCOL_VERSION, } from "@agentclientprotocol/sdk"; import { type ServerType, serve } from "@hono/node-server"; -import { getCurrentBranch } from "@posthog/git/queries"; +import { + getChangedFilesDetailed, + getCurrentBranch, + getDiffStats, + getFileAtHead, + getStagedDiff, + getSyncStatus, + getUnstagedDiff, + getDiffHead as gitGetDiffHead, + stageFiles as gitStageFiles, + unstageFiles as gitUnstageFiles, +} from "@posthog/git/queries"; import { Hono } from "hono"; import { z } from "zod"; import packageJson from "../../package.json" with { type: "json" }; @@ -53,7 +64,11 @@ import { promptBlocksToText, } from "./cloud-prompt"; import { type JwtPayload, JwtValidationError, validateJwt } from "./jwt"; -import { jsonRpcRequestSchema, validateCommandParams } from "./schemas"; +import { + isSandboxMethod, + jsonRpcRequestSchema, + validateCommandParams, +} from "./schemas"; import type { AgentServerConfig } from "./types"; const agentErrorClassificationSchema = z.enum([ @@ -392,10 +407,6 @@ export class AgentServer { ); } - if (!this.session || this.session.payload.run_id !== payload.run_id) { - return c.json({ error: "No active session for this run" }, 400); - } - const rawBody = await c.req.json().catch(() => null); const parseResult = jsonRpcRequestSchema.safeParse(rawBody); @@ -423,6 +434,35 @@ export class AgentServer { ); } + // Sandbox commands don't require an active agent session — they operate + // directly on the sandbox filesystem and git state. + if (isSandboxMethod(command.method)) { + try { + const result = await this.executeSandboxCommand( + command.method, + (paramsValidation.data as Record) ?? {}, + ); + return c.json({ + jsonrpc: "2.0", + id: command.id, + result, + }); + } catch (error) { + return c.json({ + jsonrpc: "2.0", + id: command.id, + error: { + code: -32000, + message: error instanceof Error ? error.message : "Unknown error", + }, + }); + } + } + + if (!this.session || this.session.payload.run_id !== payload.run_id) { + return c.json({ error: "No active session for this run" }, 400); + } + try { const result = await this.executeCommand( command.method, @@ -733,6 +773,140 @@ export class AgentServer { } } + /** Resolves a file path within the repo, rejecting path traversal attempts. */ + private resolveSandboxPath(repoPath: string, filePath: string): string { + const resolved = resolve(join(repoPath, filePath)); + const repoRoot = resolve(repoPath); + if (!resolved.startsWith(`${repoRoot}/`) && resolved !== repoRoot) { + throw new Error("Path traversal not allowed"); + } + return resolved; + } + + private async executeSandboxCommand( + method: string, + params: Record, + ): Promise { + const repoPath = this.config.repositoryPath; + if (!repoPath) { + throw new Error( + "No repository configured for this sandbox — sandbox commands are unavailable", + ); + } + + switch (method) { + case "git/changed_files": { + const files = await getChangedFilesDetailed(repoPath); + return { files }; + } + + case "git/diff_cached": { + const ignoreWhitespace = params.ignoreWhitespace as boolean | undefined; + const diff = await getStagedDiff(repoPath, { ignoreWhitespace }); + return { diff }; + } + + case "git/diff_unstaged": { + const ignoreWhitespace = params.ignoreWhitespace as boolean | undefined; + const diff = await getUnstagedDiff(repoPath, { ignoreWhitespace }); + return { diff }; + } + + case "git/diff_head": { + const ignoreWhitespace = params.ignoreWhitespace as boolean | undefined; + const diff = await gitGetDiffHead(repoPath, { ignoreWhitespace }); + return { diff }; + } + + case "git/diff_stats": { + const stats = await getDiffStats(repoPath); + return stats; + } + + case "git/current_branch": { + const branch = await getCurrentBranch(repoPath); + return { branch }; + } + + case "git/file_at_head": { + const filePath = params.filePath as string; + this.resolveSandboxPath(repoPath, filePath); + const content = await getFileAtHead(repoPath, filePath); + return { content }; + } + + case "git/stage_files": { + const paths = params.paths as string[]; + for (const p of paths) this.resolveSandboxPath(repoPath, p); + await gitStageFiles(repoPath, paths); + const [files, stats] = await Promise.all([ + getChangedFilesDetailed(repoPath), + getDiffStats(repoPath), + ]); + return { changedFiles: files, diffStats: stats }; + } + + case "git/unstage_files": { + const paths = params.paths as string[]; + for (const p of paths) this.resolveSandboxPath(repoPath, p); + await gitUnstageFiles(repoPath, paths); + const [files, stats] = await Promise.all([ + getChangedFilesDetailed(repoPath), + getDiffStats(repoPath), + ]); + return { changedFiles: files, diffStats: stats }; + } + + case "git/discard_file": { + const filePath = params.filePath as string; + const fileStatus = params.fileStatus as string; + this.resolveSandboxPath(repoPath, filePath); + + if (fileStatus === "untracked") { + await unlink(join(repoPath, filePath)); + } else { + const { getGitOperationManager } = await import( + "@posthog/git/operation-manager" + ); + const manager = getGitOperationManager(); + await manager.executeWrite(repoPath, (git) => + git.checkout(["HEAD", "--", filePath]), + ); + } + + const [files, stats] = await Promise.all([ + getChangedFilesDetailed(repoPath), + getDiffStats(repoPath), + ]); + return { success: true, changedFiles: files, diffStats: stats }; + } + + case "git/sync_status": { + const status = await getSyncStatus(repoPath); + return status; + } + + case "git/repo_info": { + const [branch, status, stats] = await Promise.all([ + getCurrentBranch(repoPath), + getSyncStatus(repoPath), + getDiffStats(repoPath), + ]); + return { branch, syncStatus: status, diffStats: stats }; + } + + case "fs/read_file": { + const filePath = params.filePath as string; + const safePath = this.resolveSandboxPath(repoPath, filePath); + const content = await readFile(safePath, "utf-8"); + return { content }; + } + + default: + throw new Error(`Unknown sandbox method: ${method}`); + } + } + private async initializeSession( payload: JwtPayload, sseController: SseController | null, diff --git a/packages/agent/src/server/schemas.test.ts b/packages/agent/src/server/schemas.test.ts index 898f1a270..f002076ca 100644 --- a/packages/agent/src/server/schemas.test.ts +++ b/packages/agent/src/server/schemas.test.ts @@ -234,4 +234,103 @@ describe("validateCommandParams", () => { expect(result.success).toBe(false); }); + + describe("sandbox git commands", () => { + it.each([ + "git/changed_files", + "git/diff_stats", + "git/current_branch", + "git/sync_status", + "git/repo_info", + ])("accepts %s with empty params", (method) => { + const result = validateCommandParams(method, {}); + expect(result.success).toBe(true); + }); + + it.each(["git/diff_cached", "git/diff_unstaged", "git/diff_head"])( + "accepts %s with ignoreWhitespace", + (method) => { + expect( + validateCommandParams(method, { ignoreWhitespace: true }).success, + ).toBe(true); + expect(validateCommandParams(method, {}).success).toBe(true); + }, + ); + + it("accepts git/file_at_head with filePath", () => { + expect( + validateCommandParams("git/file_at_head", { filePath: "src/main.ts" }) + .success, + ).toBe(true); + }); + + it("rejects git/file_at_head without filePath", () => { + expect(validateCommandParams("git/file_at_head", {}).success).toBe(false); + }); + + it("accepts git/stage_files with paths", () => { + expect( + validateCommandParams("git/stage_files", { + paths: ["a.ts", "b.ts"], + }).success, + ).toBe(true); + }); + + it("rejects git/stage_files with empty paths", () => { + expect( + validateCommandParams("git/stage_files", { paths: [] }).success, + ).toBe(false); + }); + + it("rejects git/unstage_files without paths", () => { + expect(validateCommandParams("git/unstage_files", {}).success).toBe( + false, + ); + }); + + it("accepts git/discard_file with valid status", () => { + for (const status of [ + "modified", + "added", + "deleted", + "renamed", + "untracked", + ]) { + expect( + validateCommandParams("git/discard_file", { + filePath: "test.ts", + fileStatus: status, + }).success, + ).toBe(true); + } + }); + + it("rejects git/discard_file with invalid status", () => { + expect( + validateCommandParams("git/discard_file", { + filePath: "test.ts", + fileStatus: "invalid", + }).success, + ).toBe(false); + }); + + it("rejects git/discard_file without filePath", () => { + expect( + validateCommandParams("git/discard_file", { + fileStatus: "modified", + }).success, + ).toBe(false); + }); + + it("accepts fs/read_file with filePath", () => { + expect( + validateCommandParams("fs/read_file", { filePath: "README.md" }) + .success, + ).toBe(true); + }); + + it("rejects fs/read_file without filePath", () => { + expect(validateCommandParams("fs/read_file", {}).success).toBe(false); + }); + }); }); diff --git a/packages/agent/src/server/schemas.ts b/packages/agent/src/server/schemas.ts index 6f27df93a..b849a4139 100644 --- a/packages/agent/src/server/schemas.ts +++ b/packages/agent/src/server/schemas.ts @@ -83,6 +83,43 @@ export const refreshSessionParamsSchema = z.object({ mcpServers: mcpServersSchema, }); +// --- Sandbox command schemas --- + +export const sandboxGitChangedFilesParams = z.object({}); + +export const sandboxGitDiffParams = z.object({ + ignoreWhitespace: z.boolean().optional(), +}); + +export const sandboxGitDiffStatsParams = z.object({}); + +export const sandboxGitCurrentBranchParams = z.object({}); + +export const sandboxGitFileAtHeadParams = z.object({ + filePath: z.string().min(1, "filePath is required"), +}); + +export const sandboxGitStageFilesParams = z.object({ + paths: z.array(z.string().min(1)).min(1, "At least one path is required"), +}); + +export const sandboxGitUnstageFilesParams = z.object({ + paths: z.array(z.string().min(1)).min(1, "At least one path is required"), +}); + +export const sandboxGitDiscardFileParams = z.object({ + filePath: z.string().min(1, "filePath is required"), + fileStatus: z.enum(["modified", "added", "deleted", "renamed", "untracked"]), +}); + +export const sandboxGitSyncStatusParams = z.object({}); + +export const sandboxGitRepoInfoParams = z.object({}); + +export const sandboxFsReadFileParams = z.object({ + filePath: z.string().min(1, "filePath is required"), +}); + export const commandParamsSchemas = { user_message: userMessageParamsSchema, "posthog/user_message": userMessageParamsSchema, @@ -99,8 +136,30 @@ export const commandParamsSchemas = { "_posthog/refresh_session": refreshSessionParamsSchema, } as const; +export const sandboxCommandParamsSchemas = { + "git/changed_files": sandboxGitChangedFilesParams, + "git/diff_cached": sandboxGitDiffParams, + "git/diff_unstaged": sandboxGitDiffParams, + "git/diff_head": sandboxGitDiffParams, + "git/diff_stats": sandboxGitDiffStatsParams, + "git/current_branch": sandboxGitCurrentBranchParams, + "git/file_at_head": sandboxGitFileAtHeadParams, + "git/stage_files": sandboxGitStageFilesParams, + "git/unstage_files": sandboxGitUnstageFilesParams, + "git/discard_file": sandboxGitDiscardFileParams, + "git/sync_status": sandboxGitSyncStatusParams, + "git/repo_info": sandboxGitRepoInfoParams, + "fs/read_file": sandboxFsReadFileParams, +} as const; + +export type SandboxCommandMethod = keyof typeof sandboxCommandParamsSchemas; + export type CommandMethod = keyof typeof commandParamsSchemas; +export function isSandboxMethod(method: string): boolean { + return method in sandboxCommandParamsSchemas; +} + export function validateCommandParams( method: string, params: unknown, @@ -109,7 +168,8 @@ export function validateCommandParams( commandParamsSchemas[method as CommandMethod] ?? commandParamsSchemas[ method.replace(/^_?posthog\//, "") as keyof typeof commandParamsSchemas - ]; + ] ?? + sandboxCommandParamsSchemas[method as SandboxCommandMethod]; if (!schema) { return { success: false, error: `Unknown method: ${method}` }; From 7fb7b590cba4d3dca251c2b057fde2a8ccf144d3 Mon Sep 17 00:00:00 2001 From: Vojta Bartos Date: Wed, 22 Apr 2026 14:05:23 +0200 Subject: [PATCH 2/2] feat(code): unified ReviewPage with ReviewGitProvider for local and cloud runs --- .../src/main/services/cloud-task/schemas.ts | 14 + .../components/CloudReviewPage.tsx | 144 ---------- .../code-review/components/ReviewPage.tsx | 269 ++++-------------- .../code-review/hooks/ReviewGitProvider.tsx | 163 +++++++++++ .../code-review/hooks/useReviewData.ts | 232 +++++++++++++++ .../code-review/hooks/useReviewDiffs.ts | 113 -------- .../code-review/hooks/useSandboxGit.ts | 189 ++++++++++++ .../task-detail/components/TaskDetail.tsx | 12 +- 8 files changed, 647 insertions(+), 489 deletions(-) delete mode 100644 apps/code/src/renderer/features/code-review/components/CloudReviewPage.tsx create mode 100644 apps/code/src/renderer/features/code-review/hooks/ReviewGitProvider.tsx create mode 100644 apps/code/src/renderer/features/code-review/hooks/useReviewData.ts delete mode 100644 apps/code/src/renderer/features/code-review/hooks/useReviewDiffs.ts create mode 100644 apps/code/src/renderer/features/code-review/hooks/useSandboxGit.ts diff --git a/apps/code/src/main/services/cloud-task/schemas.ts b/apps/code/src/main/services/cloud-task/schemas.ts index 69512afb7..780668a0f 100644 --- a/apps/code/src/main/services/cloud-task/schemas.ts +++ b/apps/code/src/main/services/cloud-task/schemas.ts @@ -56,6 +56,20 @@ export const sendCommandInput = z.object({ "close", "permission_response", "set_config_option", + // Sandbox commands — operate on the sandbox filesystem/git directly + "git/changed_files", + "git/diff_cached", + "git/diff_unstaged", + "git/diff_head", + "git/diff_stats", + "git/current_branch", + "git/file_at_head", + "git/stage_files", + "git/unstage_files", + "git/discard_file", + "git/sync_status", + "git/repo_info", + "fs/read_file", ]), params: z.record(z.string(), z.unknown()).optional(), }); diff --git a/apps/code/src/renderer/features/code-review/components/CloudReviewPage.tsx b/apps/code/src/renderer/features/code-review/components/CloudReviewPage.tsx deleted file mode 100644 index c333f4082..000000000 --- a/apps/code/src/renderer/features/code-review/components/CloudReviewPage.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { useDiffViewerStore } from "@features/code-editor/stores/diffViewerStore"; -import { usePrDetails } from "@features/git-interaction/hooks/usePrDetails"; -import { useCloudChangedFiles } from "@features/task-detail/hooks/useCloudChangedFiles"; -import { extractCloudFileDiff } from "@features/task-detail/utils/cloudToolChanges"; -import { Flex, Spinner, Text } from "@radix-ui/themes"; -import { useReviewNavigationStore } from "@renderer/features/code-review/stores/reviewNavigationStore"; -import type { Task } from "@shared/types"; -import { useMemo } from "react"; -import { LazyDiff } from "./LazyDiff"; -import { PatchedFileDiff } from "./PatchedFileDiff"; -import { - DeferredDiffPlaceholder, - ReviewShell, - useReviewState, -} from "./ReviewShell"; - -interface CloudReviewPageProps { - task: Task; -} - -export function CloudReviewPage({ task }: CloudReviewPageProps) { - const taskId = task.id; - const isReviewOpen = useReviewNavigationStore( - (s) => (s.reviewModes[taskId] ?? "closed") !== "closed", - ); - const showReviewComments = useDiffViewerStore((s) => s.showReviewComments); - const { - effectiveBranch, - prUrl, - isRunActive, - remoteFiles, - reviewFiles, - toolCalls, - isLoading, - } = useCloudChangedFiles(taskId, task, isReviewOpen); - const { commentThreads } = usePrDetails(prUrl, { - includeComments: isReviewOpen && showReviewComments, - }); - - const allPaths = useMemo(() => reviewFiles.map((f) => f.path), [reviewFiles]); - - const { - diffOptions, - linesAdded, - linesRemoved, - collapsedFiles, - toggleFile, - expandAll, - collapseAll, - uncollapseFile, - revealFile, - getDeferredReason, - } = useReviewState(reviewFiles, allPaths); - - const toolCallDiffs = useMemo(() => { - if (remoteFiles.length > 0) return null; - const diffs = new Map< - string, - { oldText: string | null; newText: string | null } - >(); - for (const file of reviewFiles) { - const diff = extractCloudFileDiff(toolCalls, file.path); - if (diff) diffs.set(file.path, diff); - } - return diffs; - }, [remoteFiles.length, toolCalls, reviewFiles]); - - if (!prUrl && !effectiveBranch && reviewFiles.length === 0) { - if (isRunActive) { - return ( - - - - Waiting for changes... - - - ); - } - return null; - } - - return ( - - {reviewFiles.map((file) => { - const isCollapsed = collapsedFiles.has(file.path); - const deferredReason = getDeferredReason(file.path); - - if (deferredReason) { - return ( -
- toggleFile(file.path)} - onShow={() => revealFile(file.path)} - /> -
- ); - } - - const githubFileUrl = prUrl - ? `${prUrl}/files#diff-${file.path.replaceAll("/", "-")}` - : undefined; - - return ( -
- - toggleFile(file.path)} - commentThreads={showReviewComments ? commentThreads : undefined} - fallback={toolCallDiffs?.get(file.path) ?? null} - externalUrl={githubFileUrl} - /> - -
- ); - })} -
- ); -} diff --git a/apps/code/src/renderer/features/code-review/components/ReviewPage.tsx b/apps/code/src/renderer/features/code-review/components/ReviewPage.tsx index 25798936b..0484892fd 100644 --- a/apps/code/src/renderer/features/code-review/components/ReviewPage.tsx +++ b/apps/code/src/renderer/features/code-review/components/ReviewPage.tsx @@ -1,25 +1,13 @@ -import { useDiffViewerStore } from "@features/code-editor/stores/diffViewerStore"; -import { useGitQueries } from "@features/git-interaction/hooks/useGitQueries"; import { makeFileKey } from "@features/git-interaction/utils/fileKey"; -import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; -import { useCwd } from "@features/sidebar/hooks/useCwd"; -import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; import type { parsePatchFiles } from "@pierre/diffs"; import { Flex, Text } from "@radix-ui/themes"; -import { useReviewNavigationStore } from "@renderer/features/code-review/stores/reviewNavigationStore"; -import { useTRPC } from "@renderer/trpc/client"; import type { ChangedFile, Task } from "@shared/types"; import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; -import { useReviewDiffs } from "../hooks/useReviewDiffs"; +import { ReviewGitProvider, useReviewGit } from "../hooks/ReviewGitProvider"; import type { DiffOptions } from "../types"; -import { - type ResolvedDiffSource, - resolveDiffSource, -} from "../utils/resolveDiffSource"; import { InteractiveFileDiff } from "./InteractiveFileDiff"; import { LazyDiff } from "./LazyDiff"; -import { PatchedFileDiff } from "./PatchedFileDiff"; import { DeferredDiffPlaceholder, type DeferredReason, @@ -29,57 +17,34 @@ import { useReviewState, } from "./ReviewShell"; -const EMPTY_BRANCH_FILES: ChangedFile[] = []; - interface ReviewPageProps { task: Task; } export function ReviewPage({ task }: ReviewPageProps) { - const taskId = task.id; - const repoPath = useCwd(taskId); - const workspace = useWorkspace(taskId); - const linkedBranch = workspace?.linkedBranch ?? null; - const openFile = usePanelLayoutStore((s) => s.openFile); - - const isReviewOpen = useReviewNavigationStore( - (s) => (s.reviewModes[taskId] ?? "closed") !== "closed", - ); - - const configuredSource = useDiffViewerStore( - (s) => s.diffSource[taskId] ?? null, + return ( + + + ); +} +function ReviewPageBody({ task }: { task: Task }) { + const taskId = task.id; const { - repoInfo, - aheadOfDefault, - defaultBranch, - changedFiles: workspaceFiles, - } = useGitQueries(repoPath); - const hasLocalChanges = workspaceFiles.length > 0; - const branchSourceAvailable = !!linkedBranch && aheadOfDefault > 0; - - const effectiveSource = resolveDiffSource({ - configured: configuredSource, - hasLocalChanges, - linkedBranch, - aheadOfDefault, - }); - - const isLocalActive = isReviewOpen && effectiveSource === "local"; - - const { - changedFiles, - changesLoading, - hasStagedFiles, - stagedParsedFiles, - unstagedParsedFiles, - untrackedFiles, - totalFileCount, - allPaths, - diffLoading, - refetch, - } = useReviewDiffs(repoPath, isLocalActive); + reviewData: { + changedFiles, + changesLoading, + hasStagedFiles, + stagedParsedFiles, + unstagedParsedFiles, + untrackedFiles, + totalFileCount, + allPaths, + diffLoading, + refetch, + }, + } = useReviewGit(); const { diffOptions, @@ -94,41 +59,6 @@ export function ReviewPage({ task }: ReviewPageProps) { uncollapseFile, } = useReviewState(changedFiles, allPaths); - if (!repoPath) { - return ( - - - No repository path available - - - ); - } - - if (effectiveSource === "branch") { - return ( - - ); - } - - const sharedDiffProps = { - repoPath, - taskId, - diffOptions, - collapsedFiles, - toggleFile, - revealFile, - getDeferredReason, - openFile, - }; - return ( {hasStagedFiles && stagedParsedFiles.length > 0 && ( <> - + )} {hasStagedFiles && (unstagedParsedFiles.length > 0 || untrackedFiles.length > 0) && ( )} - + {untrackedFiles.map((file) => { const key = makeFileKey(file.staged, file.path); const isCollapsed = collapsedFiles.has(key); @@ -165,7 +109,6 @@ export function ReviewPage({ task }: ReviewPageProps) { toggleFile(key)} @@ -179,113 +122,6 @@ export function ReviewPage({ task }: ReviewPageProps) { ); } -function BranchReviewPage({ - task, - branch, - repoInfo, - defaultBranch, - isReviewOpen, - effectiveSource, - branchSourceAvailable, -}: { - task: Task; - branch: string; - repoInfo: { organization: string; repository: string } | undefined; - defaultBranch: string | null; - isReviewOpen: boolean; - effectiveSource: ResolvedDiffSource; - branchSourceAvailable: boolean; -}) { - const taskId = task.id; - const trpc = useTRPC(); - - const repoSlug = repoInfo - ? `${repoInfo.organization}/${repoInfo.repository}` - : null; - - const { data: files = EMPTY_BRANCH_FILES, isLoading } = useQuery( - trpc.git.getBranchChangedFiles.queryOptions( - { repo: repoSlug as string, branch }, - { - enabled: isReviewOpen && !!repoSlug, - staleTime: 30_000, - refetchInterval: 30_000, - retry: 1, - }, - ), - ); - - const allPaths = useMemo(() => files.map((f) => f.path), [files]); - - const { - diffOptions, - linesAdded, - linesRemoved, - collapsedFiles, - toggleFile, - expandAll, - collapseAll, - uncollapseFile, - revealFile, - getDeferredReason, - } = useReviewState(files, allPaths); - - return ( - - {files.map((file) => { - const isCollapsed = collapsedFiles.has(file.path); - const deferredReason = getDeferredReason(file.path); - - if (deferredReason) { - return ( -
- toggleFile(file.path)} - onShow={() => revealFile(file.path)} - /> -
- ); - } - - return ( -
- - toggleFile(file.path)} - /> - -
- ); - })} -
- ); -} - function SectionLabel({ label }: { label: string }) { return ( @@ -299,28 +135,25 @@ function SectionLabel({ label }: { label: string }) { interface FileDiffListProps { files: ReturnType[number]["files"]; staged?: boolean; - repoPath: string; taskId: string; diffOptions: DiffOptions; collapsedFiles: Set; toggleFile: (key: string) => void; revealFile: (key: string) => void; getDeferredReason: (key: string) => DeferredReason | null; - openFile: (taskId: string, path: string, preview: boolean) => void; } function FileDiffList({ files, staged = false, - repoPath, taskId, diffOptions, collapsedFiles, toggleFile, revealFile, getDeferredReason, - openFile, }: FileDiffListProps) { + const { openFile } = useReviewGit(); return files.map((fileDiff) => { const filePath = fileDiff.name ?? fileDiff.prevName ?? ""; const key = makeFileKey(staged, filePath); @@ -349,7 +182,6 @@ function FileDiffList({ ( @@ -357,9 +189,7 @@ function FileDiffList({ fileDiff={fd} collapsed={isCollapsed} onToggle={() => toggleFile(key)} - onOpenFile={() => - openFile(taskId, `${repoPath}/${filePath}`, false) - } + onOpenFile={openFile ? () => openFile(filePath) : undefined} /> )} /> @@ -371,26 +201,23 @@ function FileDiffList({ function UntrackedFileDiff({ file, - repoPath, taskId, options, collapsed, onToggle, }: { file: ChangedFile; - repoPath: string; taskId: string; options: DiffOptions; collapsed: boolean; onToggle: () => void; }) { - const trpc = useTRPC(); - const { data: content } = useQuery( - trpc.fs.readRepoFile.queryOptions( - { repoPath, filePath: file.path }, - { staleTime: 30_000 }, - ), - ); + const { readFileFn, readFileQueryKeyPrefix } = useReviewGit(); + const { data: content } = useQuery({ + queryKey: [...readFileQueryKeyPrefix, file.path], + queryFn: () => readFileFn(file.path), + staleTime: 30_000, + }); const fileName = file.path.split("/").pop() || file.path; const oldFile = useMemo(() => ({ name: fileName, contents: "" }), [fileName]); diff --git a/apps/code/src/renderer/features/code-review/hooks/ReviewGitProvider.tsx b/apps/code/src/renderer/features/code-review/hooks/ReviewGitProvider.tsx new file mode 100644 index 000000000..9b9b6013d --- /dev/null +++ b/apps/code/src/renderer/features/code-review/hooks/ReviewGitProvider.tsx @@ -0,0 +1,163 @@ +import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; +import { useCwd } from "@features/sidebar/hooks/useCwd"; +import { useIsWorkspaceCloudRun } from "@features/workspace/hooks/useWorkspace"; +import { useReviewNavigationStore } from "@renderer/features/code-review/stores/reviewNavigationStore"; +import { trpcClient } from "@renderer/trpc/client"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useMemo, +} from "react"; +import { + type DiffStats, + type ReviewDataResult, + useCloudReviewData, + useLocalReviewData, +} from "./useReviewData"; +import { sendSandboxCommand, useCloudCommandContext } from "./useSandboxGit"; + +export interface ReviewGitOps { + reviewData: ReviewDataResult; + diffStats: DiffStats; + diffStatsLoading: boolean; + readFileFn: (filePath: string) => Promise; + readFileQueryKeyPrefix: readonly unknown[]; + /** Opens a file in the editor panel. Undefined when not supported (e.g., cloud runs). */ + openFile: ((filePath: string) => void) | undefined; +} + +const ReviewGitContext = createContext(null); + +export function useReviewGit(): ReviewGitOps { + const ctx = useContext(ReviewGitContext); + if (!ctx) { + throw new Error("useReviewGit must be used within a ReviewGitProvider"); + } + return ctx; +} + +// --- Public provider --- + +interface ReviewGitProviderProps { + taskId: string; + children: ReactNode; +} + +export function ReviewGitProvider({ + taskId, + children, +}: ReviewGitProviderProps) { + const isCloud = useIsWorkspaceCloudRun(taskId); + if (isCloud) { + return ( + + {children} + + ); + } + return ( + {children} + ); +} + +// --- Local provider --- + +function LocalReviewGitProvider({ taskId, children }: ReviewGitProviderProps) { + const repoPath = useCwd(taskId); + const isReviewOpen = useReviewNavigationStore( + (s) => (s.reviewModes[taskId] ?? "closed") !== "closed", + ); + + const reviewData = useLocalReviewData(repoPath, isReviewOpen); + const panelOpenFile = usePanelLayoutStore((s) => s.openFile); + + const openFile = useCallback( + (filePath: string) => { + if (!repoPath) return; + panelOpenFile(taskId, `${repoPath}/${filePath}`, false); + }, + [taskId, repoPath, panelOpenFile], + ); + + const readFileFn = useCallback( + async (filePath: string): Promise => { + if (!repoPath) return ""; + return ( + (await trpcClient.fs.readRepoFile.query({ repoPath, filePath })) ?? "" + ); + }, + [repoPath], + ); + + const readFileQueryKeyPrefix = useMemo( + () => ["local-file", taskId] as const, + [taskId], + ); + + const value = useMemo( + () => ({ + reviewData, + diffStats: reviewData.diffStats, + diffStatsLoading: false, + readFileFn, + readFileQueryKeyPrefix, + openFile: repoPath ? openFile : undefined, + }), + [reviewData, readFileFn, readFileQueryKeyPrefix, openFile, repoPath], + ); + + return ( + + {children} + + ); +} + +// --- Cloud provider --- + +function CloudReviewGitProvider({ taskId, children }: ReviewGitProviderProps) { + const isReviewOpen = useReviewNavigationStore( + (s) => (s.reviewModes[taskId] ?? "closed") !== "closed", + ); + + const reviewData = useCloudReviewData(taskId, isReviewOpen); + const ctx = useCloudCommandContext(taskId); + + const readFileFn = useCallback( + async (filePath: string): Promise => { + if (!ctx) return ""; + const result = await sendSandboxCommand<{ content: string }>( + ctx, + "fs/read_file", + { filePath }, + ); + return result.content; + }, + [ctx], + ); + + const readFileQueryKeyPrefix = useMemo( + () => ["sandbox-file", taskId] as const, + [taskId], + ); + + const value = useMemo( + () => ({ + reviewData, + diffStats: reviewData.diffStats, + diffStatsLoading: reviewData.changesLoading, + readFileFn, + openFile: undefined, + readFileQueryKeyPrefix, + }), + [reviewData, readFileFn, readFileQueryKeyPrefix], + ); + + return ( + + {children} + + ); +} diff --git a/apps/code/src/renderer/features/code-review/hooks/useReviewData.ts b/apps/code/src/renderer/features/code-review/hooks/useReviewData.ts new file mode 100644 index 000000000..ba0e0c29a --- /dev/null +++ b/apps/code/src/renderer/features/code-review/hooks/useReviewData.ts @@ -0,0 +1,232 @@ +import { useDiffViewerStore } from "@features/code-editor/stores/diffViewerStore"; +import { useGitQueries } from "@features/git-interaction/hooks/useGitQueries"; +import type { DiffStats } from "@features/git-interaction/utils/diffStats"; +import { makeFileKey } from "@features/git-interaction/utils/fileKey"; +import { invalidateGitWorkingTreeQueries } from "@features/git-interaction/utils/gitCacheKeys"; +import { parsePatchFiles } from "@pierre/diffs"; +import { useTRPC } from "@renderer/trpc/client"; +import type { ChangedFile } from "@shared/types"; +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; +import { + useInvalidateSandboxQueries, + useSandboxChangedFiles, + useSandboxDiffCached, + useSandboxDiffUnstaged, +} from "./useSandboxGit"; + +export type { DiffStats }; + +export interface ReviewDataResult { + changedFiles: ChangedFile[]; + changesLoading: boolean; + diffStats: DiffStats; + hasStagedFiles: boolean; + stagedParsedFiles: ReturnType[number]["files"]; + unstagedParsedFiles: ReturnType[number]["files"]; + untrackedFiles: ChangedFile[]; + totalFileCount: number; + allPaths: string[]; + diffLoading: boolean; + refetch: () => void; +} + +// --- Shared diff transform --- + +function useDiffTransform( + changedFiles: ChangedFile[], + rawDiffCached: string | undefined, + rawDiffUnstaged: string | undefined, + diffCachedLoading: boolean, + diffUnstagedLoading: boolean, +) { + const hasStagedFiles = useMemo( + () => changedFiles.some((f) => f.staged), + [changedFiles], + ); + + const diffLoading = + diffUnstagedLoading || (hasStagedFiles && diffCachedLoading); + + const stagedParsedFiles = useMemo( + () => + rawDiffCached + ? parsePatchFiles(rawDiffCached).flatMap((p) => p.files) + : [], + [rawDiffCached], + ); + + const unstagedParsedFiles = useMemo( + () => + rawDiffUnstaged + ? parsePatchFiles(rawDiffUnstaged).flatMap((p) => p.files) + : [], + [rawDiffUnstaged], + ); + + const untrackedFiles = useMemo( + () => changedFiles.filter((f) => f.status === "untracked"), + [changedFiles], + ); + + const totalFileCount = + stagedParsedFiles.length + + unstagedParsedFiles.length + + untrackedFiles.length; + + const allPaths = useMemo( + () => [ + ...stagedParsedFiles.map((f) => + makeFileKey(true, f.name ?? f.prevName ?? ""), + ), + ...unstagedParsedFiles.map((f) => + makeFileKey(false, f.name ?? f.prevName ?? ""), + ), + ...untrackedFiles.map((f) => makeFileKey(f.staged, f.path)), + ], + [stagedParsedFiles, unstagedParsedFiles, untrackedFiles], + ); + + return { + hasStagedFiles, + stagedParsedFiles, + unstagedParsedFiles, + untrackedFiles, + totalFileCount, + allPaths, + diffLoading, + }; +} + +// --- Local implementation --- + +export function useLocalReviewData( + repoPath: string | undefined, + isActive: boolean, +): ReviewDataResult { + const trpc = useTRPC(); + const { changedFiles, changesLoading, diffStats } = useGitQueries(repoPath); + const hideWhitespace = useDiffViewerStore((s) => s.hideWhitespaceChanges); + + const { + data: rawDiffCached, + isLoading: diffCachedLoading, + refetch: refetchDiffCached, + } = useQuery( + trpc.git.getDiffCached.queryOptions( + { directoryPath: repoPath as string, ignoreWhitespace: hideWhitespace }, + { + enabled: isActive && !!repoPath && changedFiles.some((f) => f.staged), + staleTime: 30_000, + refetchOnMount: "always", + }, + ), + ); + + const { + data: rawDiffUnstaged, + isLoading: diffUnstagedLoading, + refetch: refetchDiffUnstaged, + } = useQuery( + trpc.git.getDiffUnstaged.queryOptions( + { directoryPath: repoPath as string, ignoreWhitespace: hideWhitespace }, + { + enabled: isActive && !!repoPath, + staleTime: 30_000, + refetchOnMount: "always", + }, + ), + ); + + const transform = useDiffTransform( + changedFiles, + rawDiffCached, + rawDiffUnstaged, + diffCachedLoading, + diffUnstagedLoading, + ); + + const refetch = useCallback(() => { + if (repoPath) invalidateGitWorkingTreeQueries(repoPath); + refetchDiffUnstaged(); + if (transform.hasStagedFiles) refetchDiffCached(); + }, [ + repoPath, + transform.hasStagedFiles, + refetchDiffCached, + refetchDiffUnstaged, + ]); + + return { + changedFiles, + changesLoading, + diffStats, + ...transform, + refetch, + }; +} + +// --- Cloud implementation (sandbox commands) --- + +export function useCloudReviewData( + taskId: string, + isActive: boolean, +): ReviewDataResult { + const hideWhitespace = useDiffViewerStore((s) => s.hideWhitespaceChanges); + const invalidateSandbox = useInvalidateSandboxQueries(taskId); + + // Changed files always polls so DiffStatsBadge has data even when the + // review panel is closed. The heavier diff queries only fire when active. + const { data: changedFiles = [], isLoading: changesLoading } = + useSandboxChangedFiles(taskId, { + refetchInterval: 10_000, + }); + + const hasStagedFiles = useMemo( + () => changedFiles.some((f: ChangedFile) => f.staged), + [changedFiles], + ); + + const { data: rawDiffCached, isLoading: diffCachedLoading } = + useSandboxDiffCached(taskId, { + enabled: isActive && hasStagedFiles, + ignoreWhitespace: hideWhitespace, + }); + + const { data: rawDiffUnstaged, isLoading: diffUnstagedLoading } = + useSandboxDiffUnstaged(taskId, { + enabled: isActive, + ignoreWhitespace: hideWhitespace, + }); + + const transform = useDiffTransform( + changedFiles, + rawDiffCached, + rawDiffUnstaged, + diffCachedLoading, + diffUnstagedLoading, + ); + + const refetch = useCallback(() => { + invalidateSandbox(); + }, [invalidateSandbox]); + + // Cloud doesn't have a separate diff stats query — derive from changed files + const diffStats = useMemo(() => { + let linesAdded = 0; + let linesRemoved = 0; + for (const f of changedFiles) { + linesAdded += f.linesAdded ?? 0; + linesRemoved += f.linesRemoved ?? 0; + } + return { filesChanged: changedFiles.length, linesAdded, linesRemoved }; + }, [changedFiles]); + + return { + changedFiles, + changesLoading, + diffStats, + ...transform, + refetch, + }; +} diff --git a/apps/code/src/renderer/features/code-review/hooks/useReviewDiffs.ts b/apps/code/src/renderer/features/code-review/hooks/useReviewDiffs.ts deleted file mode 100644 index 35f10631a..000000000 --- a/apps/code/src/renderer/features/code-review/hooks/useReviewDiffs.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { useDiffViewerStore } from "@features/code-editor/stores/diffViewerStore"; -import { useGitQueries } from "@features/git-interaction/hooks/useGitQueries"; -import { makeFileKey } from "@features/git-interaction/utils/fileKey"; -import { invalidateGitWorkingTreeQueries } from "@features/git-interaction/utils/gitCacheKeys"; -import { parsePatchFiles } from "@pierre/diffs"; -import { useTRPC } from "@renderer/trpc/client"; -import { useQuery } from "@tanstack/react-query"; -import { useCallback, useMemo } from "react"; - -export function useReviewDiffs( - repoPath: string | undefined, - isActive: boolean, -) { - const trpc = useTRPC(); - const { changedFiles, changesLoading } = useGitQueries(repoPath); - const hideWhitespace = useDiffViewerStore((s) => s.hideWhitespaceChanges); - - const hasStagedFiles = useMemo( - () => changedFiles.some((f) => f.staged), - [changedFiles], - ); - - const { - data: rawDiffCached, - isLoading: diffCachedLoading, - refetch: refetchDiffCached, - } = useQuery( - trpc.git.getDiffCached.queryOptions( - { directoryPath: repoPath as string, ignoreWhitespace: hideWhitespace }, - { - enabled: isActive && !!repoPath && hasStagedFiles, - staleTime: 30_000, - refetchOnMount: "always", - }, - ), - ); - - const { - data: rawDiffUnstaged, - isLoading: diffUnstagedLoading, - refetch: refetchDiffUnstaged, - } = useQuery( - trpc.git.getDiffUnstaged.queryOptions( - { directoryPath: repoPath as string, ignoreWhitespace: hideWhitespace }, - { - enabled: isActive && !!repoPath, - staleTime: 30_000, - refetchOnMount: "always", - }, - ), - ); - - const diffLoading = - diffUnstagedLoading || (hasStagedFiles && diffCachedLoading); - - const stagedParsedFiles = useMemo( - () => - rawDiffCached - ? parsePatchFiles(rawDiffCached).flatMap((p) => p.files) - : [], - [rawDiffCached], - ); - - const unstagedParsedFiles = useMemo( - () => - rawDiffUnstaged - ? parsePatchFiles(rawDiffUnstaged).flatMap((p) => p.files) - : [], - [rawDiffUnstaged], - ); - - const untrackedFiles = useMemo( - () => changedFiles.filter((f) => f.status === "untracked"), - [changedFiles], - ); - - const totalFileCount = - stagedParsedFiles.length + - unstagedParsedFiles.length + - untrackedFiles.length; - - const allPaths = useMemo( - () => [ - ...stagedParsedFiles.map((f) => - makeFileKey(true, f.name ?? f.prevName ?? ""), - ), - ...unstagedParsedFiles.map((f) => - makeFileKey(false, f.name ?? f.prevName ?? ""), - ), - ...untrackedFiles.map((f) => makeFileKey(f.staged, f.path)), - ], - [stagedParsedFiles, unstagedParsedFiles, untrackedFiles], - ); - - const refetch = useCallback(() => { - if (repoPath) invalidateGitWorkingTreeQueries(repoPath); - refetchDiffUnstaged(); - if (hasStagedFiles) refetchDiffCached(); - }, [repoPath, hasStagedFiles, refetchDiffCached, refetchDiffUnstaged]); - - return { - changedFiles, - changesLoading, - hasStagedFiles, - stagedParsedFiles, - unstagedParsedFiles, - untrackedFiles, - totalFileCount, - allPaths, - diffLoading, - refetch, - }; -} diff --git a/apps/code/src/renderer/features/code-review/hooks/useSandboxGit.ts b/apps/code/src/renderer/features/code-review/hooks/useSandboxGit.ts new file mode 100644 index 000000000..ab8414b08 --- /dev/null +++ b/apps/code/src/renderer/features/code-review/hooks/useSandboxGit.ts @@ -0,0 +1,189 @@ +import { useAuthState } from "@features/auth/hooks/authQueries"; +import type { DiffStats } from "@features/git-interaction/utils/diffStats"; +import { useSessionStore } from "@features/sessions/stores/sessionStore"; +import { trpcClient } from "@renderer/trpc/client"; +import type { ChangedFile } from "@shared/types"; +import { getCloudUrlFromRegion } from "@shared/utils/urls"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; + +interface SandboxChangedFilesResult { + files: ChangedFile[]; +} + +interface SandboxDiffResult { + diff: string; +} + +// --- Cloud command context --- + +interface CloudCommandContext { + taskId: string; + runId: string; + apiHost: string; + teamId: number; +} + +export function useCloudCommandContext( + taskId: string | undefined, +): CloudCommandContext | null { + const taskRunId = useSessionStore((s) => { + if (!taskId) return undefined; + return s.taskIdIndex[taskId]; + }); + const { data: authState } = useAuthState(); + const cloudRegion = authState?.cloudRegion ?? null; + const projectId = authState?.projectId ?? null; + + return useMemo(() => { + if (!taskId || !taskRunId || !cloudRegion || !projectId) { + return null; + } + return { + taskId, + runId: taskRunId, + apiHost: getCloudUrlFromRegion(cloudRegion), + teamId: projectId, + }; + }, [taskId, taskRunId, cloudRegion, projectId]); +} + +export async function sendSandboxCommand( + ctx: CloudCommandContext, + method: string, + params?: Record, +): Promise { + const result = await trpcClient.cloudTask.sendCommand.mutate({ + taskId: ctx.taskId, + runId: ctx.runId, + apiHost: ctx.apiHost, + teamId: ctx.teamId, + method: method as "git/changed_files", + params, + }); + + if (!result.success) { + throw new Error(result.error ?? `Sandbox command ${method} failed`); + } + + return result.result as T; +} + +// --- Query hooks --- + +/** + * Fetches changed files from the sandbox. Returns the same ChangedFile[] + * shape as the local `trpc.git.getChangedFilesHead` query. + */ +export function useSandboxChangedFiles( + taskId: string | undefined, + options?: { enabled?: boolean; refetchInterval?: number }, +) { + const ctx = useCloudCommandContext(taskId); + + return useQuery({ + queryKey: ["sandbox", "git_changed_files", taskId], + queryFn: async () => { + if (!ctx) throw new Error("No cloud context"); + const result = await sendSandboxCommand( + ctx, + "git/changed_files", + ); + return result.files; + }, + enabled: (options?.enabled ?? true) && !!ctx, + staleTime: 10_000, + refetchInterval: options?.refetchInterval, + }); +} + +/** + * Fetches staged diff (git diff --cached) from the sandbox. + */ +export function useSandboxDiffCached( + taskId: string | undefined, + options?: { enabled?: boolean; ignoreWhitespace?: boolean }, +) { + const ctx = useCloudCommandContext(taskId); + + return useQuery({ + queryKey: ["sandbox", "git_diff_cached", taskId, options?.ignoreWhitespace], + queryFn: async () => { + if (!ctx) throw new Error("No cloud context"); + const result = await sendSandboxCommand( + ctx, + "git/diff_cached", + { ignoreWhitespace: options?.ignoreWhitespace }, + ); + return result.diff; + }, + enabled: (options?.enabled ?? true) && !!ctx, + staleTime: 10_000, + }); +} + +/** + * Fetches unstaged diff from the sandbox. + */ +export function useSandboxDiffUnstaged( + taskId: string | undefined, + options?: { enabled?: boolean; ignoreWhitespace?: boolean }, +) { + const ctx = useCloudCommandContext(taskId); + + return useQuery({ + queryKey: [ + "sandbox", + "git_diff_unstaged", + taskId, + options?.ignoreWhitespace, + ], + queryFn: async () => { + if (!ctx) throw new Error("No cloud context"); + const result = await sendSandboxCommand( + ctx, + "git/diff_unstaged", + { ignoreWhitespace: options?.ignoreWhitespace }, + ); + return result.diff; + }, + enabled: (options?.enabled ?? true) && !!ctx, + staleTime: 10_000, + }); +} + +/** + * Fetches diff stats from the sandbox. + */ +export function useSandboxDiffStats( + taskId: string | undefined, + options?: { enabled?: boolean; refetchInterval?: number }, +) { + const ctx = useCloudCommandContext(taskId); + + return useQuery({ + queryKey: ["sandbox", "git_diff_stats", taskId], + queryFn: async () => { + if (!ctx) throw new Error("No cloud context"); + return sendSandboxCommand(ctx, "git/diff_stats"); + }, + enabled: (options?.enabled ?? true) && !!ctx, + staleTime: 10_000, + refetchInterval: options?.refetchInterval, + }); +} + +/** + * Invalidate all sandbox git query caches for a task. + */ +export function useInvalidateSandboxQueries(taskId: string | undefined) { + const queryClient = useQueryClient(); + return useCallback(() => { + if (!taskId) return; + void queryClient.invalidateQueries({ + queryKey: ["sandbox"], + predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[2] === taskId, + }); + }, [queryClient, taskId]); +} diff --git a/apps/code/src/renderer/features/task-detail/components/TaskDetail.tsx b/apps/code/src/renderer/features/task-detail/components/TaskDetail.tsx index ce30b2d49..d0b0727e4 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskDetail.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskDetail.tsx @@ -1,4 +1,3 @@ -import { CloudReviewPage } from "@features/code-review/components/CloudReviewPage"; import { ReviewPage } from "@features/code-review/components/ReviewPage"; import { useReviewNavigationStore } from "@features/code-review/stores/reviewNavigationStore"; import { FilePicker } from "@features/command/components/FilePicker"; @@ -15,7 +14,6 @@ import { useTaskData } from "@features/task-detail/hooks/useTaskData"; import { useUpdateTask } from "@features/tasks/hooks/useTasks"; import { useTaskStore } from "@features/tasks/stores/taskStore"; import { useWorkspaceEvents } from "@features/workspace/hooks"; -import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; import { useBlurOnEscape } from "@hooks/useBlurOnEscape"; import { useFileWatcher } from "@hooks/useFileWatcher"; import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; @@ -169,10 +167,6 @@ export function TaskDetail({ task: initialTask }: TaskDetailProps) { const reviewMode = useReviewNavigationStore( (s) => s.reviewModes[taskId] ?? "closed", ); - const workspace = useWorkspace(taskId); - const isCloud = - workspace?.mode === "cloud" || task.latest_run?.environment === "cloud"; - const isReviewOpen = reviewMode !== "closed"; const isExpanded = reviewMode === "expanded"; @@ -266,11 +260,7 @@ export function TaskDetail({ task: initialTask }: TaskDetailProps) { visibility: isReviewOpen ? undefined : "hidden", }} > - {isCloud ? ( - - ) : ( - - )} +