authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-19 20:02:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-19 20:02:20-07:00
logd2c0a6a48f50f319815a8cf5501f9e2e9b8ca617
treea22178dfa669927b5ffbabc09cbd303e9504a8bf
parent1cf3647da62ee4c363f7c3d963c709dbddaa6297
signature Signed by PGP key B5690EEEBB952194

Add diff scope switching and provider update settings (#3169)


20 files changed, 1179 insertions(+), 624 deletions(-)

apps/server/src/vcs/GitVcsDriverCore.test.ts+73
...@@ -100,6 +100,41 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -100,6 +100,41 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
100 assert.deepStrictEqual(paths, ["complete.txt", "final.txt"]);100 assert.deepStrictEqual(paths, ["complete.txt", "final.txt"]);
101 }),101 }),
102 );102 );
103
104 it.effect("honors whitespace filtering for worktree and branch previews", () =>
105 Effect.gen(function* () {
106 const cwd = yield* makeTmpDir();
107 const { initialBranch } = yield* initRepoWithCommit(cwd);
108 const driver = yield* GitVcsDriver.GitVcsDriver;
109 yield* git(cwd, ["checkout", "-b", "feature/whitespace"]);
110 yield* writeTextFile(cwd, "README.md", "# test\n");
111 yield* git(cwd, ["add", "README.md"]);
112 yield* git(cwd, ["commit", "-m", "change whitespace"]);
113 yield* writeTextFile(cwd, "README.md", "# test\n");
114
115 const included = yield* driver.getReviewDiffPreview({
116 cwd,
117 baseRef: initialBranch,
118 ignoreWhitespace: false,
119 });
120 const ignored = yield* driver.getReviewDiffPreview({
121 cwd,
122 baseRef: initialBranch,
123 ignoreWhitespace: true,
124 });
125
126 assert.isNotEmpty(included.sources.find((source) => source.kind === "working-tree")?.diff);
127 assert.isNotEmpty(included.sources.find((source) => source.kind === "branch-range")?.diff);
128 assert.strictEqual(
129 ignored.sources.find((source) => source.kind === "working-tree")?.diff,
130 "",
131 );
132 assert.strictEqual(
133 ignored.sources.find((source) => source.kind === "branch-range")?.diff,
134 "",
135 );
136 }),
137 );
103 });138 });
104139
105 describe("repository status", () => {140 describe("repository status", () => {
...@@ -342,6 +377,44 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -342,6 +377,44 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
342 });377 });
343378
344 describe("refName operations", () => {379 describe("refName operations", () => {
380 it.effect("optionally includes remote refs that match local branches", () =>
381 Effect.gen(function* () {
382 const cwd = yield* makeTmpDir();
383 const remote = yield* makeTmpDir("git-vcs-driver-remote-");
384 const { initialBranch } = yield* initRepoWithCommit(cwd);
385 yield* git(remote, ["init", "--bare"]);
386 yield* git(cwd, ["remote", "add", "origin", remote]);
387 yield* git(cwd, ["push", "-u", "origin", initialBranch]);
388 const driver = yield* GitVcsDriver.GitVcsDriver;
389
390 const deduplicated = yield* driver.listRefs({ cwd });
391 assert.equal(
392 deduplicated.refs.some((ref) => ref.name === `origin/${initialBranch}`),
393 false,
394 );
395
396 const complete = yield* driver.listRefs({ cwd, includeMatchingRemoteRefs: true });
397 assert.equal(
398 complete.refs.some((ref) => ref.name === initialBranch),
399 true,
400 );
401 assert.equal(
402 complete.refs.some((ref) => ref.name === `origin/${initialBranch}`),
403 true,
404 );
405
406 const remoteOnly = yield* driver.listRefs({
407 cwd,
408 includeMatchingRemoteRefs: true,
409 refKind: "remote",
410 limit: 1,
411 });
412 assert.equal(remoteOnly.refs.length, 1);
413 assert.equal(remoteOnly.refs[0]?.name, `origin/${initialBranch}`);
414 assert.equal(remoteOnly.refs[0]?.isRemote, true);
415 }),
416 );
417
345 it.effect("creates, checks out, renames, and lists refs", () =>418 it.effect("creates, checks out, renames, and lists refs", () =>
346 Effect.gen(function* () {419 Effect.gen(function* () {
347 const cwd = yield* makeTmpDir();420 const cwd = yield* makeTmpDir();
apps/server/src/vcs/GitVcsDriverCore.ts+25-6
...@@ -1817,7 +1817,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1817,7 +1817,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1817 const dirtyTrackedResult = yield* executeGit(1817 const dirtyTrackedResult = yield* executeGit(
1818 "GitVcsDriver.getReviewDiffPreview.dirtyTracked",1818 "GitVcsDriver.getReviewDiffPreview.dirtyTracked",
1819 input.cwd,1819 input.cwd,
1820 ["diff", "--patch", "--minimal", "HEAD", "--"],1820 [
1821 "diff",
1822 "--patch",
1823 "--minimal",
1824 ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []),
1825 "HEAD",
1826 "--",
1827 ],
1821 {1828 {
1822 maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES,1829 maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES,
1823 appendTruncationMarker: true,1830 appendTruncationMarker: true,
...@@ -1843,7 +1850,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1843,7 +1850,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1843 ? yield* executeGit(1850 ? yield* executeGit(
1844 "GitVcsDriver.getReviewDiffPreview.base",1851 "GitVcsDriver.getReviewDiffPreview.base",
1845 input.cwd,1852 input.cwd,
1846 ["diff", "--patch", "--minimal", `${baseRef}...HEAD`],1853 [
1854 "diff",
1855 "--patch",
1856 "--minimal",
1857 ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []),
1858 `${baseRef}...HEAD`,
1859 ],
1847 {1860 {
1848 maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES,1861 maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES,
1849 appendTruncationMarker: true,1862 appendTruncationMarker: true,
...@@ -2127,11 +2140,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2127,11 +2140,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2127 })2140 })
2128 : [];2141 : [];
21292142
2143 const allBranches = input.includeMatchingRemoteRefs
2144 ? [...localBranches, ...remoteBranches]
2145 : dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]);
2146 const branchesForKind =
2147 input.refKind === "local"
2148 ? allBranches.filter((ref) => !ref.isRemote)
2149 : input.refKind === "remote"
2150 ? allBranches.filter((ref) => ref.isRemote)
2151 : allBranches;
2130 const refs = paginateBranches({2152 const refs = paginateBranches({
2131 refs: filterBranchesForListQuery(2153 refs: filterBranchesForListQuery(branchesForKind, input.query),
2132 dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]),
2133 input.query,
2134 ),
2135 cursor: input.cursor,2154 cursor: input.cursor,
2136 limit: input.limit,2155 limit: input.limit,
2137 });2156 });
apps/web/src/components/ChatView.tsx+13-120
...@@ -42,7 +42,7 @@ import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/ter...@@ -42,7 +42,7 @@ import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/ter
42import { Debouncer } from "@tanstack/react-pacer";42import { Debouncer } from "@tanstack/react-pacer";
43import { useAtomValue } from "@effect/atom-react";43import { useAtomValue } from "@effect/atom-react";
44import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";44import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
45import { useNavigate, useSearch } from "@tanstack/react-router";45import { useNavigate } from "@tanstack/react-router";
46import { useShallow } from "zustand/react/shallow";46import { useShallow } from "zustand/react/shallow";
47import {47import {
48 isAtomCommandInterrupted,48 isAtomCommandInterrupted,
...@@ -55,7 +55,7 @@ import * as Cause from "effect/Cause";...@@ -55,7 +55,7 @@ import * as Cause from "effect/Cause";
55import { AsyncResult } from "effect/unstable/reactivity";55import { AsyncResult } from "effect/unstable/reactivity";
56import { isElectron } from "../env";56import { isElectron } from "../env";
57import { readLocalApi } from "../localApi";57import { readLocalApi } from "../localApi";
58import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";58import { useDiffPanelStore } from "../diffPanelStore";
59import {59import {
60 collapseExpandedComposerCursor,60 collapseExpandedComposerCursor,
61 parseStandaloneComposerSlashCommand,61 parseStandaloneComposerSlashCommand,
...@@ -104,7 +104,7 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";...@@ -104,7 +104,7 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
104import { useMediaQuery } from "../hooks/useMediaQuery";104import { useMediaQuery } from "../hooks/useMediaQuery";
105import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";105import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
106import {106import {
107 selectActiveRightPanelKindWithUrl,107 selectActiveRightPanel,
108 selectActiveRightPanelSurface,108 selectActiveRightPanelSurface,
109 selectThreadRightPanelState,109 selectThreadRightPanelState,
110 type RightPanelSurface,110 type RightPanelSurface,
...@@ -1034,10 +1034,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1034,10 +1034,6 @@ function ChatViewContent(props: ChatViewProps) {
1034 const timestampFormat = settings.timestampFormat;1034 const timestampFormat = settings.timestampFormat;
1035 const autoOpenPlanSidebar = settings.autoOpenPlanSidebar;1035 const autoOpenPlanSidebar = settings.autoOpenPlanSidebar;
1036 const navigate = useNavigate();1036 const navigate = useNavigate();
1037 const rawSearch = useSearch({
1038 strict: false,
1039 select: (params) => parseDiffRouteSearch(params),
1040 });
1041 const { resolvedTheme } = useTheme();1037 const { resolvedTheme } = useTheme();
1042 // Granular store selectors — avoid subscribing to prompt changes.1038 // Granular store selectors — avoid subscribing to prompt changes.
1043 const composerRuntimeMode = useComposerDraftStore(1039 const composerRuntimeMode = useComposerDraftStore(
...@@ -1217,7 +1213,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1217,7 +1213,6 @@ function ChatViewContent(props: ChatViewProps) {
1217 composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;1213 composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
1218 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;1214 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
1219 const canCheckoutPullRequestIntoThread = isLocalDraftThread;1215 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
1220 const diffOpen = rawSearch.diff === "1";
1221 const activeThreadId = activeThread?.id ?? null;1216 const activeThreadId = activeThread?.id ?? null;
1222 const runningTerminalIds = useThreadRunningTerminalIds({1217 const runningTerminalIds = useThreadRunningTerminalIds({
1223 environmentId: activeThread?.environmentId ?? null,1218 environmentId: activeThread?.environmentId ?? null,
...@@ -1259,8 +1254,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1259,8 +1254,9 @@ function ChatViewContent(props: ChatViewProps) {
1259 );1254 );
1260 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;1255 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
1261 const activeRightPanelKind = useRightPanelStore((state) =>1256 const activeRightPanelKind = useRightPanelStore((state) =>
1262 selectActiveRightPanelKindWithUrl(state.byThreadKey, activeThreadRef, diffOpen),1257 selectActiveRightPanel(state.byThreadKey, activeThreadRef),
1263 );1258 );
1259 const diffOpen = activeRightPanelKind === "diff";
1264 const rightPanelState = useRightPanelStore((state) =>1260 const rightPanelState = useRightPanelStore((state) =>
1265 selectThreadRightPanelState(state.byThreadKey, activeThreadRef),1261 selectThreadRightPanelState(state.byThreadKey, activeThreadRef),
1266 );1262 );
...@@ -1295,11 +1291,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1295,11 +1291,6 @@ function ChatViewContent(props: ChatViewProps) {
12951291
1296 const planSidebarOpen = activeRightPanelKind === "plan";1292 const planSidebarOpen = activeRightPanelKind === "plan";
12971293
1298 useEffect(() => {
1299 if (!activeThreadRef || !diffOpen) return;
1300 useRightPanelStore.getState().open(activeThreadRef, "diff");
1301 }, [activeThreadRef, diffOpen]);
1302
1303 const existingOpenTerminalThreadKeys = useMemo(() => {1294 const existingOpenTerminalThreadKeys = useMemo(() => {
1304 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);1295 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);
1305 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));1296 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
...@@ -2151,27 +2142,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2151,27 +2142,7 @@ function ChatViewContent(props: ChatViewProps) {
2151 if (activeThreadRef) {2142 if (activeThreadRef) {
2152 useRightPanelStore.getState().toggle(activeThreadRef, "diff");2143 useRightPanelStore.getState().toggle(activeThreadRef, "diff");
2153 }2144 }
2154 void navigate({2145 }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]);
2155 to: "/$environmentId/$threadId",
2156 params: {
2157 environmentId,
2158 threadId,
2159 },
2160 replace: true,
2161 search: (previous) => {
2162 const rest = stripDiffSearchParams(previous);
2163 return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" };
2164 },
2165 });
2166 }, [
2167 activeThreadRef,
2168 diffOpen,
2169 environmentId,
2170 isServerThread,
2171 navigate,
2172 onDiffPanelOpen,
2173 threadId,
2174 ]);
21752146
2176 const envLocked = Boolean(2147 const envLocked = Boolean(
2177 activeThread &&2148 activeThread &&
...@@ -2757,21 +2728,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2757,21 +2728,7 @@ function ChatViewContent(props: ChatViewProps) {
2757 if (!activeThreadRef || !isServerThread || !isGitRepo) return;2728 if (!activeThreadRef || !isServerThread || !isGitRepo) return;
2758 useRightPanelStore.getState().open(activeThreadRef, "diff");2729 useRightPanelStore.getState().open(activeThreadRef, "diff");
2759 onDiffPanelOpen?.();2730 onDiffPanelOpen?.();
2760 void navigate({2731 }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]);
2761 to: "/$environmentId/$threadId",
2762 params: { environmentId, threadId },
2763 replace: true,
2764 search: (previous) => ({ ...stripDiffSearchParams(previous), diff: "1" }),
2765 });
2766 }, [
2767 activeThreadRef,
2768 environmentId,
2769 isGitRepo,
2770 isServerThread,
2771 navigate,
2772 onDiffPanelOpen,
2773 threadId,
2774 ]);
2775 const addFilesSurface = useCallback(() => {2732 const addFilesSurface = useCallback(() => {
2776 if (!activeThreadRef || !activeProject) return;2733 if (!activeThreadRef || !activeProject) return;
2777 useRightPanelStore.getState().open(activeThreadRef, "files");2734 useRightPanelStore.getState().open(activeThreadRef, "files");
...@@ -2789,30 +2746,13 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2789,30 +2746,13 @@ function ChatViewContent(props: ChatViewProps) {
2789 useRightPanelStore.getState().close(activeThreadRef);2746 useRightPanelStore.getState().close(activeThreadRef);
2790 return;2747 return;
2791 }2748 }
2792 if (diffOpen) {
2793 void navigate({
2794 to: "/$environmentId/$threadId",
2795 params: { environmentId, threadId },
2796 replace: true,
2797 search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }),
2798 });
2799 }
2800 const activeTabId = activePreviewState.activeTabId;2749 const activeTabId = activePreviewState.activeTabId;
2801 if (activeTabId) {2750 if (activeTabId) {
2802 useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId);2751 useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId);
2803 } else {2752 } else {
2804 createBrowserSurface();2753 createBrowserSurface();
2805 }2754 }
2806 }, [2755 }, [activePreviewState.activeTabId, activeThreadRef, createBrowserSurface, previewPanelOpen]);
2807 activePreviewState.activeTabId,
2808 activeThreadRef,
2809 createBrowserSurface,
2810 diffOpen,
2811 environmentId,
2812 navigate,
2813 previewPanelOpen,
2814 threadId,
2815 ]);
2816 const closePreviewPanel = useCallback(() => {2756 const closePreviewPanel = useCallback(() => {
2817 if (activeThreadRef) {2757 if (activeThreadRef) {
2818 setMaximizedRightPanelThreadKey(null);2758 setMaximizedRightPanelThreadKey(null);
...@@ -2936,31 +2876,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2936,31 +2876,9 @@ function ChatViewContent(props: ChatViewProps) {
2936 }2876 }
2937 if (surface.kind === "diff" && !diffOpen) {2877 if (surface.kind === "diff" && !diffOpen) {
2938 onDiffPanelOpen?.();2878 onDiffPanelOpen?.();
2939 void navigate({
2940 to: "/$environmentId/$threadId",
2941 params: { environmentId, threadId },
2942 replace: true,
2943 search: (previous) => ({ ...stripDiffSearchParams(previous), diff: "1" }),
2944 });
2945 } else if (surface.kind !== "diff" && diffOpen) {
2946 void navigate({
2947 to: "/$environmentId/$threadId",
2948 params: { environmentId, threadId },
2949 replace: true,
2950 search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }),
2951 });
2952 }2879 }
2953 },2880 },
2954 [2881 [activeThreadRef, diffOpen, dismissPlanSidebarForCurrentTurn, onDiffPanelOpen, planSidebarOpen],
2955 activeThreadRef,
2956 diffOpen,
2957 dismissPlanSidebarForCurrentTurn,
2958 environmentId,
2959 navigate,
2960 onDiffPanelOpen,
2961 planSidebarOpen,
2962 threadId,
2963 ],
2964 );2882 );
2965 const toggleRightPanel = useCallback(() => {2883 const toggleRightPanel = useCallback(() => {
2966 if (!activeThreadRef) return;2884 if (!activeThreadRef) return;
...@@ -3006,26 +2924,14 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3006,26 +2924,14 @@ function ChatViewContent(props: ChatViewProps) {
3006 }2924 }
3007 }2925 }
3008 }2926 }
3009 if (diffOpen && surfaces.some((surface) => surface.kind === "diff")) {
3010 void navigate({
3011 to: "/$environmentId/$threadId",
3012 params: { environmentId, threadId },
3013 replace: true,
3014 search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }),
3015 });
3016 }
3017 },2927 },
3018 [2928 [
3019 activeThreadRef,2929 activeThreadRef,
3020 activePreviewState.sessions,2930 activePreviewState.sessions,
3021 closePreview,2931 closePreview,
3022 closeTerminalMutation,2932 closeTerminalMutation,
3023 diffOpen,
3024 dismissPlanSidebarForCurrentTurn,2933 dismissPlanSidebarForCurrentTurn,
3025 environmentId,
3026 navigate,
3027 storeCloseTerminal,2934 storeCloseTerminal,
3028 threadId,
3029 ],2935 ],
3030 );2936 );
3031 const syncActivePreviewSurface = useCallback(() => {2937 const syncActivePreviewSurface = useCallback(() => {
...@@ -4631,25 +4537,12 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4631,25 +4537,12 @@ function ChatViewContent(props: ChatViewProps) {
4631 }, []);4537 }, []);
4632 const onOpenTurnDiff = useCallback(4538 const onOpenTurnDiff = useCallback(
4633 (turnId: TurnId, filePath?: string) => {4539 (turnId: TurnId, filePath?: string) => {
4634 if (!isServerThread) {4540 if (!isServerThread || !activeThreadRef) return;
4635 return;4541 useDiffPanelStore.getState().selectTurn(activeThreadRef, turnId, filePath);
4636 }4542 useRightPanelStore.getState().open(activeThreadRef, "diff");
4637 onDiffPanelOpen?.();4543 onDiffPanelOpen?.();
4638 void navigate({
4639 to: "/$environmentId/$threadId",
4640 params: {
4641 environmentId,
4642 threadId,
4643 },
4644 search: (previous) => {
4645 const rest = stripDiffSearchParams(previous);
4646 return filePath
4647 ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath }
4648 : { ...rest, diff: "1", diffTurnId: turnId };
4649 },
4650 });
4651 },4544 },
4652 [environmentId, isServerThread, navigate, onDiffPanelOpen, threadId],4545 [activeThreadRef, isServerThread, onDiffPanelOpen],
4653 );4546 );
4654 // Both the Map and the revert handler are read from refs at call-time so4547 // Both the Map and the revert handler are read from refs at call-time so
4655 // the callback reference is fully stable and never busts context identity.4548 // the callback reference is fully stable and never busts context identity.
apps/web/src/components/DiffPanel.tsx+436-345
...@@ -1,35 +1,28 @@...@@ -1,35 +1,28 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { Virtualizer } from "@pierre/diffs/react";2import { useParams } from "@tanstack/react-router";
3import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
4import { scopeThreadRef } from "@t3tools/client-runtime/environment";
5import {3import {
6 isAtomCommandInterrupted,4 isAtomCommandInterrupted,
7 squashAtomCommandFailure,5 squashAtomCommandFailure,
8} from "@t3tools/client-runtime/state/runtime";6} from "@t3tools/client-runtime/state/runtime";
9import type { ScopedThreadRef, TurnId } from "@t3tools/contracts";7import type { ScopedThreadRef, TurnId } from "@t3tools/contracts";
10import {8import {
9 ArrowRightIcon,
10 CheckIcon,
11 ChevronDownIcon,11 ChevronDownIcon,
12 ChevronLeftIcon,
13 ChevronRightIcon,12 ChevronRightIcon,
14 Columns2Icon,13 Columns2Icon,
15 PilcrowIcon,14 PilcrowIcon,
16 Rows3Icon,15 Rows3Icon,
16 SearchIcon,
17 TextWrapIcon,17 TextWrapIcon,
18} from "lucide-react";18} from "lucide-react";
19import {19import { useCallback, useEffect, useMemo, useRef, useState } from "react";
20 type WheelEvent as ReactWheelEvent,
21 useCallback,
22 useEffect,
23 useMemo,
24 useRef,
25 useState,
26} from "react";
27import { useOpenInPreferredEditor } from "../editorPreferences";20import { useOpenInPreferredEditor } from "../editorPreferences";
28import { type DraftId } from "../composerDraftStore";21import { type DraftId } from "../composerDraftStore";
29import { openDiffFilePrimaryAction } from "../diffFileActions";22import { openDiffFilePrimaryAction } from "../diffFileActions";
30import { useCheckpointDiff } from "~/lib/checkpointDiffState";23import { useCheckpointDiff } from "~/lib/checkpointDiffState";
31import { cn } from "~/lib/utils";24import { cn } from "~/lib/utils";
32import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";25import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore";
33import { useTheme } from "../hooks/useTheme";26import { useTheme } from "../hooks/useTheme";
34import {27import {
35 buildFileDiffRenderKey,28 buildFileDiffRenderKey,
...@@ -40,19 +33,41 @@ import {...@@ -40,19 +33,41 @@ import {
40} from "../lib/diffRendering";33} from "../lib/diffRendering";
41import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";34import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
42import { useProject, useThread } from "../state/entities";35import { useProject, useThread } from "../state/entities";
43import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";36import { resolveThreadRouteRef } from "../threadRoutes";
44import { useSettings } from "../hooks/useSettings";37import { useSettings } from "../hooks/useSettings";
45import { formatShortTimestamp } from "../timestampFormat";38import { formatShortTimestamp } from "../timestampFormat";
46import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";39import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
47import { AnnotatableFileDiff } from "./diffs/AnnotatableFileDiff";40import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableFileDiff";
48import { ToggleGroup, Toggle } from "./ui/toggle-group";41import { ToggleGroup, Toggle } from "./ui/toggle-group";
42import { Switch } from "./ui/switch";
43import {
44 Combobox,
45 ComboboxEmpty,
46 ComboboxInput,
47 ComboboxItem,
48 ComboboxList,
49 ComboboxPopup,
50 ComboboxTrigger,
51} from "./ui/combobox";
52import {
53 DropdownMenu,
54 DropdownMenuContent,
55 DropdownMenuItem,
56 DropdownMenuSub,
57 DropdownMenuSubContent,
58 DropdownMenuSubTrigger,
59 DropdownMenuTrigger,
60} from "./ui/menu";
49import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";61import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
50import { useEnvironmentQuery } from "../state/query";62import { useEnvironmentQuery } from "../state/query";
51import { serverEnvironment } from "../state/server";63import { serverEnvironment } from "../state/server";
64import { reviewEnvironment } from "../state/review";
52import { vcsEnvironment } from "../state/vcs";65import { vcsEnvironment } from "../state/vcs";
66import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices";
5367
54type DiffRenderMode = "stacked" | "split";68type DiffRenderMode = "stacked" | "split";
55type DiffThemeType = "light" | "dark";69type DiffThemeType = "light" | "dark";
70const AUTOMATIC_BASE_REF = "__automatic_base_ref__";
5671
57interface CollapsedDiffFilesState {72interface CollapsedDiffFilesState {
58 readonly scopeKey: string | null;73 readonly scopeKey: string | null;
...@@ -167,27 +182,24 @@ interface DiffPanelProps {...@@ -167,27 +182,24 @@ interface DiffPanelProps {
167export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";182export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
168183
169export default function DiffPanel({ mode = "inline", composerDraftTarget }: DiffPanelProps) {184export default function DiffPanel({ mode = "inline", composerDraftTarget }: DiffPanelProps) {
170 const navigate = useNavigate();
171 const { resolvedTheme } = useTheme();185 const { resolvedTheme } = useTheme();
172 const settings = useSettings();186 const settings = useSettings();
173 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");187 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
174 const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap);188 const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap);
175 const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace);189 const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace);
190 const [baseRefQuery, setBaseRefQuery] = useState("");
176 const [collapsedDiffFiles, setCollapsedDiffFiles] = useState<CollapsedDiffFilesState>(() => ({191 const [collapsedDiffFiles, setCollapsedDiffFiles] = useState<CollapsedDiffFilesState>(() => ({
177 scopeKey: null,192 scopeKey: null,
178 fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS,193 fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS,
179 }));194 }));
180 const patchViewportRef = useRef<HTMLDivElement>(null);195 const codeViewRef = useRef<AnnotatableCodeViewHandle>(null);
181 const turnStripRef = useRef<HTMLDivElement>(null);
182 const previousDiffOpenRef = useRef(false);
183 const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);
184 const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false);
185 const routeThreadRef = useParams({196 const routeThreadRef = useParams({
186 strict: false,197 strict: false,
187 select: (params) => resolveThreadRouteRef(params),198 select: (params) => resolveThreadRouteRef(params),
188 });199 });
189 const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) });200 const diffSelection = useDiffPanelStore((state) =>
190 const diffOpen = diffSearch.diff === "1";201 selectThreadDiffPanelSelection(state.byThreadKey, routeThreadRef),
202 );
191 const activeThreadId = routeThreadRef?.threadId ?? null;203 const activeThreadId = routeThreadRef?.threadId ?? null;
192 const activeThread = useThread(routeThreadRef);204 const activeThread = useThread(routeThreadRef);
193 const activeProjectId = activeThread?.projectId ?? null;205 const activeProjectId = activeThread?.projectId ?? null;
...@@ -233,8 +245,20 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff...@@ -233,8 +245,20 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
233 [inferredCheckpointTurnCountByTurnId, turnDiffSummaries],245 [inferredCheckpointTurnCountByTurnId, turnDiffSummaries],
234 );246 );
235247
236 const selectedTurnId = diffSearch.diffTurnId ?? null;248 useEffect(() => {
237 const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null;249 if (!routeThreadRef || diffSelection.kind !== "turn") return;
250 useDiffPanelStore.getState().reconcileTurnSelection(
251 routeThreadRef,
252 orderedTurnDiffSummaries.map((summary) => summary.turnId),
253 );
254 }, [diffSelection, orderedTurnDiffSummaries, routeThreadRef]);
255
256 const selectedTurnId = diffSelection.kind === "turn" ? diffSelection.turnId : null;
257 const selectedGitScope = diffSelection.kind === "unstaged" ? "unstaged" : "branch";
258 const selectedBaseRef = diffSelection.kind === "branch" ? diffSelection.baseRef : null;
259 const selectedFilePath = diffSelection.kind === "turn" ? diffSelection.filePath : null;
260 const selectedFileRevealRequestId =
261 diffSelection.kind === "turn" ? diffSelection.revealRequestId : 0;
238 const selectedTurn =262 const selectedTurn =
239 selectedTurnId === null263 selectedTurnId === null
240 ? undefined264 ? undefined
...@@ -243,7 +267,16 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff...@@ -243,7 +267,16 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
243 const selectedCheckpointTurnCount =267 const selectedCheckpointTurnCount =
244 selectedTurn &&268 selectedTurn &&
245 (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]);269 (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]);
246 const reviewSectionId = selectedTurn ? `turn:${selectedTurn.turnId}` : "conversation";270 const latestTurn = orderedTurnDiffSummaries[0];
271 const selectedScopeLabel =
272 selectedTurnId === null
273 ? selectedGitScope === "unstaged"
274 ? "Working tree"
275 : "Branch changes"
276 : selectedTurn?.turnId === latestTurn?.turnId
277 ? "Latest turn"
278 : `Turn ${selectedCheckpointTurnCount ?? "?"}`;
279 const reviewSectionId = selectedTurn ? `turn:${selectedTurn.turnId}` : selectedGitScope;
247 const collapseScopeKey = routeThreadRef280 const collapseScopeKey = routeThreadRef
248 ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}`281 ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}`
249 : null;282 : null;
...@@ -253,7 +286,9 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff...@@ -253,7 +286,9 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
253 : EMPTY_COLLAPSED_DIFF_FILE_KEYS;286 : EMPTY_COLLAPSED_DIFF_FILE_KEYS;
254 const reviewSectionTitle = selectedTurn287 const reviewSectionTitle = selectedTurn
255 ? `Turn ${selectedCheckpointTurnCount ?? "?"}`288 ? `Turn ${selectedCheckpointTurnCount ?? "?"}`
256 : "All turns";289 : selectedGitScope === "unstaged"
290 ? "Working tree"
291 : "Branch changes";
257 const selectedCheckpointRange = useMemo(292 const selectedCheckpointRange = useMemo(
258 () =>293 () =>
259 typeof selectedCheckpointTurnCount === "number"294 typeof selectedCheckpointTurnCount === "number"
...@@ -264,62 +299,116 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff...@@ -264,62 +299,116 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
264 : null,299 : null,
265 [selectedCheckpointTurnCount],300 [selectedCheckpointTurnCount],
266 );301 );
267 const conversationCheckpointTurnCount = useMemo(() => {
268 const turnCounts: Array<number> = [];
269 for (const summary of orderedTurnDiffSummaries) {
270 const value =
271 summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId];
272 if (typeof value === "number") {
273 turnCounts.push(value);
274 }
275 }
276 if (turnCounts.length === 0) {
277 return undefined;
278 }
279 const latest = Math.max(...turnCounts);
280 return latest > 0 ? latest : undefined;
281 }, [inferredCheckpointTurnCountByTurnId, orderedTurnDiffSummaries]);
282 const conversationCheckpointRange = useMemo(
283 () =>
284 !selectedTurn && typeof conversationCheckpointTurnCount === "number"
285 ? {
286 fromTurnCount: 0,
287 toTurnCount: conversationCheckpointTurnCount,
288 }
289 : null,
290 [conversationCheckpointTurnCount, selectedTurn],
291 );
292 const activeCheckpointRange = selectedTurn
293 ? selectedCheckpointRange
294 : conversationCheckpointRange;
295 const conversationCacheScope = useMemo(() => {
296 if (selectedTurn || orderedTurnDiffSummaries.length === 0) {
297 return null;
298 }
299 return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`;
300 }, [orderedTurnDiffSummaries, selectedTurn]);
301 const activeCheckpointDiff = useCheckpointDiff(302 const activeCheckpointDiff = useCheckpointDiff(
302 {303 {
303 environmentId: activeThread?.environmentId ?? null,304 environmentId: activeThread?.environmentId ?? null,
304 threadId: activeThreadId,305 threadId: activeThreadId,
305 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,306 fromTurnCount: selectedCheckpointRange?.fromTurnCount ?? null,
306 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,307 toTurnCount: selectedCheckpointRange?.toTurnCount ?? null,
307 ignoreWhitespace: diffIgnoreWhitespace,308 ignoreWhitespace: diffIgnoreWhitespace,
308 cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope,309 cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : null,
309 },310 },
310 { enabled: isGitRepo },311 { enabled: isGitRepo && selectedTurn !== undefined },
311 );312 );
312 const selectedTurnCheckpointDiff = selectedTurn ? activeCheckpointDiff.data?.diff : undefined;313 const primaryBranchDiffPreview = useEnvironmentQuery(
313 const conversationCheckpointDiff = selectedTurn ? undefined : activeCheckpointDiff.data?.diff;314 selectedTurnId === null && activeThread && activeCwd
314 const isLoadingCheckpointDiff = activeCheckpointDiff.isPending;315 ? reviewEnvironment.diffPreview({
315 const checkpointDiffError = activeCheckpointDiff.error;316 environmentId: activeThread.environmentId,
316317 input: {
317 const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff;318 cwd: activeCwd,
319 ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}),
320 ignoreWhitespace: diffIgnoreWhitespace,
321 },
322 })
323 : null,
324 );
325 const shouldRetryBranchDiffAtEnvironmentCwd =
326 selectedTurnId === null &&
327 primaryBranchDiffPreview.error?.includes("configured workspace root") === true &&
328 serverConfig?.cwd !== undefined &&
329 serverConfig.cwd !== activeCwd;
330 const fallbackBranchDiffPreview = useEnvironmentQuery(
331 shouldRetryBranchDiffAtEnvironmentCwd && activeThread && serverConfig
332 ? reviewEnvironment.diffPreview({
333 environmentId: activeThread.environmentId,
334 input: {
335 cwd: serverConfig.cwd,
336 ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}),
337 ignoreWhitespace: diffIgnoreWhitespace,
338 },
339 })
340 : null,
341 );
342 const branchDiffPreview = shouldRetryBranchDiffAtEnvironmentCwd
343 ? fallbackBranchDiffPreview
344 : primaryBranchDiffPreview;
345 const selectedGitSource = branchDiffPreview.data?.sources.find(
346 (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"),
347 );
348 const localBranchRefs = useEnvironmentQuery(
349 selectedTurnId === null &&
350 selectedGitScope === "branch" &&
351 activeThread &&
352 branchDiffPreview.data?.cwd
353 ? vcsEnvironment.listRefs({
354 environmentId: activeThread.environmentId,
355 input: {
356 cwd: branchDiffPreview.data.cwd,
357 includeMatchingRemoteRefs: true,
358 refKind: "local",
359 ...(baseRefQuery.trim().length > 0 ? { query: baseRefQuery.trim() } : {}),
360 limit: 100,
361 },
362 })
363 : null,
364 );
365 const remoteBranchRefs = useEnvironmentQuery(
366 selectedTurnId === null &&
367 selectedGitScope === "branch" &&
368 activeThread &&
369 branchDiffPreview.data?.cwd
370 ? vcsEnvironment.listRefs({
371 environmentId: activeThread.environmentId,
372 input: {
373 cwd: branchDiffPreview.data.cwd,
374 includeMatchingRemoteRefs: true,
375 refKind: "remote",
376 ...(baseRefQuery.trim().length > 0 ? { query: baseRefQuery.trim() } : {}),
377 limit: 100,
378 },
379 })
380 : null,
381 );
382 const baseRefChoices = buildBaseRefChoices(
383 localBranchRefs.data?.refs.filter((ref) => ref.name !== selectedGitSource?.headRef) ?? [],
384 remoteBranchRefs.data?.refs ?? [],
385 );
386 const matchingBaseRefChoices = filterBaseRefChoices(baseRefChoices, baseRefQuery);
387 const valueForBaseRefChoice = (choice: (typeof baseRefChoices)[number]) =>
388 selectedBaseRef && selectedBaseRef === choice.remote?.name
389 ? selectedBaseRef
390 : (choice.local?.name ?? choice.remote?.name ?? choice.id);
391 const baseRefItems = [AUTOMATIC_BASE_REF, ...baseRefChoices.map(valueForBaseRefChoice)];
392 const filteredBaseRefItems = [
393 ...(baseRefQuery.trim().length === 0 ? [AUTOMATIC_BASE_REF] : []),
394 ...matchingBaseRefChoices.map(valueForBaseRefChoice),
395 ];
396 const gitDiff = selectedGitSource?.diff;
397
398 const selectedPatch = selectedTurn ? activeCheckpointDiff.data?.diff : gitDiff;
399 const isSelectedPatchTruncated = !selectedTurn && selectedGitSource?.truncated === true;
400 const isLoadingSelectedPatch = selectedTurn
401 ? activeCheckpointDiff.isPending
402 : branchDiffPreview.isPending;
403 const selectedPatchError = selectedTurn ? activeCheckpointDiff.error : branchDiffPreview.error;
318 const hasResolvedPatch = typeof selectedPatch === "string";404 const hasResolvedPatch = typeof selectedPatch === "string";
319 const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0;405 const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0;
320 const renderablePatch = useMemo(406 const renderablePatch = useMemo(
321 () => getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`),407 () =>
322 [resolvedTheme, selectedPatch],408 getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`, {
409 compactPartialHunkOffsets: selectedTurnId === null,
410 }),
411 [resolvedTheme, selectedPatch, selectedTurnId],
323 );412 );
324 const renderableFiles = useMemo(() => {413 const renderableFiles = useMemo(() => {
325 if (!renderablePatch || renderablePatch.kind !== "files") {414 if (!renderablePatch || renderablePatch.kind !== "files") {
...@@ -332,24 +421,26 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff...@@ -332,24 +421,26 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
332 }),421 }),
333 );422 );
334 }, [renderablePatch]);423 }, [renderablePatch]);
424 const codeViewFiles = useMemo(
425 () =>
426 renderableFiles.map((fileDiff) => {
427 const fileKey = buildFileDiffRenderKey(fileDiff);
428 return {
429 fileDiff,
430 filePath: resolveFileDiffPath(fileDiff),
431 fileKey,
432 collapsed: collapsedDiffFileKeys.has(fileKey),
433 };
434 }),
435 [collapsedDiffFileKeys, renderableFiles],
436 );
335437
336 useEffect(() => {438 useEffect(() => {
337 if (diffOpen && !previousDiffOpenRef.current) {439 if (!selectedFilePath) return;
338 setDiffWordWrap(settings.diffWordWrap);440 const file = codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath);
339 setDiffIgnoreWhitespace(settings.diffIgnoreWhitespace);441 if (!file) return;
340 }442 codeViewRef.current?.scrollTo({ type: "item", id: file.fileKey, align: "start" });
341 previousDiffOpenRef.current = diffOpen;443 }, [codeViewFiles, selectedFilePath, selectedFileRevealRequestId]);
342 }, [diffOpen, settings.diffIgnoreWhitespace, settings.diffWordWrap]);
343
344 useEffect(() => {
345 if (!selectedFilePath || !patchViewportRef.current) {
346 return;
347 }
348 const target = Array.from(
349 patchViewportRef.current.querySelectorAll<HTMLElement>("[data-diff-file-path]"),
350 ).find((element) => element.dataset.diffFilePath === selectedFilePath);
351 target?.scrollIntoView({ block: "nearest" });
352 }, [selectedFilePath, renderableFiles]);
353444
354 const openDiffFile = useCallback(445 const openDiffFile = useCallback(
355 (filePath: string) => {446 (filePath: string) => {
...@@ -385,186 +476,190 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff...@@ -385,186 +476,190 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
385 );476 );
386477
387 const selectTurn = (turnId: TurnId) => {478 const selectTurn = (turnId: TurnId) => {
388 if (!activeThread) return;479 if (!routeThreadRef) return;
389 void navigate({480 useDiffPanelStore.getState().selectTurn(routeThreadRef, turnId);
390 to: "/$environmentId/$threadId",
391 params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)),
392 search: (previous) => {
393 const rest = stripDiffSearchParams(previous);
394 return { ...rest, diff: "1", diffTurnId: turnId };
395 },
396 });
397 };481 };
398 const selectWholeConversation = () => {482 const selectGitScope = (scope: "branch" | "unstaged") => {
399 if (!activeThread) return;483 if (!routeThreadRef) return;
400 void navigate({484 useDiffPanelStore.getState().selectGitScope(routeThreadRef, scope);
401 to: "/$environmentId/$threadId",485 };
402 params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)),486 const selectBranchBaseRef = (baseRef: string | null) => {
403 search: (previous) => {487 if (!routeThreadRef) return;
404 const rest = stripDiffSearchParams(previous);488 useDiffPanelStore.getState().selectBranchBaseRef(routeThreadRef, baseRef);
405 return { ...rest, diff: "1" };
406 },
407 });
408 };489 };
409 const updateTurnStripScrollState = useCallback(() => {
410 const element = turnStripRef.current;
411 if (!element) {
412 setCanScrollTurnStripLeft(false);
413 setCanScrollTurnStripRight(false);
414 return;
415 }
416
417 const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth);
418 setCanScrollTurnStripLeft(element.scrollLeft > 4);
419 setCanScrollTurnStripRight(element.scrollLeft < maxScrollLeft - 4);
420 }, []);
421 const scrollTurnStripBy = useCallback((offset: number) => {
422 const element = turnStripRef.current;
423 if (!element) return;
424 element.scrollBy({ left: offset, behavior: "smooth" });
425 }, []);
426 const onTurnStripWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
427 const element = turnStripRef.current;
428 if (!element) return;
429 if (element.scrollWidth <= element.clientWidth + 1) return;
430 if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
431
432 event.preventDefault();
433 element.scrollBy({ left: event.deltaY, behavior: "auto" });
434 }, []);
435
436 useEffect(() => {
437 const element = turnStripRef.current;
438 if (!element) return;
439
440 const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState());
441 const onScroll = () => updateTurnStripScrollState();
442
443 element.addEventListener("scroll", onScroll, { passive: true });
444
445 const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState());
446 resizeObserver.observe(element);
447
448 return () => {
449 window.cancelAnimationFrame(frameId);
450 element.removeEventListener("scroll", onScroll);
451 resizeObserver.disconnect();
452 };
453 }, [updateTurnStripScrollState]);
454
455 useEffect(() => {
456 const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState());
457 return () => {
458 window.cancelAnimationFrame(frameId);
459 };
460 }, [orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]);
461
462 useEffect(() => {
463 const element = turnStripRef.current;
464 if (!element) return;
465
466 const selectedChip = element.querySelector<HTMLElement>("[data-turn-chip-selected='true']");
467 selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
468 }, [selectedTurn?.turnId, selectedTurnId]);
469490
470 const headerRow = (491 const headerRow = (
471 <>492 <>
472 <div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]">493 <div className="flex min-w-0 flex-1 items-center gap-3 [-webkit-app-region:no-drag]">
473 <button494 <DropdownMenu>
474 type="button"495 <DropdownMenuTrigger
475 className={cn(496 className="inline-flex h-6 max-w-full items-center gap-1 rounded-md bg-muted/70 px-2 text-xs font-medium text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
476 "absolute left-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors",497 aria-label={`Diff scope: ${selectedScopeLabel}`}
477 canScrollTurnStripLeft
478 ? "border-border/70 hover:border-border hover:text-foreground"
479 : "cursor-not-allowed border-border/40 text-muted-foreground/40",
480 )}
481 onClick={() => scrollTurnStripBy(-180)}
482 disabled={!canScrollTurnStripLeft}
483 aria-label="Scroll turn list left"
484 >
485 <ChevronLeftIcon className="size-3.5" />
486 </button>
487 <button
488 type="button"
489 className={cn(
490 "absolute right-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors",
491 canScrollTurnStripRight
492 ? "border-border/70 hover:border-border hover:text-foreground"
493 : "cursor-not-allowed border-border/40 text-muted-foreground/40",
494 )}
495 onClick={() => scrollTurnStripBy(180)}
496 disabled={!canScrollTurnStripRight}
497 aria-label="Scroll turn list right"
498 >
499 <ChevronRightIcon className="size-3.5" />
500 </button>
501 <div
502 ref={turnStripRef}
503 className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5"
504 style={
505 canScrollTurnStripLeft || canScrollTurnStripRight
506 ? {
507 maskImage: `linear-gradient(to right, ${canScrollTurnStripLeft ? "transparent 24px, black 72px" : "black"}, ${canScrollTurnStripRight ? "black calc(100% - 72px), transparent calc(100% - 24px)" : "black"})`,
508 }
509 : undefined
510 }
511 onWheel={onTurnStripWheel}
512 >
513 <button
514 type="button"
515 className="shrink-0 rounded-md"
516 onClick={selectWholeConversation}
517 data-turn-chip-selected={selectedTurnId === null}
518 >498 >
519 <div499 <span className="truncate">{selectedScopeLabel}</span>
520 className={cn(500 <ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground" />
521 "rounded-md border px-2 py-1 text-left transition-colors",501 </DropdownMenuTrigger>
522 selectedTurnId === null502 <DropdownMenuContent align="start" className="w-60">
523 ? "border-border bg-accent text-accent-foreground"503 <DropdownMenuItem onClick={() => selectGitScope("unstaged")}>
524 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",504 <span>Working tree</span>
505 {selectedTurnId === null && selectedGitScope === "unstaged" && (
506 <CheckIcon className="ml-auto" />
507 )}
508 </DropdownMenuItem>
509 <DropdownMenuItem onClick={() => selectGitScope("branch")}>
510 <span>Branch changes</span>
511 {selectedTurnId === null && selectedGitScope === "branch" && (
512 <CheckIcon className="ml-auto" />
525 )}513 )}
514 </DropdownMenuItem>
515 <DropdownMenuItem
516 onClick={() => {
517 if (latestTurn) selectTurn(latestTurn.turnId);
518 }}
526 >519 >
527 <div className="text-[10px] leading-tight font-medium">All turns</div>520 <span>Latest turn</span>
528 </div>521 {selectedTurnId !== null && selectedTurn?.turnId === latestTurn?.turnId && (
529 </button>522 <CheckIcon className="ml-auto" />
530 {orderedTurnDiffSummaries.map((summary) => (523 )}
531 <Tooltip key={summary.turnId}>524 </DropdownMenuItem>
532 <TooltipTrigger525 <DropdownMenuSub>
533 render={526 <DropdownMenuSubTrigger>Turn</DropdownMenuSubTrigger>
534 <button527 <DropdownMenuSubContent className="w-64">
535 type="button"528 {orderedTurnDiffSummaries.map((summary) => {
536 aria-label={summary.turnId}529 const turnCount =
537 className="shrink-0 rounded-md"530 summary.checkpointTurnCount ??
538 onClick={() => selectTurn(summary.turnId)}531 inferredCheckpointTurnCountByTurnId[summary.turnId] ??
539 data-turn-chip-selected={summary.turnId === selectedTurn?.turnId}532 "?";
540 />533 return (
541 }534 <DropdownMenuItem
535 key={summary.turnId}
536 onClick={() => selectTurn(summary.turnId)}
537 >
538 <span>Turn {turnCount}</span>
539 <span className="ml-auto text-xs tabular-nums text-muted-foreground">
540 {formatShortTimestamp(summary.completedAt, settings.timestampFormat)}
541 </span>
542 {summary.turnId === selectedTurn?.turnId && <CheckIcon className="ml-1" />}
543 </DropdownMenuItem>
544 );
545 })}
546 </DropdownMenuSubContent>
547 </DropdownMenuSub>
548 </DropdownMenuContent>
549 </DropdownMenu>
550 {selectedTurnId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && (
551 <div
552 className="flex min-w-0 max-w-full items-center gap-2 overflow-hidden text-xs text-muted-foreground"
553 title={`${selectedGitSource.headRef ?? "HEAD"} → ${selectedGitSource.baseRef}`}
554 aria-label={`Comparing ${selectedGitSource.headRef ?? "HEAD"} against ${selectedGitSource.baseRef}`}
555 >
556 <span className="min-w-0 max-w-48 truncate">{selectedGitSource.headRef ?? "HEAD"}</span>
557 <ArrowRightIcon className="size-3.5 shrink-0 opacity-70" />
558 <Combobox
559 items={baseRefItems}
560 filteredItems={filteredBaseRefItems}
561 value={selectedBaseRef ?? AUTOMATIC_BASE_REF}
562 onOpenChange={(open) => {
563 if (!open) setBaseRefQuery("");
564 }}
565 onValueChange={(value) => {
566 if (!value) return;
567 selectBranchBaseRef(value === AUTOMATIC_BASE_REF ? null : value);
568 }}
569 >
570 <ComboboxTrigger
571 className="inline-flex min-w-0 max-w-48 items-center gap-1 overflow-hidden rounded-md px-1.5 py-1 outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
572 aria-label={`Change comparison target. Currently ${selectedGitSource.baseRef}`}
573 >
574 <span className="min-w-0 truncate">{selectedGitSource.baseRef}</span>
575 <ChevronDownIcon className="size-3.5 shrink-0 opacity-70" />
576 </ComboboxTrigger>
577 <ComboboxPopup
578 align="start"
579 className="w-72 min-w-0 max-w-[calc(100vw-1rem)] overflow-hidden [&>[data-slot=combobox-popup]]:min-w-0 [&>[data-slot=combobox-popup]]:overflow-hidden"
542 >580 >
543 <div581 <div className="min-w-0 shrink-0 px-3 pt-2.5">
544 className={cn(582 <div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring">
545 "rounded-md border px-2 py-1 text-left transition-colors",583 <SearchIcon
546 summary.turnId === selectedTurn?.turnId584 aria-hidden="true"
547 ? "border-border bg-accent text-accent-foreground"585 className="pointer-events-none absolute top-1.5 left-0 size-4 shrink-0 text-muted-foreground/55"
548 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",586 />
549 )}587 <ComboboxInput
550 >588 className="[&_input]:h-6.5 [&_input]:ps-5 [&_input]:font-sans [&_input]:leading-6.5"
551 <div className="flex items-center gap-1">589 inputClassName="rounded-none bg-transparent text-sm"
552 <span className="text-[10px] leading-tight font-medium">590 placeholder="Search refs..."
553 Turn{" "}591 showTrigger={false}
554 {summary.checkpointTurnCount ??592 size="sm"
555 inferredCheckpointTurnCountByTurnId[summary.turnId] ??593 unstyled
556 "?"}594 value={baseRefQuery}
557 </span>595 onChange={(event) => setBaseRefQuery(event.target.value)}
558 <span className="text-[9px] leading-tight opacity-70 tabular-nums">596 />
559 {formatShortTimestamp(summary.completedAt, settings.timestampFormat)}
560 </span>
561 </div>597 </div>
562 </div>598 </div>
563 </TooltipTrigger>599 <div className="grid shrink-0 grid-cols-[1rem_minmax(0,1fr)] items-center gap-2 border-b border-border/70 ps-3 pe-6.5 pt-2 pb-1.5 font-medium text-[10px] text-muted-foreground uppercase tracking-wide">
564 <TooltipPopup side="top">{summary.turnId}</TooltipPopup>600 <span aria-hidden="true" />
565 </Tooltip>601 <div className="grid min-w-0 grid-cols-[minmax(0,1fr)_2rem] items-center">
566 ))}602 <span>Branch</span>
567 </div>603 <span className="text-right">Remote</span>
604 </div>
605 </div>
606 <ComboboxEmpty>No matching refs.</ComboboxEmpty>
607 <ComboboxList className="max-h-64 min-w-0 overflow-x-hidden">
608 <ComboboxItem
609 className="h-8 w-full min-w-0 grid-cols-[1rem_minmax(0,1fr)] py-0"
610 contentClassName="w-full min-w-0 overflow-hidden"
611 value={AUTOMATIC_BASE_REF}
612 >
613 <span className="block min-w-0 truncate">Automatic</span>
614 </ComboboxItem>
615 {baseRefChoices.map((choice) => {
616 const item = valueForBaseRefChoice(choice);
617 const hasBoth = choice.local !== null && choice.remote !== null;
618 const useRemote = choice.remote?.name === item;
619 return (
620 <ComboboxItem
621 key={choice.id}
622 className="h-8 w-full min-w-0 grid-cols-[1rem_minmax(0,1fr)] py-0"
623 contentClassName="w-full min-w-0 overflow-hidden"
624 value={item}
625 >
626 <div className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_2rem] items-center overflow-hidden">
627 <span className="block min-w-0 truncate pe-2">{choice.label}</span>
628 {hasBoth ? (
629 <div
630 className="flex justify-end"
631 onClick={(event) => event.stopPropagation()}
632 onPointerDown={(event) => event.stopPropagation()}
633 >
634 <Switch
635 aria-label={`Use remote version of ${choice.label}`}
636 checked={useRemote}
637 className="[--thumb-size:--spacing(3)]"
638 onCheckedChange={(checked) => {
639 const nextRef = checked
640 ? choice.remote?.name
641 : choice.local?.name;
642 if (nextRef) selectBranchBaseRef(nextRef);
643 }}
644 />
645 </div>
646 ) : choice.remote ? (
647 <span
648 className="flex justify-end text-muted-foreground"
649 title="Remote only"
650 >
651 <CheckIcon aria-hidden="true" className="size-3" />
652 </span>
653 ) : null}
654 </div>
655 </ComboboxItem>
656 );
657 })}
658 </ComboboxList>
659 </ComboboxPopup>
660 </Combobox>
661 </div>
662 )}
568 </div>663 </div>
569 <div className="flex shrink-0 items-center gap-1 [-webkit-app-region:no-drag]">664 <div className="flex shrink-0 items-center gap-1 [-webkit-app-region:no-drag]">
570 <ToggleGroup665 <ToggleGroup
...@@ -644,24 +739,35 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff...@@ -644,24 +739,35 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
644 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">739 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
645 Turn diffs are unavailable because this project is not a git repository.740 Turn diffs are unavailable because this project is not a git repository.
646 </div>741 </div>
647 ) : orderedTurnDiffSummaries.length === 0 ? (742 ) : selectedTurnId !== null && orderedTurnDiffSummaries.length === 0 ? (
648 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">743 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
649 No completed turns yet.744 No completed turns yet.
650 </div>745 </div>
651 ) : (746 ) : (
652 <>747 <>
653 <div748 <div className="diff-panel-viewport flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
654 ref={patchViewportRef}749 {isSelectedPatchTruncated && (
655 className="diff-panel-viewport min-h-0 min-w-0 flex-1 overflow-hidden"750 <p className="shrink-0 border-b border-border/70 bg-muted/40 px-3 py-1.5 text-[11px] text-muted-foreground">
656 >751 This diff was truncated because it exceeded the preview limit. The changes shown are
657 {checkpointDiffError && !renderablePatch && (752 incomplete.
753 </p>
754 )}
755 {selectedPatchError && !renderablePatch && (
658 <div className="px-3">756 <div className="px-3">
659 <p className="mb-2 text-[11px] text-red-500/80">{checkpointDiffError}</p>757 <p className="mb-2 text-[11px] text-red-500/80">{selectedPatchError}</p>
660 </div>758 </div>
661 )}759 )}
662 {!renderablePatch ? (760 {!renderablePatch ? (
663 isLoadingCheckpointDiff ? (761 isLoadingSelectedPatch ? (
664 <DiffPanelLoadingState label="Loading checkpoint diff..." />762 <DiffPanelLoadingState
763 label={
764 selectedTurn
765 ? "Loading checkpoint diff..."
766 : selectedGitScope === "unstaged"
767 ? "Loading working tree diff..."
768 : "Loading branch diff..."
769 }
770 />
665 ) : (771 ) : (
666 <div className="flex h-full items-center justify-center px-3 py-2 text-xs text-muted-foreground/70">772 <div className="flex h-full items-center justify-center px-3 py-2 text-xs text-muted-foreground/70">
667 <p>773 <p>
...@@ -672,88 +778,73 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff...@@ -672,88 +778,73 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
672 </div>778 </div>
673 )779 )
674 ) : renderablePatch.kind === "files" ? (780 ) : renderablePatch.kind === "files" ? (
675 <Virtualizer781 <div
676 className="diff-render-surface h-full min-h-0 overflow-auto px-2 pb-2"782 className="min-h-0 flex-1"
677 config={{783 onClickCapture={(event) => {
678 overscrollSize: 600,784 const composedPath = event.nativeEvent.composedPath?.() ?? [];
679 intersectionObserverMargin: 1200,785 const title = composedPath.find(
786 (node): node is HTMLElement =>
787 node instanceof HTMLElement && node.hasAttribute("data-title"),
788 );
789 const filePath = title?.textContent?.trim();
790 if (filePath) openDiffFile(filePath);
680 }}791 }}
681 >792 >
682 {renderableFiles.map((fileDiff) => {793 <AnnotatableCodeView
683 const filePath = resolveFileDiffPath(fileDiff);794 viewerRef={codeViewRef}
684 const fileKey = buildFileDiffRenderKey(fileDiff);795 key={collapseScopeKey ?? reviewSectionId}
685 const themedFileKey = `${fileKey}:${resolvedTheme}`;796 className="diff-render-surface h-full min-h-0 overflow-auto"
686 const collapsed = collapsedDiffFileKeys.has(fileKey);797 files={codeViewFiles}
687 return (798 sectionId={reviewSectionId}
688 <div799 sectionTitle={reviewSectionTitle}
689 key={themedFileKey}800 composerDraftTarget={composerDraftTarget}
690 data-diff-file-path={filePath}801 renderHeaderPrefix={(fileDiff, fileKey, collapsed) => {
691 className="diff-render-file group/diff-file mb-2 rounded-md first:mt-2 last:mb-0"802 const filePath = resolveFileDiffPath(fileDiff);
692 onClickCapture={(event) => {803 return (
693 const nativeEvent = event.nativeEvent as MouseEvent;804 <Tooltip>
694 const composedPath = nativeEvent.composedPath?.() ?? [];805 <TooltipTrigger
695 const clickedHeader = composedPath.some((node) => {806 render={
696 if (!(node instanceof Element)) return false;807 <button
697 return node.hasAttribute("data-title");808 type="button"
698 });809 className={cn(
699 if (!clickedHeader) return;810 "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",
700 openDiffFile(filePath);811 getDiffCollapseIconClassName(fileDiff),
701 }}
702 >
703 <AnnotatableFileDiff
704 fileDiff={fileDiff}
705 filePath={filePath}
706 sectionId={reviewSectionId}
707 sectionTitle={reviewSectionTitle}
708 composerDraftTarget={composerDraftTarget}
709 renderHeaderPrefix={() => (
710 <Tooltip>
711 <TooltipTrigger
712 render={
713 <button
714 type="button"
715 className={cn(
716 "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",
717 getDiffCollapseIconClassName(fileDiff),
718 )}
719 aria-label={
720 collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`
721 }
722 aria-expanded={!collapsed}
723 onClick={(event) => {
724 event.stopPropagation();
725 toggleDiffFileCollapsed(fileKey);
726 }}
727 />
728 }
729 >
730 {collapsed ? (
731 <ChevronRightIcon className="size-4" />
732 ) : (
733 <ChevronDownIcon className="size-4" />
734 )}812 )}
735 </TooltipTrigger>813 aria-label={collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`}
736 <TooltipPopup side="top">814 aria-expanded={!collapsed}
737 {collapsed ? "Expand diff" : "Collapse diff"}815 onClick={(event) => {
738 </TooltipPopup>816 event.stopPropagation();
739 </Tooltip>817 toggleDiffFileCollapsed(fileKey);
740 )}818 }}
741 options={{819 />
742 collapsed,820 }
743 diffStyle: diffRenderMode === "split" ? "split" : "unified",821 >
744 lineDiffType: "none",822 {collapsed ? (
745 overflow: diffWordWrap ? "wrap" : "scroll",823 <ChevronRightIcon className="size-4" />
746 theme: resolveDiffThemeName(resolvedTheme),824 ) : (
747 themeType: resolvedTheme as DiffThemeType,825 <ChevronDownIcon className="size-4" />
748 unsafeCSS: DIFF_PANEL_UNSAFE_CSS,826 )}
749 }}827 </TooltipTrigger>
750 />828 <TooltipPopup side="top">
751 </div>829 {collapsed ? "Expand diff" : "Collapse diff"}
752 );830 </TooltipPopup>
753 })}831 </Tooltip>
754 </Virtualizer>832 );
833 }}
834 options={{
835 diffStyle: diffRenderMode === "split" ? "split" : "unified",
836 lineDiffType: "none",
837 overflow: diffWordWrap ? "wrap" : "scroll",
838 theme: resolveDiffThemeName(resolvedTheme),
839 themeType: resolvedTheme as DiffThemeType,
840 unsafeCSS: DIFF_PANEL_UNSAFE_CSS,
841 stickyHeaders: true,
842 layout: { paddingTop: 8, paddingBottom: 8, gap: 8 },
843 }}
844 />
845 </div>
755 ) : (846 ) : (
756 <div className="h-full overflow-auto p-2">847 <div className="min-h-0 flex-1 overflow-auto p-2">
757 <div className="space-y-2">848 <div className="space-y-2">
758 <p className="text-[11px] text-muted-foreground/75">{renderablePatch.reason}</p>849 <p className="text-[11px] text-muted-foreground/75">{renderablePatch.reason}</p>
759 <pre850 <pre
apps/web/src/components/DiffPanelShell.tsx+2-8
...@@ -48,14 +48,8 @@ export function DiffPanelShell(props: {...@@ -48,14 +48,8 @@ export function DiffPanelShell(props: {
48export function DiffPanelHeaderSkeleton() {48export function DiffPanelHeaderSkeleton() {
49 return (49 return (
50 <>50 <>
51 <div className="relative min-w-0 flex-1">51 <div className="min-w-0 flex-1">
52 <Skeleton className="absolute left-0 top-1/2 size-6 -translate-y-1/2 rounded-md border border-border/50" />52 <Skeleton className="h-8 w-32 rounded-lg" />
53 <Skeleton className="absolute right-0 top-1/2 size-6 -translate-y-1/2 rounded-md border border-border/50" />
54 <div className="flex gap-1 overflow-hidden px-8 py-0.5">
55 <Skeleton className="h-6 w-16 shrink-0 rounded-md" />
56 <Skeleton className="h-6 w-24 shrink-0 rounded-md" />
57 <Skeleton className="h-6 w-24 shrink-0 rounded-md max-sm:hidden" />
58 </div>
59 </div>53 </div>
60 <div className="flex shrink-0 gap-1">54 <div className="flex shrink-0 gap-1">
61 <Skeleton className="size-7 rounded-md" />55 <Skeleton className="size-7 rounded-md" />
apps/web/src/components/diffs/AnnotatableFileDiff.tsx+209-2
...@@ -1,14 +1,23 @@...@@ -1,14 +1,23 @@
1import type {1import type {
2 AnnotationSide,2 AnnotationSide,
3 CodeViewDiffItem,
4 CodeViewItem,
3 DiffLineAnnotation,5 DiffLineAnnotation,
4 FileDiffMetadata,6 FileDiffMetadata,
5 SelectedLineRange,7 SelectedLineRange,
6} from "@pierre/diffs";8} from "@pierre/diffs";
7import { FileDiff, type FileDiffProps } from "@pierre/diffs/react";9import {
10 CodeView,
11 type CodeViewHandle,
12 type CodeViewProps,
13 FileDiff,
14 type FileDiffProps,
15} from "@pierre/diffs/react";
8import type { ScopedThreadRef } from "@t3tools/contracts";16import type { ScopedThreadRef } from "@t3tools/contracts";
9import { useCallback, useMemo, useState, type ReactNode } from "react";17import { useCallback, useMemo, useState, type ReactNode, type Ref } from "react";
1018
11import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";19import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
20import { fnv1a32 } from "~/lib/diffRendering";
12import {21import {
13 buildDiffReviewComment,22 buildDiffReviewComment,
14 restoreDiffReviewCommentRange,23 restoreDiffReviewCommentRange,
...@@ -31,6 +40,7 @@ interface DiffCommentAnnotationGroup {...@@ -31,6 +40,7 @@ interface DiffCommentAnnotationGroup {
31}40}
3241
33type DiffCommentLineAnnotation = DiffLineAnnotation<DiffCommentAnnotationGroup>;42type DiffCommentLineAnnotation = DiffLineAnnotation<DiffCommentAnnotationGroup>;
43export type AnnotatableCodeViewHandle = CodeViewHandle<DiffCommentAnnotationGroup>;
34const EMPTY_REVIEW_COMMENTS: ReadonlyArray<ReviewCommentContext> = [];44const EMPTY_REVIEW_COMMENTS: ReadonlyArray<ReviewCommentContext> = [];
3545
36function annotationSide(range: SelectedLineRange): AnnotationSide {46function annotationSide(range: SelectedLineRange): AnnotationSide {
...@@ -237,3 +247,200 @@ export function AnnotatableFileDiff({...@@ -237,3 +247,200 @@ export function AnnotatableFileDiff({
237 />247 />
238 );248 );
239}249}
250
251interface AnnotatableCodeViewProps {
252 files: ReadonlyArray<{
253 fileDiff: FileDiffMetadata;
254 filePath: string;
255 fileKey: string;
256 collapsed: boolean;
257 }>;
258 sectionId: string;
259 sectionTitle: string;
260 composerDraftTarget: ScopedThreadRef | DraftId;
261 options: NonNullable<CodeViewProps<DiffCommentAnnotationGroup>["options"]>;
262 viewerRef?: Ref<AnnotatableCodeViewHandle>;
263 className?: string;
264 renderHeaderPrefix: (
265 fileDiff: FileDiffMetadata,
266 fileKey: string,
267 collapsed: boolean,
268 ) => ReactNode;
269}
270
271interface DiffSelectionContext {
272 item: CodeViewItem<DiffCommentAnnotationGroup>;
273}
274
275export function AnnotatableCodeView({
276 files,
277 sectionId,
278 sectionTitle,
279 composerDraftTarget,
280 options,
281 viewerRef,
282 className,
283 renderHeaderPrefix,
284}: AnnotatableCodeViewProps) {
285 const addReviewComment = useComposerDraftStore((store) => store.addReviewComment);
286 const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment);
287 const reviewComments = useComposerDraftStore(
288 (store) => store.getComposerDraft(composerDraftTarget)?.reviewComments ?? EMPTY_REVIEW_COMMENTS,
289 );
290 const [selectedLines, setSelectedLines] = useState<{
291 id: string;
292 range: SelectedLineRange;
293 } | null>(null);
294 const [draft, setDraft] = useState<{
295 fileKey: string;
296 annotation: DiffCommentLineAnnotation;
297 } | null>(null);
298
299 const filesByKey = useMemo(() => new Map(files.map((file) => [file.fileKey, file])), [files]);
300 const items = useMemo<CodeViewDiffItem<DiffCommentAnnotationGroup>[]>(
301 () =>
302 files.map(({ fileDiff, filePath, fileKey, collapsed }) => {
303 const persisted = reviewComments
304 .filter(
305 (comment) =>
306 comment.sectionId === sectionId &&
307 comment.filePath === filePath &&
308 (comment.fenceLanguage ?? "diff") === "diff",
309 )
310 .reduce<DiffCommentLineAnnotation[]>((annotations, comment) => {
311 const range = restoreDiffReviewCommentRange(fileDiff, comment);
312 if (!range) return annotations;
313 return appendAnnotationEntry(annotations, range, {
314 id: comment.id,
315 kind: "comment",
316 range,
317 rangeLabel: comment.rangeLabel,
318 text: comment.text,
319 });
320 }, []);
321 const annotations =
322 draft?.fileKey === fileKey ? [...persisted, draft.annotation] : persisted;
323 return {
324 id: fileKey,
325 type: "diff",
326 fileDiff,
327 annotations,
328 collapsed,
329 version: fnv1a32(
330 `${collapsed ? "1" : "0"}:${annotations
331 .flatMap((annotation) =>
332 annotation.metadata.entries.map(
333 (entry) => `${entry.id}:${entry.rangeLabel}:${entry.text}`,
334 ),
335 )
336 .join(":")}`,
337 ),
338 };
339 }),
340 [draft, files, reviewComments, sectionId],
341 );
342
343 const removeEntry = useCallback(
344 (entryId: string) => {
345 setSelectedLines(null);
346 if (draft?.annotation.metadata.entries.some((entry) => entry.id === entryId)) {
347 setDraft(null);
348 } else {
349 removeReviewComment(composerDraftTarget, entryId);
350 }
351 },
352 [composerDraftTarget, draft, removeReviewComment],
353 );
354
355 const submitEntry = useCallback(
356 (entryId: string, text: string) => {
357 const entry = draft?.annotation.metadata.entries.find(
358 (candidate) => candidate.id === entryId,
359 );
360 const file = draft ? filesByKey.get(draft.fileKey) : undefined;
361 if (!entry || !file) return;
362 const comment = buildDiffReviewComment({
363 id: entry.id,
364 sectionId,
365 sectionTitle,
366 filePath: file.filePath,
367 fileDiff: file.fileDiff,
368 range: entry.range,
369 text,
370 });
371 if (comment) addReviewComment(composerDraftTarget, comment);
372 setSelectedLines(null);
373 setDraft(null);
374 },
375 [addReviewComment, composerDraftTarget, draft, filesByKey, sectionId, sectionTitle],
376 );
377
378 const beginComment = useCallback(
379 (range: SelectedLineRange | null, context: DiffSelectionContext) => {
380 if (!range) return;
381 const item = context.item;
382 if (item.type !== "diff") return;
383 const file = filesByKey.get(item.id);
384 if (!file) return;
385 const id = nextFileCommentId();
386 const comment = buildDiffReviewComment({
387 id,
388 sectionId,
389 sectionTitle,
390 filePath: file.filePath,
391 fileDiff: file.fileDiff,
392 range,
393 text: "",
394 });
395 if (!comment) return;
396 setDraft({
397 fileKey: item.id,
398 annotation: {
399 side: annotationSide(range),
400 lineNumber: range.end,
401 metadata: {
402 entries: [{ id, kind: "draft", range, rangeLabel: comment.rangeLabel, text: "" }],
403 },
404 },
405 });
406 },
407 [filesByKey, sectionId, sectionTitle],
408 );
409
410 const hasOpenComment = draft !== null;
411 return (
412 <CodeView<DiffCommentAnnotationGroup>
413 {...(viewerRef ? { ref: viewerRef } : {})}
414 {...(className ? { className } : {})}
415 items={items}
416 selectedLines={selectedLines}
417 onSelectedLinesChange={setSelectedLines}
418 options={{
419 ...options,
420 enableGutterUtility: !hasOpenComment,
421 enableLineSelection: !hasOpenComment,
422 onLineSelectionEnd: beginComment,
423 }}
424 renderHeaderPrefix={(item) =>
425 item.type === "diff"
426 ? renderHeaderPrefix(item.fileDiff, item.id, item.collapsed === true)
427 : null
428 }
429 renderAnnotation={(annotation) => (
430 <div className="py-1">
431 {annotation.metadata.entries.map((entry) => (
432 <LocalCommentAnnotation
433 key={entry.id}
434 kind={entry.kind}
435 rangeLabel={entry.rangeLabel}
436 text={entry.text}
437 onCancel={() => removeEntry(entry.id)}
438 onComment={(text) => submitEntry(entry.id, text)}
439 onDelete={() => removeEntry(entry.id)}
440 />
441 ))}
442 </div>
443 )}
444 />
445 );
446}
apps/web/src/diffPanelStore.test.ts created+68
...@@ -0,0 +1,68 @@
1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts";
3import { beforeEach, describe, expect, it } from "vite-plus/test";
4
5import { selectThreadDiffPanelSelection, useDiffPanelStore } from "./diffPanelStore";
6
7const THREAD_REF = scopeThreadRef(EnvironmentId.make("environment-1"), ThreadId.make("thread-1"));
8
9describe("diffPanelStore", () => {
10 beforeEach(() => useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {} }));
11
12 it("defaults each thread to branch changes with automatic base selection", () => {
13 expect(
14 selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),
15 ).toEqual({ kind: "branch", baseRef: null });
16 });
17
18 it("clears incompatible selection fields when changing scopes", () => {
19 const store = useDiffPanelStore.getState();
20 store.selectTurn(THREAD_REF, TurnId.make("turn-1"), "src/app.ts");
21 store.selectGitScope(THREAD_REF, "unstaged");
22
23 expect(
24 selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),
25 ).toEqual({ kind: "unstaged" });
26
27 useDiffPanelStore.getState().selectBranchBaseRef(THREAD_REF, " origin/main ");
28 expect(
29 selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),
30 ).toEqual({ kind: "branch", baseRef: "origin/main" });
31 });
32
33 it("increments the reveal request when opening the same turn file again", () => {
34 const turnId = TurnId.make("turn-1");
35 useDiffPanelStore.getState().selectTurn(THREAD_REF, turnId, "src/app.ts");
36 useDiffPanelStore.getState().selectTurn(THREAD_REF, turnId, "src/app.ts");
37
38 expect(
39 selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),
40 ).toEqual({ kind: "turn", turnId, filePath: "src/app.ts", revealRequestId: 2 });
41 });
42
43 it("restores the selected branch base after visiting another scope", () => {
44 useDiffPanelStore.getState().selectBranchBaseRef(THREAD_REF, "origin/main");
45 useDiffPanelStore.getState().selectGitScope(THREAD_REF, "unstaged");
46 useDiffPanelStore.getState().selectGitScope(THREAD_REF, "branch");
47
48 expect(
49 selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),
50 ).toEqual({ kind: "branch", baseRef: "origin/main" });
51 });
52
53 it("reconciles a missing turn selection to the latest available turn", () => {
54 const missingTurnId = TurnId.make("turn-missing");
55 const latestTurnId = TurnId.make("turn-latest");
56 useDiffPanelStore.getState().selectTurn(THREAD_REF, missingTurnId, "src/app.ts");
57 useDiffPanelStore.getState().reconcileTurnSelection(THREAD_REF, [latestTurnId]);
58
59 expect(
60 selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),
61 ).toEqual({
62 kind: "turn",
63 turnId: latestTurnId,
64 filePath: "src/app.ts",
65 revealRequestId: 1,
66 });
67 });
68});
apps/web/src/diffPanelStore.ts created+139
...@@ -0,0 +1,139 @@
1import { scopedThreadKey } from "@t3tools/client-runtime/environment";
2import type { ScopedThreadRef, TurnId } from "@t3tools/contracts";
3import { create } from "zustand";
4import { createJSONStorage, persist } from "zustand/middleware";
5
6import { resolveStorage } from "./lib/storage";
7
8export type DiffPanelSelection =
9 | { kind: "branch"; baseRef: string | null }
10 | { kind: "unstaged" }
11 | { kind: "turn"; turnId: TurnId; filePath: string | null; revealRequestId: number };
12
13const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null };
14
15interface DiffPanelStoreState {
16 byThreadKey: Record<string, DiffPanelSelection>;
17 branchBaseRefByThreadKey: Record<string, string | null>;
18 selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void;
19 selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void;
20 selectTurn: (ref: ScopedThreadRef, turnId: TurnId, filePath?: string) => void;
21 reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray<TurnId>) => void;
22 removeThread: (ref: ScopedThreadRef) => void;
23}
24
25function normalizeBaseRef(baseRef: string | null): string | null {
26 const normalized = baseRef?.trim();
27 return normalized ? normalized : null;
28}
29
30export const useDiffPanelStore = create<DiffPanelStoreState>()(
31 persist(
32 (set) => ({
33 byThreadKey: {},
34 branchBaseRefByThreadKey: {},
35 selectGitScope: (ref, scope) =>
36 set((state) => {
37 const threadKey = scopedThreadKey(ref);
38 const previous = state.byThreadKey[threadKey];
39 const previousBaseRef =
40 previous?.kind === "branch"
41 ? previous.baseRef
42 : (state.branchBaseRefByThreadKey[threadKey] ?? null);
43 return {
44 byThreadKey: {
45 ...state.byThreadKey,
46 [threadKey]:
47 scope === "branch"
48 ? { kind: "branch", baseRef: previousBaseRef }
49 : { kind: "unstaged" },
50 },
51 branchBaseRefByThreadKey:
52 previous?.kind === "branch"
53 ? { ...state.branchBaseRefByThreadKey, [threadKey]: previous.baseRef }
54 : state.branchBaseRefByThreadKey,
55 };
56 }),
57 selectBranchBaseRef: (ref, baseRef) =>
58 set((state) => {
59 const threadKey = scopedThreadKey(ref);
60 const normalizedBaseRef = normalizeBaseRef(baseRef);
61 return {
62 byThreadKey: {
63 ...state.byThreadKey,
64 [threadKey]: { kind: "branch", baseRef: normalizedBaseRef },
65 },
66 branchBaseRefByThreadKey: {
67 ...state.branchBaseRefByThreadKey,
68 [threadKey]: normalizedBaseRef,
69 },
70 };
71 }),
72 selectTurn: (ref, turnId, filePath) =>
73 set((state) => {
74 const threadKey = scopedThreadKey(ref);
75 const previous = state.byThreadKey[threadKey];
76 return {
77 byThreadKey: {
78 ...state.byThreadKey,
79 [threadKey]: {
80 kind: "turn",
81 turnId,
82 filePath: filePath?.trim() || null,
83 revealRequestId: previous?.kind === "turn" ? previous.revealRequestId + 1 : 1,
84 },
85 },
86 };
87 }),
88 reconcileTurnSelection: (ref, availableTurnIds) =>
89 set((state) => {
90 const threadKey = scopedThreadKey(ref);
91 const previous = state.byThreadKey[threadKey];
92 const latestTurnId = availableTurnIds[0];
93 if (
94 previous?.kind !== "turn" ||
95 latestTurnId === undefined ||
96 availableTurnIds.includes(previous.turnId)
97 ) {
98 return state;
99 }
100 return {
101 byThreadKey: {
102 ...state.byThreadKey,
103 [threadKey]: { ...previous, turnId: latestTurnId },
104 },
105 };
106 }),
107 removeThread: (ref) =>
108 set((state) => {
109 const threadKey = scopedThreadKey(ref);
110 if (!(threadKey in state.byThreadKey) && !(threadKey in state.branchBaseRefByThreadKey)) {
111 return state;
112 }
113 const { [threadKey]: _removed, ...byThreadKey } = state.byThreadKey;
114 const { [threadKey]: _removedBaseRef, ...branchBaseRefByThreadKey } =
115 state.branchBaseRefByThreadKey;
116 return { byThreadKey, branchBaseRefByThreadKey };
117 }),
118 }),
119 {
120 name: "t3code:diff-panel-state:v1",
121 version: 1,
122 storage: createJSONStorage(() =>
123 resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
124 ),
125 partialize: (state) => ({
126 byThreadKey: state.byThreadKey,
127 branchBaseRefByThreadKey: state.branchBaseRefByThreadKey,
128 }),
129 },
130 ),
131);
132
133export function selectThreadDiffPanelSelection(
134 byThreadKey: Record<string, DiffPanelSelection>,
135 ref: ScopedThreadRef | null | undefined,
136): DiffPanelSelection {
137 if (!ref) return DEFAULT_SELECTION;
138 return byThreadKey[scopedThreadKey(ref)] ?? DEFAULT_SELECTION;
139}
apps/web/src/diffRouteSearch.test.ts deleted-74
...@@ -1,74 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { parseDiffRouteSearch } from "./diffRouteSearch";
4
5describe("parseDiffRouteSearch", () => {
6 it("parses valid diff search values", () => {
7 const parsed = parseDiffRouteSearch({
8 diff: "1",
9 diffTurnId: "turn-1",
10 diffFilePath: "src/app.ts",
11 });
12
13 expect(parsed).toEqual({
14 diff: "1",
15 diffTurnId: "turn-1",
16 diffFilePath: "src/app.ts",
17 });
18 });
19
20 it("treats numeric and boolean diff toggles as open", () => {
21 expect(
22 parseDiffRouteSearch({
23 diff: 1,
24 diffTurnId: "turn-1",
25 }),
26 ).toEqual({
27 diff: "1",
28 diffTurnId: "turn-1",
29 });
30
31 expect(
32 parseDiffRouteSearch({
33 diff: true,
34 diffTurnId: "turn-1",
35 }),
36 ).toEqual({
37 diff: "1",
38 diffTurnId: "turn-1",
39 });
40 });
41
42 it("drops turn and file values when diff is closed", () => {
43 const parsed = parseDiffRouteSearch({
44 diff: "0",
45 diffTurnId: "turn-1",
46 diffFilePath: "src/app.ts",
47 });
48
49 expect(parsed).toEqual({});
50 });
51
52 it("drops file value when turn is not selected", () => {
53 const parsed = parseDiffRouteSearch({
54 diff: "1",
55 diffFilePath: "src/app.ts",
56 });
57
58 expect(parsed).toEqual({
59 diff: "1",
60 });
61 });
62
63 it("normalizes whitespace-only values", () => {
64 const parsed = parseDiffRouteSearch({
65 diff: "1",
66 diffTurnId: " ",
67 diffFilePath: " ",
68 });
69
70 expect(parsed).toEqual({
71 diff: "1",
72 });
73 });
74});
apps/web/src/diffRouteSearch.ts deleted-39
...@@ -1,39 +0,0 @@
1import { TurnId } from "@t3tools/contracts";
2
3export interface DiffRouteSearch {
4 diff?: "1" | undefined;
5 diffTurnId?: TurnId | undefined;
6 diffFilePath?: string | undefined;
7}
8
9function isDiffOpenValue(value: unknown): boolean {
10 return value === "1" || value === 1 || value === true;
11}
12
13function normalizeSearchString(value: unknown): string | undefined {
14 if (typeof value !== "string") {
15 return undefined;
16 }
17 const normalized = value.trim();
18 return normalized.length > 0 ? normalized : undefined;
19}
20
21export function stripDiffSearchParams<T extends Record<string, unknown>>(
22 params: T,
23): Omit<T, "diff" | "diffTurnId" | "diffFilePath"> {
24 const { diff: _diff, diffTurnId: _diffTurnId, diffFilePath: _diffFilePath, ...rest } = params;
25 return rest as Omit<T, "diff" | "diffTurnId" | "diffFilePath">;
26}
27
28export function parseDiffRouteSearch(search: Record<string, unknown>): DiffRouteSearch {
29 const diff = isDiffOpenValue(search.diff) ? "1" : undefined;
30 const diffTurnIdRaw = diff ? normalizeSearchString(search.diffTurnId) : undefined;
31 const diffTurnId = diffTurnIdRaw ? TurnId.make(diffTurnIdRaw) : undefined;
32 const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined;
33
34 return {
35 ...(diff ? { diff } : {}),
36 ...(diffTurnId ? { diffTurnId } : {}),
37 ...(diffFilePath ? { diffFilePath } : {}),
38 };
39}
apps/web/src/index.css+2-1
...@@ -689,7 +689,8 @@ label:has(> select#reasoning-effort) select {...@@ -689,7 +689,8 @@ label:has(> select#reasoning-effort) select {
689 background: color-mix(in srgb, var(--background) 94%, var(--card));689 background: color-mix(in srgb, var(--background) 94%, var(--card));
690}690}
691691
692.diff-render-file {692.diff-render-file,
693.diff-render-surface > diffs-container {
693 border: 1px solid var(--border);694 border: 1px solid var(--border);
694 border-radius: 0.5rem;695 border-radius: 0.5rem;
695 overflow: clip;696 overflow: clip;
apps/web/src/lib/baseRefChoices.test.ts created+52
...@@ -0,0 +1,52 @@
1import { describe, expect, it } from "vite-plus/test";
2import type { VcsRef } from "@t3tools/contracts";
3import { buildBaseRefChoices, filterBaseRefChoices } from "./baseRefChoices";
4
5function ref(name: string, remoteName?: string): VcsRef {
6 return {
7 name,
8 current: false,
9 isDefault: false,
10 isRemote: remoteName !== undefined,
11 ...(remoteName ? { remoteName } : {}),
12 worktreePath: null,
13 };
14}
15
16describe("buildBaseRefChoices", () => {
17 it("pairs matching local and remote branches and prefers origin", () => {
18 const choices = buildBaseRefChoices(
19 [ref("main")],
20 [ref("upstream/main", "upstream"), ref("origin/main", "origin")],
21 );
22
23 expect(choices).toEqual([
24 expect.objectContaining({
25 label: "main",
26 local: expect.objectContaining({ name: "main" }),
27 remote: expect.objectContaining({ name: "origin/main" }),
28 }),
29 expect.objectContaining({
30 label: "upstream/main",
31 local: null,
32 remote: expect.objectContaining({ name: "upstream/main" }),
33 }),
34 ]);
35 });
36});
37
38describe("filterBaseRefChoices", () => {
39 it("filters stale server results against the current query", () => {
40 const choices = buildBaseRefChoices(
41 [ref("main"), ref("feature/search")],
42 [ref("origin/main", "origin"), ref("origin/feature/search", "origin")],
43 );
44
45 expect(filterBaseRefChoices(choices, "SEARCH").map((choice) => choice.label)).toEqual([
46 "feature/search",
47 ]);
48 expect(filterBaseRefChoices(choices, "origin/main").map((choice) => choice.label)).toEqual([
49 "main",
50 ]);
51 });
52});
apps/web/src/lib/baseRefChoices.ts created+61
...@@ -0,0 +1,61 @@
1import type { VcsRef } from "@t3tools/contracts";
2
3export interface BaseRefChoice {
4 readonly id: string;
5 readonly label: string;
6 readonly local: VcsRef | null;
7 readonly remote: VcsRef | null;
8}
9
10function remoteBranchName(ref: VcsRef): string {
11 if (ref.remoteName && ref.name.startsWith(`${ref.remoteName}/`)) {
12 return ref.name.slice(ref.remoteName.length + 1);
13 }
14 return ref.name;
15}
16
17export function buildBaseRefChoices(
18 localRefs: ReadonlyArray<VcsRef>,
19 remoteRefs: ReadonlyArray<VcsRef>,
20): ReadonlyArray<BaseRefChoice> {
21 const unusedRemoteRefs = new Set(remoteRefs);
22 const pairedChoices = localRefs.map((local) => {
23 const matches = remoteRefs.filter(
24 (remote) => unusedRemoteRefs.has(remote) && remoteBranchName(remote) === local.name,
25 );
26 const remote =
27 matches.find((candidate) => candidate.remoteName === "origin") ?? matches[0] ?? null;
28 if (remote) unusedRemoteRefs.delete(remote);
29 return {
30 id: `local:${local.name}`,
31 label: local.name,
32 local,
33 remote,
34 };
35 });
36
37 const remoteOnlyChoices = remoteRefs
38 .filter((remote) => unusedRemoteRefs.has(remote))
39 .map((remote) => ({
40 id: `remote:${remote.name}`,
41 label: remote.name,
42 local: null,
43 remote,
44 }));
45
46 return [...pairedChoices, ...remoteOnlyChoices];
47}
48
49export function filterBaseRefChoices(
50 choices: ReadonlyArray<BaseRefChoice>,
51 query: string,
52): ReadonlyArray<BaseRefChoice> {
53 const normalizedQuery = query.trim().toLocaleLowerCase();
54 if (normalizedQuery.length === 0) return choices;
55 return choices.filter(
56 (choice) =>
57 choice.label.toLocaleLowerCase().includes(normalizedQuery) ||
58 choice.local?.name.toLocaleLowerCase().includes(normalizedQuery) === true ||
59 choice.remote?.name.toLocaleLowerCase().includes(normalizedQuery) === true,
60 );
61}
apps/web/src/lib/diffRendering.test.ts+54-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import { buildPatchCacheKey } from "./diffRendering";2import { buildPatchCacheKey, getRenderablePatch } from "./diffRendering";
33
4describe("buildPatchCacheKey", () => {4describe("buildPatchCacheKey", () => {
5 it("returns a stable cache key for identical content", () => {5 it("returns a stable cache key for identical content", () => {
...@@ -29,3 +29,56 @@ describe("buildPatchCacheKey", () => {...@@ -29,3 +29,56 @@ describe("buildPatchCacheKey", () => {
29 );29 );
30 });30 });
31});31});
32
33describe("getRenderablePatch", () => {
34 it("compacts partial hunk render offsets for virtualized review diffs", () => {
35 const patch = [
36 "diff --git a/example.ts b/example.ts",
37 "index 1111111..2222222 100644",
38 "--- a/example.ts",
39 "+++ b/example.ts",
40 "@@ -48,4 +48,4 @@",
41 " context",
42 "-before",
43 "+after",
44 " context",
45 " context",
46 "@@ -80,3 +80,4 @@",
47 " context",
48 "+added",
49 " context",
50 " context",
51 ].join("\n");
52
53 const parsed = getRenderablePatch(patch, "review", {
54 compactPartialHunkOffsets: true,
55 });
56 expect(parsed?.kind).toBe("files");
57 if (parsed?.kind !== "files") return;
58
59 const file = parsed.files[0];
60 expect(file?.hunks[0]?.collapsedBefore).toBe(47);
61 expect(file?.hunks[0]?.unifiedLineStart).toBe(0);
62 expect(file?.hunks[1]?.collapsedBefore).toBeGreaterThan(0);
63 expect(file?.hunks[1]?.unifiedLineStart).toBe(file?.hunks[0]?.unifiedLineCount);
64 expect(file?.unifiedLineCount).toBe(
65 file?.hunks.reduce((total, hunk) => total + hunk.unifiedLineCount, 0),
66 );
67 });
68
69 it("retains source-file offsets for checkpoint diffs", () => {
70 const patch = [
71 "diff --git a/example.ts b/example.ts",
72 "--- a/example.ts",
73 "+++ b/example.ts",
74 "@@ -48,1 +48,1 @@",
75 "-before",
76 "+after",
77 ].join("\n");
78
79 const parsed = getRenderablePatch(patch, "checkpoint");
80 expect(parsed?.kind).toBe("files");
81 if (parsed?.kind !== "files") return;
82 expect(parsed.files[0]?.hunks[0]?.unifiedLineStart).toBe(47);
83 });
84});
apps/web/src/lib/diffRendering.ts+41-1
...@@ -52,9 +52,45 @@ export type RenderablePatch =...@@ -52,9 +52,45 @@ export type RenderablePatch =
52 reason: string;52 reason: string;
53 };53 };
5454
55interface RenderablePatchOptions {
56 /**
57 * Pierre's partial-patch parser keeps hunk render starts in source-file
58 * coordinates. Its virtualizer iterates partial patches as compact rows, so
59 * review diffs need compact render starts while retaining collapsedBefore
60 * for the "N unmodified lines" separator.
61 */
62 compactPartialHunkOffsets?: boolean;
63}
64
65export function compactPartialHunkOffsets(file: FileDiffMetadata): FileDiffMetadata {
66 if (!file.isPartial) return file;
67
68 let splitLineStart = 0;
69 let unifiedLineStart = 0;
70 const hunks = file.hunks.map((hunk) => {
71 const compactHunk = {
72 ...hunk,
73 splitLineStart,
74 unifiedLineStart,
75 };
76 splitLineStart += hunk.splitLineCount;
77 unifiedLineStart += hunk.unifiedLineCount;
78 return compactHunk;
79 });
80
81 return {
82 ...file,
83 hunks,
84 splitLineCount: splitLineStart,
85 unifiedLineCount: unifiedLineStart,
86 ...(file.cacheKey ? { cacheKey: `${file.cacheKey}:compact-partial` } : {}),
87 };
88}
89
55export function getRenderablePatch(90export function getRenderablePatch(
56 patch: string | undefined,91 patch: string | undefined,
57 cacheScope = "diff-panel",92 cacheScope = "diff-panel",
93 options: RenderablePatchOptions = {},
58): RenderablePatch | null {94): RenderablePatch | null {
59 if (!patch) return null;95 if (!patch) return null;
60 const normalizedPatch = patch.trim();96 const normalizedPatch = patch.trim();
...@@ -65,7 +101,11 @@ export function getRenderablePatch(...@@ -65,7 +101,11 @@ export function getRenderablePatch(
65 normalizedPatch,101 normalizedPatch,
66 buildPatchCacheKey(normalizedPatch, cacheScope),102 buildPatchCacheKey(normalizedPatch, cacheScope),
67 );103 );
68 const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files);104 const files = parsedPatches.flatMap((parsedPatch) =>
105 options.compactPartialHunkOffsets
106 ? parsedPatch.files.map(compactPartialHunkOffsets)
107 : parsedPatch.files,
108 );
69 if (files.length > 0) {109 if (files.length > 0) {
70 return { kind: "files", files };110 return { kind: "files", files };
71 }111 }
apps/web/src/rightPanelStore.test.ts-11
...@@ -6,7 +6,6 @@ import {...@@ -6,7 +6,6 @@ import {
6 migratePersistedRightPanelState,6 migratePersistedRightPanelState,
7 selectActiveRightPanel,7 selectActiveRightPanel,
8 selectActiveRightPanelSurface,8 selectActiveRightPanelSurface,
9 selectActiveRightPanelKindWithUrl,
10 selectThreadRightPanelState,9 selectThreadRightPanelState,
11 useRightPanelStore,10 useRightPanelStore,
12} from "./rightPanelStore";11} from "./rightPanelStore";
...@@ -254,16 +253,6 @@ describe("rightPanelStore", () => {...@@ -254,16 +253,6 @@ describe("rightPanelStore", () => {
254 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("plan");253 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("plan");
255 });254 });
256255
257 it("?diff=1 always wins over persisted state", () => {
258 useRightPanelStore.getState().open(refA, "preview");
259 expect(
260 selectActiveRightPanelKindWithUrl(useRightPanelStore.getState().byThreadKey, refA, true),
261 ).toBe("diff");
262 expect(
263 selectActiveRightPanelKindWithUrl(useRightPanelStore.getState().byThreadKey, refA, false),
264 ).toBe("preview");
265 });
266
267 it("removeThread clears persisted state", () => {256 it("removeThread clears persisted state", () => {
268 useRightPanelStore.getState().open(refA, "plan");257 useRightPanelStore.getState().open(refA, "plan");
269 useRightPanelStore.getState().removeThread(refA);258 useRightPanelStore.getState().removeThread(refA);
apps/web/src/rightPanelStore.ts-10
...@@ -550,13 +550,3 @@ export function selectActiveRightPanelSurface(...@@ -550,13 +550,3 @@ export function selectActiveRightPanelSurface(
550 if (!state.isOpen) return null;550 if (!state.isOpen) return null;
551 return state.surfaces.find((surface) => surface.id === state.activeSurfaceId) ?? null;551 return state.surfaces.find((surface) => surface.id === state.activeSurfaceId) ?? null;
552}552}
553
554export function selectActiveRightPanelKindWithUrl(
555 byThreadKey: Record<string, ThreadRightPanelState>,
556 ref: ScopedThreadRef | null | undefined,
557 diffSearchActive: boolean,
558): RightPanelKind | null {
559 if (!selectThreadRightPanelState(byThreadKey, ref).isOpen) return null;
560 if (diffSearchActive) return "diff";
561 return selectActiveRightPanel(byThreadKey, ref);
562}
apps/web/src/routes/_chat.$environmentId.$threadId.tsx+1-6
...@@ -1,10 +1,9 @@...@@ -1,10 +1,9 @@
1import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router";1import { createFileRoute, useNavigate } from "@tanstack/react-router";
2import { useEffect } from "react";2import { useEffect } from "react";
33
4import ChatView from "../components/ChatView";4import ChatView from "../components/ChatView";
5import { threadHasStarted } from "../components/ChatView.logic";5import { threadHasStarted } from "../components/ChatView.logic";
6import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";6import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";
7import { type DiffRouteSearch, parseDiffRouteSearch } from "../diffRouteSearch";
8import { resolveThreadRouteRef } from "../threadRoutes";7import { resolveThreadRouteRef } from "../threadRoutes";
9import { SidebarInset } from "~/components/ui/sidebar";8import { SidebarInset } from "~/components/ui/sidebar";
10import { useEnvironmentThreadRefs, useThreadDetail, useThreadShell } from "../state/entities";9import { useEnvironmentThreadRefs, useThreadDetail, useThreadShell } from "../state/entities";
...@@ -74,9 +73,5 @@ function ChatThreadRouteView() {...@@ -74,9 +73,5 @@ function ChatThreadRouteView() {
74}73}
7574
76export const Route = createFileRoute("/_chat/$environmentId/$threadId")({75export const Route = createFileRoute("/_chat/$environmentId/$threadId")({
77 validateSearch: (search) => parseDiffRouteSearch(search),
78 search: {
79 middlewares: [retainSearchParams<DiffRouteSearch>(["diff"])],
80 },
81 component: ChatThreadRouteView,76 component: ChatThreadRouteView,
82});77});
packages/contracts/src/git.ts+2
...@@ -125,6 +125,8 @@ export const VcsListRefsInput = Schema.Struct({...@@ -125,6 +125,8 @@ export const VcsListRefsInput = Schema.Struct({
125 cwd: TrimmedNonEmptyStringSchema,125 cwd: TrimmedNonEmptyStringSchema,
126 query: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(256))),126 query: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(256))),
127 cursor: Schema.optional(NonNegativeInt),127 cursor: Schema.optional(NonNegativeInt),
128 includeMatchingRemoteRefs: Schema.optional(Schema.Boolean),
129 refKind: Schema.optional(Schema.Literals(["all", "local", "remote"])),
128 limit: Schema.optional(130 limit: Schema.optional(
129 PositiveInt.check(Schema.isLessThanOrEqualTo(GIT_LIST_BRANCHES_MAX_LIMIT)),131 PositiveInt.check(Schema.isLessThanOrEqualTo(GIT_LIST_BRANCHES_MAX_LIMIT)),
130 ),132 ),
packages/contracts/src/review.ts+1
...@@ -6,6 +6,7 @@ import { VcsError } from "./vcs.ts";...@@ -6,6 +6,7 @@ import { VcsError } from "./vcs.ts";
6export const ReviewDiffPreviewInput = Schema.Struct({6export const ReviewDiffPreviewInput = Schema.Struct({
7 cwd: TrimmedNonEmptyString,7 cwd: TrimmedNonEmptyString,
8 baseRef: Schema.optional(TrimmedNonEmptyString),8 baseRef: Schema.optional(TrimmedNonEmptyString),
9 ignoreWhitespace: Schema.optionalKey(Schema.Boolean),
9});10});
10export type ReviewDiffPreviewInput = typeof ReviewDiffPreviewInput.Type;11export type ReviewDiffPreviewInput = typeof ReviewDiffPreviewInput.Type;
1112