From 1a7dc29ada8c6e291791a1234296cced05955c1b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 25 May 2026 21:04:45 +0000 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=A4=96=20fix:=20cap=20immersive=20rev?= =?UTF-8?q?iew=20file=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with mux on behalf of Mike. Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `1208316{MUX_COSTS_USD:-unknown}` --- .../CodeReview/ImmersiveReviewView.test.tsx | 48 +++- .../CodeReview/ImmersiveReviewView.tsx | 231 +++++++++++------- src/browser/utils/fileRead.test.ts | 22 +- src/browser/utils/fileRead.ts | 30 ++- 4 files changed, 234 insertions(+), 97 deletions(-) diff --git a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.test.tsx b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.test.tsx index 9a2eb6b801..81012bc3f9 100644 --- a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.test.tsx +++ b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { cleanup, fireEvent, render } from "@testing-library/react"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; import { GlobalWindow } from "happy-dom"; import { useEffect, useState, type ComponentProps } from "react"; @@ -163,6 +163,52 @@ describe("ImmersiveReviewView", () => { globalThis.cancelAnimationFrame = originalCancelAnimationFrame; }); + test("renders the hunk overlay while full-file context is still pending", async () => { + type ExecuteBashValue = Awaited>; + let resolveRead: ((value: ExecuteBashValue) => void) | undefined; + const pendingRead = new Promise((resolve) => { + resolveRead = resolve; + }); + mockApi.workspace.executeBash = mock(() => pendingRead); + + const view = renderImmersiveReview(); + + expect(view.container.textContent ?? "").toContain("new line"); + await waitFor(() => expect(mockApi.workspace.executeBash).toHaveBeenCalledTimes(1)); + + if (!resolveRead) { + throw new Error("Read promise resolver was not captured"); + } + resolveRead({ + success: true, + data: { + success: true, + output: "0", + exitCode: 0, + }, + }); + }); + + test("skips full-file reads when the selected hunk starts beyond the render budget", () => { + const farHunk = createHunk({ + id: "hunk-far", + oldStart: 5000, + newStart: 5000, + header: "@@ -5000 +5000 @@", + content: "-old far line\n+new far line", + }); + + const view = renderImmersiveReview({ + fileTree: createFileTree(farHunk.filePath), + hunks: [farHunk], + allHunks: [farHunk], + selectedHunkId: farHunk.id, + }); + + expect(view.container.textContent ?? "").toContain("new far line"); + expect(mockApi.workspace.executeBash).not.toHaveBeenCalled(); + }); + test("weights completion by changed lines instead of hunk count", () => { const smallHunk = createHunk({ id: "hunk-small", diff --git a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx index 5bf1bad1d7..9db1ac2977 100644 --- a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx +++ b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx @@ -128,6 +128,9 @@ interface ImmersiveOverlayData { hunkLineRanges: Map; } +const MAX_FULL_FILE_CONTEXT_LINES = 1500; +const MAX_FULL_FILE_CONTEXT_BYTES = 256 * 1024; + const LINE_JUMP_SIZE = 10; // Keep syntax highlighting on for larger review files now that per-line tooltip overhead is gone, // but still cap it to avoid pathological DOM costs on extremely large diffs. @@ -196,6 +199,43 @@ function normalizeFileLines(content: string): string[] { return lines.filter((line, idx) => idx < lines.length - 1 || line !== ""); } +function isWithinFullFileContextLineBudget(content: string): boolean { + let lineCount = content.length > 0 ? 1 : 0; + for (let index = 0; index < content.length; index += 1) { + if (content.charCodeAt(index) === 10) { + lineCount += 1; + if (lineCount > MAX_FULL_FILE_CONTEXT_LINES) { + return false; + } + } + } + return true; +} + +function shouldAttemptFullFileContext(sortedHunks: readonly DiffHunk[]): boolean { + if (sortedHunks.length === 0) { + return false; + } + + for (const hunk of sortedHunks) { + const lastDisplayLine = hunk.newStart + Math.max(hunk.newLines, 1) - 1; + if (lastDisplayLine > MAX_FULL_FILE_CONTEXT_LINES) { + return false; + } + } + + return true; +} + +function buildFileContentCacheKey( + workspaceId: string, + filePath: string, + sortedHunks: readonly DiffHunk[] +): string { + const hunkSignature = sortedHunks.map((hunk) => hunk.id).join("|"); + return [workspaceId, filePath, hunkSignature].join("\u0000"); +} + function buildOverlayFromFileContent( fileContent: string, sortedHunks: DiffHunk[] @@ -541,93 +581,118 @@ export const ImmersiveReviewView: React.FC = (props) = content: null, isSettled: true, }); + const fileContentCacheRef = useRef>(new Map()); + const shouldLoadFullFileContext = useMemo( + () => shouldAttemptFullFileContext(currentFileHunks), + [currentFileHunks] + ); + const activeFileContentCacheKey = useMemo( + () => + activeFilePath + ? buildFileContentCacheKey(props.workspaceId, activeFilePath, currentFileHunks) + : null, + [activeFilePath, currentFileHunks, props.workspaceId] + ); - // Hold diff reveal during file switches until loading + initial scroll are complete. + // Hold diff reveal during file switches until the initial scroll is complete. const [pendingRevealFilePath, setPendingRevealFilePath] = useState(null); const revealAnimationFrameRef = useRef(null); - // Load full file content so immersive mode can render one coherent file with hunk overlays. - // Keep a per-file loading state so switches can show a splash until loading settles, - // which avoids a visible fallback-overlay -> full-content jump. + // Load full file context only when it is cheap. The hunk overlay remains visible + // while this request is pending so file switches do not block on bash/base64 I/O. useEffect(() => { const apiClient = api; const filePath = activeFilePath; + const cacheKey = activeFileContentCacheKey; - if (!filePath || !apiClient) { + const settleContent = (content: string | null) => { setActiveFileContentState({ filePath: filePath ?? null, - content: null, + content, isSettled: true, }); + }; + + if (!filePath || !apiClient || !shouldLoadFullFileContext || !cacheKey) { + settleContent(null); + return; + } + + if (fileContentCacheRef.current.has(cacheKey)) { + settleContent(fileContentCacheRef.current.get(cacheKey) ?? null); return; } const resolvedApi: NonNullable = apiClient; const resolvedFilePath: string = filePath; - let cancelled = false; + setActiveFileContentState({ filePath: resolvedFilePath, content: null, isSettled: false, }); - async function loadActiveFileContent() { - try { - // Keep plain file reads on the shared container root so immersive review can open - // sibling-project files without forcing the primary repo checkout. - const fileResult = await resolvedApi.workspace.executeBash({ - workspaceId: props.workspaceId, - script: buildReadFileScript(resolvedFilePath), + const settleLoadedContent = (content: string | null) => { + fileContentCacheRef.current.set(cacheKey, content); + if (!cancelled) { + setActiveFileContentState({ + filePath: resolvedFilePath, + content, + isSettled: true, }); + } + }; - if (cancelled) { - return; - } + async function loadActiveFileContent() { + // Keep plain file reads on the shared container root so immersive review can open + // sibling-project files without forcing the primary repo checkout. + const fileResult = await resolvedApi.workspace.executeBash({ + workspaceId: props.workspaceId, + script: buildReadFileScript(resolvedFilePath, { + maxSizeBytes: MAX_FULL_FILE_CONTEXT_BYTES, + maxLineCount: MAX_FULL_FILE_CONTEXT_LINES, + }), + }); - if (!fileResult.success) { - setActiveFileContentState({ - filePath: resolvedFilePath, - content: null, - isSettled: true, - }); - return; - } + if (cancelled) { + return; + } - const bashResult = fileResult.data; + if (!fileResult.success) { + settleLoadedContent(null); + return; + } - if (!bashResult.success && !bashResult.output) { - setActiveFileContentState({ - filePath: resolvedFilePath, - content: null, - isSettled: true, - }); - return; - } + const bashResult = fileResult.data; - const data = processFileContents(bashResult.output ?? "", bashResult.exitCode); - setActiveFileContentState({ - filePath: resolvedFilePath, - content: data.type === "text" ? data.content : null, - isSettled: true, - }); - } catch { - if (!cancelled) { - setActiveFileContentState({ - filePath: resolvedFilePath, - content: null, - isSettled: true, - }); - } + if (!bashResult.success && !bashResult.output) { + settleLoadedContent(null); + return; } + + const data = processFileContents(bashResult.output ?? "", bashResult.exitCode); + const content = + data.type === "text" && isWithinFullFileContextLineBudget(data.content) + ? data.content + : null; + settleLoadedContent(content); } - void loadActiveFileContent(); + loadActiveFileContent().catch(() => { + settleLoadedContent(null); + }); return () => { cancelled = true; }; - }, [api, props.workspaceId, activeFilePath]); + }, [ + activeFileContentCacheKey, + activeFilePath, + api, + props.workspaceId, + shouldLoadFullFileContext, + ]); const isActiveFileContentSettled = !activeFilePath || @@ -637,10 +702,6 @@ export const ImmersiveReviewView: React.FC = (props) = ? activeFileContentState.content : null; - const isActiveFileContentLoading = Boolean( - activeFilePath && currentFileHunks.length > 0 && !isActiveFileContentSettled - ); - const overlayData = useMemo(() => { if (currentFileHunks.length === 0) { return { @@ -782,7 +843,7 @@ export const ImmersiveReviewView: React.FC = (props) = selectedHunkId !== null && currentFileHunks.some((hunk) => hunk.id === selectedHunkId); useEffect(() => { - if (!isActiveFileRevealPending || !isActiveFileContentSettled) { + if (!isActiveFileRevealPending) { return; } @@ -805,7 +866,6 @@ export const ImmersiveReviewView: React.FC = (props) = currentFileHunks.length, hasResolvedSelectedHunkForReveal, isActiveFileRevealPending, - isActiveFileContentSettled, selectedHunkRevealTargetLineIndex, ]); @@ -1579,10 +1639,6 @@ export const ImmersiveReviewView: React.FC = (props) = } } - if (isActiveFileRevealPending && !isActiveFileContentSettled) { - return; - } - const lineIndexForScroll = isActiveFileRevealPending ? revealTargetLineIndex : activeLineIndex; if (lineIndexForScroll === null) { return; @@ -1641,7 +1697,6 @@ export const ImmersiveReviewView: React.FC = (props) = }, [ activeFilePath, activeLineIndex, - isActiveFileContentSettled, isActiveFileRevealPending, overlayData.content, revealTargetLineIndex, @@ -1941,44 +1996,36 @@ export const ImmersiveReviewView: React.FC = (props) = ) : (
- {isActiveFileContentLoading ? ( -
+ {isActiveFileRevealPending && ( +
Loading file...
- ) : ( - <> - {isActiveFileRevealPending && ( -
- Loading file... -
- )} -
- -
- )} +
+ +
)}
- {!isReviewComplete && overlayData && !isTouchExperience && !isActiveFileContentLoading && ( + {!isReviewComplete && !isTouchExperience && ( { test("generates script with size check", () => { @@ -17,6 +22,13 @@ describe("buildReadFileScript", () => { const script = buildReadFileScript("file'with'quotes.txt"); expect(script).toContain("'file'\"'\"'with'\"'\"'quotes.txt'"); }); + + test("supports smaller caller-specific size and line budgets", () => { + const script = buildReadFileScript("test.txt", { maxSizeBytes: 1234, maxLineCount: 99 }); + + expect(script).toContain('[ "$size" -gt 1234 ] && exit 42'); + expect(script).toContain("awk 'NR > 99 { exit 43 }' 'test.txt' || exit 43"); + }); }); describe("processFileContents", () => { @@ -28,6 +40,14 @@ describe("processFileContents", () => { }); }); + test("returns error for too many lines", () => { + const result = processFileContents("", EXIT_CODE_TOO_MANY_LINES); + expect(result).toEqual({ + type: "error", + message: "File has too many lines to display.", + }); + }); + test("handles empty file", () => { const result = processFileContents("0", 0); expect(result).toEqual({ type: "text", content: "", size: 0 }); diff --git a/src/browser/utils/fileRead.ts b/src/browser/utils/fileRead.ts index c616f34716..67e76d2ec7 100644 --- a/src/browser/utils/fileRead.ts +++ b/src/browser/utils/fileRead.ts @@ -8,6 +8,14 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024; /** Exit code for "file too large". */ export const EXIT_CODE_TOO_LARGE = 42; +/** Exit code for "file has too many lines for the current UI budget". */ +export const EXIT_CODE_TOO_MANY_LINES = 43; + +interface ReadFileScriptOptions { + maxSizeBytes?: number; + maxLineCount?: number; +} + /** Magic bytes for image type detection. */ const IMAGE_MAGIC_BYTES: Array<{ bytes: number[]; mime: string }> = [ { bytes: [0x89, 0x50, 0x4e, 0x47], mime: "image/png" }, @@ -94,13 +102,25 @@ function detectBinary(buffer: Uint8Array): boolean { } /** - * Generate bash script to read file contents with size check. + * Generate bash script to read file contents with size and optional line-count checks. * Uses base64 encoding for all files to handle binary safely. */ -export function buildReadFileScript(relativePath: string): string { +export function buildReadFileScript( + relativePath: string, + options: ReadFileScriptOptions = {} +): string { const file = shellEscape(relativePath); + const maxSizeBytes = Math.max(0, Math.trunc(options.maxSizeBytes ?? MAX_FILE_SIZE)); + const maxLineCount = + options.maxLineCount == null ? null : Math.max(0, Math.trunc(options.maxLineCount)); + const lineLimitScript = + maxLineCount == null + ? "" + : ` +awk 'NR > ${maxLineCount} { exit ${EXIT_CODE_TOO_MANY_LINES} }' ${file} || exit ${EXIT_CODE_TOO_MANY_LINES}`; + return `size=$(stat -c %s ${file} 2>/dev/null || stat -f %z ${file}) -[ "$size" -gt ${MAX_FILE_SIZE} ] && exit ${EXIT_CODE_TOO_LARGE} +[ "$size" -gt ${maxSizeBytes} ] && exit ${EXIT_CODE_TOO_LARGE}${lineLimitScript} echo "$size" base64 < ${file}`; } @@ -137,6 +157,10 @@ export function processFileContents(output: string, exitCode: number): FileConte return { type: "error", message: "File is too large to display. Maximum: 10 MB." }; } + if (exitCode === EXIT_CODE_TOO_MANY_LINES) { + return { type: "error", message: "File has too many lines to display." }; + } + const { size, base64 } = parseReadFileOutput(output); let buffer: Uint8Array; From 835e5e8496f8cfded93e4adae748daea007cdff6 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 25 May 2026 21:11:22 +0000 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=A4=96=20tests:=20scope=20immersive?= =?UTF-8?q?=20note=20story=20assertion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with mux on behalf of Mike. Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `1263891{MUX_COSTS_USD:-unknown}` --- .../CodeReview/ImmersiveReviewView.stories.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.stories.tsx b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.stories.tsx index 6fa504f967..39b51822f2 100644 --- a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.stories.tsx +++ b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.stories.tsx @@ -436,17 +436,18 @@ export const ImmersiveNotesSidebarActionFooter: Story = { await waitFor( () => { canvas.getByTestId("immersive-review-view"); - canvas.getByText(/Keep the formatter instance shared/i); }, { timeout: 10_000 } ); - const noteCard = canvasElement.querySelector('[data-note-index="0"]'); + const noteCard = Array.from( + canvasElement.querySelectorAll("[data-note-index]") + ).find((card) => card.textContent?.includes("Keep the formatter instance shared")); if (!noteCard) { - throw new Error("Expected the first immersive review note card to render."); + throw new Error("Expected an immersive review note card with the formatter note to render."); } - // Focus the first card so Storybook captures the reserved footer state that prevents + // Focus the matching card so Storybook captures the reserved footer state that prevents // the note preview layout from shifting when review actions appear. Focus is more // deterministic than hover in the CI interaction runner while exercising the same UI. noteCard.focus(); From 6fb02aff57c525c1bfdcff202606536e66e2ade3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 25 May 2026 21:15:59 +0000 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=A4=96=20fix:=20gate=20immersive=20co?= =?UTF-8?q?ntext=20by=20selected=20hunk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with mux on behalf of Mike. Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `1294644{MUX_COSTS_USD:-unknown}` --- .../CodeReview/ImmersiveReviewView.test.tsx | 55 +++++++++++++++++++ .../CodeReview/ImmersiveReviewView.tsx | 29 +++------- 2 files changed, 62 insertions(+), 22 deletions(-) diff --git a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.test.tsx b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.test.tsx index 81012bc3f9..4324586c90 100644 --- a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.test.tsx +++ b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.test.tsx @@ -83,6 +83,10 @@ function createFileTreeForPaths(filePaths: string[]): FileTreeNode { return root; } +function encodeFileReadOutput(content: string): string { + return `${Buffer.byteLength(content, "utf8")}\n${Buffer.from(content, "utf8").toString("base64")}`; +} + function renderImmersiveReview( overrides: Partial> = {} ) { @@ -209,6 +213,57 @@ describe("ImmersiveReviewView", () => { expect(mockApi.workspace.executeBash).not.toHaveBeenCalled(); }); + test("loads full-file context for an in-budget selected hunk even when another hunk is far away", async () => { + const nearHunk = createHunk({ + id: "hunk-near", + newStart: 40, + newLines: 1, + header: "@@ -40 +40 @@", + content: "-old near line\n+new near line", + }); + const farHunk = createHunk({ + id: "hunk-far", + newStart: 5000, + newLines: 1, + header: "@@ -5000 +5000 @@", + content: "-old far line\n+new far line", + }); + + renderImmersiveReview({ + fileTree: createFileTree(nearHunk.filePath), + hunks: [nearHunk, farHunk], + allHunks: [nearHunk, farHunk], + selectedHunkId: nearHunk.id, + }); + + await waitFor(() => expect(mockApi.workspace.executeBash).toHaveBeenCalledTimes(1)); + }); + + test("accepts full-file context at the line budget when the file ends with a newline", async () => { + const lineBudget = 1500; + const fileContent = `${[ + "new line", + "context after selected hunk", + ...Array.from({ length: lineBudget - 2 }, (_, index) => `filler ${index}`), + ].join("\n")}\n`; + mockApi.workspace.executeBash = mock(() => + Promise.resolve({ + success: true as const, + data: { + success: true, + output: encodeFileReadOutput(fileContent), + exitCode: 0, + }, + }) + ); + + const view = renderImmersiveReview(); + + await waitFor(() => + expect(view.container.textContent ?? "").toContain("context after selected hunk") + ); + }); + test("weights completion by changed lines instead of hunk count", () => { const smallHunk = createHunk({ id: "hunk-small", diff --git a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx index 9db1ac2977..d9581ae9c0 100644 --- a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx +++ b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx @@ -200,31 +200,16 @@ function normalizeFileLines(content: string): string[] { } function isWithinFullFileContextLineBudget(content: string): boolean { - let lineCount = content.length > 0 ? 1 : 0; - for (let index = 0; index < content.length; index += 1) { - if (content.charCodeAt(index) === 10) { - lineCount += 1; - if (lineCount > MAX_FULL_FILE_CONTEXT_LINES) { - return false; - } - } - } - return true; + return normalizeFileLines(content).length <= MAX_FULL_FILE_CONTEXT_LINES; } -function shouldAttemptFullFileContext(sortedHunks: readonly DiffHunk[]): boolean { - if (sortedHunks.length === 0) { +function shouldAttemptFullFileContext(selectedHunk: DiffHunk | null): boolean { + if (!selectedHunk) { return false; } - for (const hunk of sortedHunks) { - const lastDisplayLine = hunk.newStart + Math.max(hunk.newLines, 1) - 1; - if (lastDisplayLine > MAX_FULL_FILE_CONTEXT_LINES) { - return false; - } - } - - return true; + const lastDisplayLine = selectedHunk.newStart + Math.max(selectedHunk.newLines, 1) - 1; + return lastDisplayLine <= MAX_FULL_FILE_CONTEXT_LINES; } function buildFileContentCacheKey( @@ -583,8 +568,8 @@ export const ImmersiveReviewView: React.FC = (props) = }); const fileContentCacheRef = useRef>(new Map()); const shouldLoadFullFileContext = useMemo( - () => shouldAttemptFullFileContext(currentFileHunks), - [currentFileHunks] + () => shouldAttemptFullFileContext(selectedHunk), + [selectedHunk] ); const activeFileContentCacheKey = useMemo( () => From 3ae3a98e3d1fd20372e2515a8eb2f07566936b5b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 25 May 2026 21:19:57 +0000 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=A4=96=20fix:=20retry=20transient=20i?= =?UTF-8?q?mmersive=20file=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with mux on behalf of Mike. Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `1316239{MUX_COSTS_USD:-unknown}` --- .../CodeReview/ImmersiveReviewView.test.tsx | 47 +++++++++++++++++++ .../CodeReview/ImmersiveReviewView.tsx | 24 +++++++--- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.test.tsx b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.test.tsx index 4324586c90..1b99068951 100644 --- a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.test.tsx +++ b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.test.tsx @@ -264,6 +264,53 @@ describe("ImmersiveReviewView", () => { ); }); + test("retries full-file context after a transient read failure", async () => { + const firstHunk = createHunk({ id: "hunk-first", filePath: "src/first.ts" }); + const secondHunk = createHunk({ id: "hunk-second", filePath: "src/second.ts" }); + const allHunks = [firstHunk, secondHunk]; + const fileTree = createFileTreeForPaths(allHunks.map((hunk) => hunk.filePath)); + const onSelectHunk = mock((_hunkId: string | null) => undefined); + mockApi.workspace.executeBash = mock(() => + Promise.resolve({ + success: true as const, + data: { + success: false, + output: "", + exitCode: 1, + }, + }) + ); + + const renderView = (selectedHunkId: string) => ( + + false} + onToggleRead={mock(() => undefined)} + onMarkFileAsRead={mock(() => undefined)} + selectedHunkId={selectedHunkId} + onSelectHunk={onSelectHunk} + onExit={mock(() => undefined)} + isTouchImmersive={true} + reviewsByFilePath={new Map()} + firstSeenMap={{}} + /> + + ); + + const view = render(renderView(firstHunk.id)); + await waitFor(() => expect(mockApi.workspace.executeBash).toHaveBeenCalledTimes(1)); + + view.rerender(renderView(secondHunk.id)); + await waitFor(() => expect(mockApi.workspace.executeBash).toHaveBeenCalledTimes(2)); + + view.rerender(renderView(firstHunk.id)); + await waitFor(() => expect(mockApi.workspace.executeBash).toHaveBeenCalledTimes(3)); + }); + test("weights completion by changed lines instead of hunk count", () => { const smallHunk = createHunk({ id: "hunk-small", diff --git a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx index d9581ae9c0..81be637836 100644 --- a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx +++ b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx @@ -42,7 +42,12 @@ import { matchesKeybind, } from "@/browser/utils/ui/keybinds"; import { stopKeyboardPropagation } from "@/browser/utils/events"; -import { buildReadFileScript, processFileContents } from "@/browser/utils/fileRead"; +import { + buildReadFileScript, + EXIT_CODE_TOO_LARGE, + EXIT_CODE_TOO_MANY_LINES, + processFileContents, +} from "@/browser/utils/fileRead"; import { TooltipIfPresent } from "@/browser/components/Tooltip/Tooltip"; import { parseReviewLineRange, @@ -618,8 +623,10 @@ export const ImmersiveReviewView: React.FC = (props) = isSettled: false, }); - const settleLoadedContent = (content: string | null) => { - fileContentCacheRef.current.set(cacheKey, content); + const settleLoadedContent = (content: string | null, shouldCache: boolean) => { + if (shouldCache) { + fileContentCacheRef.current.set(cacheKey, content); + } if (!cancelled) { setActiveFileContentState({ filePath: resolvedFilePath, @@ -645,14 +652,17 @@ export const ImmersiveReviewView: React.FC = (props) = } if (!fileResult.success) { - settleLoadedContent(null); + settleLoadedContent(null, false); return; } const bashResult = fileResult.data; + const isDeterministicBudgetMiss = + bashResult.exitCode === EXIT_CODE_TOO_LARGE || + bashResult.exitCode === EXIT_CODE_TOO_MANY_LINES; if (!bashResult.success && !bashResult.output) { - settleLoadedContent(null); + settleLoadedContent(null, isDeterministicBudgetMiss); return; } @@ -661,11 +671,11 @@ export const ImmersiveReviewView: React.FC = (props) = data.type === "text" && isWithinFullFileContextLineBudget(data.content) ? data.content : null; - settleLoadedContent(content); + settleLoadedContent(content, content != null || isDeterministicBudgetMiss); } loadActiveFileContent().catch(() => { - settleLoadedContent(null); + settleLoadedContent(null, false); }); return () => { From 0f8e0c24d4e87943f1ddfba227a4a83094dfcb79 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 25 May 2026 21:26:15 +0000 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=A4=96=20fix:=20preserve=20file=20rea?= =?UTF-8?q?d=20line-check=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with mux on behalf of Mike. Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `1354559{MUX_COSTS_USD:-unknown}` --- src/browser/utils/fileRead.test.ts | 30 +++++++++++++++++++++++++++++- src/browser/utils/fileRead.ts | 4 +++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/browser/utils/fileRead.test.ts b/src/browser/utils/fileRead.test.ts index ad25f45350..01c40d2849 100644 --- a/src/browser/utils/fileRead.test.ts +++ b/src/browser/utils/fileRead.test.ts @@ -1,3 +1,7 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, test } from "bun:test"; import { buildReadFileScript, @@ -27,7 +31,31 @@ describe("buildReadFileScript", () => { const script = buildReadFileScript("test.txt", { maxSizeBytes: 1234, maxLineCount: 99 }); expect(script).toContain('[ "$size" -gt 1234 ] && exit 42'); - expect(script).toContain("awk 'NR > 99 { exit 43 }' 'test.txt' || exit 43"); + expect(script).toContain("awk 'NR > 99 { exit 43 }' 'test.txt'"); + expect(script).toContain('exit "$awk_status"'); + }); + + test("preserves non-budget awk failures while keeping line-budget exits", () => { + const tempDir = mkdtempSync(join(tmpdir(), "mux-file-read-")); + + try { + const missingFileResult = spawnSync( + "bash", + ["-lc", buildReadFileScript("missing.txt", { maxLineCount: 1 })], + { cwd: tempDir } + ); + expect(missingFileResult.status).not.toBe(EXIT_CODE_TOO_MANY_LINES); + + writeFileSync(join(tempDir, "two-lines.txt"), "first\nsecond\n"); + const tooManyLinesResult = spawnSync( + "bash", + ["-lc", buildReadFileScript("two-lines.txt", { maxLineCount: 1 })], + { cwd: tempDir } + ); + expect(tooManyLinesResult.status).toBe(EXIT_CODE_TOO_MANY_LINES); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } }); }); diff --git a/src/browser/utils/fileRead.ts b/src/browser/utils/fileRead.ts index 67e76d2ec7..beee17ee28 100644 --- a/src/browser/utils/fileRead.ts +++ b/src/browser/utils/fileRead.ts @@ -117,7 +117,9 @@ export function buildReadFileScript( maxLineCount == null ? "" : ` -awk 'NR > ${maxLineCount} { exit ${EXIT_CODE_TOO_MANY_LINES} }' ${file} || exit ${EXIT_CODE_TOO_MANY_LINES}`; +awk 'NR > ${maxLineCount} { exit ${EXIT_CODE_TOO_MANY_LINES} }' ${file} +awk_status=$? +[ "$awk_status" -ne 0 ] && exit "$awk_status"`; return `size=$(stat -c %s ${file} 2>/dev/null || stat -f %z ${file}) [ "$size" -gt ${maxSizeBytes} ] && exit ${EXIT_CODE_TOO_LARGE}${lineLimitScript} From bb2d141d376a018e09dfc5914485f0fc4594cebc Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 25 May 2026 21:52:49 +0000 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=A4=96=20tests:=20preserve=20immersiv?= =?UTF-8?q?e=20note=20story=20focus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with mux on behalf of Mike. Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `1728864{MUX_COSTS_USD:-unknown}` --- .../CodeReview/ImmersiveReviewView.stories.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.stories.tsx b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.stories.tsx index 39b51822f2..586b44160e 100644 --- a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.stories.tsx +++ b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.stories.tsx @@ -436,18 +436,19 @@ export const ImmersiveNotesSidebarActionFooter: Story = { await waitFor( () => { canvas.getByTestId("immersive-review-view"); + if (canvas.queryAllByText(/Keep the formatter instance shared/i).length === 0) { + throw new Error("Expected the immersive review formatter note to render."); + } }, { timeout: 10_000 } ); - const noteCard = Array.from( - canvasElement.querySelectorAll("[data-note-index]") - ).find((card) => card.textContent?.includes("Keep the formatter instance shared")); + const noteCard = canvasElement.querySelector('[data-note-index="0"]'); if (!noteCard) { - throw new Error("Expected an immersive review note card with the formatter note to render."); + throw new Error("Expected the first immersive review note card to render."); } - // Focus the matching card so Storybook captures the reserved footer state that prevents + // Focus the first card so Storybook captures the reserved footer state that prevents // the note preview layout from shifting when review actions appear. Focus is more // deterministic than hover in the CI interaction runner while exercising the same UI. noteCard.focus(); From 8ae735191c2297066f02c820beef3f1520955a85 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 26 May 2026 09:29:56 +0000 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=A4=96=20fix:=20avoid=20per-key=20rev?= =?UTF-8?q?iew=20composer=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with mux on behalf of Mike. Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `4061985{MUX_COSTS_USD:-unknown}` --- src/browser/features/Shared/DiffRenderer.tsx | 43 +++---------------- ...SelectableDiffRenderer.dragSelect.test.tsx | 34 +++++++++++++++ 2 files changed, 41 insertions(+), 36 deletions(-) diff --git a/src/browser/features/Shared/DiffRenderer.tsx b/src/browser/features/Shared/DiffRenderer.tsx index 05fcdb3c9f..58cfa26d56 100644 --- a/src/browser/features/Shared/DiffRenderer.tsx +++ b/src/browser/features/Shared/DiffRenderer.tsx @@ -717,28 +717,6 @@ const ReviewNoteInput: React.FC = React.memo( }) => { const { showOld, showNew } = getLineNumberModeFlags(lineNumberMode); const textareaRef = React.useRef(null); - const resizeFrameRef = React.useRef(null); - - const resizeTextarea = React.useCallback(() => { - const textarea = textareaRef.current; - if (!textarea) { - return; - } - - textarea.style.height = "auto"; - textarea.style.height = `${textarea.scrollHeight}px`; - }, []); - - const scheduleTextareaResize = React.useCallback(() => { - if (resizeFrameRef.current !== null) { - cancelAnimationFrame(resizeFrameRef.current); - } - - resizeFrameRef.current = window.requestAnimationFrame(() => { - resizeFrameRef.current = null; - resizeTextarea(); - }); - }, [resizeTextarea]); // Keep the composer uncontrolled so typing does not trigger per-key React re-renders // through immersive diff overlays. Parent-initiated prefill changes are synced here. @@ -749,21 +727,11 @@ const ReviewNoteInput: React.FC = React.memo( } textarea.value = initialNoteText ?? ""; - scheduleTextareaResize(); - }, [initialNoteText, scheduleTextareaResize]); + }, [initialNoteText]); // Auto-focus on mount. React.useEffect(() => { textareaRef.current?.focus(); - scheduleTextareaResize(); - }, [scheduleTextareaResize]); - - React.useEffect(() => { - return () => { - if (resizeFrameRef.current !== null) { - cancelAnimationFrame(resizeFrameRef.current); - } - }; }, []); const handleSubmit = () => { @@ -881,15 +849,18 @@ const ReviewNoteInput: React.FC = React.memo( className="w-[3px] shrink-0" style={{ background: "var(--color-review-accent)" }} /> + {/* Avoid JS autosize here. Reading scrollHeight on every input forces layout across + large immersive diffs, which can make each typed character feel delayed. */}