diff --git a/site/package.json b/site/package.json
index 519ec47024..6025d7ca34 100644
--- a/site/package.json
+++ b/site/package.json
@@ -60,7 +60,8 @@
"@mui/material": "5.18.0",
"@mui/system": "5.18.0",
"@novnc/novnc": "^1.5.0",
- "@pierre/diffs": "1.1.19",
+ "@pierre/diffs": "1.2.4",
+ "@pierre/trees": "1.0.0-beta.4",
"@tanstack/react-query-devtools": "5.77.0",
"@xterm/addon-canvas": "0.7.0",
"@xterm/addon-fit": "0.11.0",
diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml
index b1b8fa8a40..a05af01e68 100644
--- a/site/pnpm-lock.yaml
+++ b/site/pnpm-lock.yaml
@@ -96,8 +96,11 @@ importers:
specifier: ^1.5.0
version: 1.5.0
'@pierre/diffs':
- specifier: 1.1.19
- version: 1.1.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ specifier: 1.2.4
+ version: 1.2.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@pierre/trees':
+ specifier: 1.0.0-beta.4
+ version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@tanstack/react-query-devtools':
specifier: 5.77.0
version: 5.77.0(@tanstack/react-query@5.77.0(react@19.2.6))(react@19.2.6)
@@ -1414,16 +1417,22 @@ packages:
cpu: [x64]
os: [win32]
- '@pierre/diffs@1.1.19':
- resolution: {integrity: sha512-eYyDW69heXd7i9zdkWogGYosHzoYF2dstV6uDcmnQAf72uRChs3hrpf/7ym/ayTiwD8a+TQ7oZ5vNNb0tstJvA==, tarball: https://registry.npmjs.org/@pierre/diffs/-/diffs-1.1.19.tgz}
+ '@pierre/diffs@1.2.4':
+ resolution: {integrity: sha512-SEuYxGpSCHVvfoLly/Q/OYpJSBLWaVLV3M3wI/VBW7aZmzYenNe4aXjOf5sIKJMWW5gbZe9WdLvtKUt6cQ1k1A==, tarball: https://registry.npmjs.org/@pierre/diffs/-/diffs-1.2.4.tgz}
peerDependencies:
react: ^18.3.1 || ^19.0.0
react-dom: ^18.3.1 || ^19.0.0
- '@pierre/theme@0.0.28':
- resolution: {integrity: sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw==, tarball: https://registry.npmjs.org/@pierre/theme/-/theme-0.0.28.tgz}
+ '@pierre/theme@1.0.3':
+ resolution: {integrity: sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA==, tarball: https://registry.npmjs.org/@pierre/theme/-/theme-1.0.3.tgz}
engines: {vscode: ^1.0.0}
+ '@pierre/trees@1.0.0-beta.4':
+ resolution: {integrity: sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag==, tarball: https://registry.npmjs.org/@pierre/trees/-/trees-1.0.0-beta.4.tgz}
+ peerDependencies:
+ react: ^18.3.1 || ^19.0.0
+ react-dom: ^18.3.1 || ^19.0.0
+
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==, tarball: https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz}
engines: {node: '>=14'}
@@ -5179,6 +5188,14 @@ packages:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==, tarball: https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz}
engines: {node: '>=20'}
+ preact-render-to-string@6.6.5:
+ resolution: {integrity: sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA==, tarball: https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.6.5.tgz}
+ peerDependencies:
+ preact: '>=10 || >= 11.0.0-0'
+
+ preact@11.0.0-beta.0:
+ resolution: {integrity: sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg==, tarball: https://registry.npmjs.org/preact/-/preact-11.0.0-beta.0.tgz}
+
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==, tarball: https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz}
engines: {node: '>= 0.8.0'}
@@ -7491,9 +7508,9 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.14.0':
optional: true
- '@pierre/diffs@1.1.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ '@pierre/diffs@1.2.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@pierre/theme': 0.0.28
+ '@pierre/theme': 1.0.3
'@shikijs/transformers': 3.23.0
diff: 8.0.3
hast-util-to-html: 9.0.5
@@ -7502,7 +7519,14 @@ snapshots:
react-dom: 19.2.6(react@19.2.6)
shiki: 3.23.0
- '@pierre/theme@0.0.28': {}
+ '@pierre/theme@1.0.3': {}
+
+ '@pierre/trees@1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ preact: 11.0.0-beta.0
+ preact-render-to-string: 6.6.5(preact@11.0.0-beta.0)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -11612,6 +11636,12 @@ snapshots:
powershell-utils@0.1.0: {}
+ preact-render-to-string@6.6.5(preact@11.0.0-beta.0):
+ dependencies:
+ preact: 11.0.0-beta.0
+
+ preact@11.0.0-beta.0: {}
+
prelude-ls@1.2.1:
optional: true
diff --git a/site/src/pages/AgentsPage/components/ChatElements/Response.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/Response.stories.tsx
index 17b6101448..622c65eee4 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/Response.stories.tsx
+++ b/site/src/pages/AgentsPage/components/ChatElements/Response.stories.tsx
@@ -104,73 +104,18 @@ const expectCodeBlock = async (
expect(host.style.getPropertyValue("--diffs-font-size")).toBe("12px");
expect(host.style.getPropertyValue("--diffs-line-height")).toBe("20px");
+ expect(canvasElement.textContent ?? "").not.toContain("```");
+
const shadowRoot = host.shadowRoot;
if (!shadowRoot) {
throw new Error("Expected FileViewer to render code in its shadow root.");
}
- expect(shadowRoot.textContent ?? "").not.toContain("```");
-
- const pre = shadowRoot.querySelector(
- "pre[data-file][data-disable-line-numbers]",
- );
- expect(pre).toBeInTheDocument();
- if (!(pre instanceof HTMLElement)) {
- throw new Error("Expected FileViewer to render a pre element.");
- }
-
- const code = shadowRoot.querySelector("[data-code]");
- expect(code).toBeInTheDocument();
- if (!(code instanceof HTMLElement)) {
- throw new Error("Expected FileViewer to render a code container.");
- }
-
- const line = shadowRoot.querySelector("[data-line]");
- expect(line).toBeInTheDocument();
- if (!(line instanceof HTMLElement)) {
- throw new Error("Expected FileViewer to render code lines.");
- }
-
- const gutter = shadowRoot.querySelector("[data-column-number]");
- expect(gutter).toBeInTheDocument();
- if (!(gutter instanceof HTMLElement)) {
- throw new Error("Expected FileViewer to render its line-number gutter.");
- }
-
- const preStyles = getComputedStyle(pre);
- expect(preStyles.fontSize).toBe("12px");
- expect(preStyles.lineHeight).toBe("20px");
-
- const codeStyles = getComputedStyle(code);
- expect(codeStyles.paddingTop).toBe("8px");
- expect(codeStyles.paddingBottom).toBe("8px");
- expect(codeStyles.paddingBottom).toBe(codeStyles.paddingTop);
- expect(codeStyles.overflow).toBe("visible");
-
- const lineStyles = getComputedStyle(line);
- expect(lineStyles.paddingLeft).toBe("12px");
- expect(lineStyles.paddingRight).toBe("12px");
- expect(lineStyles.paddingRight).toBe(lineStyles.paddingLeft);
- expect(lineStyles.minHeight).toBe("20px");
-
- const gutterStyles = getComputedStyle(gutter);
- expect(gutterStyles.minWidth).toBe("0px");
- expect(gutterStyles.paddingLeft).toBe("0px");
- expect(gutterStyles.paddingRight).toBe("0px");
if (options.highlighted) {
- let highlightedToken: HTMLElement | null = null;
await waitFor(() => {
const token = shadowRoot.querySelector("span[style*='color']");
expect(token).toBeInTheDocument();
- if (!(token instanceof HTMLElement)) {
- throw new Error("Expected FileViewer to render highlighted tokens.");
- }
- highlightedToken = token;
});
- if (!highlightedToken) {
- throw new Error("Expected FileViewer to render highlighted tokens.");
- }
- expect(getComputedStyle(highlightedToken).color).not.toBe(lineStyles.color);
}
return host;
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/DiffFileHeader.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/DiffFileHeader.tsx
new file mode 100644
index 0000000000..3e6493a371
--- /dev/null
+++ b/site/src/pages/AgentsPage/components/ChatElements/tools/DiffFileHeader.tsx
@@ -0,0 +1,52 @@
+import type { FileContents, FileDiffMetadata } from "@pierre/diffs";
+import { cn } from "#/utils/cn";
+import { countChangedLines } from "../../../utils/countChangedLines";
+import { changeColor, changeLabel } from "../../../utils/diffColors";
+
+export function DiffFileHeader({
+ file,
+}: {
+ file: FileDiffMetadata | FileContents;
+}) {
+ const isDiff = "type" in file;
+ const stats = isDiff ? countChangedLines(file) : null;
+
+ return (
+
+
+ {isDiff && (
+
+ {changeLabel(file.type)}
+
+ )}
+ {isDiff && file.prevName && file.prevName !== file.name && (
+
+ {file.prevName}
+
+ )}
+
+ {file.name}
+
+
+ {stats && (stats.additions > 0 || stats.deletions > 0) && (
+
+ {stats.deletions > 0 && (
+
+ −{stats.deletions}
+
+ )}
+ {stats.additions > 0 && (
+
+ +{stats.additions}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx
index 93b8d5f177..0df55ff068 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx
+++ b/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx
@@ -4,6 +4,7 @@ import { FileDiff } from "@pierre/diffs/react";
import type React from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
+import { DiffFileHeader } from "./DiffFileHeader";
import {
type AgentDisplayState,
isAgentDisplayFullyExpanded,
@@ -85,6 +86,9 @@ export const EditFilesTool: React.FC<{
fileDiff={stripNoNewline(diff)}
options={getDiffViewerOptions(isDark)}
style={DIFFS_FONT_STYLE}
+ renderCustomHeader={(fileDiff) => (
+
+ )}
/>
) : null,
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx
index 92fc966d9e..a3295b5f63 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx
+++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx
@@ -23,10 +23,15 @@ const longExecuteCommand =
const TEST_PNG_B64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4n539HwAHFwLVF8kc1wAAAABJRU5ErkJggg==";
-const getDiffsText = (element: HTMLElement) =>
- Array.from(element.querySelectorAll("diffs-container"))
- .map((container) => container.shadowRoot?.textContent ?? "")
- .join("\n");
+const expectDiffText = async (element: HTMLElement, text: string) => {
+ await waitFor(() =>
+ expect(
+ Array.from(element.querySelectorAll("diffs-container")).some((host) =>
+ host.shadowRoot?.textContent?.includes(text),
+ ),
+ ).toBe(true),
+ );
+};
const meta: Meta = {
title: "pages/AgentsPage/ChatElements/tools/Tool",
@@ -1266,11 +1271,7 @@ export const MCPToolCompleted: Story = {
await userEvent.click(toggle);
expect(canvas.getByText("Input")).toBeVisible();
expect(canvas.getByText("Output")).toBeVisible();
- await waitFor(() => {
- const diffsText = getDiffsText(canvasElement);
- expect(diffsText).toContain("backend");
- expect(diffsText).toContain("Fix auth flow");
- });
+ await expectDiffText(canvasElement, "Fix auth flow");
},
};
@@ -1306,9 +1307,7 @@ export const MCPToolNoResult: Story = {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
expect(canvas.getByText("Input")).toBeVisible();
- await waitFor(() => {
- expect(getDiffsText(canvasElement)).toContain("New issue");
- });
+ await expectDiffText(canvasElement, "New issue");
},
};
@@ -1392,11 +1391,8 @@ export const WorkspaceMCPToolCompleted: Story = {
await userEvent.click(canvas.getByRole("button"));
expect(canvas.getByText("Input")).toBeVisible();
expect(canvas.getByText("Output")).toBeVisible();
- await waitFor(() => {
- const diffsText = getDiffsText(canvasElement);
- expect(diffsText).toContain("message");
- expect(diffsText).toContain("hello from workspace MCP");
- });
+ await expectDiffText(canvasElement, "message");
+ await expectDiffText(canvasElement, "hello from workspace MCP");
},
};
@@ -2079,17 +2075,7 @@ export const ReadFileLongLine: Story = {
await userEvent.click(
canvas.getByRole("button", { name: /Read config.ts/i }),
);
- await waitFor(() =>
- expect(getDiffsText(canvasElement)).toContain("apiUrl"),
- );
- await waitFor(() => {
- const host = canvasElement.querySelector("diffs-container");
- const code = host?.shadowRoot?.querySelector("[data-code]");
- expect(code).toBeInstanceOf(HTMLElement);
- if (code instanceof HTMLElement) {
- expect(getComputedStyle(code).overflow).toBe("visible");
- }
- });
+ await expectDiffText(canvasElement, "apiUrl");
},
};
@@ -2104,19 +2090,20 @@ export const ReadFileTallAndWide: Story = {
await userEvent.click(
canvas.getByRole("button", { name: /Read config.ts/i }),
);
- await waitFor(() =>
- expect(getDiffsText(canvasElement)).toContain("apiUrl"),
- );
- const viewport = [
- ...canvasElement.querySelectorAll(
- "[data-radix-scroll-area-viewport]",
- ),
- ].find(
- (v) => v.scrollWidth > v.clientWidth && v.scrollHeight > v.clientHeight,
- );
- if (!viewport) {
- throw new Error("Expected a viewport overflowing on both axes.");
- }
+ await expectDiffText(canvasElement, "apiUrl");
+ const viewport = await waitFor(() => {
+ const target = [
+ ...canvasElement.querySelectorAll(
+ "[data-radix-scroll-area-viewport]",
+ ),
+ ].find(
+ (v) => v.scrollWidth > v.clientWidth && v.scrollHeight > v.clientHeight,
+ );
+ if (!target) {
+ throw new Error("Expected a viewport overflowing on both axes.");
+ }
+ return target;
+ });
viewport.dispatchEvent(
new WheelEvent("wheel", { deltaY: 200, bubbles: true, cancelable: true }),
);
@@ -2136,9 +2123,7 @@ export const GenericToolLongOutput: Story = {
await userEvent.click(
canvas.getByRole("button", { name: /some_custom_tool/i }),
);
- await waitFor(() =>
- expect(getDiffsText(canvasElement)).toContain("apiUrl"),
- );
+ await expectDiffText(canvasElement, "apiUrl");
},
};
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx
index 9aa8c36c1e..619a4b1929 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx
+++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx
@@ -12,6 +12,7 @@ import {
import { ChatSummarizedTool } from "./ChatSummarizedTool";
import { ComputerTool } from "./ComputerTool";
import { CreateWorkspaceTool } from "./CreateWorkspaceTool";
+import { DiffFileHeader } from "./DiffFileHeader";
import { EditFilesTool } from "./EditFilesTool";
import {
ExecuteAuthRequiredTool,
@@ -828,7 +829,16 @@ const ToolFileViewer: FC = ({ label, file, options }) => (
scrollBarClassName="w-1.5"
horizontalScrollBarClassName="h-1.5"
>
-
+
+ }
+ />
>
);
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx
index 6923e9b50c..54fb1a8381 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx
+++ b/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx
@@ -4,6 +4,7 @@ import { FileDiff } from "@pierre/diffs/react";
import type React from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
+import { DiffFileHeader } from "./DiffFileHeader";
import {
type AgentDisplayState,
isAgentDisplayFullyExpanded,
@@ -66,6 +67,9 @@ export const WriteFileTool: React.FC<{
fileDiff={stripNoNewline(diff)}
options={getDiffViewerOptions(isDark)}
style={DIFFS_FONT_STYLE}
+ renderCustomHeader={(fileDiff) => (
+
+ )}
/>
)}
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts
index a3785989d8..44ff5c75df 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts
+++ b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts
@@ -990,24 +990,35 @@ describe("constants", () => {
expect(DIFFS_FONT_STYLE).toHaveProperty("--diffs-line-height", "1.5");
});
- it("fileViewerCSS is a non-empty string", () => {
- expect(typeof fileViewerCSS).toBe("string");
- expect(fileViewerCSS.length).toBeGreaterThan(0);
+ it("DIFFS_FONT_STYLE uses theme-aware diff variables", () => {
+ expect(DIFFS_FONT_STYLE).toHaveProperty(
+ "--diffs-addition-color-override",
+ "hsl(var(--git-added))",
+ );
+ expect(DIFFS_FONT_STYLE).toHaveProperty(
+ "--diffs-deletion-color-override",
+ "hsl(var(--git-deleted))",
+ );
+ expect(DIFFS_FONT_STYLE).toHaveProperty(
+ "--diffs-bg-addition-override",
+ "hsl(var(--surface-git-added))",
+ );
+ expect(DIFFS_FONT_STYLE).toHaveProperty(
+ "--diffs-bg-deletion-override",
+ "hsl(var(--surface-git-deleted))",
+ );
});
- it("diffViewerCSS includes border-left style", () => {
- expect(diffViewerCSS).toContain("border-left");
+ it("fileViewerCSS keeps file viewer backgrounds transparent", () => {
+ expect(fileViewerCSS).toContain("background-color: transparent");
+ expect(fileViewerCSS).toContain("[data-diffs-header]");
+ expect(fileViewerCSS).not.toContain("[data-code]");
});
- it("diffViewerCSS uses theme-aware changed line backgrounds", () => {
- expect(diffViewerCSS).toContain("--diffs-addition-color-override");
- expect(diffViewerCSS).toContain("--diffs-deletion-color-override");
- expect(diffViewerCSS).toContain("--diffs-bg-addition-override");
- expect(diffViewerCSS).toContain("--diffs-bg-deletion-override");
- expect(diffViewerCSS).toContain("var(--surface-git-added)");
- expect(diffViewerCSS).toContain("var(--surface-git-deleted)");
- expect(diffViewerCSS).toContain("[data-line-type='change-addition']");
- expect(diffViewerCSS).toContain("[data-line-type='change-deletion']");
+ it("diffViewerCSS keeps hunk separator styling scoped", () => {
+ expect(diffViewerCSS).toContain("[data-separator='line-info']");
+ expect(diffViewerCSS).toContain("[data-separator-content]");
+ expect(diffViewerCSS).not.toContain("[data-diffs-header]");
});
});
@@ -1063,6 +1074,27 @@ describe("parseServerEditResults", () => {
});
describe("parseServerEditDiffText", () => {
+ const changedLineContents = (
+ diff: NonNullable>,
+ ) =>
+ diff.hunks.flatMap((hunk) =>
+ hunk.hunkContent.flatMap((content) => {
+ if (content.type !== "change") {
+ return [];
+ }
+ return [
+ ...diff.deletionLines.slice(
+ content.deletionLineIndex,
+ content.deletionLineIndex + content.deletions,
+ ),
+ ...diff.additionLines.slice(
+ content.additionLineIndex,
+ content.additionLineIndex + content.additions,
+ ),
+ ].map((line) => line.trimEnd());
+ }),
+ );
+
it("returns null for an empty string (no-op edit)", () => {
expect(parseServerEditDiffText("")).toBeNull();
});
@@ -1074,6 +1106,46 @@ describe("parseServerEditDiffText", () => {
expect(diff).not.toBeNull();
expect(diff?.name).toBe("/abs/a.txt");
});
+
+ it("parses quoted git diff headers", () => {
+ const diff = parseServerEditDiffText(
+ [
+ 'diff --git "a/path with spaces.ts" "b/path with spaces.ts"',
+ "index 1111111..2222222 100644",
+ '--- "a/path with spaces.ts"',
+ '+++ "b/path with spaces.ts"',
+ "@@ -1 +1 @@",
+ "-old value",
+ "+new value",
+ "",
+ ].join("\n"),
+ );
+
+ expect(diff).not.toBeNull();
+ expect(diff?.name).toBe("path with spaces.ts");
+ expect(changedLineContents(diff!)).toEqual(["old value", "new value"]);
+ });
+
+ it("parses diffs that include git patch footer metadata", () => {
+ const diff = parseServerEditDiffText(
+ [
+ "diff --git a/example.ts b/example.ts",
+ "index 1111111..2222222 100644",
+ "--- a/example.ts",
+ "+++ b/example.ts",
+ "@@ -1 +1 @@",
+ "-old value",
+ "+new value",
+ "-- ",
+ "2.45.0",
+ "",
+ ].join("\n"),
+ );
+
+ expect(diff).not.toBeNull();
+ expect(diff?.name).toBe("example.ts");
+ expect(changedLineContents(diff!)).toEqual(["old value", "new value"]);
+ });
});
describe("summarizeParsedCommands", () => {
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts
index 2995058fe6..b8a4314525 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts
+++ b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts
@@ -291,52 +291,11 @@ export const formatResultOutput = (result: unknown): string | null => {
export const fileViewerCSS = [
"pre, [data-line], [data-diffs-header] { background-color: transparent !important; }",
- "[data-code] { overflow: visible !important; }",
-].join(" ");
-
-// Selection override CSS maps the library's gold/yellow selection
-// palette to the Coder blue accent (`--content-link`) so line
-// highlighting feels native to the rest of the page.
-//
-// The library has two selection code paths: context lines use
-// `--diffs-bg-selection`, but change-addition/deletion lines
-// use a separate `color-mix()` against `--diffs-line-bg`. To
-// guarantee a uniform highlight across all line types we set
-// the CSS variables for annotations AND apply direct rules
-// with `!important` for line and gutter elements.
-const SELECTION_OVERRIDE_CSS = [
- // Variable overrides for annotation areas and library internals.
- ":host {",
- " --diffs-bg-selection-override: hsl(var(--content-link) / 0.08);",
- " --diffs-bg-selection-number-override: hsl(var(--content-link) / 0.13);",
- " --diffs-selection-number-fg: hsl(var(--content-link));",
- " --diffs-gap-style: 1px solid hsl(var(--border-default));",
- "}",
- // Direct rules that override both context and change-line
- // selection backgrounds so every selected line looks the same.
- "[data-selected-line][data-line] {",
- " background-color: hsl(var(--content-link) / 0.08) !important;",
- "}",
- "[data-selected-line][data-column-number] {",
- " background-color: hsl(var(--content-link) / 0.13) !important;",
- " color: hsl(var(--content-link)) !important;",
- "}",
- // Clear the selection tint from annotation rows so the inline
- // prompt input stands out clearly against the selected lines.
- "[data-line-annotation][data-selected-line] [data-annotation-content] {",
- " background-color: transparent !important;",
- "}",
- "[data-line-annotation][data-selected-line]::before {",
- " background-color: transparent !important;",
- "}",
- "[data-selected-line][data-gutter-buffer='annotation'] {",
- " background-color: transparent !important;",
- "}",
].join(" ");
// Restyled separators: quiet, full-width dividers that fade
// into the background instead of drawing attention.
-const SEPARATOR_CSS = [
+export const SEPARATOR_CSS = [
// Transparent backgrounds so separators blend with the
// code area rather than forming a distinct band.
":host {",
@@ -379,119 +338,8 @@ const SEPARATOR_CSS = [
"}",
].join(" ");
-// Shared header styling applied to all diff viewers (both the
-// conversation-inline diffs and the right-tab panel). This gives
-// every diff header the same font sizing, change-type badges,
-// and stat-count pills regardless of where it appears.
-const DIFF_HEADER_CSS = [
- // Header layout: consistent sizing and padding across contexts.
- "[data-diffs-header] {",
- " font-size: 13px;",
- " min-height: 32px !important;",
- " padding-block: 8px !important;",
- " padding-inline: 10px 6px !important;",
- " border-bottom: 1px solid hsl(var(--border-default));",
- "}",
-
- // Title text: sans-serif, slightly smaller than header chrome.
- "[data-diffs-header] [data-title] {",
- " font-size: 12px;",
- " color: hsl(var(--content-primary));",
- "}",
-
- // Replace the library's built-in SVG change-type icons with
- // single-letter badges (A/D/M/R) via CSS-generated content.
- "[data-change-icon] { display: none !important; }",
- // Baseline-align the badge letter with the filename so their
- // text baselines match despite different font sizes (11px vs
- // 12px). Without this the box-centering default shifts the
- // badge a fraction of a pixel above the title.
- "[data-diffs-header] [data-header-content] { align-items: baseline; overflow: hidden; }",
- "[data-diffs-header] [data-rename-icon] { align-self: center; }",
- "[data-diffs-header] [data-header-content]::before {",
- " font-size: 11px;",
- " font-weight: 600;",
- " flex-shrink: 0;",
- "}",
- "[data-diffs-header][data-change-type='new'] [data-header-content]::before {",
- " content: 'A';",
- " color: hsl(var(--git-added));",
- "}",
- "[data-diffs-header][data-change-type='change'] [data-header-content]::before {",
- " content: 'M';",
- " color: hsl(var(--git-modified));",
- "}",
- "[data-diffs-header][data-change-type='deleted'] [data-header-content]::before {",
- " content: 'D';",
- " color: hsl(var(--git-deleted));",
- "}",
- "[data-diffs-header][data-change-type='rename-pure'] [data-header-content]::before,",
- "[data-diffs-header][data-change-type='rename-changed'] [data-header-content]::before {",
- " content: 'R';",
- " color: hsl(var(--git-modified));",
- "}",
-
- // Stat counts styled as compact pill badges.
- "[data-diffs-header] [data-metadata] {",
- " flex-shrink: 0;",
- " flex-direction: row-reverse;",
- " align-items: stretch;",
- " gap: 0 !important;",
- " padding: 0;",
- " border: 1px solid hsl(var(--border-default));",
- " border-radius: 3px;",
- " overflow: hidden;",
- "}",
- "[data-diffs-header] [data-additions-count],",
- "[data-diffs-header] [data-deletions-count] {",
- " font-family: var(--diffs-font-family, var(--diffs-font-fallback));",
- " font-size: 12px;",
- " font-weight: 500;",
- " line-height: 20px;",
- " padding-inline: 4px;",
- " border-radius: 0;",
- "}",
- "[data-diffs-header] [data-additions-count] {",
- " color: hsl(var(--git-added-bright)) !important;",
- " background-color: hsl(var(--surface-git-added));",
- "}",
- "[data-diffs-header] [data-deletions-count] {",
- " color: hsl(var(--git-deleted-bright)) !important;",
- " background-color: hsl(var(--surface-git-deleted));",
- "}",
-].join(" ");
-
-const CHANGE_LINE_CSS = [
- ":host {",
- " --diffs-addition-color-override: hsl(var(--git-added));",
- " --diffs-deletion-color-override: hsl(var(--git-deleted));",
- " --diffs-bg-addition-override: hsl(var(--surface-git-added));",
- " --diffs-bg-deletion-override: hsl(var(--surface-git-deleted));",
- " --diffs-bg-addition-number-override: hsl(var(--surface-git-added));",
- " --diffs-bg-deletion-number-override: hsl(var(--surface-git-deleted));",
- "}",
- "[data-line-type='change-addition']:not([data-selected-line]) {",
- " background-color: hsl(var(--surface-git-added)) !important;",
- "}",
- "[data-line-type='change-deletion']:not([data-selected-line]) {",
- " background-color: hsl(var(--surface-git-deleted)) !important;",
- "}",
-].join(" ");
-
export const diffViewerCSS = [
- // Make context lines transparent so they blend with the page,
- // while changed lines use the same theme-aware git surfaces as
- // the file headers and stats.
- "pre, [data-line]:not([data-selected-line]):not([data-line-type='change-addition']):not([data-line-type='change-deletion']), [data-diffs-header] { background-color: transparent !important; }",
- "[data-diffs-header] { border-left: 1px solid var(--border); }",
- // The library reserves a 6 px horizontal scrollbar track on
- // [data-code] via overflow: scroll clip. In wrap mode lines
- // never overflow, so hide the track to remove the phantom gap.
- "[data-code] { scrollbar-width: none !important; }",
- "[data-code]::-webkit-scrollbar { height: 0 !important; }",
- DIFF_HEADER_CSS,
- CHANGE_LINE_CSS,
- SELECTION_OVERRIDE_CSS,
+ "pre, [data-line]:not([data-line-type='change-addition']):not([data-line-type='change-deletion']) { background-color: transparent !important; }",
SEPARATOR_CSS,
].join(" ");
@@ -566,6 +414,16 @@ export const DIFFS_FONT_STYLE = {
"--diffs-header-font-family": '"Geist Variable", system-ui, sans-serif',
"--diffs-font-size": "11px",
"--diffs-line-height": "1.5",
+ "--diffs-addition-color-override": "hsl(var(--git-added))",
+ "--diffs-deletion-color-override": "hsl(var(--git-deleted))",
+ "--diffs-bg-addition-override": "hsl(var(--surface-git-added))",
+ "--diffs-bg-deletion-override": "hsl(var(--surface-git-deleted))",
+ "--diffs-bg-addition-number-override": "hsl(var(--surface-git-added))",
+ "--diffs-bg-deletion-number-override": "hsl(var(--surface-git-deleted))",
+ "--diffs-bg-selection-override": "hsl(var(--content-link) / 0.08)",
+ "--diffs-bg-selection-number-override": "hsl(var(--content-link) / 0.13)",
+ "--diffs-selection-number-fg": "hsl(var(--content-link))",
+ "--diffs-gap-style": "1px solid hsl(var(--border-default))",
} as CSSProperties;
/**
diff --git a/site/src/pages/AgentsPage/components/DiffViewer/CommentableDiffViewer.tsx b/site/src/pages/AgentsPage/components/DiffViewer/CommentableDiffViewer.tsx
index c2fa76a22b..68e8ecb91d 100644
--- a/site/src/pages/AgentsPage/components/DiffViewer/CommentableDiffViewer.tsx
+++ b/site/src/pages/AgentsPage/components/DiffViewer/CommentableDiffViewer.tsx
@@ -210,6 +210,10 @@ export const CommentableDiffViewer: FC = ({
// ---------------------------------------------------------------
const [activeCommentBox, setActiveCommentBox] =
useState(null);
+ const [previewSelection, setPreviewSelection] = useState<{
+ fileName: string;
+ range: SelectedLineRange;
+ } | null>(null);
const activeCommentBoxRef = useRef(null);
@@ -228,6 +232,7 @@ export const CommentableDiffViewer: FC = ({
annotationSide: "additions" | "deletions";
},
) => {
+ setPreviewSelection(null);
updateCommentBox({
fileName,
start: props.lineNumber,
@@ -239,18 +244,21 @@ export const CommentableDiffViewer: FC = ({
const handleLineSelected = (
fileName: string,
- range: {
- start: number;
- end: number;
- side?: "additions" | "deletions";
- endSide?: "additions" | "deletions";
- } | null,
+ range: SelectedLineRange | null,
) => {
const result = commentBoxFromRange(fileName, range);
if (result === "ignore") return;
+ setPreviewSelection(null);
updateCommentBox(result);
};
+ const handleLineSelectionChange = (
+ fileName: string,
+ range: SelectedLineRange | null,
+ ) => {
+ setPreviewSelection(range ? { fileName, range } : null);
+ };
+
// ---------------------------------------------------------------
// Annotation helpers
// ---------------------------------------------------------------
@@ -273,10 +281,14 @@ export const CommentableDiffViewer: FC = ({
if (activeCommentBox && activeCommentBox.fileName === fileName) {
return selectedLinesForBox(activeCommentBox);
}
+ if (previewSelection && previewSelection.fileName === fileName) {
+ return previewSelection.range;
+ }
return null;
};
const handleCancelComment = () => {
+ setPreviewSelection(null);
updateCommentBox(null);
};
@@ -327,6 +339,7 @@ export const CommentableDiffViewer: FC = ({
parsedFiles={parsedFiles}
onLineNumberClick={handleLineNumberClick}
onLineSelected={handleLineSelected}
+ onLineSelectionChange={handleLineSelectionChange}
getLineAnnotations={getLineAnnotations}
getSelectedLines={getSelectedLines}
renderAnnotation={renderAnnotation}
diff --git a/site/src/pages/AgentsPage/components/DiffViewer/DiffViewer.stories.tsx b/site/src/pages/AgentsPage/components/DiffViewer/DiffViewer.stories.tsx
index 2025291bf3..140711ccae 100644
--- a/site/src/pages/AgentsPage/components/DiffViewer/DiffViewer.stories.tsx
+++ b/site/src/pages/AgentsPage/components/DiffViewer/DiffViewer.stories.tsx
@@ -446,42 +446,11 @@ export const LargeDiff: Story = {
),
],
play: async ({ canvasElement }) => {
- // Wait for the file tree sidebar to render, proving that
- // isExpanded activates the tree + observer code path.
+ // The @pierre/trees file tree mounts a `file-tree-container` custom
+ // element once the sidebar is shown (isExpanded). Assert it appears.
await waitFor(() => {
- const nav = canvasElement.querySelector("nav");
- expect(nav).not.toBeNull();
- });
-
- // Find the diff content viewport (the one containing file
- // sections) rather than the file-tree sidebar viewport.
- const fileSection = canvasElement.querySelector("[data-file-name]");
- const viewport = fileSection?.closest(
- "[data-radix-scroll-area-viewport]",
- );
- if (!viewport) throw new Error("diff viewport not found");
-
- // Capture the initial active file (whichever the observer picked
- // up at mount), then scroll and verify it changed.
- let initialFile: string | undefined;
- await waitFor(() => {
- const btn = canvasElement.querySelector(
- 'nav button[aria-current="true"]',
- );
- expect(btn).not.toBeNull();
- initialFile = btn!.title;
- });
-
- // Scroll to roughly the middle of the diff content.
- viewport.scrollTop = viewport.scrollHeight / 2;
-
- // The observer should fire and highlight a different file.
- await waitFor(() => {
- const btn = canvasElement.querySelector(
- 'nav button[aria-current="true"]',
- );
- expect(btn).not.toBeNull();
- expect(btn!.title).not.toBe(initialFile);
+ const tree = canvasElement.querySelector("file-tree-container");
+ expect(tree).not.toBeNull();
});
},
};
diff --git a/site/src/pages/AgentsPage/components/DiffViewer/DiffViewer.tsx b/site/src/pages/AgentsPage/components/DiffViewer/DiffViewer.tsx
index 8e6267bfd3..1be5667847 100644
--- a/site/src/pages/AgentsPage/components/DiffViewer/DiffViewer.tsx
+++ b/site/src/pages/AgentsPage/components/DiffViewer/DiffViewer.tsx
@@ -1,127 +1,324 @@
import { useTheme } from "@emotion/react";
import type {
+ CodeViewHandle,
+ CodeViewItem,
DiffLineAnnotation,
FileDiffMetadata,
SelectedLineRange,
VirtualFileMetrics,
-} from "@pierre/diffs";
-import { Virtualizer } from "@pierre/diffs";
-import { FileDiff, VirtualizerContext } from "@pierre/diffs/react";
-import { ChevronRightIcon } from "lucide-react";
+} from "@pierre/diffs/react";
+import { CodeView } from "@pierre/diffs/react";
+import type { FileTreeSortComparator, GitStatusEntry } from "@pierre/trees";
+import { FileTree, useFileTree } from "@pierre/trees/react";
import {
type ComponentProps,
+ type CSSProperties,
type FC,
- memo,
+ Fragment,
type ReactNode,
- useCallback,
useEffect,
useRef,
useState,
} from "react";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
-import { FileIcon } from "#/components/FileIcon/FileIcon";
-import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import { Skeleton } from "#/components/Skeleton/Skeleton";
import { cn } from "#/utils/cn";
+import { countChangedLines } from "../../utils/countChangedLines";
import { changeColor, changeLabel } from "../../utils/diffColors";
-import {
- DIFFS_FONT_STYLE,
- getDiffViewerOptions,
-} from "../ChatElements/tools/utils";
+import { SEPARATOR_CSS } from "../ChatElements/tools/utils";
import { useActiveFileTracking } from "./useActiveFileTracking";
-// -------------------------------------------------------------------
-// Public interface
-// -------------------------------------------------------------------
-
interface DiffViewerProps {
- /** Parsed file diffs to render. */
parsedFiles: readonly FileDiffMetadata[];
- /** Cache key prefix for parsePatchFiles worker pool LRU cache. */
- cacheKeyPrefix?: string;
- /** Whether the panel is in expanded mode (affects file tree threshold). */
isExpanded?: boolean;
- /** Loading state. */
isLoading?: boolean;
- /** Error state. */
error?: unknown;
- /** Empty state message. */
emptyMessage?: string;
- /** Which diff rendering style to use. */
diffStyle: DiffStyle;
- /**
- * Called when a line number gutter element is clicked.
- * Receives the file name and click metadata.
- */
onLineNumberClick?: (
fileName: string,
props: { lineNumber: number; annotationSide: "additions" | "deletions" },
) => void;
- /**
- * Called when a range of lines is selected (e.g. shift-click).
- * Receives the file name and the selected range (or null on
- * deselection).
- */
- onLineSelected?: (
+ /** Fires when a line selection is committed (e.g. on pointer up). */
+ onLineSelected?: (fileName: string, range: SelectedLineRange | null) => void;
+ /** Fires continuously as the selection range changes during a drag. */
+ onLineSelectionChange?: (
fileName: string,
- range: {
- start: number;
- end: number;
- side?: "additions" | "deletions";
- endSide?: "additions" | "deletions";
- } | null,
+ range: SelectedLineRange | null,
) => void;
- /**
- * Returns line annotations for the given file. Used to render
- * inline widgets such as comment inputs.
- */
getLineAnnotations?: (fileName: string) => DiffLineAnnotation[];
- /**
- * Returns the selected line range for the given file, if any.
- * Used to visually highlight the lines being commented on.
- */
getSelectedLines?: (fileName: string) => SelectedLineRange | null;
- /**
- * Renderer for line annotations returned by `getLineAnnotations`.
- */
renderAnnotation?: (annotation: DiffLineAnnotation) => ReactNode;
- /**
- * When set to a file name, DiffViewer scrolls to that file and
- * highlights it in the tree. The parent should reset this to
- * null via `onScrollToFileComplete` after the scroll completes.
- */
scrollToFile?: string | null;
- /** Called after scrollToFile has been processed. */
onScrollToFileComplete?: () => void;
}
-// -------------------------------------------------------------------
-// Constants
-// -------------------------------------------------------------------
+export type DiffStyle = "unified" | "split";
+const DIFF_STYLE_KEY = "agents.diff-view-style";
-/**
- * Minimum container width (px) at which the file tree sidebar
- * is shown alongside the diff list.
- */
+const DIFF_VIEWER_LINE_HEIGHT = 16.5;
+const DIFF_HEADER_HEIGHT = 32;
+const HUNK_SEPARATOR_HEIGHT = 28;
+
+// Minimum width (px) of the diff container at which the file tree sidebar is
+// shown alongside the diff. Below this the diff takes the full width unless the
+// viewer is explicitly expanded.
const FILE_TREE_THRESHOLD = 1000;
-/**
- * Extra CSS injected via the diff viewer's `unsafeCSS` option to make
- * file headers sticky with a solid background. The shared header
- * styling (font sizing, change-type badges, stat-count pills) lives
- * in `diffViewerCSS` from utils.ts and is already included in the
- * base options returned by `getDiffViewerOptions`.
- */
-const STICKY_HEADER_CSS = [
- "[data-diffs-header] {",
- " position: sticky; top: 0; z-index: 10;",
- " background-color: hsl(var(--surface-secondary)) !important;",
- " padding-block: 0 !important;",
+const diffViewerStyle = {
+ "--diffs-font-family": '"Geist Mono Variable", monospace, monospace',
+ "--diffs-header-font-family": '"Geist Variable", system-ui, sans-serif',
+ "--diffs-font-size": "11px",
+ "--diffs-line-height": `${DIFF_VIEWER_LINE_HEIGHT}px`,
+} satisfies CSSProperties;
+
+const diffViewerMetrics: Partial = {
+ diffHeaderHeight: DIFF_HEADER_HEIGHT,
+ hunkSeparatorHeight: HUNK_SEPARATOR_HEIGHT,
+ lineHeight: DIFF_VIEWER_LINE_HEIGHT,
+};
+
+const fileTreeStyle = {
+ height: "100%",
+ "--trees-font-family-override": '"Geist Variable", system-ui, sans-serif',
+ "--trees-font-size-override": "13px",
+ "--trees-border-color-override": "hsl(var(--border-default))",
+ "--trees-bg-override": "hsl(var(--surface-primary))",
+ "--trees-fg-override": "hsl(var(--content-primary))",
+ "--trees-muted-fg-override": "hsl(var(--content-secondary))",
+ "--trees-selected-bg-override": "hsl(var(--surface-secondary))",
+ "--trees-padding-inline-override": "0px",
+ "--trees-item-margin-x-override": "0px",
+ "--trees-border-radius-override": "0px",
+ "--trees-git-added-color-override": "hsl(var(--git-added))",
+ "--trees-git-deleted-color-override": "hsl(var(--git-deleted))",
+ "--trees-git-modified-color-override": "hsl(var(--git-modified))",
+ "--trees-git-renamed-color-override": "hsl(var(--git-modified))",
+} satisfies CSSProperties;
+
+// Single full-path ordering rule shared by the sidebar tree and the flat diff
+// list so the two cannot drift apart. useFileTree applies the sort comparator
+// to the whole flat set of path entries (directories included), so it must be
+// a full-path comparator, not a basename one: at the first differing segment a
+// directory sorts before a file, dot-prefixed names first, then
+// case-insensitive locale order; a shorter path sorts before its descendants.
+function compareTreeEntries(
+ aSegments: readonly string[],
+ aIsDirectory: boolean,
+ bSegments: readonly string[],
+ bIsDirectory: boolean,
+): number {
+ const shared = Math.min(aSegments.length, bSegments.length);
+ for (let i = 0; i < shared; i++) {
+ const aSegment = aSegments[i];
+ const bSegment = bSegments[i];
+ if (aSegment === bSegment) {
+ continue;
+ }
+ // A segment is a directory when it is not the last one, or when the
+ // entry itself is a directory whose final segment this is.
+ const aSegmentIsDir = i < aSegments.length - 1 || aIsDirectory;
+ const bSegmentIsDir = i < bSegments.length - 1 || bIsDirectory;
+ if (aSegmentIsDir !== bSegmentIsDir) {
+ return aSegmentIsDir ? -1 : 1;
+ }
+ const aIsDot = aSegment.charCodeAt(0) === 46;
+ const bIsDot = bSegment.charCodeAt(0) === 46;
+ if (aIsDot !== bIsDot) {
+ return aIsDot ? -1 : 1;
+ }
+ return aSegment.toLowerCase().localeCompare(bSegment.toLowerCase());
+ }
+ if (aSegments.length !== bSegments.length) {
+ return aSegments.length - bSegments.length;
+ }
+ if (aIsDirectory === bIsDirectory) {
+ return 0;
+ }
+ return aIsDirectory ? -1 : 1;
+}
+
+// Passed to useFileTree so the sidebar uses compareTreeEntries rather than the
+// library's undocumented default sort. The entry carries the full segment
+// list. Exported for unit tests that verify the tree order matches the diff.
+export const treeSortComparator: FileTreeSortComparator = (left, right) =>
+ compareTreeEntries(
+ left.segments,
+ left.isDirectory,
+ right.segments,
+ right.isDirectory,
+ );
+
+// Orders the flat diff list to the sidebar tree's leaf order so scrolling moves
+// monotonically down the tree. Diff entries are always files, so it runs the
+// same compareTreeEntries the tree uses, keeping the two orders identical by
+// construction. Exported for unit tests that pin the shared ordering.
+export function compareTreePaths(a: string, b: string): number {
+ return compareTreeEntries(a.split("/"), false, b.split("/"), false);
+}
+
+// CodeView's syncItemRecord skips reusing a record when item.version is
+// unchanged, so the version must reflect annotation content rather than count.
+// Moving the active comment box to another line in the same file keeps the
+// count at 1 but must still re-render, so fold each annotation's side and line
+// into the version. Exported for unit tests.
+export function annotationsVersion(
+ annotations: readonly DiffLineAnnotation[] | undefined,
+): number {
+ if (!annotations || annotations.length === 0) {
+ return 0;
+ }
+ return annotations.reduce(
+ (version, annotation) =>
+ version * 31 +
+ annotation.lineNumber * 2 +
+ (annotation.side === "additions" ? 1 : 0),
+ annotations.length,
+ );
+}
+
+// The library forces classic, space-reserving scrollbars via
+// scrollbar-gutter: stable, leaving a permanent empty strip on the right.
+// Restore the default gutter so the scrollbar overlays content while scrolling
+// instead of reserving width, and keep it slim when it does appear.
+const fileTreeUnsafeCSS = [
+ "[data-file-tree-virtualized-scroll='true'] {",
+ " scrollbar-gutter: auto !important;",
+ " scrollbar-width: thin;",
"}",
].join(" ");
-export type DiffStyle = "unified" | "split";
-const DIFF_STYLE_KEY = "agents.diff-view-style";
+function gitStatusForFile(
+ fileDiff: FileDiffMetadata,
+): GitStatusEntry["status"] {
+ switch (fileDiff.type) {
+ case "new":
+ return "added";
+ case "deleted":
+ return "deleted";
+ case "rename-pure":
+ case "rename-changed":
+ return "renamed";
+ default:
+ return "modified";
+ }
+}
+
+function HeaderContent({ fileDiff }: { fileDiff: FileDiffMetadata }) {
+ const { additions, deletions } = countChangedLines(fileDiff);
+ return (
+
+
+
+ {changeLabel(fileDiff.type)}
+
+ {fileDiff.prevName && fileDiff.prevName !== fileDiff.name && (
+
+ {fileDiff.prevName}
+
+ )}
+
+ {fileDiff.name}
+
+
+ {(additions > 0 || deletions > 0) && (
+
+ {deletions > 0 && (
+
+ −{deletions}
+
+ )}
+ {additions > 0 && (
+
+ +{additions}
+
+ )}
+
+ )}
+
+ );
+}
+
+function DiffFileTree({
+ files,
+ activePath,
+ onUserSelectPath,
+}: {
+ files: readonly FileDiffMetadata[];
+ activePath: string | null;
+ onUserSelectPath: (fileName: string) => void;
+}) {
+ const paths = files.map((file) => file.name);
+ const gitStatus = files.map((file) => ({
+ path: file.name,
+ status: gitStatusForFile(file),
+ }));
+ // Tracks the row currently selected in the model so scroll-driven syncing
+ // can skip redundant selects.
+ const selectedPathRef = useRef(null);
+ // Set while we replace the selection programmatically. The model emits
+ // selection events synchronously, so this drops the echoes from our own
+ // deselect/select calls in one window instead of looping back into
+ // onUserSelectPath. A user click can only land between effect runs, when
+ // the flag is already false.
+ const isSyncingSelectionRef = useRef(false);
+ const { model } = useFileTree({
+ density: "compact",
+ flattenEmptyDirectories: false,
+ gitStatus,
+ initialExpansion: "open",
+ sort: treeSortComparator,
+ unsafeCSS: fileTreeUnsafeCSS,
+ onSelectionChange: (selectedPaths) => {
+ const selectedPath = selectedPaths.at(-1) ?? null;
+ selectedPathRef.current = selectedPath;
+ if (isSyncingSelectionRef.current) {
+ return;
+ }
+ if (selectedPath) {
+ onUserSelectPath(selectedPath);
+ }
+ },
+ paths,
+ });
+
+ useEffect(() => {
+ model.resetPaths(paths);
+ model.setGitStatus(gitStatus);
+ }, [gitStatus, model, paths]);
+
+ // Drive the tree selection from the active file. The @pierre/trees item
+ // handle's select() is additive, so a scrolled-past file stays
+ // highlighted unless its selection is cleared. Replace the whole
+ // selection with just the active file, suppressing the echoed events.
+ useEffect(() => {
+ if (!activePath || selectedPathRef.current === activePath) return;
+ const item = model.getItem(activePath);
+ if (!item) return;
+ isSyncingSelectionRef.current = true;
+ for (const path of model.getSelectedPaths()) {
+ if (path !== activePath) {
+ model.getItem(path)?.deselect();
+ }
+ }
+ item.select();
+ isSyncingSelectionRef.current = false;
+ selectedPathRef.current = activePath;
+ }, [activePath, model]);
+
+ return (
+
+ );
+}
export function loadDiffStyle(): DiffStyle {
const stored = localStorage.getItem(DIFF_STYLE_KEY);
@@ -135,383 +332,76 @@ export function saveDiffStyle(style: DiffStyle): void {
localStorage.setItem(DIFF_STYLE_KEY, style);
}
-/** Width of the file tree sidebar in pixels. */
-const FILE_TREE_WIDTH = 300;
-
-// -------------------------------------------------------------------
-// Estimated diff height for lazy loading
-// -------------------------------------------------------------------
-
-/**
- * Estimated height per line in the diff viewer (px). The
- * library's shadow DOM applies box-sizing: border-box to all
- * elements, and code lines have no padding or border, so the
- * rendered height equals the CSS line-height: 11px × 1.5 = 16.5.
- */
-const LINE_HEIGHT_PX = 16.5;
-
-/** Height of the file header row rendered by @pierre/diffs. */
-const HEADER_HEIGHT_PX = 36;
-
-/**
- * Metrics that tell the @pierre/diffs virtualizer how tall each
- * element actually is after our CSS overrides. Without these the
- * library falls back to its built-in defaults (20 px lines,
- * 44 px headers, 32 px separators) which are larger than our
- * custom styling, causing visible blank buffers in the viewport.
- */
-const VIRTUALIZER_METRICS: VirtualFileMetrics = {
- hunkLineCount: 50,
- lineHeight: LINE_HEIGHT_PX,
- diffHeaderHeight: 32, // 32 px min-height (border-box includes borders)
- hunkSeparatorHeight: 28, // height: 28px !important in SEPARATOR_CSS
- fileGap: 2, // padding-bottom: max(0, gap-block - 6px) = 2px
-};
-
-/**
- * Estimate the rendered pixel height of a file diff so the
- * placeholder occupies roughly the same space. This keeps the
- * scroll position stable as files are lazily mounted.
- */
-function estimateDiffHeight(fileDiff: FileDiffMetadata): number {
- return HEADER_HEIGHT_PX + fileDiff.unifiedLineCount * LINE_HEIGHT_PX;
-}
-
-// -------------------------------------------------------------------
-// File tree data model
-// -------------------------------------------------------------------
-
-interface FileTreeNode {
- name: string;
- fullPath: string;
- type: "file" | "directory";
- children: FileTreeNode[];
- fileDiff?: FileDiffMetadata;
-}
-
-/**
- * Builds a nested tree from a flat list of file diffs. Directory
- * nodes are created for every intermediate path segment. The
- * result is sorted with directories first, then alphabetically.
- * Single-child directory chains are collapsed so that e.g.
- * `src/pages/AgentsPage` renders as one row.
- */
-function buildFileTree(files: readonly FileDiffMetadata[]): FileTreeNode[] {
- const root: FileTreeNode[] = [];
-
- for (const file of files) {
- const segments = file.name.split("/");
- let children = root;
-
- // Walk / create intermediate directory nodes.
- for (let i = 0; i < segments.length - 1; i++) {
- const seg = segments[i];
- let dir = children.find((n) => n.type === "directory" && n.name === seg);
- if (!dir) {
- dir = {
- name: seg,
- fullPath: segments.slice(0, i + 1).join("/"),
- type: "directory",
- children: [],
- };
- children.push(dir);
- }
- children = dir.children;
- }
-
- // Leaf file node.
- const fileName = segments[segments.length - 1];
- children.push({
- name: fileName,
- fullPath: file.name,
- type: "file",
- children: [],
- fileDiff: file,
- });
- }
-
- const sortNodes = (nodes: FileTreeNode[]): FileTreeNode[] => {
- for (const node of nodes) {
- if (node.children.length > 0) {
- node.children = sortNodes(node.children);
- }
- }
- return nodes.sort((a, b) => {
- if (a.type !== b.type) {
- return a.type === "directory" ? -1 : 1;
- }
- return a.name.localeCompare(b.name);
- });
- };
-
- // Collapse single-child directory chains into one node whose
- // name uses path separators, e.g. "src/pages/AgentsPage".
- const collapse = (nodes: FileTreeNode[]): FileTreeNode[] => {
- for (const node of nodes) {
- if (node.type === "directory") {
- node.children = collapse(node.children);
- // If this directory has exactly one child and it is also
- // a directory, merge them.
- while (
- node.children.length === 1 &&
- node.children[0].type === "directory"
- ) {
- const child = node.children[0];
- node.name = `${node.name}/${child.name}`;
- node.fullPath = child.fullPath;
- node.children = child.children;
- }
- }
- }
- return nodes;
- };
-
- return collapse(sortNodes(root));
-}
-
-// -------------------------------------------------------------------
-// Tree node renderer
-// -------------------------------------------------------------------
-
-interface FileTreeNodeViewProps {
- node: FileTreeNode;
- depth: number;
- activeFile: string | null;
- onFileClick: (fullPath: string) => void;
-}
-
-function FileTreeNodeViewInner({
- node,
- depth,
- activeFile,
- onFileClick,
-}: FileTreeNodeViewProps) {
- const [expanded, setExpanded] = useState(true);
-
- if (node.type === "directory") {
- return (
-
- setExpanded((v) => !v)}
- className="flex w-full items-center gap-1.5 rounded-none border-none bg-transparent py-1 text-left text-content-secondary hover:bg-surface-secondary cursor-pointer outline-none"
- style={{ paddingLeft: 4 + depth * 8, fontSize: 13 }}
- aria-expanded={expanded}
- >
-
- {node.name}
-
- {expanded &&
- node.children.map((child) => (
-
- ))}
-
- );
- }
-
- const isActive = activeFile === node.fullPath;
-
+// The loading state mirrors the real diff layout: flat, full-width
+// file headers with a change badge and stat pill, gutter-aligned code
+// lines, and centered hunk separators. Keeping the same shape avoids a
+// jarring swap when the parsed diff replaces the placeholder.
+function SkeletonLine({ width }: { width: string }) {
return (
- onFileClick(node.fullPath)}
- className={cn(
- "flex w-full items-center gap-1.5 rounded-none border-none bg-transparent py-1 text-left cursor-pointer outline-none border-0 border-r-2 border-solid border-transparent",
- "hover:bg-surface-secondary",
- isActive && "bg-surface-secondary border-content-link",
- )}
- style={{ paddingLeft: 4 + depth * 8 + 12, fontSize: 13 }}
- title={node.fullPath}
+
-
-
- {node.name}
-
- {node.fileDiff?.type && (
-
- {changeLabel(node.fileDiff.type)}
-
- )}
-
+
+
+
);
}
-// memo requires stable props; the React Compiler provides them here.
-const FileTreeNodeView = memo(FileTreeNodeViewInner);
-
-// -------------------------------------------------------------------
-// Virtualized scroll container
-// -------------------------------------------------------------------
-
-/**
- * Wraps the diff list in a Radix ScrollArea and wires up the
- * @pierre/diffs Virtualizer. Extracted into its own component so
- * that the useCallback for ref-callback stability lives here
- * instead of in DiffViewer. This lets the React Compiler skip
- * only this small wrapper (where it can't preserve useCallback)
- * while still optimizing the much larger parent.
- *
- * The ref callback is placed on a content div *inside* the
- * ScrollArea, then walks up with closest() to the Radix viewport.
- * React fires children's refs bottom-up during commit, so every
- * VirtualizedFileDiff instance has already connected to the
- * virtualizer by the time this ref fires and calls setup().
- */
-const DiffScrollContainer: FC<{
- children: ReactNode;
- className?: string;
- diffViewportRef: React.RefObject;
-}> = ({ children, className, diffViewportRef }) => {
- const [virtualizer] = useState(() => new Virtualizer());
-
- // useCallback is required for correctness: in React 19 an
- // unstable ref callback triggers old-cleanup → new-callback
- // on every render, which calls virtualizer.cleanUp() and
- // wipes the observer map. The compiler can't preserve this
- // useCallback, but that only causes it to skip this small
- // wrapper, not the entire DiffViewer.
- const contentRef = useCallback(
- (node: HTMLDivElement | null) => {
- const viewport = node?.closest(
- "[data-radix-scroll-area-viewport]",
- );
- if (!viewport) return;
-
- diffViewportRef.current = viewport;
- virtualizer.setup(viewport);
-
- return () => {
- virtualizer.cleanUp();
- diffViewportRef.current = null;
- };
- },
- [virtualizer, diffViewportRef],
- );
-
+function SkeletonSeparator() {
return (
-
-
-
- {children}
+
+ );
+}
+
+function SkeletonFile({ groups }: { groups: readonly (readonly string[])[] }) {
+ return (
+
+
+
+
+
-
-
- );
-};
-
-// -------------------------------------------------------------------
-// Lazy file diff wrapper
-// -------------------------------------------------------------------
-
-/**
- * Wraps a single `
` with an IntersectionObserver so the
- * heavy component (Shadow DOM + shiki highlighting) is only mounted
- * once the placeholder scrolls into or near the viewport.
- *
- * Once mounted the component stays mounted. We never unmount a
- * FileDiff that the user has already scrolled past, which avoids
- * layout shifts and repeated highlighting work.
- */
-interface LazyFileDiffProps {
- fileDiff: FileDiffMetadata;
- options: ComponentProps["options"];
- lineAnnotations?: DiffLineAnnotation[];
- renderAnnotation?: (annotation: DiffLineAnnotation) => ReactNode;
- selectedLines?: SelectedLineRange | null;
-}
-
-function LazyFileDiffInner({
- fileDiff,
- options,
- lineAnnotations,
- renderAnnotation: renderAnnotationProp,
- selectedLines,
-}: LazyFileDiffProps) {
- const placeholderRef = useRef(null);
- const [visible, setVisible] = useState(false);
-
- useEffect(() => {
- const el = placeholderRef.current;
- if (!el || visible) {
- return;
- }
- const observer = new IntersectionObserver(
- ([entry]) => {
- if (entry.isIntersecting) {
- setVisible(true);
- observer.disconnect();
- }
- },
- // Pre-load files that are within one viewport-height of
- // the visible area so they are ready before the user
- // scrolls to them.
- { rootMargin: "100% 0px" },
- );
- observer.observe(el);
- return () => observer.disconnect();
- }, [visible]);
-
- if (!visible) {
- return (
-
-
-
-
-
+
- );
- }
-
- return (
-
+
+ {groups.map((lines, groupIndex) => (
+
+ {groupIndex > 0 && }
+ {lines.map((width, lineIndex) => (
+
+ ))}
+
+ ))}
+
+
);
}
-// memo requires stable props; the React Compiler provides them here.
-const LazyFileDiff = memo(LazyFileDiffInner);
+const DIFF_SKELETON_FILES = [
+ [
+ ["w-3/4", "w-1/2", "w-5/6"],
+ ["w-2/3", "w-2/5", "w-3/4", "w-1/3"],
+ ],
+ [
+ ["w-1/2", "w-5/6"],
+ ["w-2/3", "w-1/3"],
+ ],
+] as const;
-// -------------------------------------------------------------------
-// Main component
-// -------------------------------------------------------------------
+function DiffViewerSkeleton() {
+ return (
+
+ {DIFF_SKELETON_FILES.map((groups, fileIndex) => (
+
+ ))}
+
+ );
+}
export const DiffViewer: FC
= ({
parsedFiles,
@@ -522,6 +412,7 @@ export const DiffViewer: FC = ({
diffStyle,
onLineNumberClick,
onLineSelected,
+ onLineSelectionChange,
getLineAnnotations,
getSelectedLines,
renderAnnotation,
@@ -529,260 +420,164 @@ export const DiffViewer: FC = ({
onScrollToFileComplete,
}) => {
const theme = useTheme();
+ const codeViewRef = useRef>(null);
const isDark = theme.palette.mode === "dark";
+ const [activeFile, setActiveFile] = useState(null);
- const base = getDiffViewerOptions(isDark);
- const diffOptions = {
- ...base,
- diffStyle,
- // Extend the base CSS to make file headers sticky so they
- // remain visible while scrolling through long diffs.
- unsafeCSS: `${base.unsafeCSS ?? ""} ${STICKY_HEADER_CSS}`,
- };
-
- const fileOptions = {
- ...diffOptions,
- overflow: "wrap" as const,
- enableLineSelection: true,
- enableHoverUtility: true,
- onLineSelected() {
- // TODO: Make this add context to the input so the
- // user can type.
- },
- };
-
- // When the parent provides per-file callbacks (e.g. line click
- // handlers for comment inputs), build options per file. Otherwise
- // share a single stable object to avoid unnecessary re-highlights.
- const hasPerFileCallbacks = Boolean(onLineNumberClick || onLineSelected);
-
- const getOptionsForFile = (fileName: string) => ({
- ...diffOptions,
- overflow: "wrap" as const,
- enableLineSelection: true,
- enableHoverUtility: true,
- ...(onLineNumberClick && {
- onLineNumberClick: (props: {
- lineNumber: number;
- annotationSide: "additions" | "deletions";
- }) => onLineNumberClick(fileName, props),
- }),
- onLineSelected: onLineSelected
- ? (
- range: {
- start: number;
- end: number;
- side?: "additions" | "deletions";
- endSide?: "additions" | "deletions";
- } | null,
- ) => onLineSelected(fileName, range)
- : () => {
- // TODO: Make this add context to the input.
- },
- });
-
- const fileTree = buildFileTree(parsedFiles);
-
- // Sort diff blocks in the same order the file tree displays them
- // (directories first, then alphabetical) so the rendering is
- // consistent regardless of whether the sidebar is visible.
- const sortedFiles = (() => {
- const order = new Map();
- const walk = (nodes: FileTreeNode[]) => {
- for (const node of nodes) {
- if (node.type === "file") {
- order.set(node.fullPath, order.size);
- } else {
- walk(node.children);
- }
- }
- };
- walk(fileTree);
- return [...parsedFiles].sort(
- (a, b) => (order.get(a.name) ?? 0) - (order.get(b.name) ?? 0),
- );
- })();
-
- // Pre-compute per-file options so each LazyFileDiff receives a
- // stable reference and avoids re-highlighting on parent re-render.
- const perFileOptions = (() => {
- if (!hasPerFileCallbacks) return null;
- const map = new Map["options"]>();
- for (const file of sortedFiles) {
- map.set(file.name, getOptionsForFile(file.name));
- }
- return map;
- })();
-
- // Pre-compute per-file line annotations for the same reason.
- const perFileAnnotations = (() => {
- if (!getLineAnnotations) return null;
- return new Map(
- sortedFiles
- .map((f) => [f.name, getLineAnnotations(f.name)] as const)
- .filter(
- (entry): entry is [string, DiffLineAnnotation[]] =>
- entry[1].length > 0,
- ),
- );
- })();
-
- // Pre-compute per-file selected lines so each LazyFileDiff
- // receives a stable reference. Without this, calling
- // getSelectedLines during render returns a new object every
- // time, which busts the memo comparator and forces an
- // expensive Shadow DOM + shiki re-highlight.
- const perFileSelectedLines = (() => {
- if (!getSelectedLines) return null;
- return new Map(
- sortedFiles
- .map((f) => [f.name, getSelectedLines(f.name)] as const)
- .filter(
- (entry): entry is [string, SelectedLineRange] => entry[1] != null,
- ),
- );
- })();
-
- // ---------------------------------------------------------------
- // Container width measurement via ResizeObserver so we can decide
- // whether to show the file tree sidebar without a prop from the
- // parent.
- // ---------------------------------------------------------------
- const [containerWidth, setContainerWidth] = useState(0);
+ // Measure the diff container so the file tree only appears when there is
+ // enough horizontal room, rather than keying off the viewport width.
const [containerEl, setContainerEl] = useState(null);
-
+ const [containerWidth, setContainerWidth] = useState(0);
useEffect(() => {
if (!containerEl) return;
setContainerWidth(containerEl.getBoundingClientRect().width);
- const ro = new ResizeObserver(([entry]) => {
+ const observer = new ResizeObserver(([entry]) => {
setContainerWidth(entry.contentRect.width);
});
- ro.observe(containerEl);
- return () => ro.disconnect();
+ observer.observe(containerEl);
+ return () => observer.disconnect();
}, [containerEl]);
- const showTree =
- (isExpanded || containerWidth >= FILE_TREE_THRESHOLD) &&
- sortedFiles.length > 0;
+ const showTree = isExpanded || containerWidth >= FILE_TREE_THRESHOLD;
+ const handleScroll = useActiveFileTracking({
+ enabled: showTree,
+ onActiveFileChange: (path) =>
+ setActiveFile((current) => (current === path ? current : path)),
+ });
- const diffViewportRef = useRef(null);
- const { treeActiveFile, setFileRef, handleFileClick } = useActiveFileTracking(
- {
- viewportRef: diffViewportRef,
- sortedFiles,
- enabled: showTree,
- scrollToFile,
- onScrollToFileComplete,
+ const options: ComponentProps>["options"] = {
+ diffStyle,
+ diffIndicators: "bars",
+ overflow: "scroll",
+ stickyHeaders: true,
+ layout: { paddingTop: 0, paddingBottom: 0, gap: 0 },
+ hunkSeparators: "line-info",
+ itemMetrics: diffViewerMetrics,
+ unsafeCSS: SEPARATOR_CSS,
+ themeType: isDark ? "dark" : "light",
+ theme: isDark ? "github-dark-high-contrast" : "github-light",
+ enableLineSelection: true,
+ enableGutterUtility: true,
+ onLineNumberClick: (props, item) => {
+ if (item.type === "diff" && props.type === "diff-line") {
+ onLineNumberClick?.(item.item.id, props);
+ }
},
+ onLineSelected: (range, item) => {
+ if (item.type === "diff") {
+ onLineSelected?.(item.item.id, range);
+ }
+ },
+ onLineSelectionChange: (range, item) => {
+ if (item.type === "diff") {
+ onLineSelectionChange?.(item.item.id, range);
+ }
+ },
+ onGutterUtilityClick: (range, item) => {
+ if (item.type === "diff") {
+ onLineSelected?.(item.item.id, range);
+ }
+ },
+ };
+
+ // Render the diff in the same order the sidebar tree lays out its leaves so
+ // scrolling moves monotonically down the tree.
+ const sortedFiles = [...parsedFiles].sort((a, b) =>
+ compareTreePaths(a.name, b.name),
);
- // ---------------------------------------------------------------
- // Loading state
- // ---------------------------------------------------------------
+ const items: CodeViewItem[] = sortedFiles.map((fileDiff) => {
+ const annotations = getLineAnnotations?.(fileDiff.name);
+ return {
+ id: fileDiff.name,
+ type: "diff",
+ fileDiff,
+ annotations,
+ version: annotationsVersion(annotations),
+ };
+ });
+
+ const selectedLines = (() => {
+ if (!getSelectedLines) return undefined;
+ for (const fileDiff of sortedFiles) {
+ const range = getSelectedLines(fileDiff.name);
+ if (range) return { id: fileDiff.name, range };
+ }
+ return null;
+ })();
+
+ const canScroll = !isLoading && !error && items.length > 0;
+
+ useEffect(() => {
+ if (!scrollToFile) return;
+ // CodeView is not mounted while loading/erroring/empty, so the ref is
+ // null. Skip the scroll without signalling completion so the parent
+ // keeps the pending target and retries once the view is ready.
+ if (!canScroll || !codeViewRef.current) return;
+ codeViewRef.current.scrollTo({
+ type: "item",
+ id: scrollToFile,
+ align: "start",
+ behavior: "instant",
+ });
+ onScrollToFileComplete?.();
+ }, [scrollToFile, onScrollToFileComplete, canScroll]);
+
if (isLoading) {
- return (
-
-
- {Array.from({ length: 3 }, (_, i) => (
-
-
-
-
-
-
- ))}
-
-
- );
+ return ;
}
- // ---------------------------------------------------------------
- // Error state
- // ---------------------------------------------------------------
if (error) {
+ return ;
+ }
+
+ if (items.length === 0) {
return (
-
-
+
+ {emptyMessage}
);
}
- // ---------------------------------------------------------------
- // Main render
- // ---------------------------------------------------------------
return (
- {/* Diff contents */}
- {sortedFiles.length === 0 ? (
-
- {emptyMessage}
-
- ) : (
-
- {/* File tree sidebar */}
- {showTree && (
-
-
- {fileTree.map((node) => (
-
- ))}
-
-
- )}
-
- {sortedFiles.map((fileDiff, i) => {
- const isLast = i === sortedFiles.length - 1;
- return (
- setFileRef(fileDiff.name, el)}
- className={cn(
- "[contain:layout_style]",
- i > 0 &&
- "border-0 border-t border-solid border-border-default",
- )}
- >
-
- {isLast && (
-
- {`${sortedFiles.length} ${sortedFiles.length === 1 ? "file" : "files"} changed`}
-
- )}
-
- );
- })}
-
-
+ {showTree && (
+
+ {
+ setActiveFile(fileName);
+ codeViewRef.current?.scrollTo({
+ type: "item",
+ id: fileName,
+ align: "start",
+ behavior: "instant",
+ });
+ }}
+ />
+
)}
+
+ item.type === "diff" ? (
+
+ ) : null
+ }
+ renderAnnotation={(annotation) =>
+ "side" in annotation ? renderAnnotation?.(annotation) : null
+ }
+ />
);
};
diff --git a/site/src/pages/AgentsPage/components/DiffViewer/annotationsVersion.test.ts b/site/src/pages/AgentsPage/components/DiffViewer/annotationsVersion.test.ts
new file mode 100644
index 0000000000..ba8724715f
--- /dev/null
+++ b/site/src/pages/AgentsPage/components/DiffViewer/annotationsVersion.test.ts
@@ -0,0 +1,44 @@
+import type { DiffLineAnnotation } from "@pierre/diffs/react";
+import { describe, expect, it } from "vitest";
+import { annotationsVersion } from "./DiffViewer";
+
+const annotation = (
+ lineNumber: number,
+ side: "additions" | "deletions" = "additions",
+): DiffLineAnnotation
=> ({
+ side,
+ lineNumber,
+ metadata: "active-input",
+});
+
+describe("annotationsVersion", () => {
+ it("is 0 when there are no annotations", () => {
+ expect(annotationsVersion(undefined)).toBe(0);
+ expect(annotationsVersion([])).toBe(0);
+ });
+
+ it("changes when the line moves but the count stays the same", () => {
+ // The regression: a single active comment box moving between lines keeps
+ // the count at 1, so a count-based version would not change and CodeView
+ // would skip the update.
+ expect(annotationsVersion([annotation(5)])).not.toBe(
+ annotationsVersion([annotation(10)]),
+ );
+ });
+
+ it("changes when only the side flips", () => {
+ expect(annotationsVersion([annotation(5, "additions")])).not.toBe(
+ annotationsVersion([annotation(5, "deletions")]),
+ );
+ });
+
+ it("is stable for identical annotation content", () => {
+ expect(annotationsVersion([annotation(7, "deletions")])).toBe(
+ annotationsVersion([annotation(7, "deletions")]),
+ );
+ });
+
+ it("differs from the empty state for any annotation", () => {
+ expect(annotationsVersion([annotation(0)])).not.toBe(0);
+ });
+});
diff --git a/site/src/pages/AgentsPage/components/DiffViewer/compareTreePaths.test.ts b/site/src/pages/AgentsPage/components/DiffViewer/compareTreePaths.test.ts
new file mode 100644
index 0000000000..50c512278d
--- /dev/null
+++ b/site/src/pages/AgentsPage/components/DiffViewer/compareTreePaths.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it } from "vitest";
+import { compareTreePaths, treeSortComparator } from "./DiffViewer";
+
+// Mirrors how @pierre/trees feeds its sort comparator: it sorts one flat list
+// of every path entry (files and the intermediate directories), then builds
+// the tree. Expands file paths into that entry set so a test can sort it with
+// treeSortComparator and read back the resulting file (leaf) order.
+function treeFileOrder(files: readonly string[]): string[] {
+ const entries = new Map<
+ string,
+ {
+ basename: string;
+ depth: number;
+ isDirectory: boolean;
+ path: string;
+ segments: string[];
+ }
+ >();
+ for (const file of files) {
+ const segments = file.split("/");
+ for (let i = 0; i < segments.length; i++) {
+ const slice = segments.slice(0, i + 1);
+ const path = slice.join("/");
+ entries.set(path, {
+ basename: slice[slice.length - 1],
+ depth: slice.length,
+ isDirectory: i < segments.length - 1,
+ path,
+ segments: slice,
+ });
+ }
+ }
+ return [...entries.values()]
+ .sort(treeSortComparator)
+ .filter((entry) => !entry.isDirectory)
+ .map((entry) => entry.path);
+}
+
+describe("compareTreePaths", () => {
+ it("orders directories before sibling files and keeps dot-prefixed first", () => {
+ const sorted = [".config/a.ts", "b.ts", "a/z.ts", "b/c/d.ts"].sort(
+ compareTreePaths,
+ );
+
+ expect(sorted).toEqual([".config/a.ts", "a/z.ts", "b/c/d.ts", "b.ts"]);
+ });
+
+ it("sorts dot-prefixed names before other names at the same level", () => {
+ expect([".env", "app.ts", ".gitignore"].sort(compareTreePaths)).toEqual([
+ ".env",
+ ".gitignore",
+ "app.ts",
+ ]);
+ });
+
+ it("breaks ties case-insensitively", () => {
+ expect(["Beta.ts", "alpha.ts", "Alpha.ts"].sort(compareTreePaths)).toEqual([
+ "alpha.ts",
+ "Alpha.ts",
+ "Beta.ts",
+ ]);
+ });
+
+ it("is a stable total order regardless of input order", () => {
+ const files = [
+ "src/zeta.ts",
+ "src/alpha/index.ts",
+ "README.md",
+ ".github/workflows/ci.yml",
+ "src/alpha.ts",
+ ];
+ const forward = [...files].sort(compareTreePaths);
+ const reversed = [...files].reverse().sort(compareTreePaths);
+ expect(reversed).toEqual(forward);
+ expect(forward).toEqual([
+ ".github/workflows/ci.yml",
+ "src/alpha/index.ts",
+ "src/alpha.ts",
+ "src/zeta.ts",
+ "README.md",
+ ]);
+ });
+
+ it("matches the tree's leaf order so the diff and sidebar stay in sync", () => {
+ const files = [
+ "src/zeta.ts",
+ "lib/a.ts",
+ "src/alpha/index.ts",
+ "README.md",
+ ".github/workflows/ci.yml",
+ "src/alpha.ts",
+ "lib/b/c.ts",
+ ];
+ // The sidebar tree (treeSortComparator over the full entry set) and the
+ // flat diff list (compareTreePaths) must produce the same file order.
+ expect([...files].sort(compareTreePaths)).toEqual(treeFileOrder(files));
+ });
+});
diff --git a/site/src/pages/AgentsPage/components/DiffViewer/useActiveFileTracking.test.ts b/site/src/pages/AgentsPage/components/DiffViewer/useActiveFileTracking.test.ts
new file mode 100644
index 0000000000..955a5709ad
--- /dev/null
+++ b/site/src/pages/AgentsPage/components/DiffViewer/useActiveFileTracking.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from "vitest";
+import { getActiveFile, type ScrollViewer } from "./useActiveFileTracking";
+
+// Builds a ScrollViewer stub from a path -> top map. A top of `undefined`
+// models an item the library has not measured yet.
+function viewerFrom(
+ tops: ReadonlyArray,
+): ScrollViewer {
+ const map = new Map(tops);
+ return {
+ getRenderedItems: () => tops.map(([id]) => ({ id })),
+ getTopForItem: (id) => map.get(id),
+ };
+}
+
+describe("getActiveFile", () => {
+ it("picks the item closest to the top that has crossed it", () => {
+ const viewer = viewerFrom([
+ ["a.ts", 0],
+ ["b.ts", 300],
+ ["c.ts", 900],
+ ]);
+ // At scrollTop 500, b.ts (top 300) is the last file whose start has
+ // scrolled past the fold; c.ts (top 900) is still below it.
+ expect(getActiveFile(500, viewer)).toBe("b.ts");
+ });
+
+ it("treats an item within the threshold slack as already active", () => {
+ const viewer = viewerFrom([
+ ["a.ts", 0],
+ // 4px below the fold, inside ACTIVE_FILE_SCROLL_THRESHOLD.
+ ["b.ts", 104],
+ ]);
+ expect(getActiveFile(100, viewer)).toBe("b.ts");
+ });
+
+ it("excludes items past the threshold slack", () => {
+ const viewer = viewerFrom([
+ ["a.ts", 0],
+ // 5px below the fold, just outside the threshold.
+ ["b.ts", 105],
+ ]);
+ expect(getActiveFile(100, viewer)).toBe("a.ts");
+ });
+
+ it("falls back to the first rendered item when none have crossed", () => {
+ const viewer = viewerFrom([
+ ["a.ts", 300],
+ ["b.ts", 400],
+ ]);
+ expect(getActiveFile(0, viewer)).toBe("a.ts");
+ });
+
+ it("ignores unmeasured items", () => {
+ const viewer = viewerFrom([
+ ["a.ts", -10],
+ ["b.ts", undefined],
+ ]);
+ expect(getActiveFile(0, viewer)).toBe("a.ts");
+ });
+
+ it("returns undefined when nothing is rendered", () => {
+ expect(getActiveFile(0, viewerFrom([]))).toBeUndefined();
+ });
+});
diff --git a/site/src/pages/AgentsPage/components/DiffViewer/useActiveFileTracking.ts b/site/src/pages/AgentsPage/components/DiffViewer/useActiveFileTracking.ts
index 2e659f7c66..6ccc0adf8c 100644
--- a/site/src/pages/AgentsPage/components/DiffViewer/useActiveFileTracking.ts
+++ b/site/src/pages/AgentsPage/components/DiffViewer/useActiveFileTracking.ts
@@ -1,146 +1,72 @@
-import type { FileDiffMetadata } from "@pierre/diffs";
-import { type RefObject, useEffect, useRef, useState } from "react";
+import { useEffect, useRef } from "react";
-// Leaves a 5% strip at the top of the viewport as the "active file" band.
-const VIEWPORT_BOTTOM_MARGIN_RATIO = 0.95;
+// Pixels of slack added when matching a file against the scroll offset, so a
+// file counts as active while its sticky header is still pinned at the top.
+const ACTIVE_FILE_SCROLL_THRESHOLD = 4;
-interface UseActiveFileTrackingOptions {
- viewportRef: RefObject;
- sortedFiles: readonly FileDiffMetadata[];
- enabled: boolean;
- scrollToFile?: string | null;
- onScrollToFileComplete?: () => void;
+// Minimal view of the @pierre/diffs CodeView instance handed to `onScroll`.
+// `getRenderedItems` only returns the small set of currently virtualized
+// items, so deriving the active file from it avoids scanning every file.
+export interface ScrollViewer {
+ getRenderedItems(): readonly { id: string }[];
+ getTopForItem(id: string): number | undefined;
}
-interface UseActiveFileTrackingReturn {
- treeActiveFile: string | null;
- setFileRef: (name: string, el: HTMLDivElement | null) => void;
- handleFileClick: (name: string) => void;
+// The active file is the rendered item closest to the top edge that has
+// already crossed it (largest top still at or above the fold). Exported for
+// unit tests that pin the closest-to-top selection logic.
+export function getActiveFile(
+ scrollTop: number,
+ viewer: ScrollViewer,
+): string | undefined {
+ const rendered = viewer.getRenderedItems();
+ const limit = scrollTop + ACTIVE_FILE_SCROLL_THRESHOLD;
+ let activePath: string | undefined;
+ let activeTop = Number.NEGATIVE_INFINITY;
+ for (const item of rendered) {
+ const top = viewer.getTopForItem(item.id);
+ if (top !== undefined && top <= limit && top > activeTop) {
+ activeTop = top;
+ activePath = item.id;
+ }
+ }
+ return activePath ?? rendered[0]?.id;
}
+/**
+ * Reports the diff file scrolled to the top as the user scrolls. Returns a
+ * CodeView `onScroll` handler. Tree-agnostic: callers decide what to do with
+ * the active path (the diff viewer feeds it to the sidebar selection).
+ */
export function useActiveFileTracking({
- viewportRef,
- sortedFiles,
enabled,
- scrollToFile,
- onScrollToFileComplete,
-}: UseActiveFileTrackingOptions): UseActiveFileTrackingReturn {
- const fileRefs = useRef>(new Map());
- const [treeActiveFile, setTreeActiveFile] = useState(null);
-
- const [viewportHeight, setViewportHeight] = useState(0);
-
- // viewportRef is a stable RefObject whose identity never changes, so
- // an effect that depends on it won't re-run when .current transitions
- // from null to the actual DOM node (e.g. after a loading state).
- // Keep a state mirror that flips exactly once when the element mounts.
- const [viewportEl, setViewportEl] = useState(null);
- useEffect(() => {
- setViewportEl(viewportRef.current);
- });
+ onActiveFileChange,
+}: {
+ enabled: boolean;
+ onActiveFileChange: (path: string) => void;
+}) {
+ const rafRef = useRef(null);
useEffect(() => {
- if (!viewportEl) return;
- setViewportHeight(viewportEl.clientHeight);
- const ro = new ResizeObserver(([entry]) => {
- setViewportHeight(Math.round(entry.contentRect.height));
+ return () => {
+ if (rafRef.current !== null) {
+ cancelAnimationFrame(rafRef.current);
+ }
+ };
+ }, []);
+
+ return (scrollTop: number, viewer: ScrollViewer) => {
+ if (!enabled) return;
+ if (rafRef.current !== null) {
+ cancelAnimationFrame(rafRef.current);
+ }
+ // Coalesce bursts of scroll events into one update per frame.
+ rafRef.current = requestAnimationFrame(() => {
+ rafRef.current = null;
+ const next = getActiveFile(scrollTop, viewer);
+ if (next) {
+ onActiveFileChange(next);
+ }
});
- ro.observe(viewportEl);
- return () => ro.disconnect();
- }, [viewportEl]);
-
- const sortedFilesRef = useRef(sortedFiles);
- useEffect(() => {
- sortedFilesRef.current = sortedFiles;
- });
-
- const fileListKey = sortedFiles.map((f) => f.name).join("\0");
-
- const setFileRef = (name: string, el: HTMLDivElement | null) => {
- if (el) {
- fileRefs.current.set(name, el);
- } else {
- fileRefs.current.delete(name);
- }
- };
-
- useEffect(() => {
- if (!enabled || fileListKey === "" || viewportHeight === 0) return;
- if (!viewportEl) return;
-
- const bottomMargin = Math.round(
- viewportHeight * VIEWPORT_BOTTOM_MARGIN_RATIO,
- );
-
- const intersecting = new Set();
-
- const observer = new IntersectionObserver(
- (entries) => {
- for (const entry of entries) {
- const name = (entry.target as HTMLElement).dataset.fileName;
- if (!name) continue;
- if (entry.isIntersecting) {
- intersecting.add(name);
- } else {
- intersecting.delete(name);
- }
- }
- for (const file of sortedFilesRef.current) {
- if (intersecting.has(file.name)) {
- setTreeActiveFile(file.name);
- break;
- }
- }
- },
- {
- root: viewportEl,
- // Observe only the top ~5% strip of the viewport height.
- rootMargin: `0px 0px -${bottomMargin}px 0px`,
- threshold: 0,
- },
- );
-
- for (const [, el] of fileRefs.current.entries()) {
- observer.observe(el);
- }
-
- return () => observer.disconnect();
- }, [enabled, fileListKey, viewportEl, viewportHeight]);
-
- const handleFileClick = (name: string) => {
- const el = fileRefs.current.get(name);
- if (el) {
- el.scrollIntoView({ block: "start", behavior: "instant" });
- setTreeActiveFile(name);
- }
- };
-
- // biome-ignore lint/correctness/useExhaustiveDependencies: fileListKey is an intentional trigger dep. The effect reads fileRefs (a mutable ref) and must retry when the file list changes so a previously-unmounted element can be found.
- useEffect(() => {
- if (!scrollToFile) return;
- const el = fileRefs.current.get(scrollToFile);
- if (el) {
- el.scrollIntoView({ block: "start", behavior: "instant" });
- setTreeActiveFile(scrollToFile);
- onScrollToFileComplete?.();
- return;
- }
- // Element not found. If the target isn't even in the current file
- // list (e.g. stale chip after the diff changed), complete the
- // request so the parent can clear its scroll target. Otherwise
- // the target is present but not yet mounted; wait for fileListKey
- // to change again.
- const existsInFileList = sortedFilesRef.current.some(
- (f) => f.name === scrollToFile,
- );
- if (!existsInFileList) {
- onScrollToFileComplete?.();
- }
- }, [scrollToFile, onScrollToFileComplete, fileListKey]);
-
- return {
- treeActiveFile,
- setFileRef,
- handleFileClick,
};
}
diff --git a/site/src/pages/AgentsPage/components/GitPanel/GitPanel.stories.tsx b/site/src/pages/AgentsPage/components/GitPanel/GitPanel.stories.tsx
index c7b7789381..1bf84e69c4 100644
--- a/site/src/pages/AgentsPage/components/GitPanel/GitPanel.stories.tsx
+++ b/site/src/pages/AgentsPage/components/GitPanel/GitPanel.stories.tsx
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
-import { expect, fn, spyOn, userEvent, within } from "storybook/test";
+import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test";
import { API } from "#/api/api";
import type {
ChatDiffContents,
@@ -334,35 +334,19 @@ export const InlineCommentInput: Story = {
});
},
play: async ({ canvasElement }) => {
- // Wait for the diff to load and render inside Shadow DOM.
- // The line numbers live inside @pierre/diffs FileDiff web
- // components, so we need to wait a bit for them to mount.
- await new Promise((resolve) => setTimeout(resolve, 2000));
-
- // Find a line number element inside a Shadow DOM diff viewer.
- // The diff renders in shadow roots, so we look for the
- // host elements and query inside their shadow DOMs.
- const diffHosts = canvasElement.querySelectorAll("[data-diffs]");
-
- for (const host of diffHosts) {
- const shadow = host.shadowRoot;
- if (!shadow) continue;
-
- // Look for a line number cell — they have data-line-number.
- const lineNumber = shadow.querySelector(
- "[data-line-number]",
- ) as HTMLElement | null;
- if (lineNumber) {
- await userEvent.click(lineNumber);
- break;
+ const canvas = within(canvasElement);
+ const lineNumber = await waitFor(() => {
+ for (const host of canvasElement.querySelectorAll("diffs-container")) {
+ const target = host.shadowRoot?.querySelector(
+ "[data-column-number]",
+ ) as HTMLElement | null;
+ if (target) return target;
}
- }
+ throw new Error("No rendered diff line number found");
+ });
- // Verify the inline prompt appeared.
- const textarea = canvasElement.querySelector("textarea");
- if (textarea) {
- expect(textarea).toBeInTheDocument();
- }
+ await userEvent.click(lineNumber);
+ expect(canvas.getByRole("textbox")).toBeInTheDocument();
},
};
diff --git a/site/src/pages/AgentsPage/utils/countChangedLines.ts b/site/src/pages/AgentsPage/utils/countChangedLines.ts
new file mode 100644
index 0000000000..e27b2b32d4
--- /dev/null
+++ b/site/src/pages/AgentsPage/utils/countChangedLines.ts
@@ -0,0 +1,12 @@
+import type { FileDiffMetadata } from "@pierre/diffs";
+
+/** Sums added and deleted line counts across every hunk in a file diff. */
+export function countChangedLines(fileDiff: FileDiffMetadata) {
+ let additions = 0;
+ let deletions = 0;
+ for (const hunk of fileDiff.hunks) {
+ additions += hunk.additionLines;
+ deletions += hunk.deletionLines;
+ }
+ return { additions, deletions };
+}