History

fix(web): better right panel (new diffs styling) (#5260)

maria committed 3 months ago

Commit 4fb03af1 parent d7950ac23 files · +1087 −155Browse files

Unified · Split

23 files
  1. apps/server/src/auth/RpcAuthorization.ts +1 −0
  2. apps/server/src/review/ReviewService.test.ts +33 −0
  3. apps/server/src/review/ReviewService.ts +30 −3
  4. apps/server/src/server.test.ts +21 −0
  5. apps/server/src/vcs/GitVcsDriver.ts +5 −0
  6. apps/server/src/vcs/GitVcsDriverCore.test.ts +116 −1
  7. apps/server/src/vcs/GitVcsDriverCore.ts +160 −0
  8. apps/server/src/ws.ts +6 −0
  9. apps/web/src/components/ChatView.tsx +8 −1
  10. apps/web/src/components/DiffPanel.tsx +329 −40
  11. apps/web/src/components/DiffPanelShell.tsx +46 −22
  12. apps/web/src/components/RightPanelTabs.tsx +34 −39
  13. apps/web/src/components/diffs/AnnotatableCodeView.test.tsx +59 −0
  14. apps/web/src/components/diffs/AnnotatableCodeView.tsx +29 −16
  15. apps/web/src/components/files/LocalCommentAnnotation.test.tsx +66 −0
  16. apps/web/src/components/files/LocalCommentAnnotation.tsx +42 −29
  17. apps/web/src/index.css +7 −0
  18. apps/web/src/lib/diffRendering.test.ts +30 −1
  19. apps/web/src/lib/diffRendering.ts +4 −1
  20. packages/client-runtime/src/state/review.ts +25 −1
  21. packages/contracts/src/ipc.ts +9 −1
  22. packages/contracts/src/review.ts +17 −0
  23. packages/contracts/src/rpc.ts +10 −0
apps/server/src/auth/RpcAuthorization.ts+1−0
@@ -75,6 +75,7 @@ export const RPC_REQUIRED_SCOPES = { · 74 unmodified lines
7575 [WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope,
7676 [WS_METHODS.vcsInit]: AuthOrchestrationOperateScope,
7777 [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope,
78+ [WS_METHODS.reviewGetDiffFileContents]: AuthReviewWriteScope,
7879 [WS_METHODS.terminalOpen]: AuthTerminalOperateScope,
7980 [WS_METHODS.terminalAttach]: AuthTerminalOperateScope,
8081 [WS_METHODS.terminalWrite]: AuthTerminalOperateScope,
apps/server/src/review/ReviewService.test.ts+33−0
@@ -57,6 +57,39 @@ describe("ReviewService", () => { · 56 unmodified lines
5757 }).pipe(Effect.provide(NodeServices.layer)),
5858 );
5959
60+ it.effect("attributes file-content workspace violations to the file-content operation", () =>
61+ Effect.gen(function* () {
62+ const fs = yield* FileSystem.FileSystem;
63+ const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" });
64+ const outsideRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-outside-" });
65+ const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" });
66+ const detectCalls: Array<{ readonly cwd: string }> = [];
67+
68+ const error = yield* Effect.gen(function* () {
69+ const review = yield* ReviewService.ReviewService;
70+ return yield* review
71+ .getDiffFileContents({
72+ cwd: outsideRoot,
73+ sourceKind: "working-tree",
74+ changeType: "change",
75+ baseRef: "HEAD",
76+ headRef: null,
77+ oldPath: "file.ts",
78+ newPath: "file.ts",
79+ })
80+ .pipe(Effect.flip);
81+ }).pipe(Effect.provide(makeLayer({ workspaceRoot, baseDir, detectCalls })));
82+
83+ assert.strictEqual(error._tag, "VcsRepositoryDetectionError");
84+ assert.strictEqual(error.operation, "ReviewService.getDiffFileContents");
85+ assert.match(
86+ "detail" in error ? error.detail : "",
87+ /must stay within the configured workspace root/,
88+ );
89+ assert.deepStrictEqual(detectCalls, []);
90+ }).pipe(Effect.provide(NodeServices.layer)),
91+ );
92+
6093 it.effect("allows diff preview cwd inside the configured workspace root", () =>
6194 Effect.gen(function* () {
6295 const fs = yield* FileSystem.FileSystem;
apps/server/src/review/ReviewService.ts+30−3
@@ -8,6 +8,8 @@ import * as Path from "effect/Path"; · 7 unmodified lines
88 import {
99 VcsRepositoryDetectionError,
1010 VcsUnsupportedOperationError,
11+ type ReviewDiffFileContentsInput,
12+ type ReviewDiffFileContentsResult,
1113 type ReviewDiffPreviewError,
1214 type ReviewDiffPreviewInput,
1315 type ReviewDiffPreviewResult,
@@ -23,6 +25,9 @@ export class ReviewService extends Context.Service< · 9 unmodified lines
2325 readonly getDiffPreview: (
2426 input: ReviewDiffPreviewInput,
2527 ) => Effect.Effect<ReviewDiffPreviewResult, ReviewDiffPreviewError>;
28+ readonly getDiffFileContents: (
29+ input: ReviewDiffFileContentsInput,
30+ ) => Effect.Effect<ReviewDiffFileContentsResult, ReviewDiffPreviewError>;
2631 }
2732 >()("t3/review/ReviewService") {}
2833
@@ -58,6 +63,7 @@ export const make = Effect.gen(function* () { · 29 unmodified lines
5863 };
5964
6065 const assertWorkspaceBoundCwd = Effect.fn("ReviewService.assertWorkspaceBoundCwd")(function* (
66+ operation: "ReviewService.getDiffPreview" | "ReviewService.getDiffFileContents",
6167 cwd: string,
6268 ) {
6369 const [candidate, workspaceRoot, worktreesRoot] = yield* Effect.all([
@@ -71,16 +77,19 @@ export const make = Effect.gen(function* () { · 7 unmodified lines
7177 }
7278
7379 return yield* new VcsRepositoryDetectionError({
74− operation: "ReviewService.getDiffPreview",
80+ operation,
7581 cwd,
76− detail: "Review diff preview cwd must stay within the configured workspace root.",
82+ detail:
83+ operation === "ReviewService.getDiffPreview"
84+ ? "Review diff preview cwd must stay within the configured workspace root."
85+ : "Review diff file contents cwd must stay within the configured workspace root.",
7786 });
7887 });
7988
8089 const getDiffPreview: ReviewService["Service"]["getDiffPreview"] = Effect.fn(
8190 "ReviewService.getDiffPreview",
8291 )(function* (input) {
83− yield* assertWorkspaceBoundCwd(input.cwd);
92+ yield* assertWorkspaceBoundCwd("ReviewService.getDiffPreview", input.cwd);
8493
8594 const handle = yield* vcsRegistry.detect({ cwd: input.cwd, requestedKind: "auto" });
8695 if (!handle) {
@@ -106,8 +115,26 @@ export const make = Effect.gen(function* () { · 19 unmodified lines
106115 return yield* getDriverDiffPreview(input);
107116 });
108117
118+ const getDiffFileContents: ReviewService["Service"]["getDiffFileContents"] = Effect.fn(
119+ "ReviewService.getDiffFileContents",
120+ )(function* (input) {
121+ yield* assertWorkspaceBoundCwd("ReviewService.getDiffFileContents", input.cwd);
122+
123+ const handle = yield* vcsRegistry.detect({ cwd: input.cwd, requestedKind: "auto" });
124+ if (handle?.kind !== "git") {
125+ return yield* new VcsUnsupportedOperationError({
126+ operation: "ReviewService.getDiffFileContents",
127+ kind: handle?.kind ?? "unknown",
128+ detail: "Unchanged diff expansion currently requires a Git repository.",
129+ });
130+ }
131+
132+ return yield* git.getReviewDiffFileContents(input);
133+ });
134+
109135 return ReviewService.of({
110136 getDiffPreview,
137+ getDiffFileContents,
111138 });
112139 });
113140
apps/server/src/server.test.ts+21−0
@@ -5249,6 +5249,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 5248 unmodified lines
52495249 },
52505250 ],
52515251 }),
5252+ getDiffFileContents: () =>
5253+ Effect.succeed({
5254+ oldContents: "before\n",
5255+ newContents: "after\n",
5256+ }),
52525257 },
52535258 },
52545259 });
@@ -5363,6 +5368,22 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 108 unmodified lines
53635368 ),
53645369 );
53655370 assert.equal(diffPreview.sources[0]?.diff, "dirty-diff");
5371+
5372+ const diffFileContents = yield* Effect.scoped(
5373+ withWsRpcClient(wsUrl, (client) =>
5374+ client[WS_METHODS.reviewGetDiffFileContents]({
5375+ cwd: "/tmp/repo",
5376+ sourceKind: "working-tree",
5377+ changeType: "change",
5378+ baseRef: "HEAD",
5379+ headRef: null,
5380+ oldPath: "README.md",
5381+ newPath: "README.md",
5382+ }),
5383+ ),
5384+ );
5385+ assert.equal(diffFileContents.oldContents, "before\n");
5386+ assert.equal(diffFileContents.newContents, "after\n");
53665387 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
53675388 );
53685389
apps/server/src/vcs/GitVcsDriver.ts+5−0
@@ -20,6 +20,8 @@ import { · 19 unmodified lines
2020 type VcsCreateWorktreeResult,
2121 type ReviewDiffPreviewInput,
2222 type ReviewDiffPreviewResult,
23+ type ReviewDiffFileContentsInput,
24+ type ReviewDiffFileContentsResult,
2325 type VcsInitInput,
2426 type VcsListRefsInput,
2527 type VcsListRefsResult,
@@ -221,6 +223,9 @@ export class GitVcsDriver extends Context.Service< · 195 unmodified lines
221223 readonly getReviewDiffPreview: (
222224 input: ReviewDiffPreviewInput,
223225 ) => Effect.Effect<ReviewDiffPreviewResult, GitCommandError>;
226+ readonly getReviewDiffFileContents: (
227+ input: ReviewDiffFileContentsInput,
228+ ) => Effect.Effect<ReviewDiffFileContentsResult, GitCommandError>;
224229 readonly readConfigValue: (
225230 cwd: string,
226231 key: string,
apps/server/src/vcs/GitVcsDriverCore.test.ts+116−1
@@ -14,7 +14,7 @@ import * as Stream from "effect/Stream"; · 13 unmodified lines
1414 import * as TestClock from "effect/testing/TestClock";
1515 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1616
17−import { GitCommandError } from "@t3tools/contracts";
17+import { GitCommandError, type ReviewDiffFileContentsInput } from "@t3tools/contracts";
1818 import { ServerConfig } from "../config.ts";
1919 import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts";
2020 import * as GitVcsDriver from "./GitVcsDriver.ts";
@@ -78,6 +78,20 @@ const writeTextFile = ( · 57 unmodified lines
7878 yield* fileSystem.writeFileString(filePath, contents);
7979 });
8080
81+const makeReviewDiffFileContentsInput = (
82+ cwd: string,
83+ overrides: Partial<Omit<ReviewDiffFileContentsInput, "cwd">> = {},
84+): ReviewDiffFileContentsInput => ({
85+ cwd,
86+ sourceKind: "working-tree",
87+ changeType: "change",
88+ baseRef: "HEAD",
89+ headRef: null,
90+ oldPath: "README.md",
91+ newPath: "README.md",
92+ ...overrides,
93+});
94+
8195 const git = (
8296 cwd: string,
8397 args: ReadonlyArray<string>,
@@ -803,6 +817,107 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { · 719 unmodified lines
803817 );
804818 }),
805819 );
820+
821+ it.effect("loads full file contents for working-tree diff expansion", () =>
822+ Effect.gen(function* () {
823+ const cwd = yield* makeTmpDir();
824+ yield* initRepoWithCommit(cwd);
825+ const driver = yield* GitVcsDriver.GitVcsDriver;
826+ const pathService = yield* Path.Path;
827+ yield* writeTextFile(cwd, "nested/.keep", "");
828+ yield* writeTextFile(cwd, "README.md", "# changed\nunchanged context\n");
829+
830+ const contents = yield* driver.getReviewDiffFileContents(
831+ makeReviewDiffFileContentsInput(pathService.join(cwd, "nested")),
832+ );
833+
834+ assert.strictEqual(contents.oldContents, "# test\n");
835+ assert.strictEqual(contents.newContents, "# changed\nunchanged context\n");
836+ }),
837+ );
838+
839+ it.effect("attributes working-tree filesystem failures to the failing operation", () =>
840+ Effect.gen(function* () {
841+ const cwd = yield* makeTmpDir();
842+ yield* initRepoWithCommit(cwd);
843+ const driver = yield* GitVcsDriver.GitVcsDriver;
844+
845+ const error = yield* driver
846+ .getReviewDiffFileContents(
847+ makeReviewDiffFileContentsInput(cwd, {
848+ changeType: "new",
849+ oldPath: "missing.ts",
850+ newPath: "missing.ts",
851+ }),
852+ )
853+ .pipe(Effect.flip);
854+
855+ assert.deepInclude(error, {
856+ _tag: "GitCommandError",
857+ operation: "GitVcsDriver.getReviewDiffFileContents.workingTree.fs.realPath",
858+ command: "fs.realPath",
859+ cwd,
860+ detail: "Could not resolve diff file 'missing.ts'.",
861+ });
862+ }),
863+ );
864+
865+ it.effect("loads new and deleted files without reading their missing side", () =>
866+ Effect.gen(function* () {
867+ const cwd = yield* makeTmpDir();
868+ yield* initRepoWithCommit(cwd);
869+ const driver = yield* GitVcsDriver.GitVcsDriver;
870+ const fileSystem = yield* FileSystem.FileSystem;
871+ const pathService = yield* Path.Path;
872+ yield* writeTextFile(cwd, "added.ts", "export const added = true;\n");
873+ yield* fileSystem.remove(pathService.join(cwd, "README.md"));
874+
875+ const [added, deleted] = yield* Effect.all([
876+ driver.getReviewDiffFileContents(
877+ makeReviewDiffFileContentsInput(cwd, {
878+ changeType: "new",
879+ oldPath: "added.ts",
880+ newPath: "added.ts",
881+ }),
882+ ),
883+ driver.getReviewDiffFileContents(
884+ makeReviewDiffFileContentsInput(cwd, { changeType: "deleted" }),
885+ ),
886+ ]);
887+
888+ assert.deepStrictEqual(added, {
889+ oldContents: "",
890+ newContents: "export const added = true;\n",
891+ });
892+ assert.deepStrictEqual(deleted, {
893+ oldContents: "# test\n",
894+ newContents: "",
895+ });
896+ }),
897+ );
898+
899+ it.effect("loads merge-base and head contents for branch diff expansion", () =>
900+ Effect.gen(function* () {
901+ const cwd = yield* makeTmpDir();
902+ const { initialBranch } = yield* initRepoWithCommit(cwd);
903+ const driver = yield* GitVcsDriver.GitVcsDriver;
904+ yield* git(cwd, ["checkout", "-b", "feature/context"]);
905+ yield* writeTextFile(cwd, "README.md", "# branch change\nunchanged context\n");
906+ yield* git(cwd, ["add", "README.md"]);
907+ yield* git(cwd, ["commit", "-m", "change readme"]);
908+
909+ const contents = yield* driver.getReviewDiffFileContents(
910+ makeReviewDiffFileContentsInput(cwd, {
911+ sourceKind: "branch-range",
912+ baseRef: initialBranch,
913+ headRef: "feature/context",
914+ }),
915+ );
916+
917+ assert.strictEqual(contents.oldContents, "# test\n");
918+ assert.strictEqual(contents.newContents, "# branch change\nunchanged context\n");
919+ }),
920+ );
806921 });
807922
808923 describe("repository status", () => {
apps/server/src/vcs/GitVcsDriverCore.ts+160−0
@@ -21,6 +21,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; · 20 unmodified lines
2121
2222 import {
2323 GitCommandError,
24+ type ReviewDiffFileContentsInput,
2425 type ReviewDiffPreviewInput,
2526 type ReviewDiffPreviewSource,
2627 type VcsRef,
@@ -46,6 +47,7 @@ const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; · 19 unmodified lines
4647 const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000;
4748 const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000;
4849 const REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES = 80_000;
50+const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024;
4951 const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 120_000;
5052 const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
5153 const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
@@ -2270,6 +2272,163 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 2218 unmodified lines
22702272 };
22712273 });
22722274
2275+ const reviewDiffFileError = (
2276+ input: ReviewDiffFileContentsInput,
2277+ detail: string,
2278+ cause?: unknown,
2279+ ) =>
2280+ new GitCommandError({
2281+ operation: "GitVcsDriver.getReviewDiffFileContents",
2282+ command: "git",
2283+ cwd: input.cwd,
2284+ detail,
2285+ ...(cause === undefined ? {} : { cause }),
2286+ });
2287+
2288+ const isPathWithinRoot = (root: string, candidate: string) => {
2289+ const relative = path.relative(root, candidate);
2290+ return (
2291+ relative === "" ||
2292+ (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
2293+ );
2294+ };
2295+
2296+ const readReviewFileAtRevision = Effect.fn("readReviewFileAtRevision")(function* (
2297+ input: ReviewDiffFileContentsInput,
2298+ revision: string,
2299+ relativePath: string,
2300+ ) {
2301+ const result = yield* executeGit(
2302+ "GitVcsDriver.getReviewDiffFileContents.revision",
2303+ input.cwd,
2304+ ["show", `${revision}:${relativePath}`],
2305+ { maxOutputBytes: REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES },
2306+ );
2307+ if (result.stdout.includes("\0")) {
2308+ return yield* reviewDiffFileError(input, `Cannot expand binary file '${relativePath}'.`);
2309+ }
2310+ return result.stdout;
2311+ });
2312+
2313+ const readWorkingTreeReviewFile = Effect.fn("readWorkingTreeReviewFile")(function* (
2314+ input: ReviewDiffFileContentsInput,
2315+ repositoryRoot: string,
2316+ ) {
2317+ const fileError = (stage: string, detail: string, cause?: unknown) =>
2318+ new GitCommandError({
2319+ operation: `GitVcsDriver.getReviewDiffFileContents.workingTree.${stage}`,
2320+ command: stage,
2321+ cwd: input.cwd,
2322+ detail,
2323+ ...(cause === undefined ? {} : { cause }),
2324+ });
2325+ const requestedPath = path.resolve(repositoryRoot, input.newPath);
2326+ if (!isPathWithinRoot(repositoryRoot, requestedPath)) {
2327+ return yield* fileError(
2328+ "path.resolve",
2329+ `Diff file '${input.newPath}' resolves outside the review workspace.`,
2330+ );
2331+ }
2332+
2333+ const [realRepositoryRoot, realTarget] = yield* Effect.all([
2334+ fileSystem.realPath(repositoryRoot),
2335+ fileSystem.realPath(requestedPath),
2336+ ]).pipe(
2337+ Effect.mapError((cause) =>
2338+ fileError("fs.realPath", `Could not resolve diff file '${input.newPath}'.`, cause),
2339+ ),
2340+ );
2341+ if (!isPathWithinRoot(realRepositoryRoot, realTarget)) {
2342+ return yield* fileError(
2343+ "fs.realPath",
2344+ `Diff file '${input.newPath}' resolves outside the review workspace.`,
2345+ );
2346+ }
2347+
2348+ const info = yield* fileSystem
2349+ .stat(realTarget)
2350+ .pipe(
2351+ Effect.mapError((cause) =>
2352+ fileError("fs.stat", `Could not inspect diff file '${input.newPath}'.`, cause),
2353+ ),
2354+ );
2355+ if (info.type !== "File") {
2356+ return yield* fileError("fs.stat", `Diff path '${input.newPath}' is not a file.`);
2357+ }
2358+ if (info.size > BigInt(REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES)) {
2359+ return yield* fileError(
2360+ "fs.stat",
2361+ `Diff file '${input.newPath}' exceeds the 1 MB expansion limit.`,
2362+ );
2363+ }
2364+
2365+ const bytes = yield* fileSystem
2366+ .readFile(realTarget)
2367+ .pipe(
2368+ Effect.mapError((cause) =>
2369+ fileError("fs.readFile", `Could not read diff file '${input.newPath}'.`, cause),
2370+ ),
2371+ );
2372+ if (bytes.includes(0)) {
2373+ return yield* fileError("fs.readFile", `Cannot expand binary file '${input.newPath}'.`);
2374+ }
2375+ return new TextDecoder("utf-8").decode(bytes);
2376+ });
2377+
2378+ const getReviewDiffFileContents = Effect.fn("getReviewDiffFileContents")(function* (
2379+ input: ReviewDiffFileContentsInput,
2380+ ) {
2381+ if (input.sourceKind === "working-tree") {
2382+ const repositoryRoot = yield* runGitStdout(
2383+ "GitVcsDriver.getReviewDiffFileContents.repositoryRoot",
2384+ input.cwd,
2385+ ["rev-parse", "--show-toplevel"],
2386+ ).pipe(Effect.map((value) => value.trim()));
2387+ if (repositoryRoot.length === 0) {
2388+ return yield* reviewDiffFileError(input, "Could not resolve the Git repository root.");
2389+ }
2390+ const [oldContents, newContents] = yield* Effect.all(
2391+ [
2392+ input.changeType === "new"
2393+ ? Effect.succeed("")
2394+ : readReviewFileAtRevision(input, input.baseRef ?? "HEAD", input.oldPath),
2395+ input.changeType === "deleted"
2396+ ? Effect.succeed("")
2397+ : readWorkingTreeReviewFile(input, repositoryRoot),
2398+ ],
2399+ { concurrency: 2 },
2400+ );
2401+ return { oldContents, newContents };
2402+ }
2403+
2404+ if (!input.baseRef || !input.headRef) {
2405+ return yield* reviewDiffFileError(
2406+ input,
2407+ "Branch diff file expansion requires both base and head refs.",
2408+ );
2409+ }
2410+ const mergeBase = yield* runGitStdout(
2411+ "GitVcsDriver.getReviewDiffFileContents.mergeBase",
2412+ input.cwd,
2413+ ["merge-base", input.baseRef, input.headRef],
2414+ ).pipe(Effect.map((value) => value.trim()));
2415+ if (mergeBase.length === 0) {
2416+ return yield* reviewDiffFileError(input, "Could not resolve the branch comparison base.");
2417+ }
2418+ const [oldContents, newContents] = yield* Effect.all(
2419+ [
2420+ input.changeType === "new"
2421+ ? Effect.succeed("")
2422+ : readReviewFileAtRevision(input, mergeBase, input.oldPath),
2423+ input.changeType === "deleted"
2424+ ? Effect.succeed("")
2425+ : readReviewFileAtRevision(input, input.headRef, input.newPath),
2426+ ],
2427+ { concurrency: 2 },
2428+ );
2429+ return { oldContents, newContents };
2430+ });
2431+
22732432 const readConfigValue: GitVcsDriver.GitVcsDriver["Service"]["readConfigValue"] = (cwd, key) =>
22742433 runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe(
22752434 Effect.map((stdout) => stdout.trim()),
@@ -2903,6 +3062,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 627 unmodified lines
29033062 pullCurrentBranch: (cwd) => withListRefsInvalidation(cwd, pullCurrentBranch(cwd)),
29043063 readRangeContext,
29053064 getReviewDiffPreview,
3065+ getReviewDiffFileContents,
29063066 readConfigValue,
29073067 listRefs,
29083068 createWorktree: (input) => withListRefsInvalidation(input.cwd, createWorktree(input)),
apps/server/src/ws.ts+6−0
@@ -1854,6 +1854,12 @@ const makeWsRpcLayer = ( · 1853 unmodified lines
18541854 observeRpcEffect(WS_METHODS.reviewGetDiffPreview, review.getDiffPreview(input), {
18551855 "rpc.aggregate": "review",
18561856 }),
1857+ [WS_METHODS.reviewGetDiffFileContents]: (input) =>
1858+ observeRpcEffect(
1859+ WS_METHODS.reviewGetDiffFileContents,
1860+ review.getDiffFileContents(input),
1861+ { "rpc.aggregate": "review" },
1862+ ),
18571863 [WS_METHODS.terminalOpen]: (input) =>
18581864 observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), {
18591865 "rpc.aggregate": "terminal",
apps/web/src/components/ChatView.tsx+8−1
@@ -5648,7 +5648,14 @@ function ChatViewContent(props: ChatViewProps) { · 5647 unmodified lines
56485648 />
56495649 );
56505650 const panelLayoutControls = (
5651− <div className="workspace-titlebar-controls z-50 mr-px gap-1 [-webkit-app-region:no-drag]">
5651+ <div
5652+ className={cn(
5653+ "workspace-titlebar-controls z-50 gap-1 [-webkit-app-region:no-drag]",
5654+ rightPanelOpen && !shouldUsePlanSidebarSheet
5655+ ? "right-2 wco:right-[var(--workspace-controls-right)]"
5656+ : "mr-px",
5657+ )}
5658+ >
56525659 {rightPanelOpen && !shouldUsePlanSidebarSheet ? (
56535660 <RightPanelMaximizeControl
56545661 maximized={rightPanelMaximized}
apps/web/src/components/DiffPanel.tsx+329−40
@@ -1,4 +1,5 @@
11 import { useAtomValue } from "@effect/atom-react";
2+import type { FileDiffContentsLoader } from "@pierre/diffs";
23 import { useParams } from "@tanstack/react-router";
34 import {
45 isAtomCommandInterrupted,
@@ -15,6 +16,7 @@ import { · 10 unmodified lines
1516 ChevronsUpDownIcon,
1617 Columns2Icon,
1718 PilcrowIcon,
19+ RefreshCwIcon,
1820 Rows3Icon,
1921 SearchIcon,
2022 TextWrapIcon,
@@ -67,6 +69,7 @@ import { · 46 unmodified lines
6769 } from "./ui/menu";
6870 import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
6971 import { useEnvironmentQuery } from "../state/query";
72+import { useAtomCommand } from "../state/use-atom-command";
7073 import { serverEnvironment } from "../state/server";
7174 import { reviewEnvironment } from "../state/review";
7275 import { vcsEnvironment } from "../state/vcs";
@@ -91,9 +94,9 @@ const DIFF_PANEL_UNSAFE_CSS = ` · 18 unmodified lines
9194 [data-virtualizer-buffer] {
9295 --diffs-header-font-family: var(--font-sans) !important;
9396 --diffs-font-family: var(--font-mono) !important;
94− --diffs-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important;
95− --diffs-light-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important;
96− --diffs-dark-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important;
97+ --diffs-bg: var(--background) !important;
98+ --diffs-light-bg: var(--background) !important;
99+ --diffs-dark-bg: var(--background) !important;
97100 --diffs-token-light-bg: transparent;
98101 --diffs-token-dark-bg: transparent;
99102
@@ -102,13 +105,25 @@ const DIFF_PANEL_UNSAFE_CSS = ` · 2 unmodified lines
102105 --diffs-bg-separator-override: color-mix(in srgb, var(--background) 95%, var(--foreground));
103106 --diffs-bg-buffer-override: color-mix(in srgb, var(--background) 90%, var(--foreground));
104107
105− --diffs-bg-addition-override: color-mix(in srgb, var(--background) 92%, var(--success));
106− --diffs-bg-addition-number-override: color-mix(in srgb, var(--background) 88%, var(--success));
108+ --diffs-bg-addition-override: light-dark(
109+ color-mix(in srgb, var(--background) 50%, var(--success)),
110+ color-mix(in srgb, var(--background) 70%, var(--success))
111+ );
112+ --diffs-bg-addition-number-override: light-dark(
113+ color-mix(in srgb, var(--background) 35%, var(--success)),
114+ color-mix(in srgb, var(--background) 60%, var(--success))
115+ );
107116 --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--success));
108117 --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, var(--success));
109118
110− --diffs-bg-deletion-override: color-mix(in srgb, var(--background) 92%, var(--destructive));
111− --diffs-bg-deletion-number-override: color-mix(in srgb, var(--background) 88%, var(--destructive));
119+ --diffs-bg-deletion-override: light-dark(
120+ color-mix(in srgb, var(--background) 50%, var(--destructive)),
121+ color-mix(in srgb, var(--background) 70%, var(--destructive))
122+ );
123+ --diffs-bg-deletion-number-override: light-dark(
124+ color-mix(in srgb, var(--background) 35%, var(--destructive)),
125+ color-mix(in srgb, var(--background) 60%, var(--destructive))
126+ );
112127 --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--destructive));
113128 --diffs-bg-deletion-emphasis-override: color-mix(
114129 in srgb,
@@ -119,9 +134,66 @@ const DIFF_PANEL_UNSAFE_CSS = ` · 4 unmodified lines
119134 background-color: var(--diffs-bg) !important;
120135 }
121136
137+:is(
138+ [data-line],
139+ [data-line-annotation],
140+ [data-merge-conflict],
141+ [data-merge-conflict-actions],
142+ [data-no-newline]
143+)[data-selected-line] {
144+ --diffs-line-bg: light-dark(
145+ color-mix(
146+ in lab,
147+ var(--background) 88%,
148+ color-mix(in srgb, var(--background) 50%, var(--diffs-modified-base))
149+ ),
150+ color-mix(
151+ in lab,
152+ var(--background) 80%,
153+ color-mix(in srgb, var(--background) 70%, var(--diffs-modified-base))
154+ )
155+ ) !important;
156+}
157+
158+:is([data-gutter-buffer], [data-column-number])[data-selected-line] {
159+ --diffs-line-bg: light-dark(
160+ color-mix(
161+ in lab,
162+ var(--background) 91%,
163+ color-mix(in srgb, var(--background) 35%, var(--diffs-modified-base))
164+ ),
165+ color-mix(
166+ in lab,
167+ var(--background) 85%,
168+ color-mix(in srgb, var(--background) 60%, var(--diffs-modified-base))
169+ )
170+ ) !important;
171+}
172+
173+[data-indicators="bars"]
174+ :is([data-column-number], [data-gutter-buffer="annotation"])[data-selected-line] {
175+ position: relative;
176+}
177+
178+[data-indicators="bars"]
179+ :is([data-column-number], [data-gutter-buffer="annotation"])[data-selected-line]::before {
180+ position: absolute !important;
181+ inset-block: 0 !important;
182+ inset-inline-start: 0 !important;
183+ display: block !important;
184+ width: 4px !important;
185+ min-width: 4px !important;
186+ max-width: 4px !important;
187+ height: auto !important;
188+ padding: 0 !important;
189+ content: "" !important;
190+ background-color: var(--diffs-modified-base) !important;
191+ background-image: none !important;
192+}
193+
122194 [data-file-info] {
123− background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important;
124− border-block-color: var(--border) !important;
195+ background-color: var(--background) !important;
196+ border-block-color: transparent !important;
125197 color: var(--foreground) !important;
126198 }
127199
@@ -129,14 +201,103 @@ const DIFF_PANEL_UNSAFE_CSS = ` · 1 unmodified line
129201 position: sticky !important;
130202 top: 0;
131203 z-index: 4;
132− background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important;
133− border-bottom: 1px solid var(--border) !important;
204+ background-color: var(--background) !important;
205+ border-bottom-color: transparent !important;
134206 align-items: center !important;
135207 font-family: var(--font-sans) !important;
136208 font-size: 12px !important;
137209 line-height: 1 !important;
138210 min-height: 32px !important;
139211 padding-block: 6px !important;
212+ padding-inline: 8px 12px !important;
213+}
214+
215+[data-diffs-header]:hover {
216+ background-color: color-mix(in srgb, var(--background) 97%, var(--foreground)) !important;
217+}
218+
219+:is([data-separator="line-info"], [data-separator="line-info-basic"]) {
220+ height: 24px !important;
221+ margin-block: 0 !important;
222+ background-color: var(--background) !important;
223+}
224+
225+:is([data-separator="line-info"], [data-separator="line-info-basic"])
226+ [data-separator-wrapper] {
227+ padding-inline: 8px 12px !important;
228+ background-color: transparent !important;
229+}
230+
231+:is([data-separator="line-info"], [data-separator="line-info-basic"])
232+ [data-separator-content] {
233+ gap: 8px;
234+ padding-inline: 0 !important;
235+ background-color: transparent !important;
236+ color: color-mix(in srgb, var(--foreground) 52%, var(--background)) !important;
237+ font-family: var(--font-sans) !important;
238+ font-size: 11px !important;
239+ text-decoration: none !important;
240+}
241+
242+:is([data-separator="line-info"], [data-separator="line-info-basic"])
243+ [data-unmodified-lines] {
244+ display: flex !important;
245+ min-width: 0;
246+ flex: 1 1 auto;
247+ align-items: center;
248+ gap: 8px;
249+ cursor: pointer;
250+}
251+
252+:is([data-separator="line-info"], [data-separator="line-info-basic"])
253+ [data-unmodified-lines]::before,
254+:is([data-separator="line-info"], [data-separator="line-info-basic"])
255+ [data-unmodified-lines]::after {
256+ width: auto;
257+ height: 1px;
258+ flex: 1 1 auto;
259+ content: "";
260+ background-color: color-mix(in srgb, var(--background) 92%, var(--foreground));
261+}
262+
263+:is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index]
264+ [data-separator-wrapper] {
265+ grid-template-columns: 0 minmax(0, 1fr) !important;
266+}
267+
268+:is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index]
269+ [data-separator-content] {
270+ grid-column: 2 !important;
271+}
272+
273+:is([data-separator="line-info"], [data-separator="line-info-basic"])
274+ [data-expand-button] {
275+ display: none !important;
276+}
277+
278+:is([data-separator="line-info"], [data-separator="line-info-basic"]):has(
279+ [data-expand-button]
280+ )
281+ [data-separator-content] {
282+ cursor: pointer;
283+}
284+
285+:is([data-separator="line-info"], [data-separator="line-info-basic"]):has(
286+ [data-expand-button]
287+ ):hover
288+ [data-separator-content] {
289+ color: color-mix(in srgb, var(--foreground) 76%, var(--background)) !important;
290+}
291+
292+:is([data-separator="line-info"], [data-separator="line-info-basic"]):has(
293+ [data-expand-button]
294+ ):hover
295+ [data-unmodified-lines]::before,
296+:is([data-separator="line-info"], [data-separator="line-info-basic"]):has(
297+ [data-expand-button]
298+ ):hover
299+ [data-unmodified-lines]::after {
300+ background-color: color-mix(in srgb, var(--background) 84%, var(--foreground));
140301 }
141302
142303 [data-diffs-header] [data-header-content] {
@@ -205,7 +366,12 @@ export default function DiffPanel({ · 62 unmodified lines
205366 scopeKey: null,
206367 fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS,
207368 }));
369+ const [codeViewRevision, setCodeViewRevision] = useState(0);
208370 const codeViewRef = useRef<AnnotatableCodeViewHandle>(null);
371+ const lastCompletedTurnRefreshRef = useRef<{
372+ readonly threadKey: string | null;
373+ readonly turnId: TurnId | null;
374+ } | null>(null);
209375
210376 const routeThreadRef = useParams({
211377 strict: false,
@@ -230,6 +396,7 @@ export default function DiffPanel({ · 18 unmodified lines
230396 activeThread?.environmentId ?? null,
231397 serverConfig?.availableEditors ?? [],
232398 );
399+ const getDiffFileContents = useAtomCommand(reviewEnvironment.diffFileContents);
233400 const gitStatusQuery = useEnvironmentQuery(
234401 activeThread !== null && activeThread !== undefined && activeCwd != null
235402 ? vcsEnvironment.status({
@@ -298,6 +465,7 @@ export default function DiffPanel({ · 62 unmodified lines
298465 const collapseScopeKey = routeThreadRef
299466 ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}`
300467 : null;
468+ const codeViewMountKey = `${collapseScopeKey ?? reviewSectionId}:${codeViewRevision}`;
301469 const collapsedDiffFileKeys =
302470 collapsedDiffFiles.scopeKey === collapseScopeKey
303471 ? collapsedDiffFiles.fileKeys
@@ -360,9 +528,93 @@ export default function DiffPanel({ · 56 unmodified lines
360528 const branchDiffPreview = shouldRetryBranchDiffAtEnvironmentCwd
361529 ? fallbackBranchDiffPreview
362530 : primaryBranchDiffPreview;
531+ const refreshBranchDiffPreview = branchDiffPreview.refresh;
532+ const canRefreshGitDiff =
533+ isGitRepo && selectedTurnId === null && activeThread != null && activeCwd != null;
534+ const activeThreadRefreshKey = routeThreadRef
535+ ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}`
536+ : null;
537+
538+ useEffect(() => {
539+ if (!canRefreshGitDiff) return;
540+ const refreshOnFocus = () => refreshBranchDiffPreview();
541+ window.addEventListener("focus", refreshOnFocus);
542+ return () => window.removeEventListener("focus", refreshOnFocus);
543+ }, [canRefreshGitDiff, refreshBranchDiffPreview]);
544+
545+ useEffect(() => {
546+ const current = {
547+ threadKey: activeThreadRefreshKey,
548+ turnId: latestTurn?.turnId ?? null,
549+ };
550+ const previous = lastCompletedTurnRefreshRef.current;
551+ if (!canRefreshGitDiff) {
552+ return;
553+ }
554+ if (previous === null || previous.threadKey !== current.threadKey) {
555+ lastCompletedTurnRefreshRef.current = current;
556+ return;
557+ }
558+ if (previous.turnId === current.turnId) return;
559+ refreshBranchDiffPreview();
560+ lastCompletedTurnRefreshRef.current = current;
561+ }, [activeThreadRefreshKey, canRefreshGitDiff, latestTurn?.turnId, refreshBranchDiffPreview]);
562+
363563 const selectedGitSource = branchDiffPreview.data?.sources.find(
364564 (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"),
365565 );
566+ const loadDiffFiles = useMemo<FileDiffContentsLoader | undefined>(() => {
567+ const preview = branchDiffPreview.data;
568+ if (selectedTurnId !== null || !activeThread || !preview || !selectedGitSource) {
569+ return undefined;
570+ }
571+
572+ const source = selectedGitSource;
573+ return async (fileDiff) => {
574+ const newPath = resolveFileDiffPath(fileDiff);
575+ const oldPath = fileDiff.prevName
576+ ? resolveFileDiffPath({ ...fileDiff, name: fileDiff.prevName })
577+ : newPath;
578+ const result = await getDiffFileContents({
579+ environmentId: activeThread.environmentId,
580+ input: {
581+ cwd: preview.cwd,
582+ sourceKind: source.kind,
583+ changeType: fileDiff.type,
584+ baseRef: source.baseRef,
585+ headRef: source.headRef,
586+ oldPath,
587+ newPath,
588+ },
589+ });
590+ if (result._tag !== "Success") {
591+ throw squashAtomCommandFailure(result);
592+ }
593+
594+ const newFile = {
595+ name: newPath,
596+ contents: result.value.newContents,
597+ cacheKey: `${source.diffHash}:new:${newPath}`,
598+ };
599+ if (fileDiff.type === "rename-pure") {
600+ return { oldFile: null, newFile };
601+ }
602+ return {
603+ oldFile: {
604+ name: oldPath,
605+ contents: result.value.oldContents,
606+ cacheKey: `${source.diffHash}:old:${oldPath}`,
607+ },
608+ newFile,
609+ };
610+ };
611+ }, [
612+ activeThread,
613+ branchDiffPreview.data,
614+ getDiffFileContents,
615+ selectedGitSource,
616+ selectedTurnId,
617+ ]);
366618 const localBranchRefs = useEnvironmentQuery(
367619 selectedTurnId === null &&
368620 selectedGitScope === "branch" &&
@@ -439,10 +691,17 @@ export default function DiffPanel({ · 70 unmodified lines
439691 }),
440692 );
441693 }, [renderablePatch]);
694+ const renderableFileEntries = useMemo(
695+ () =>
696+ renderableFiles.map((fileDiff) => ({
697+ fileDiff,
698+ fileKey: buildFileDiffRenderKey(fileDiff),
699+ })),
700+ [renderableFiles],
701+ );
442702 const codeViewFiles = useMemo(
443703 () =>
444− renderableFiles.map((fileDiff) => {
445− const fileKey = buildFileDiffRenderKey(fileDiff);
704+ renderableFileEntries.map(({ fileDiff, fileKey }) => {
446705 return {
447706 fileDiff,
448707 filePath: resolveFileDiffPath(fileDiff),
@@ -450,18 +709,19 @@ export default function DiffPanel({ · 1 unmodified line
450709 collapsed: collapsedDiffFileKeys.has(fileKey),
451710 };
452711 }),
453− [collapsedDiffFileKeys, renderableFiles],
712+ [collapsedDiffFileKeys, renderableFileEntries],
454713 );
455714 const diffFileKeys = useMemo(() => codeViewFiles.map((file) => file.fileKey), [codeViewFiles]);
456715 const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys);
457716 const diffLineStat = useMemo(() => getDiffLineStat(renderableFiles), [renderableFiles]);
717+ const selectedDiffFileKey = selectedFilePath
718+ ? (codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath)?.fileKey ?? null)
719+ : null;
458720
459721 useEffect(() => {
460− if (!selectedFilePath) return;
461− const file = codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath);
462− if (!file) return;
463− codeViewRef.current?.scrollTo({ type: "item", id: file.fileKey, align: "start" });
464− }, [codeViewFiles, selectedFilePath, selectedFileRevealRequestId]);
722+ if (!selectedDiffFileKey) return;
723+ codeViewRef.current?.scrollTo({ type: "item", id: selectedDiffFileKey, align: "start" });
724+ }, [codeViewMountKey, selectedDiffFileKey, selectedFileRevealRequestId]);
465725
466726 const openDiffFile = useCallback(
467727 (filePath: string) => {
@@ -506,6 +766,7 @@ export default function DiffPanel({ · 38 unmodified lines
506766 );
507767
508768 const toggleDiffFileCollapse = useCallback(() => {
769+ setCodeViewRevision((current) => current + 1);
509770 setCollapsedDiffFiles((current) => {
510771 const currentKeys =
511772 current.scopeKey === collapseScopeKey ? current.fileKeys : EMPTY_COLLAPSED_DIFF_FILE_KEYS;
@@ -724,23 +985,45 @@ export default function DiffPanel({ · 212 unmodified lines
724985 layout="inline"
725986 />
726987 )}
988+ {canRefreshGitDiff && (
989+ <Tooltip>
990+ <TooltipTrigger
991+ render={
992+ <Button
993+ type="button"
994+ size="icon-sm"
995+ variant="ghost"
996+ aria-label={branchDiffPreview.isPending ? "Refreshing diff" : "Refresh diff"}
997+ onClick={refreshBranchDiffPreview}
998+ />
999+ }
1000+ >
1001+ <RefreshCwIcon
1002+ className={cn("size-3.5", branchDiffPreview.isPending && "animate-spin")}
1003+ />
1004+ </TooltipTrigger>
1005+ <TooltipPopup side="top">
1006+ {branchDiffPreview.isPending ? "Refreshing diff…" : "Refresh diff"}
1007+ </TooltipPopup>
1008+ </Tooltip>
1009+ )}
7271010 {codeViewFiles.length > 0 && (
7281011 <Tooltip>
7291012 <TooltipTrigger
7301013 render={
7311014 <Button
7321015 type="button"
733− size="icon-xs"
734− variant="outline"
1016+ size="icon-sm"
1017+ variant="ghost"
7351018 aria-label={allDiffFilesCollapsed ? "Expand all files" : "Collapse all files"}
7361019 onClick={toggleDiffFileCollapse}
7371020 />
7381021 }
7391022 >
7401023 {allDiffFilesCollapsed ? (
741− <ChevronsUpDownIcon className="size-3" />
1024+ <ChevronsUpDownIcon className="size-3.5" />
7421025 ) : (
743− <ChevronsDownUpIcon className="size-3" />
1026+ <ChevronsDownUpIcon className="size-3.5" />
7441027 )}
7451028 </TooltipTrigger>
7461029 <TooltipPopup side="top">
@@ -749,9 +1032,8 @@ export default function DiffPanel({ · 2 unmodified lines
7491032 </Tooltip>
7501033 )}
7511034 <ToggleGroup
752− className="shrink-0"
753− variant="outline"
754− size="xs"
1035+ className="shrink-0 gap-1"
1036+ size="sm"
7551037 value={[diffRenderMode]}
7561038 onValueChange={(value) => {
7571039 const next = value[0];
@@ -760,11 +1042,11 @@ export default function DiffPanel({ · 2 unmodified lines
7601042 }
7611043 }}
7621044 >
763− <Toggle aria-label="Stacked diff view" value="stacked">
764− <Rows3Icon className="size-3" />
1045+ <Toggle aria-label="Stacked diff view" value="stacked" variant="ghost">
1046+ <Rows3Icon className="size-3.5" />
7651047 </Toggle>
766− <Toggle aria-label="Split diff view" value="split">
767− <Columns2Icon className="size-3" />
1048+ <Toggle aria-label="Split diff view" value="split" variant="ghost">
1049+ <Columns2Icon className="size-3.5" />
7681050 </Toggle>
7691051 </ToggleGroup>
7701052 <Tooltip>
@@ -772,8 +1054,8 @@ export default function DiffPanel({ · 1 unmodified line
7721054 render={
7731055 <Toggle
7741056 aria-label={wordWrap ? "Disable diff line wrapping" : "Enable diff line wrapping"}
775− variant="outline"
776− size="xs"
1057+ variant="ghost"
1058+ size="sm"
7771059 pressed={wordWrap}
7781060 onPressedChange={(pressed) => {
7791061 setWordWrap(Boolean(pressed));
@@ -781,7 +1063,7 @@ export default function DiffPanel({ · 1 unmodified line
7811063 />
7821064 }
7831065 >
784− <TextWrapIcon className="size-3" />
1066+ <TextWrapIcon className="size-3.5" />
7851067 </TooltipTrigger>
7861068 <TooltipPopup side="top">
7871069 {wordWrap ? "Disable line wrapping" : "Enable line wrapping"}
@@ -794,8 +1076,8 @@ export default function DiffPanel({ · 6 unmodified lines
7941076 aria-label={
7951077 diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"
7961078 }
797− variant="outline"
798− size="xs"
1079+ variant="ghost"
1080+ size="sm"
7991081 pressed={diffIgnoreWhitespace}
8001082 onPressedChange={(pressed) => {
8011083 setDiffIgnoreWhitespace(Boolean(pressed));
@@ -803,7 +1085,7 @@ export default function DiffPanel({ · 1 unmodified line
8031085 />
8041086 }
8051087 >
806− <PilcrowIcon className="size-3" />
1088+ <PilcrowIcon className="size-3.5" />
8071089 </TooltipTrigger>
8081090 <TooltipPopup side="top">
8091091 {diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"}
@@ -875,8 +1157,9 @@ export default function DiffPanel({ · 65 unmodified lines
8751157 }}
8761158 >
8771159 <AnnotatableCodeView
878− viewerRef={codeViewRef}
8791160 key={collapseScopeKey ?? reviewSectionId}
1161+ viewerRef={codeViewRef}
1162+ codeViewKey={codeViewMountKey}
8801163 className="diff-render-surface h-full min-h-0 overflow-auto"
8811164 files={codeViewFiles}
8821165 sectionId={reviewSectionId}
@@ -891,7 +1174,7 @@ export default function DiffPanel({ · 8 unmodified lines
8911174 <button
8921175 type="button"
8931176 className={cn(
894− "inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 transition-colors hover:bg-foreground/10 focus-visible:outline-hidden",
1177+ "-ms-0.5 inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 transition-colors hover:bg-foreground/10 focus-visible:outline-hidden",
8951178 getDiffCollapseIconClassName(fileDiff),
8961179 )}
8971180 aria-label={collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`}
@@ -923,8 +1206,14 @@ export default function DiffPanel({ · 25 unmodified lines
9231206 themeType: resolvedTheme as DiffThemeType,
9241207 unsafeCSS: DIFF_PANEL_UNSAFE_CSS,
9251208 stickyHeaders: true,
926− itemMetrics: { diffHeaderHeight: 33 },
927− layout: { paddingTop: 0, paddingBottom: 8, gap: 8 },
1209+ ...(loadDiffFiles ? { loadDiffFiles } : {}),
1210+ itemMetrics: {
1211+ diffHeaderHeight: 32,
1212+ hunkSeparatorHeight: 24,
1213+ paddingTop: 0,
1214+ paddingBottom: 0,
1215+ },
1216+ layout: { paddingTop: 0, paddingBottom: 0, gap: 0 },
9281217 }}
9291218 />
9301219 </div>
apps/web/src/components/DiffPanelShell.tsx+46−22
@@ -10,7 +10,8 @@ export type DiffPanelMode = "inline" | "sheet" | "sidebar" | "embedded"; · 9 unmodified lines
1010 function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) {
1111 const shouldUseDragRegion = isElectron && mode !== "sheet" && mode !== "embedded";
1212 return cn(
13− "flex items-center justify-between gap-2 px-4",
13+ "flex items-center justify-between gap-2",
14+ mode === "embedded" ? "px-2" : "px-4",
1415 shouldUseDragRegion
1516 ? "drag-region h-[52px] border-b border-border wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]"
1617 : "surface-subheader",
@@ -59,30 +60,53 @@ export function DiffPanelHeaderSkeleton() { · 42 unmodified lines
5960 );
6061 }
6162
63+function DiffFileHeaderSkeleton({ titleClassName }: { titleClassName: string }) {
64+ return (
65+ <div className="flex h-8 items-center gap-2 px-2 pr-3">
66+ <div className="flex size-5 shrink-0 items-center justify-center">
67+ <Skeleton className="size-2.5 rounded-[2px]" />
68+ </div>
69+ <Skeleton className="size-5 shrink-0 rounded-md" />
70+ <Skeleton className={cn("h-3 rounded-full", titleClassName)} />
71+ <div className="ml-auto flex shrink-0 items-center gap-2">
72+ <Skeleton className="h-3 w-5 rounded-full" />
73+ <Skeleton className="h-3 w-5 rounded-full" />
74+ </div>
75+ </div>
76+ );
77+}
78+
79+function DiffCodeLineSkeleton({ contentClassName }: { contentClassName: string }) {
80+ return (
81+ <div className="flex items-center gap-3">
82+ <Skeleton className="h-2.5 w-5 shrink-0 rounded-full" />
83+ <Skeleton className={cn("h-2.5 rounded-full", contentClassName)} />
84+ </div>
85+ );
86+}
87+
6288 export function DiffPanelLoadingState(props: { label: string }) {
6389 return (
64− <div className="flex min-h-0 flex-1 flex-col p-2">
65− <div
66− className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border/60 bg-card/25"
67− role="status"
68− aria-live="polite"
69− aria-label={props.label}
70− >
71− <div className="flex items-center gap-2 border-b border-border/50 px-3 py-2">
72− <Skeleton className="h-4 w-32 rounded-full" />
73− <Skeleton className="ml-auto h-4 w-20 rounded-full" />
74− </div>
75− <div className="flex min-h-0 flex-1 flex-col gap-4 px-3 py-4">
76− <div className="space-y-2">
77− <Skeleton className="h-3 w-full rounded-full" />
78− <Skeleton className="h-3 w-full rounded-full" />
79− <Skeleton className="h-3 w-10/12 rounded-full" />
80− <Skeleton className="h-3 w-11/12 rounded-full" />
81− <Skeleton className="h-3 w-9/12 rounded-full" />
82− </div>
83− <span className="sr-only">{props.label}</span>
84− </div>
90+ <div
91+ className="min-h-0 flex-1 overflow-hidden bg-background"
92+ role="status"
93+ aria-live="polite"
94+ aria-label={props.label}
95+ >
96+ <DiffFileHeaderSkeleton titleClassName="w-1/2 max-w-64" />
97+ <div className="flex h-6 items-center gap-2 px-2 pr-3">
98+ <div className="h-px flex-1 bg-border/40" />
99+ <Skeleton className="h-2.5 w-24 rounded-full" />
100+ <div className="h-px flex-1 bg-border/40" />
101+ </div>
102+ <div className="space-y-2 px-3 py-2">
103+ <DiffCodeLineSkeleton contentClassName="w-2/3" />
104+ <DiffCodeLineSkeleton contentClassName="w-4/5" />
105+ <DiffCodeLineSkeleton contentClassName="w-3/5" />
85106 </div>
107+ <DiffFileHeaderSkeleton titleClassName="w-2/5 max-w-52" />
108+ <DiffFileHeaderSkeleton titleClassName="w-3/5 max-w-72" />
109+ <span className="sr-only">{props.label}</span>
86110 </div>
87111 );
88112 }
apps/web/src/components/RightPanelTabs.tsx+34−39
@@ -221,14 +221,14 @@ function surfaceTitle( · 220 unmodified lines
221221 function PreviewFavicon({ url }: { url: string | null }) {
222222 const faviconUrl = faviconUrlForOrigin(url, 32);
223223 const [failedUrl, setFailedUrl] = useState<string | null>(null);
224− if (!faviconUrl || failedUrl === faviconUrl) return <Globe2 className="size-3.5 shrink-0" />;
224+ if (!faviconUrl || failedUrl === faviconUrl) return <Globe2 className="size-3 shrink-0" />;
225225 return (
226226 <img
227227 src={faviconUrl}
228228 alt=""
229229 aria-hidden
230230 draggable={false}
231− className="size-3.5 shrink-0 rounded-sm"
231+ className="size-3 shrink-0 rounded-sm"
232232 onError={() => setFailedUrl(faviconUrl)}
233233 />
234234 );
@@ -250,22 +250,22 @@ function SurfaceIcon({ · 15 unmodified lines
250250 return <PreviewFavicon url={url} />;
251251 }
252252 case "diff":
253− return <FileDiff className="size-3.5 shrink-0" />;
253+ return <FileDiff className="size-3 shrink-0" />;
254254 case "files":
255− return <Files className="size-3.5 shrink-0" />;
255+ return <Files className="size-3 shrink-0" />;
256256 case "file":
257257 return (
258258 <PierreEntryIcon
259259 pathValue={surface.relativePath}
260260 kind="file"
261261 theme={theme}
262− className="size-3.5"
262+ className="size-3"
263263 />
264264 );
265265 case "terminal":
266− return <TerminalSquare className="size-3.5 shrink-0" />;
266+ return <TerminalSquare className="size-3 shrink-0" />;
267267 case "plan":
268− return <ClipboardList className="size-3.5 shrink-0" />;
268+ return <ClipboardList className="size-3 shrink-0" />;
269269 }
270270 }
271271
@@ -358,7 +358,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { · 86 unmodified lines
358358 <div
359359 className={cn(
360360 "workspace-topbar gap-1 pl-2",
361− !ownsDesktopTitleBar && "[--workspace-topbar-height:--spacing(11)]",
361+ props.mode !== "inline" && "[--workspace-topbar-height:--spacing(11)]",
362362 props.mode === "inline" ? "pr-28" : "pr-3",
363363 ownsDesktopTitleBar && "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]",
364364 props.mode === "inline" && props.maximized && COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
@@ -385,62 +385,57 @@ export function RightPanelTabs(props: RightPanelTabsProps) { · 20 unmodified lines
385385 onAuxClick={(event) => handleTabAuxClick(event, surface)}
386386 onContextMenu={(event) => void handleTabContextMenu(event, surface)}
387387 className={cn(
388− "group flex h-7 min-w-25 max-w-44 shrink-0 items-center gap-1.5 rounded-md px-2 text-sm",
388+ "group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs",
389389 active
390390 ? "bg-accent text-foreground"
391391 : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
392392 )}
393393 >
394+ <button
395+ type="button"
396+ className="group/close relative flex size-4 shrink-0 items-center justify-center rounded-sm hover:bg-muted"
397+ aria-label={`Close ${title}`}
398+ onClick={() => props.onCloseSurface(surface)}
399+ >
400+ <span className="relative flex size-3 items-center justify-center group-hover/tab:hidden group-focus-visible/close:hidden">
401+ <SurfaceIcon
402+ surface={surface}
403+ sessions={props.previewSessions}
404+ theme={resolvedTheme}
405+ />
406+ {pending ? (
407+ <span
408+ className="absolute -right-0.5 -bottom-0.5 size-1.5 rounded-full bg-current"
409+ aria-hidden
410+ />
411+ ) : null}
412+ </span>
413+ <X className="hidden size-3 group-hover/tab:block group-focus-visible/close:block" />
414+ </button>
394415 <Tooltip>
395416 <TooltipTrigger
396417 render={
397418 <button
398419 type="button"
399− className="flex min-w-0 flex-1 items-center gap-1.5"
420+ className="flex min-w-0 items-center"
400421 onClick={() => props.onActivate(surface)}
401422 >
402− <SurfaceIcon
403− surface={surface}
404− sessions={props.previewSessions}
405− theme={resolvedTheme}
406− />
407423 <span className="truncate">{title}</span>
408424 </button>
409425 }
410426 />
411427 <TooltipPopup>{title}</TooltipPopup>
412428 </Tooltip>
413− <button
414− type="button"
415− className={cn(
416− "relative flex size-4 shrink-0 items-center justify-center rounded hover:bg-muted focus:opacity-100",
417− pending ? "opacity-100" : "opacity-0 group-hover:opacity-100",
418− )}
419− aria-label={`Close ${title}`}
420− onClick={() => props.onCloseSurface(surface)}
421− >
422− {pending ? (
423− <>
424− <span
425− className="size-2 rounded-full bg-current group-hover:hidden"
426− aria-hidden
427− />
428− <X className="hidden size-3 group-hover:block" />
429− </>
430− ) : (
431− <X className="size-3" />
432− )}
433− </button>
434429 </div>
435430 );
436431 })}
437432 {props.surfaces.length > 0 ? (
438433 <Menu>
439434 <MenuTrigger
440− className="relative inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
435+ className="relative inline-flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
441436 aria-label="Add panel surface"
442437 >
443− <Plus className="size-4" />
438+ <Plus className="size-3.5" />
444439 </MenuTrigger>
445440 <MenuPopup align="start" side="bottom" sideOffset={6} className="min-w-44">
446441 <SurfaceMenuItem
@@ -478,7 +473,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { · 31 unmodified lines
478473 </ScrollArea>
479474 {props.layoutControls}
480475 </div>
481− <div className="flex min-h-0 flex-1 flex-col">
476+ <div className="flex min-h-0 flex-1 flex-col" data-right-panel-surface-content>
482477 {props.activeSurfaceId === null ? (
483478 <RightPanelEmptyState
484479 onAddBrowser={props.onAddBrowser}
apps/web/src/components/diffs/AnnotatableCodeView.test.tsx+59−0
@@ -0,0 +1,59 @@
1+import { renderToStaticMarkup } from "react-dom/server";
2+import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3+
4+const testState = vi.hoisted(() => ({
5+ codeViewOptions: null as Record<string, unknown> | null,
6+}));
7+
8+vi.mock("@pierre/diffs/react", () => ({
9+ CodeView: (props: { options: Record<string, unknown> }) => {
10+ testState.codeViewOptions = props.options;
11+ return null;
12+ },
13+}));
14+
15+vi.mock("~/composerDraftStore", () => ({
16+ useComposerDraftStore: (selector: (store: Record<string, unknown>) => unknown) =>
17+ selector({
18+ addReviewComment: vi.fn(),
19+ removeReviewComment: vi.fn(),
20+ getComposerDraft: () => undefined,
21+ }),
22+}));
23+
24+vi.mock("../files/LocalCommentAnnotation", () => ({
25+ LocalCommentAnnotation: () => null,
26+}));
27+
28+vi.mock("../files/fileCommentAnnotations", () => ({
29+ nextFileCommentId: () => "comment-test",
30+}));
31+
32+import { AnnotatableCodeView } from "./AnnotatableCodeView";
33+
34+describe("AnnotatableCodeView", () => {
35+ beforeEach(() => {
36+ testState.codeViewOptions = null;
37+ });
38+
39+ it("opens comments from Pierre's gutter action without ending line selection", () => {
40+ renderToStaticMarkup(
41+ <AnnotatableCodeView
42+ codeViewKey="test-view"
43+ files={[]}
44+ sectionId="working-tree"
45+ sectionTitle="Working tree"
46+ composerDraftTarget={"draft-test" as never}
47+ options={{}}
48+ renderHeaderPrefix={() => null}
49+ />,
50+ );
51+
52+ expect(testState.codeViewOptions).toMatchObject({
53+ enableGutterUtility: true,
54+ enableLineSelection: true,
55+ onGutterUtilityClick: expect.any(Function),
56+ });
57+ expect(testState.codeViewOptions).not.toHaveProperty("onLineSelectionEnd");
58+ });
59+});
apps/web/src/components/diffs/AnnotatableCodeView.tsx+29−16
@@ -71,6 +71,7 @@ function appendAnnotationEntry( · 70 unmodified lines
7171 }
7272
7373 interface AnnotatableCodeViewProps {
74+ codeViewKey: string;
7475 files: ReadonlyArray<{
7576 fileDiff: FileDiffMetadata;
7677 filePath: string;
@@ -95,6 +96,7 @@ interface DiffSelectionContext { · 18 unmodified lines
9596 }
9697
9798 export function AnnotatableCodeView({
99+ codeViewKey,
98100 files,
99101 sectionId,
100102 sectionTitle,
@@ -117,6 +119,7 @@ export function AnnotatableCodeView({ · 16 unmodified lines
117119 fileKey: string;
118120 annotation: DiffCommentLineAnnotation;
119121 } | null>(null);
122+ const [draftText, setDraftText] = useState("");
120123
121124 const filesByKey = useMemo(() => new Map(files.map((file) => [file.fileKey, file])), [files]);
122125 const items = useMemo<CodeViewDiffItem<DiffCommentAnnotationGroup>[]>(
@@ -167,6 +170,7 @@ export function AnnotatableCodeView({ · 44 unmodified lines
167170 setSelectedLines(null);
168171 if (draft?.annotation.metadata.entries.some((entry) => entry.id === entryId)) {
169172 setDraft(null);
173+ setDraftText("");
170174 } else {
171175 removeReviewComment(composerDraftTarget, entryId);
172176 }
@@ -193,6 +197,7 @@ export function AnnotatableCodeView({ · 20 unmodified lines
193197 if (comment) addReviewComment(composerDraftTarget, comment);
194198 setSelectedLines(null);
195199 setDraft(null);
200+ setDraftText("");
196201 },
197202 [addReviewComment, composerDraftTarget, draft, filesByKey, sectionId, sectionTitle],
198203 );
@@ -215,6 +220,7 @@ export function AnnotatableCodeView({ · 16 unmodified lines
215220 text: "",
216221 });
217222 if (!comment) return;
223+ setDraftText("");
218224 setDraft({
219225 fileKey: item.id,
220226 annotation: {
@@ -232,6 +238,7 @@ export function AnnotatableCodeView({ · 11 unmodified lines
232238 const hasOpenComment = draft !== null;
233239 return (
234240 <CodeView<DiffCommentAnnotationGroup>
241+ key={codeViewKey}
235242 {...(viewerRef ? { ref: viewerRef } : {})}
236243 {...(className ? { className } : {})}
237244 items={items}
@@ -241,28 +248,34 @@ export function AnnotatableCodeView({ · 3 unmodified lines
241248 ...options,
242249 enableGutterUtility: !hasOpenComment,
243250 enableLineSelection: !hasOpenComment,
244− onLineSelectionEnd: beginComment,
251+ onGutterUtilityClick: beginComment,
245252 }}
246253 renderHeaderPrefix={(item) =>
247254 item.type === "diff"
248255 ? renderHeaderPrefix(item.fileDiff, item.id, item.collapsed === true)
249256 : null
250257 }
251− renderAnnotation={(annotation) => (
252− <div className="py-1">
253− {annotation.metadata.entries.map((entry) => (
254− <LocalCommentAnnotation
255− key={entry.id}
256− kind={entry.kind}
257− rangeLabel={entry.rangeLabel}
258− text={entry.text}
259− onCancel={() => removeEntry(entry.id)}
260− onComment={(text) => submitEntry(entry.id, text)}
261− onDelete={() => removeEntry(entry.id)}
262− />
263− ))}
264− </div>
265− )}
258+ renderAnnotation={(annotation) => {
259+ const hasDraft = annotation.metadata.entries.some((entry) => entry.kind === "draft");
260+ return (
261+ <div
262+ className={hasDraft ? "py-1" : "divide-y divide-border/30 border-y border-border/30"}
263+ >
264+ {annotation.metadata.entries.map((entry) => (
265+ <LocalCommentAnnotation
266+ key={entry.id}
267+ kind={entry.kind}
268+ rangeLabel={entry.rangeLabel}
269+ text={entry.kind === "draft" ? draftText : entry.text}
270+ onTextChange={setDraftText}
271+ onCancel={() => removeEntry(entry.id)}
272+ onComment={(text) => submitEntry(entry.id, text)}
273+ onDelete={() => removeEntry(entry.id)}
274+ />
275+ ))}
276+ </div>
277+ );
278+ }}
266279 />
267280 );
268281 }
apps/web/src/components/files/LocalCommentAnnotation.test.tsx+66−0
@@ -0,0 +1,66 @@
1+import { renderToStaticMarkup } from "react-dom/server";
2+import { describe, expect, it, vi } from "vite-plus/test";
3+
4+import { LocalCommentAnnotation } from "./LocalCommentAnnotation";
5+
6+const callbacks = {
7+ onTextChange: vi.fn(),
8+ onCancel: vi.fn(),
9+ onComment: vi.fn(),
10+ onDelete: vi.fn(),
11+};
12+
13+describe("LocalCommentAnnotation", () => {
14+ it("renders the draft composer directly in the selected diff", () => {
15+ const markup = renderToStaticMarkup(
16+ <LocalCommentAnnotation kind="draft" rangeLabel="+78" text="" {...callbacks} />,
17+ );
18+
19+ expect(markup).toContain("font-sans");
20+ expect(markup).not.toContain("chat-composer-glass");
21+ expect(markup).not.toContain("font-mono");
22+ expect(markup).not.toContain("Local comment");
23+ expect(markup).not.toContain("on +78");
24+ expect(markup).toContain("⌘/Ctrl Enter to send");
25+ expect(markup).toContain("Add a comment…");
26+ expect(markup).toContain(">Comment</button>");
27+ expect(markup).toContain("autofocus");
28+ const textareaControl = markup.match(/<span[^>]*data-slot="textarea-control"[^>]*>/)?.[0];
29+ expect(textareaControl).toBeDefined();
30+ expect(textareaControl).not.toContain("ring-ring");
31+ expect(markup).toContain("cursor-text");
32+ });
33+
34+ it("renders a saved comment without a nested card or redundant range label", () => {
35+ const markup = renderToStaticMarkup(
36+ <LocalCommentAnnotation
37+ kind="comment"
38+ rangeLabel="+78"
39+ text="Please keep this branch explicit."
40+ {...callbacks}
41+ />,
42+ );
43+
44+ expect(markup).toContain("font-sans");
45+ expect(markup).not.toContain("chat-composer-glass");
46+ expect(markup).not.toContain("on +78");
47+ expect(markup).toContain("Please keep this branch explicit.");
48+ expect(markup).toContain('aria-label="Delete comment"');
49+ expect(markup).toContain("border-s-2");
50+ expect(markup).toContain("bg-primary/[0.045]");
51+ expect(markup).toContain("lucide-message-circle");
52+ });
53+
54+ it("renders draft text owned by the annotation wrapper", () => {
55+ const markup = renderToStaticMarkup(
56+ <LocalCommentAnnotation
57+ kind="draft"
58+ rangeLabel="+78"
59+ text="Keep this unsaved draft"
60+ {...callbacks}
61+ />,
62+ );
63+
64+ expect(markup).toContain("Keep this unsaved draft");
65+ });
66+});
apps/web/src/components/files/LocalCommentAnnotation.tsx+42−29
@@ -8,6 +8,7 @@ interface LocalCommentAnnotationProps { · 7 unmodified lines
88 kind: "draft" | "comment";
99 rangeLabel: string;
1010 text: string;
11+ onTextChange?: (text: string) => void;
1112 onCancel: () => void;
1213 onComment: (text: string) => void;
1314 onDelete: () => void;
@@ -16,32 +17,34 @@ interface LocalCommentAnnotationProps { · 2 unmodified lines
1617 export function LocalCommentAnnotation({
1718 kind,
1819 rangeLabel,
19− text: savedText,
20+ text,
21+ onTextChange,
2022 onCancel,
2123 onComment,
2224 onDelete,
2325 }: LocalCommentAnnotationProps) {
24− const [text, setText] = useState("");
26+ const [localDraftText, setLocalDraftText] = useState("");
27+ const displayedText = kind === "draft" && !onTextChange ? localDraftText : text;
2528
2629 if (kind === "comment") {
2730 return (
2831 <div
2932 data-file-comment-annotation
30− className="mx-3 my-2 rounded-xl border border-border/70 bg-background p-3 shadow-sm"
33+ className="group/comment flex min-w-0 items-start gap-2.5 border-s-2 border-primary/55 bg-primary/[0.045] px-3 py-2.5 font-sans text-foreground"
3134 contentEditable={false}
3235 onPointerDown={(event) => event.stopPropagation()}
3336 >
34− <div className="flex items-center gap-2">
35− <MessageCircle className="size-4 text-muted-foreground" />
36− <span className="text-xs font-medium">Local comment</span>
37− <span className="ml-auto text-[11px] text-muted-foreground">{rangeLabel}</span>
38− <Button variant="ghost" size="icon-xs" aria-label="Delete comment" onClick={onDelete}>
39− <Trash2 className="size-3.5" />
40− </Button>
41− </div>
42− <p className="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-foreground">
43− {savedText}
44− </p>
37+ <MessageCircle className="mt-0.5 size-3.5 shrink-0 text-primary/70" aria-hidden="true" />
38+ <p className="min-w-0 flex-1 whitespace-pre-wrap text-[13px] leading-5">{displayedText}</p>
39+ <Button
40+ className="-my-1 -mr-1 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/comment:opacity-100 focus-visible:opacity-100 max-sm:opacity-100"
41+ variant="ghost"
42+ size="icon-xs"
43+ aria-label="Delete comment"
44+ onClick={onDelete}
45+ >
46+ <Trash2 className="size-3" />
47+ </Button>
4548 </div>
4649 );
4750 }
@@ -49,39 +52,49 @@ export function LocalCommentAnnotation({ · 1 unmodified line
4952 return (
5053 <div
5154 data-file-comment-annotation
52− className="mx-3 my-2 rounded-xl border border-border/70 bg-background p-3 shadow-lg"
55+ className="px-3 py-2 font-sans text-foreground"
5356 contentEditable={false}
5457 onPointerDown={(event) => event.stopPropagation()}
5558 >
56− <div className="flex items-center gap-2">
57− <MessageCircle className="size-4 text-muted-foreground" />
58− <span className="text-sm font-medium">Local comment</span>
59− </div>
60− <div className="mt-1 text-xs text-muted-foreground">Comment on lines {rangeLabel}</div>
6159 <Textarea
6260 autoFocus
63− className="mt-3"
61+ unstyled
62+ className="relative inline-flex w-full rounded-md border border-border/50 bg-background/20 font-sans text-foreground transition-colors focus-within:border-border/70 [&_[data-slot=textarea]]:min-h-12 [&_[data-slot=textarea]]:cursor-text [&_[data-slot=textarea]]:px-2.5 [&_[data-slot=textarea]]:py-1.5 [&_[data-slot=textarea]]:font-sans [&_[data-slot=textarea]]:text-xs [&_[data-slot=textarea]]:leading-5 max-sm:[&_[data-slot=textarea]]:min-h-12"
6463 size="sm"
65− value={text}
66− placeholder="Request change"
64+ value={displayedText}
65+ placeholder="Add a comment…"
6766 aria-label={`Comment on lines ${rangeLabel}`}
68− onChange={(event) => setText(event.target.value)}
67+ onChange={(event) => (onTextChange ?? setLocalDraftText)(event.target.value)}
68+ onFocus={(event) => {
69+ const end = event.currentTarget.value.length;
70+ event.currentTarget.setSelectionRange(end, end);
71+ }}
6972 onKeyDown={(event) => {
7073 if (event.key === "Escape") {
7174 event.preventDefault();
7275 onCancel();
7376 }
74− if ((event.metaKey || event.ctrlKey) && event.key === "Enter" && text.trim()) {
77+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter" && displayedText.trim()) {
7578 event.preventDefault();
76− onComment(text.trim());
79+ onComment(displayedText.trim());
7780 }
7881 }}
7982 />
80− <div className="mt-3 flex justify-end gap-2">
81− <Button variant="ghost" size="sm" onClick={onCancel}>
83+ <div className="mt-1.5 flex items-center gap-1">
84+ <span className="mr-auto text-[10px] text-muted-foreground/70">⌘/Ctrl Enter to send</span>
85+ <Button
86+ className="text-muted-foreground hover:text-foreground"
87+ variant="ghost"
88+ size="xs"
89+ onClick={onCancel}
90+ >
8291 Cancel
8392 </Button>
84− <Button size="sm" disabled={!text.trim()} onClick={() => onComment(text.trim())}>
93+ <Button
94+ size="xs"
95+ disabled={!displayedText.trim()}
96+ onClick={() => onComment(displayedText.trim())}
97+ >
8598 Comment
8699 </Button>
87100 </div>
apps/web/src/index.css+7−0
@@ -406,6 +406,13 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil · 405 unmodified lines
406406 @apply flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background;
407407 }
408408
409+ [data-preview-panel-mode="inline"] [data-right-panel-surface-content] [data-surface-subheader] {
410+ height: calc(var(--spacing) * 7);
411+ min-height: calc(var(--spacing) * 7);
412+ margin-bottom: calc(var(--spacing) * 3);
413+ border-bottom-color: transparent;
414+ }
415+
409416 .chat-composer-horizontal-inset {
410417 padding-inline-start: calc(env(safe-area-inset-left) + 0.75rem);
411418 padding-inline-end: calc(env(safe-area-inset-right) + 0.75rem);
apps/web/src/lib/diffRendering.test.ts+30−1
@@ -1,5 +1,10 @@
11 import { describe, expect, it } from "vite-plus/test";
2−import { buildPatchCacheKey, getDiffLineStat, getRenderablePatch } from "./diffRendering";
2+import {
3+ buildFileDiffRenderKey,
4+ buildPatchCacheKey,
5+ getDiffLineStat,
6+ getRenderablePatch,
7+} from "./diffRendering";
38
49 describe("buildPatchCacheKey", () => {
510 it("returns a stable cache key for identical content", () => {
@@ -83,6 +88,30 @@ describe("getRenderablePatch", () => { · 77 unmodified lines
8388 });
8489 });
8590
91+describe("buildFileDiffRenderKey", () => {
92+ it("keeps file identity stable when Pierre hydrates a partial diff", () => {
93+ const patch = [
94+ "diff --git a/example.ts b/example.ts",
95+ "--- a/example.ts",
96+ "+++ b/example.ts",
97+ "@@ -1 +1 @@",
98+ "-before",
99+ "+after",
100+ ].join("\n");
101+ const parsed = getRenderablePatch(patch, "hydrated-key");
102+ expect(parsed?.kind).toBe("files");
103+ if (parsed?.kind !== "files") return;
104+
105+ const file = parsed.files[0];
106+ expect(file).toBeDefined();
107+ if (!file) return;
108+ const key = buildFileDiffRenderKey(file);
109+ file.cacheKey = `${file.cacheKey}:hydrated`;
110+
111+ expect(buildFileDiffRenderKey(file)).toBe(key);
112+ });
113+});
114+
86115 describe("getDiffLineStat", () => {
87116 it("totals additions and deletions across every file and hunk", () => {
88117 const patch = [
apps/web/src/lib/diffRendering.ts+4−1
@@ -152,7 +152,10 @@ export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { · 151 unmodified lines
152152 }
153153
154154 export function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {
155− return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;
155+ const cacheKey = fileDiff.cacheKey;
156+ if (!cacheKey) return `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;
157+
158+ return cacheKey.endsWith(":hydrated") ? cacheKey.slice(0, -":hydrated".length) : cacheKey;
156159 }
157160
158161 export function getDiffCollapseIconClassName(fileDiff: FileDiffMetadata): string {
packages/client-runtime/src/state/review.ts+25−1
@@ -1,17 +1,41 @@
11 import { WS_METHODS } from "@t3tools/contracts";
22 import { Atom } from "effect/unstable/reactivity";
33
4−import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
4+import {
5+ createAtomCommandScheduler,
6+ createEnvironmentRpcCommand,
7+ createEnvironmentRpcQueryAtomFamily,
8+} from "./runtime.ts";
59 import type { EnvironmentRegistry } from "../connection/registry.ts";
610
711 export function createReviewEnvironmentAtoms<R, E>(
812 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
913 ) {
14+ const diffFileScheduler = createAtomCommandScheduler();
1015 return {
1116 diffPreview: createEnvironmentRpcQueryAtomFamily(runtime, {
1217 label: "environment-data:review:diff-preview",
1318 tag: WS_METHODS.reviewGetDiffPreview,
1419 staleTimeMs: 5_000,
1520 }),
21+ diffFileContents: createEnvironmentRpcCommand(runtime, {
22+ label: "environment-data:review:diff-file-contents",
23+ tag: WS_METHODS.reviewGetDiffFileContents,
24+ scheduler: diffFileScheduler,
25+ concurrency: {
26+ mode: "singleFlight",
27+ key: ({ environmentId, input }) =>
28+ JSON.stringify([
29+ environmentId,
30+ input.cwd,
31+ input.sourceKind,
32+ input.baseRef,
33+ input.headRef,
34+ input.oldPath,
35+ input.newPath,
36+ input.changeType,
37+ ]),
38+ },
39+ }),
1640 };
1741 }
packages/contracts/src/ipc.ts+9−1
@@ -18,7 +18,12 @@ import type { · 17 unmodified lines
1818 VcsStatusInput,
1919 VcsStatusResult,
2020 } from "./git.ts";
21−import type { ReviewDiffPreviewInput, ReviewDiffPreviewResult } from "./review.ts";
21+import type {
22+ ReviewDiffFileContentsInput,
23+ ReviewDiffFileContentsResult,
24+ ReviewDiffPreviewInput,
25+ ReviewDiffPreviewResult,
26+} from "./review.ts";
2227 import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts";
2328 import type { AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts";
2429 import type {
@@ -1203,6 +1208,9 @@ export interface EnvironmentApi { · 1178 unmodified lines
12031208 };
12041209 review: {
12051210 getDiffPreview: (input: ReviewDiffPreviewInput) => Promise<ReviewDiffPreviewResult>;
1211+ getDiffFileContents: (
1212+ input: ReviewDiffFileContentsInput,
1213+ ) => Promise<ReviewDiffFileContentsResult>;
12061214 };
12071215 orchestration: {
12081216 dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>;
packages/contracts/src/review.ts+17−0
@@ -25,6 +25,23 @@ export const ReviewDiffPreviewSource = Schema.Struct({ · 24 unmodified lines
2525 });
2626 export type ReviewDiffPreviewSource = typeof ReviewDiffPreviewSource.Type;
2727
28+export const ReviewDiffFileContentsInput = Schema.Struct({
29+ cwd: TrimmedNonEmptyString,
30+ sourceKind: ReviewDiffPreviewSourceKind,
31+ changeType: Schema.Literals(["change", "rename-pure", "rename-changed", "new", "deleted"]),
32+ baseRef: Schema.NullOr(TrimmedNonEmptyString),
33+ headRef: Schema.NullOr(TrimmedNonEmptyString),
34+ oldPath: TrimmedNonEmptyString,
35+ newPath: TrimmedNonEmptyString,
36+});
37+export type ReviewDiffFileContentsInput = typeof ReviewDiffFileContentsInput.Type;
38+
39+export const ReviewDiffFileContentsResult = Schema.Struct({
40+ oldContents: Schema.String,
41+ newContents: Schema.String,
42+});
43+export type ReviewDiffFileContentsResult = typeof ReviewDiffFileContentsResult.Type;
44+
2845 export const ReviewDiffPreviewResult = Schema.Struct({
2946 cwd: TrimmedNonEmptyString,
3047 generatedAt: Schema.DateTimeUtc,
packages/contracts/src/rpc.ts+10−0
@@ -45,6 +45,8 @@ import { · 44 unmodified lines
4545 VcsStatusStreamEvent,
4646 } from "./git.ts";
4747 import {
48+ ReviewDiffFileContentsInput,
49+ ReviewDiffFileContentsResult,
4850 ReviewDiffPreviewError,
4951 ReviewDiffPreviewInput,
5052 ReviewDiffPreviewResult,
@@ -197,6 +199,7 @@ export const WS_METHODS = { · 146 unmodified lines
197199
198200 // Review methods
199201 reviewGetDiffPreview: "review.getDiffPreview",
202+ reviewGetDiffFileContents: "review.getDiffFileContents",
200203
201204 // Terminal methods
202205 terminalOpen: "terminal.open",
@@ -566,6 +569,12 @@ export const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPrevie · 363 unmodified lines
566569 error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]),
567570 });
568571
572+export const WsReviewGetDiffFileContentsRpc = Rpc.make(WS_METHODS.reviewGetDiffFileContents, {
573+ payload: ReviewDiffFileContentsInput,
574+ success: ReviewDiffFileContentsResult,
575+ error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]),
576+});
577+
569578 export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, {
570579 payload: TerminalOpenInput,
571580 success: TerminalSessionSnapshot,
@@ -830,6 +839,7 @@ export const WsRpcGroup = RpcGroup.make( · 258 unmodified lines
830839 WsVcsSwitchRefRpc,
831840 WsVcsInitRpc,
832841 WsReviewGetDiffPreviewRpc,
842+ WsReviewGetDiffFileContentsRpc,
833843 WsTerminalOpenRpc,
834844 WsTerminalAttachRpc,
835845 WsTerminalWriteRpc,