From 6cbf050fa11110051e7bb49ea8a5d93b3440dc6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:16:18 +0100 Subject: [PATCH] fix: prevent agentctl OOM from unbounded diff generation in workspace tracker (#598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: prevent agentctl OOM from unbounded diff generation in workspace tracker Add per-file size cap (10MB), binary detection (null-byte in 8KB), diff output cap (256KB), and total budget (2MB) to workspace tracker diff enrichment. Strip diff content from event bus path. Surface skip reasons in frontend. Add pprof endpoints to agentctl. * fix: address PR review feedback Close stdout pipe on cmd.Start failure to prevent fd leak. Limit untracked file read to maxDiffOutputSize (256KB) instead of maxDiffFileSize (10MB) to reduce peak allocation from ~20MB to ~512KB. * fix: add filepath.Clean to os.Stat call to satisfy CodeQL path-injection check * fix: address second round of PR review feedback Extract all DiffSkipReason strings as named constants. Add diff_skip_reason to UncommittedFile type removing unsafe cast. Add unit tests for isBinaryContent, totalDiffBytes, capDiffOutput truncation, and all enrichUntrackedFileDiffs skip-reason paths. * fix: use bytes.IndexByte for null-byte check clarity * fix: show truncation banner when diff has partial content * fix: revert diff stripping from event bus — frontend needs diffs from this path E2E tests confirmed the frontend receives git status diffs through the event bus (agentctl → backend WS → event bus → gateway → frontend). Stripping diffs broke the changes panel. The per-file size caps in workspace_git_diff.go already bound the data. * fix: extract renderDiffContent to keep FileDiffSection under 100-line lint limit * fix: correct onCommentRun type in renderDiffContent helper * fix: format long destructuring line for prettier * fix: move diffSkipReasonLabel to types.ts to keep review-diff-list under 600 lines Co-authored-by: José Almeida * fix: coerce optional forceLoad to boolean for renderDiffContent * fix: shorten className and comment to stay within prettier printWidth * fix: apply prettier formatting to review-diff-list * fix: correct off-by-one in untracked file line count --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: José Almeida --- .../internal/agentctl/server/api/server.go | 38 +++ .../server/process/workspace_git_diff.go | 166 +++++++++++-- .../server/process/workspace_git_diff_test.go | 219 ++++++++++++++++++ .../internal/agentctl/types/streams/git.go | 4 + .../components/review/review-diff-list.tsx | 91 ++++++-- apps/web/components/review/types.ts | 16 ++ .../components/task/task-changes-panel.tsx | 5 +- .../lib/state/slices/session-runtime/types.ts | 1 + apps/web/lib/types/backend.ts | 1 + 9 files changed, 500 insertions(+), 41 deletions(-) create mode 100644 apps/backend/internal/agentctl/server/process/workspace_git_diff_test.go diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index a5bd5f10de..a0711dfe03 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -3,6 +3,9 @@ package api import ( "net/http" + "net/http/pprof" + "os" + "runtime" "time" "github.com/gin-gonic/gin" @@ -155,6 +158,11 @@ func (s *Server) setupRoutes() { s.mcpServer.RegisterRoutes(s.router) api.PUT("/mcp/mode", s.handleSetMcpMode) } + + // pprof + memory stats (enabled via KANDEV_DEBUG_PPROF_ENABLED=true) + if os.Getenv("KANDEV_DEBUG_PPROF_ENABLED") == "true" { + s.registerPprofRoutes() + } } // Health check response @@ -170,6 +178,36 @@ func (s *Server) handleHealth(c *gin.Context) { }) } +func (s *Server) registerPprofRoutes() { + g := s.router.Group("/debug/pprof") + g.GET("/", gin.WrapF(pprof.Index)) + g.GET("/cmdline", gin.WrapF(pprof.Cmdline)) + g.GET("/profile", gin.WrapF(pprof.Profile)) + g.GET("/symbol", gin.WrapF(pprof.Symbol)) + g.POST("/symbol", gin.WrapF(pprof.Symbol)) + g.GET("/trace", gin.WrapF(pprof.Trace)) + for _, name := range []string{"allocs", "block", "goroutine", "heap", "mutex", "threadcreate"} { + g.GET("/"+name, gin.WrapH(pprof.Handler(name))) + } + + s.router.GET("/api/v1/debug/memory", func(c *gin.Context) { + var m runtime.MemStats + runtime.ReadMemStats(&m) + c.JSON(http.StatusOK, gin.H{ + "heap_alloc_mb": float64(m.HeapAlloc) / (1024 * 1024), + "heap_inuse_mb": float64(m.HeapInuse) / (1024 * 1024), + "heap_sys_mb": float64(m.HeapSys) / (1024 * 1024), + "heap_objects": m.HeapObjects, + "goroutines": runtime.NumGoroutine(), + "num_gc": m.NumGC, + "sys_mb": float64(m.Sys) / (1024 * 1024), + "stack_inuse_mb": float64(m.StackInuse) / (1024 * 1024), + }) + }) + + s.logger.Info("pprof endpoints registered at /debug/pprof/") +} + // handleSetMcpMode changes the MCP tool mode for this instance. func (s *Server) handleSetMcpMode(c *gin.Context) { var req struct { diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_diff.go b/apps/backend/internal/agentctl/server/process/workspace_git_diff.go index 5732123418..69d39727ec 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_git_diff.go +++ b/apps/backend/internal/agentctl/server/process/workspace_git_diff.go @@ -1,8 +1,12 @@ package process import ( + "bytes" "context" + "io" + "os" "os/exec" + "path/filepath" "strconv" "strings" @@ -10,6 +14,28 @@ import ( "go.uber.org/zap" ) +const ( + // maxDiffFileSize is the maximum file size for which we generate diffs. + // Files larger than this are skipped with DiffSkipReason "too_large". + maxDiffFileSize = 10 * 1024 * 1024 // 10 MB + + // maxDiffOutputSize is the maximum diff output size per file. + // Diffs exceeding this are truncated with DiffSkipReason "truncated". + maxDiffOutputSize = 256 * 1024 // 256 KB + + // maxTotalDiffBytes is the cumulative diff budget per GitStatusUpdate. + // Once exceeded, remaining files are skipped with DiffSkipReason "budget_exceeded". + maxTotalDiffBytes = 2 * 1024 * 1024 // 2 MB + + // binaryCheckSize is how many bytes to inspect for null bytes to detect binary files. + binaryCheckSize = 8 * 1024 // 8 KB + + diffSkipReasonTooLarge = "too_large" + diffSkipReasonBinary = "binary" + diffSkipReasonTruncated = "truncated" + diffSkipReasonBudgetExceeded = "budget_exceeded" +) + // enrichWithDiffData adds diff information (additions, deletions, diff content) to file info func (wt *WorkspaceTracker) enrichWithDiffData(ctx context.Context, update *types.GitStatusUpdate) { // Always diff against HEAD for unstaged/staged content so that files committed @@ -65,6 +91,44 @@ func (wt *WorkspaceTracker) enrichWithBranchDiff(ctx context.Context, update *ty update.BranchDeletions = deletions } +// totalDiffBytes returns the cumulative size of all diff content in the update. +func totalDiffBytes(update *types.GitStatusUpdate) int64 { + var total int64 + for _, fi := range update.Files { + total += int64(len(fi.Diff)) + } + return total +} + +// capDiffOutput runs a git diff command and returns at most maxDiffOutputSize bytes. +// Returns the output string and whether it was truncated. +func capDiffOutput(ctx context.Context, workDir string, args ...string) (string, bool) { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = workDir + + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", false + } + if err := cmd.Start(); err != nil { + _ = stdout.Close() + return "", false + } + + limited := io.LimitReader(stdout, maxDiffOutputSize+1) + data, _ := io.ReadAll(limited) + truncated := len(data) > maxDiffOutputSize + if truncated { + data = data[:maxDiffOutputSize] + } + + // Drain remaining stdout so the process doesn't hang on a full pipe. + _, _ = io.Copy(io.Discard, stdout) + _ = cmd.Wait() + + return string(data), truncated +} + // enrichWithUnstagedDiff populates additions/deletions and diff content for files // with unstaged changes by comparing the worktree against baseRef. func (wt *WorkspaceTracker) enrichWithUnstagedDiff(ctx context.Context, update *types.GitStatusUpdate, baseRef string) { @@ -102,11 +166,18 @@ func (wt *WorkspaceTracker) enrichWithUnstagedDiff(ctx context.Context, update * fileInfo.Additions = additions fileInfo.Deletions = deletions - // Get the actual diff content for this file (compare against base branch) - diffCmd := exec.CommandContext(ctx, "git", "diff", baseRef, "--", filePath) - diffCmd.Dir = wt.workDir - if diffOut, err := diffCmd.Output(); err == nil { - fileInfo.Diff = string(diffOut) + if totalDiffBytes(update) >= maxTotalDiffBytes { + fileInfo.DiffSkipReason = diffSkipReasonBudgetExceeded + update.Files[filePath] = fileInfo + continue + } + + diffOut, truncated := capDiffOutput(ctx, wt.workDir, "diff", baseRef, "--", filePath) + if diffOut != "" { + fileInfo.Diff = diffOut + if truncated { + fileInfo.DiffSkipReason = diffSkipReasonTruncated + } } update.Files[filePath] = fileInfo @@ -153,16 +224,29 @@ func (wt *WorkspaceTracker) enrichWithStagedDiff(ctx context.Context, update *ty } // Get the staged diff content if we don't have diff content yet if fileInfo.Diff == "" { - diffCmd := exec.CommandContext(ctx, "git", "diff", "--cached", baseRef, "--", filePath) - diffCmd.Dir = wt.workDir - if diffOut, err := diffCmd.Output(); err == nil { - fileInfo.Diff = string(diffOut) + if totalDiffBytes(update) >= maxTotalDiffBytes { + fileInfo.DiffSkipReason = diffSkipReasonBudgetExceeded + update.Files[filePath] = fileInfo + continue + } + + diffOut, truncated := capDiffOutput(ctx, wt.workDir, "diff", "--cached", baseRef, "--", filePath) + if diffOut != "" { + fileInfo.Diff = diffOut + if truncated { + fileInfo.DiffSkipReason = diffSkipReasonTruncated + } } } update.Files[filePath] = fileInfo } } +// isBinaryContent checks for null bytes in the data, same heuristic git uses. +func isBinaryContent(data []byte) bool { + return bytes.IndexByte(data, 0) != -1 +} + // enrichUntrackedFileDiffs builds a synthetic git diff for untracked files showing all // lines as additions, so the diff viewer can display their full content. func (wt *WorkspaceTracker) enrichUntrackedFileDiffs(ctx context.Context, update *types.GitStatusUpdate) { @@ -170,19 +254,62 @@ func (wt *WorkspaceTracker) enrichUntrackedFileDiffs(ctx context.Context, update if fileInfo.Status != fileStatusUntracked { continue } - catCmd := exec.CommandContext(ctx, "cat", filePath) - catCmd.Dir = wt.workDir - catOut, err := catCmd.Output() + + if totalDiffBytes(update) >= maxTotalDiffBytes { + fileInfo.DiffSkipReason = diffSkipReasonBudgetExceeded + update.Files[filePath] = fileInfo + continue + } + + safePath, err := wt.sanitizePath(filePath) if err != nil { continue } - content := string(catOut) + + info, err := os.Stat(filepath.Clean(safePath)) + if err != nil { + continue + } + if info.Size() > maxDiffFileSize { + fileInfo.DiffSkipReason = diffSkipReasonTooLarge + update.Files[filePath] = fileInfo + continue + } + + f, err := os.Open(filepath.Clean(safePath)) + if err != nil { + continue + } + + // Read first chunk to check for binary content. + header := make([]byte, binaryCheckSize) + n, _ := f.Read(header) + if n > 0 && isBinaryContent(header[:n]) { + _ = f.Close() + fileInfo.DiffSkipReason = diffSkipReasonBinary + update.Files[filePath] = fileInfo + continue + } + + // Read only enough content to fill maxDiffOutputSize of diff output. + // No need to read the full file — any diff beyond maxDiffOutputSize gets truncated anyway. + var buf bytes.Buffer + buf.Write(header[:n]) + remaining := int64(maxDiffOutputSize) - int64(n) + if remaining > 0 { + _, _ = io.Copy(&buf, io.LimitReader(f, remaining)) + } + _ = f.Close() + + content := buf.String() lines := strings.Split(content, "\n") + // Trim trailing empty element from final newline so line count is accurate. + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } fileInfo.Additions = len(lines) fileInfo.Deletions = 0 - // Format as a proper git diff with all required headers. - // The @git-diff-view/react library requires the full git diff format. var diffBuilder strings.Builder diffBuilder.WriteString("diff --git a/" + filePath + " b/" + filePath + "\n") diffBuilder.WriteString("new file mode 100644\n") @@ -193,7 +320,14 @@ func (wt *WorkspaceTracker) enrichUntrackedFileDiffs(ctx context.Context, update for _, line := range lines { diffBuilder.WriteString("+" + line + "\n") } - fileInfo.Diff = diffBuilder.String() + + diffContent := diffBuilder.String() + if len(diffContent) > maxDiffOutputSize { + diffContent = diffContent[:maxDiffOutputSize] + fileInfo.DiffSkipReason = diffSkipReasonTruncated + } + + fileInfo.Diff = diffContent update.Files[filePath] = fileInfo } } diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_diff_test.go b/apps/backend/internal/agentctl/server/process/workspace_git_diff_test.go new file mode 100644 index 0000000000..17ebf4085f --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/workspace_git_diff_test.go @@ -0,0 +1,219 @@ +package process + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +func TestIsBinaryContent(t *testing.T) { + tests := []struct { + name string + data []byte + binary bool + }{ + {"empty", []byte{}, false}, + {"text", []byte("hello world\n"), false}, + {"utf8", []byte("héllo wörld\n"), false}, + {"null byte", []byte("hello\x00world"), true}, + {"null at start", []byte{0, 'h', 'i'}, true}, + {"ELF header", []byte{0x7f, 'E', 'L', 'F', 0, 0}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isBinaryContent(tt.data); got != tt.binary { + t.Errorf("isBinaryContent(%q) = %v, want %v", tt.data, got, tt.binary) + } + }) + } +} + +func TestTotalDiffBytes(t *testing.T) { + update := &streams.GitStatusUpdate{ + Files: map[string]streams.FileInfo{ + "a.go": {Diff: "abc"}, + "b.go": {Diff: "defgh"}, + "c.go": {Diff: ""}, + }, + } + got := totalDiffBytes(update) + if got != 8 { + t.Errorf("totalDiffBytes = %d, want 8", got) + } +} + +func TestCapDiffOutput_Truncation(t *testing.T) { + isolateTestGitEnv(t) + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + // Create a file with content larger than maxDiffOutputSize + bigContent := strings.Repeat("x", maxDiffOutputSize+1000) + writeFile(t, repoDir, "big.txt", bigContent) + runGit(t, repoDir, "add", "big.txt") + + // Get diff — should be truncated + out, truncated := capDiffOutput(context.Background(), repoDir, "diff", "--cached", "--", "big.txt") + if !truncated { + t.Error("expected truncated=true for large diff") + } + if len(out) > maxDiffOutputSize { + t.Errorf("output len=%d exceeds maxDiffOutputSize=%d", len(out), maxDiffOutputSize) + } +} + +func TestEnrichUntrackedFileDiffs_TooLarge(t *testing.T) { + isolateTestGitEnv(t) + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + // Create a file just over the size limit + bigPath := filepath.Join(repoDir, "huge.bin") + f, err := os.Create(bigPath) + if err != nil { + t.Fatal(err) + } + // Write maxDiffFileSize + 1 bytes (all 'A') + buf := make([]byte, 1024*1024) // 1MB chunks + for i := range buf { + buf[i] = 'A' + } + written := int64(0) + for written < maxDiffFileSize+1 { + chunk := buf + if remaining := maxDiffFileSize + 1 - written; remaining < int64(len(chunk)) { + chunk = chunk[:remaining] + } + n, err := f.Write(chunk) + if err != nil { + _ = f.Close() + t.Fatal(err) + } + written += int64(n) + } + _ = f.Close() + + log := newTestLogger(t) + wt := NewWorkspaceTracker(repoDir, log) + update := &streams.GitStatusUpdate{ + Files: map[string]streams.FileInfo{ + "huge.bin": {Path: "huge.bin", Status: "untracked"}, + }, + } + + wt.enrichUntrackedFileDiffs(context.Background(), update) + + fi := update.Files["huge.bin"] + if fi.DiffSkipReason != diffSkipReasonTooLarge { + t.Errorf("DiffSkipReason = %q, want %q", fi.DiffSkipReason, diffSkipReasonTooLarge) + } + if fi.Diff != "" { + t.Error("expected empty Diff for too-large file") + } +} + +func TestEnrichUntrackedFileDiffs_Binary(t *testing.T) { + isolateTestGitEnv(t) + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + // Create a binary file (contains null bytes) + binPath := filepath.Join(repoDir, "image.png") + binContent := []byte("PNG\x00\x00\x00fake binary content") + if err := os.WriteFile(binPath, binContent, 0644); err != nil { + t.Fatal(err) + } + + log := newTestLogger(t) + wt := NewWorkspaceTracker(repoDir, log) + update := &streams.GitStatusUpdate{ + Files: map[string]streams.FileInfo{ + "image.png": {Path: "image.png", Status: "untracked"}, + }, + } + + wt.enrichUntrackedFileDiffs(context.Background(), update) + + fi := update.Files["image.png"] + if fi.DiffSkipReason != diffSkipReasonBinary { + t.Errorf("DiffSkipReason = %q, want %q", fi.DiffSkipReason, diffSkipReasonBinary) + } + if fi.Diff != "" { + t.Error("expected empty Diff for binary file") + } +} + +func TestEnrichUntrackedFileDiffs_BudgetExceeded(t *testing.T) { + isolateTestGitEnv(t) + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + // Create files large enough that their synthetic diffs exceed maxTotalDiffBytes. + // Each file's diff includes headers + "+line\n" per line, so content of ~maxDiffOutputSize + // per file ensures we blow through the 2MB budget in a few files. + fileCount := 20 + contentPerFile := maxDiffOutputSize / 2 + files := make(map[string]streams.FileInfo) + + for i := 0; i < fileCount; i++ { + name := filepath.Join(repoDir, strings.Repeat("a", i+1)+".txt") + content := strings.Repeat("x\n", contentPerFile/2) + if err := os.WriteFile(name, []byte(content), 0644); err != nil { + t.Fatal(err) + } + relName := filepath.Base(name) + files[relName] = streams.FileInfo{Path: relName, Status: "untracked"} + } + + log := newTestLogger(t) + wt := NewWorkspaceTracker(repoDir, log) + update := &streams.GitStatusUpdate{Files: files} + + wt.enrichUntrackedFileDiffs(context.Background(), update) + + budgetExceededCount := 0 + for _, fi := range update.Files { + if fi.DiffSkipReason == diffSkipReasonBudgetExceeded { + budgetExceededCount++ + } + } + if budgetExceededCount == 0 { + t.Error("expected at least one file with budget_exceeded skip reason") + } +} + +func TestEnrichUntrackedFileDiffs_SmallTextFile(t *testing.T) { + isolateTestGitEnv(t) + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + txtPath := filepath.Join(repoDir, "hello.txt") + if err := os.WriteFile(txtPath, []byte("hello world\n"), 0644); err != nil { + t.Fatal(err) + } + + log := newTestLogger(t) + wt := NewWorkspaceTracker(repoDir, log) + update := &streams.GitStatusUpdate{ + Files: map[string]streams.FileInfo{ + "hello.txt": {Path: "hello.txt", Status: "untracked"}, + }, + } + + wt.enrichUntrackedFileDiffs(context.Background(), update) + + fi := update.Files["hello.txt"] + if fi.Diff == "" { + t.Error("expected non-empty Diff for small text file") + } + if fi.DiffSkipReason != "" { + t.Errorf("expected empty DiffSkipReason, got %q", fi.DiffSkipReason) + } + if fi.Additions != 1 { + t.Errorf("Additions = %d, want 1", fi.Additions) + } +} diff --git a/apps/backend/internal/agentctl/types/streams/git.go b/apps/backend/internal/agentctl/types/streams/git.go index 1ee829afb1..1bd6e1cf7d 100644 --- a/apps/backend/internal/agentctl/types/streams/git.go +++ b/apps/backend/internal/agentctl/types/streams/git.go @@ -78,6 +78,10 @@ type FileInfo struct { // Diff contains the unified diff content for this file. Diff string `json:"diff,omitempty"` + + // DiffSkipReason explains why diff content was omitted or truncated. + // Values: "too_large", "binary", "truncated", "budget_exceeded". + DiffSkipReason string `json:"diff_skip_reason,omitempty"` } // GitCommitNotification is sent when a new commit is detected in the workspace. diff --git a/apps/web/components/review/review-diff-list.tsx b/apps/web/components/review/review-diff-list.tsx index 34148580d5..f1a3311189 100644 --- a/apps/web/components/review/review-diff-list.tsx +++ b/apps/web/components/review/review-diff-list.tsx @@ -29,6 +29,7 @@ import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import { useRunComment } from "@/hooks/domains/comments/use-run-comment"; import type { DiffComment } from "@/lib/diff/types"; +import { diffSkipReasonLabel } from "./types"; import type { ReviewFile } from "./types"; function isMarkdownPath(filePath: string): boolean { @@ -451,6 +452,60 @@ function useScrollIntoViewOnSelect( }, [isSelected, sectionRef, setCollapsed]); } +function renderDiffContent(opts: { + shouldRender: boolean; + file: ReviewFile; + sessionId: string; + wordWrap: boolean; + expandUnchanged: boolean; + onRevertBlock: (filePath: string, info: RevertBlockInfo) => void; + onCommentRun: (comment: DiffComment) => void; + onToggleExpandUnchanged: () => void; +}) { + const { + shouldRender, + file, + sessionId, + wordWrap, + expandUnchanged, + onRevertBlock, + onCommentRun, + onToggleExpandUnchanged, + } = opts; + if (shouldRender && file.diff) { + return ( + <> + + {file.diff_skip_reason === "truncated" && ( +
+ Diff truncated — showing first 256 KB +
+ )} + + ); + } + return ( +
+ {diffSkipReasonLabel(file.diff_skip_reason)} +
+ ); +} + function FileDiffSection({ file, isReviewed, @@ -478,8 +533,8 @@ function FileDiffSection({ [wordWrap], ); const { isVisible, sentinelRef } = useLazyVisible(scrollContainer); - // Force load when: visible via intersection observer, or forceLoad is true (all files up to selected) - const shouldRenderContent = isVisible || forceLoad; + // Force load when visible via intersection observer, or forceLoad is true + const shouldRenderContent = isVisible || !!forceLoad; useScrollIntoViewOnSelect(isSelected, sectionRef, setCollapsed); const scrollSentinelRef = useAutoMarkOnScroll({ autoMarkOnScroll, @@ -527,28 +582,16 @@ function FileDiffSection({ />
{!collapsed && - (shouldRenderContent && file.diff ? ( - - ) : ( -
- Loading diff... -
- ))} + renderDiffContent({ + shouldRender: shouldRenderContent, + file, + sessionId, + wordWrap: effectiveWordWrap, + expandUnchanged, + onRevertBlock: handleRevertBlock, + onCommentRun: handleCommentRun, + onToggleExpandUnchanged: handleToggleExpandUnchanged, + })}
); } diff --git a/apps/web/components/review/types.ts b/apps/web/components/review/types.ts index 85999993e3..c3d4e3b4d9 100644 --- a/apps/web/components/review/types.ts +++ b/apps/web/components/review/types.ts @@ -8,8 +8,24 @@ export type ReviewFile = { deletions: number; staged: boolean; source: "uncommitted" | "committed" | "pr"; + diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded"; }; +export function diffSkipReasonLabel(reason?: string): string { + switch (reason) { + case "too_large": + return "File too large to diff (>10 MB)"; + case "binary": + return "Binary file — not diffable"; + case "truncated": + return "Diff truncated (>256 KB)"; + case "budget_exceeded": + return "Diff skipped — too many changed files"; + default: + return "Loading diff..."; + } +} + export type FileTreeNode = { name: string; path: string; diff --git a/apps/web/components/task/task-changes-panel.tsx b/apps/web/components/task/task-changes-panel.tsx index c26df7b1a2..16d0cb6577 100644 --- a/apps/web/components/task/task-changes-panel.tsx +++ b/apps/web/components/task/task-changes-panel.tsx @@ -35,6 +35,7 @@ type TaskChangesPanelProps = { type UncommittedFile = { diff?: string; + diff_skip_reason?: ReviewFile["diff_skip_reason"]; status?: string; additions?: number; deletions?: number; @@ -48,7 +49,8 @@ function addUncommittedFiles( ) { for (const [path, file] of Object.entries(files)) { const diff = file.diff ? normalizeDiffContent(file.diff) : ""; - if (diff) { + const skipReason = file.diff_skip_reason; + if (diff || skipReason) { fileMap.set(path, { path, diff, @@ -57,6 +59,7 @@ function addUncommittedFiles( deletions: file.deletions ?? 0, staged: file.staged ?? false, source: "uncommitted", + diff_skip_reason: skipReason, }); } } diff --git a/apps/web/lib/state/slices/session-runtime/types.ts b/apps/web/lib/state/slices/session-runtime/types.ts index 316c1192d1..2bd3e52fd6 100644 --- a/apps/web/lib/state/slices/session-runtime/types.ts +++ b/apps/web/lib/state/slices/session-runtime/types.ts @@ -48,6 +48,7 @@ export type FileInfo = { deletions?: number; old_path?: string; diff?: string; + diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded"; }; export type GitStatusEntry = { diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index 7aa7b60098..f5ccab5c25 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -264,6 +264,7 @@ export type FileInfo = { deletions?: number; old_path?: string; diff?: string; + diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded"; }; export type ProcessOutputPayload = {