| author | |
| committer | |
| log | d2c0a6a48f50f319815a8cf5501f9e2e9b8ca617 |
| tree | a22178dfa669927b5ffbabc09cbd303e9504a8bf |
| parent | 1cf3647da62ee4c363f7c3d963c709dbddaa6297 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 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 | }); |
| 104 | 139 | |
| 105 | 140 | describe("repository status", () => { |
| ... | ... | @@ -342,6 +377,44 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 342 | 377 | }); |
| 343 | 378 | |
| 344 | 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 | 418 | it.effect("creates, checks out, renames, and lists refs", () => |
| 346 | 419 | Effect.gen(function* () { |
| 347 | 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 | 1817 | const dirtyTrackedResult = yield* executeGit( |
| 1818 | 1818 | "GitVcsDriver.getReviewDiffPreview.dirtyTracked", |
| 1819 | 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 | 1829 | maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, |
| 1823 | 1830 | appendTruncationMarker: true, |
| ... | ... | @@ -1843,7 +1850,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1843 | 1850 | ? yield* executeGit( |
| 1844 | 1851 | "GitVcsDriver.getReviewDiffPreview.base", |
| 1845 | 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 | 1861 | maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, |
| 1849 | 1862 | appendTruncationMarker: true, |
| ... | ... | @@ -2127,11 +2140,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 2127 | 2140 | }) |
| 2128 | 2141 | : []; |
| 2129 | 2142 | |
| 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 | 2152 | const refs = paginateBranches({ |
| 2131 | refs: filterBranchesForListQuery( | |
| 2132 | dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]), | |
| 2133 | input.query, | |
| 2134 | ), | |
| 2153 | refs: filterBranchesForListQuery(branchesForKind, input.query), | |
| 2135 | 2154 | cursor: input.cursor, |
| 2136 | 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 | 42 | import { Debouncer } from "@tanstack/react-pacer"; |
| 43 | 43 | import { useAtomValue } from "@effect/atom-react"; |
| 44 | 44 | import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 45 | import { useNavigate, useSearch } from "@tanstack/react-router"; | |
| 45 | import { useNavigate } from "@tanstack/react-router"; | |
| 46 | 46 | import { useShallow } from "zustand/react/shallow"; |
| 47 | 47 | import { |
| 48 | 48 | isAtomCommandInterrupted, |
| ... | ... | @@ -55,7 +55,7 @@ import * as Cause from "effect/Cause"; |
| 55 | 55 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 56 | 56 | import { isElectron } from "../env"; |
| 57 | 57 | import { readLocalApi } from "../localApi"; |
| 58 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | |
| 58 | import { useDiffPanelStore } from "../diffPanelStore"; | |
| 59 | 59 | import { |
| 60 | 60 | collapseExpandedComposerCursor, |
| 61 | 61 | parseStandaloneComposerSlashCommand, |
| ... | ... | @@ -104,7 +104,7 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; |
| 104 | 104 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| 105 | 105 | import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; |
| 106 | 106 | import { |
| 107 | selectActiveRightPanelKindWithUrl, | |
| 107 | selectActiveRightPanel, | |
| 108 | 108 | selectActiveRightPanelSurface, |
| 109 | 109 | selectThreadRightPanelState, |
| 110 | 110 | type RightPanelSurface, |
| ... | ... | @@ -1034,10 +1034,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 1034 | 1034 | const timestampFormat = settings.timestampFormat; |
| 1035 | 1035 | const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; |
| 1036 | 1036 | const navigate = useNavigate(); |
| 1037 | const rawSearch = useSearch({ | |
| 1038 | strict: false, | |
| 1039 | select: (params) => parseDiffRouteSearch(params), | |
| 1040 | }); | |
| 1041 | 1037 | const { resolvedTheme } = useTheme(); |
| 1042 | 1038 | // Granular store selectors — avoid subscribing to prompt changes. |
| 1043 | 1039 | const composerRuntimeMode = useComposerDraftStore( |
| ... | ... | @@ -1217,7 +1213,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 1217 | 1213 | composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; |
| 1218 | 1214 | const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; |
| 1219 | 1215 | const canCheckoutPullRequestIntoThread = isLocalDraftThread; |
| 1220 | const diffOpen = rawSearch.diff === "1"; | |
| 1221 | 1216 | const activeThreadId = activeThread?.id ?? null; |
| 1222 | 1217 | const runningTerminalIds = useThreadRunningTerminalIds({ |
| 1223 | 1218 | environmentId: activeThread?.environmentId ?? null, |
| ... | ... | @@ -1259,8 +1254,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 1259 | 1254 | ); |
| 1260 | 1255 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; |
| 1261 | 1256 | const activeRightPanelKind = useRightPanelStore((state) => |
| 1262 | selectActiveRightPanelKindWithUrl(state.byThreadKey, activeThreadRef, diffOpen), | |
| 1257 | selectActiveRightPanel(state.byThreadKey, activeThreadRef), | |
| 1263 | 1258 | ); |
| 1259 | const diffOpen = activeRightPanelKind === "diff"; | |
| 1264 | 1260 | const rightPanelState = useRightPanelStore((state) => |
| 1265 | 1261 | selectThreadRightPanelState(state.byThreadKey, activeThreadRef), |
| 1266 | 1262 | ); |
| ... | ... | @@ -1295,11 +1291,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 1295 | 1291 | |
| 1296 | 1292 | const planSidebarOpen = activeRightPanelKind === "plan"; |
| 1297 | 1293 | |
| 1298 | useEffect(() => { | |
| 1299 | if (!activeThreadRef || !diffOpen) return; | |
| 1300 | useRightPanelStore.getState().open(activeThreadRef, "diff"); | |
| 1301 | }, [activeThreadRef, diffOpen]); | |
| 1302 | ||
| 1303 | 1294 | const existingOpenTerminalThreadKeys = useMemo(() => { |
| 1304 | 1295 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); |
| 1305 | 1296 | return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); |
| ... | ... | @@ -2151,27 +2142,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2151 | 2142 | if (activeThreadRef) { |
| 2152 | 2143 | useRightPanelStore.getState().toggle(activeThreadRef, "diff"); |
| 2153 | 2144 | } |
| 2154 | void navigate({ | |
| 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 | ]); | |
| 2145 | }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]); | |
| 2175 | 2146 | |
| 2176 | 2147 | const envLocked = Boolean( |
| 2177 | 2148 | activeThread && |
| ... | ... | @@ -2757,21 +2728,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2757 | 2728 | if (!activeThreadRef || !isServerThread || !isGitRepo) return; |
| 2758 | 2729 | useRightPanelStore.getState().open(activeThreadRef, "diff"); |
| 2759 | 2730 | onDiffPanelOpen?.(); |
| 2760 | void navigate({ | |
| 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 | ]); | |
| 2731 | }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]); | |
| 2775 | 2732 | const addFilesSurface = useCallback(() => { |
| 2776 | 2733 | if (!activeThreadRef || !activeProject) return; |
| 2777 | 2734 | useRightPanelStore.getState().open(activeThreadRef, "files"); |
| ... | ... | @@ -2789,30 +2746,13 @@ function ChatViewContent(props: ChatViewProps) { |
| 2789 | 2746 | useRightPanelStore.getState().close(activeThreadRef); |
| 2790 | 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 | 2749 | const activeTabId = activePreviewState.activeTabId; |
| 2801 | 2750 | if (activeTabId) { |
| 2802 | 2751 | useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId); |
| 2803 | 2752 | } else { |
| 2804 | 2753 | createBrowserSurface(); |
| 2805 | 2754 | } |
| 2806 | }, [ | |
| 2807 | activePreviewState.activeTabId, | |
| 2808 | activeThreadRef, | |
| 2809 | createBrowserSurface, | |
| 2810 | diffOpen, | |
| 2811 | environmentId, | |
| 2812 | navigate, | |
| 2813 | previewPanelOpen, | |
| 2814 | threadId, | |
| 2815 | ]); | |
| 2755 | }, [activePreviewState.activeTabId, activeThreadRef, createBrowserSurface, previewPanelOpen]); | |
| 2816 | 2756 | const closePreviewPanel = useCallback(() => { |
| 2817 | 2757 | if (activeThreadRef) { |
| 2818 | 2758 | setMaximizedRightPanelThreadKey(null); |
| ... | ... | @@ -2936,31 +2876,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 2936 | 2876 | } |
| 2937 | 2877 | if (surface.kind === "diff" && !diffOpen) { |
| 2938 | 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 | [ | |
| 2955 | activeThreadRef, | |
| 2956 | diffOpen, | |
| 2957 | dismissPlanSidebarForCurrentTurn, | |
| 2958 | environmentId, | |
| 2959 | navigate, | |
| 2960 | onDiffPanelOpen, | |
| 2961 | planSidebarOpen, | |
| 2962 | threadId, | |
| 2963 | ], | |
| 2881 | [activeThreadRef, diffOpen, dismissPlanSidebarForCurrentTurn, onDiffPanelOpen, planSidebarOpen], | |
| 2964 | 2882 | ); |
| 2965 | 2883 | const toggleRightPanel = useCallback(() => { |
| 2966 | 2884 | if (!activeThreadRef) return; |
| ... | ... | @@ -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 | 2929 | activeThreadRef, |
| 3020 | 2930 | activePreviewState.sessions, |
| 3021 | 2931 | closePreview, |
| 3022 | 2932 | closeTerminalMutation, |
| 3023 | diffOpen, | |
| 3024 | 2933 | dismissPlanSidebarForCurrentTurn, |
| 3025 | environmentId, | |
| 3026 | navigate, | |
| 3027 | 2934 | storeCloseTerminal, |
| 3028 | threadId, | |
| 3029 | 2935 | ], |
| 3030 | 2936 | ); |
| 3031 | 2937 | const syncActivePreviewSurface = useCallback(() => { |
| ... | ... | @@ -4631,25 +4537,12 @@ function ChatViewContent(props: ChatViewProps) { |
| 4631 | 4537 | }, []); |
| 4632 | 4538 | const onOpenTurnDiff = useCallback( |
| 4633 | 4539 | (turnId: TurnId, filePath?: string) => { |
| 4634 | if (!isServerThread) { | |
| 4635 | return; | |
| 4636 | } | |
| 4540 | if (!isServerThread || !activeThreadRef) return; | |
| 4541 | useDiffPanelStore.getState().selectTurn(activeThreadRef, turnId, filePath); | |
| 4542 | useRightPanelStore.getState().open(activeThreadRef, "diff"); | |
| 4637 | 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 | 4547 | // Both the Map and the revert handler are read from refs at call-time so |
| 4655 | 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 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { Virtualizer } from "@pierre/diffs/react"; | |
| 3 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; | |
| 4 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 2 | import { useParams } from "@tanstack/react-router"; | |
| 5 | 3 | import { |
| 6 | 4 | isAtomCommandInterrupted, |
| 7 | 5 | squashAtomCommandFailure, |
| 8 | 6 | } from "@t3tools/client-runtime/state/runtime"; |
| 9 | 7 | import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; |
| 10 | 8 | import { |
| 9 | ArrowRightIcon, | |
| 10 | CheckIcon, | |
| 11 | 11 | ChevronDownIcon, |
| 12 | ChevronLeftIcon, | |
| 13 | 12 | ChevronRightIcon, |
| 14 | 13 | Columns2Icon, |
| 15 | 14 | PilcrowIcon, |
| 16 | 15 | Rows3Icon, |
| 16 | SearchIcon, | |
| 17 | 17 | TextWrapIcon, |
| 18 | 18 | } from "lucide-react"; |
| 19 | import { | |
| 20 | type WheelEvent as ReactWheelEvent, | |
| 21 | useCallback, | |
| 22 | useEffect, | |
| 23 | useMemo, | |
| 24 | useRef, | |
| 25 | useState, | |
| 26 | } from "react"; | |
| 19 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 27 | 20 | import { useOpenInPreferredEditor } from "../editorPreferences"; |
| 28 | 21 | import { type DraftId } from "../composerDraftStore"; |
| 29 | 22 | import { openDiffFilePrimaryAction } from "../diffFileActions"; |
| 30 | 23 | import { useCheckpointDiff } from "~/lib/checkpointDiffState"; |
| 31 | 24 | import { cn } from "~/lib/utils"; |
| 32 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | |
| 25 | import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; | |
| 33 | 26 | import { useTheme } from "../hooks/useTheme"; |
| 34 | 27 | import { |
| 35 | 28 | buildFileDiffRenderKey, |
| ... | ... | @@ -40,19 +33,41 @@ import { |
| 40 | 33 | } from "../lib/diffRendering"; |
| 41 | 34 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 42 | 35 | import { useProject, useThread } from "../state/entities"; |
| 43 | import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; | |
| 36 | import { resolveThreadRouteRef } from "../threadRoutes"; | |
| 44 | 37 | import { useSettings } from "../hooks/useSettings"; |
| 45 | 38 | import { formatShortTimestamp } from "../timestampFormat"; |
| 46 | 39 | import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; |
| 47 | import { AnnotatableFileDiff } from "./diffs/AnnotatableFileDiff"; | |
| 40 | import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableFileDiff"; | |
| 48 | 41 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; |
| 42 | import { Switch } from "./ui/switch"; | |
| 43 | import { | |
| 44 | Combobox, | |
| 45 | ComboboxEmpty, | |
| 46 | ComboboxInput, | |
| 47 | ComboboxItem, | |
| 48 | ComboboxList, | |
| 49 | ComboboxPopup, | |
| 50 | ComboboxTrigger, | |
| 51 | } from "./ui/combobox"; | |
| 52 | import { | |
| 53 | DropdownMenu, | |
| 54 | DropdownMenuContent, | |
| 55 | DropdownMenuItem, | |
| 56 | DropdownMenuSub, | |
| 57 | DropdownMenuSubContent, | |
| 58 | DropdownMenuSubTrigger, | |
| 59 | DropdownMenuTrigger, | |
| 60 | } from "./ui/menu"; | |
| 49 | 61 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 50 | 62 | import { useEnvironmentQuery } from "../state/query"; |
| 51 | 63 | import { serverEnvironment } from "../state/server"; |
| 64 | import { reviewEnvironment } from "../state/review"; | |
| 52 | 65 | import { vcsEnvironment } from "../state/vcs"; |
| 66 | import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices"; | |
| 53 | 67 | |
| 54 | 68 | type DiffRenderMode = "stacked" | "split"; |
| 55 | 69 | type DiffThemeType = "light" | "dark"; |
| 70 | const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; | |
| 56 | 71 | |
| 57 | 72 | interface CollapsedDiffFilesState { |
| 58 | 73 | readonly scopeKey: string | null; |
| ... | ... | @@ -167,27 +182,24 @@ interface DiffPanelProps { |
| 167 | 182 | export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; |
| 168 | 183 | |
| 169 | 184 | export default function DiffPanel({ mode = "inline", composerDraftTarget }: DiffPanelProps) { |
| 170 | const navigate = useNavigate(); | |
| 171 | 185 | const { resolvedTheme } = useTheme(); |
| 172 | 186 | const settings = useSettings(); |
| 173 | 187 | const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked"); |
| 174 | 188 | const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); |
| 175 | 189 | const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); |
| 190 | const [baseRefQuery, setBaseRefQuery] = useState(""); | |
| 176 | 191 | const [collapsedDiffFiles, setCollapsedDiffFiles] = useState<CollapsedDiffFilesState>(() => ({ |
| 177 | 192 | scopeKey: null, |
| 178 | 193 | fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS, |
| 179 | 194 | })); |
| 180 | const patchViewportRef = useRef<HTMLDivElement>(null); | |
| 181 | const turnStripRef = useRef<HTMLDivElement>(null); | |
| 182 | const previousDiffOpenRef = useRef(false); | |
| 183 | const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); | |
| 184 | const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); | |
| 195 | const codeViewRef = useRef<AnnotatableCodeViewHandle>(null); | |
| 185 | 196 | const routeThreadRef = useParams({ |
| 186 | 197 | strict: false, |
| 187 | 198 | select: (params) => resolveThreadRouteRef(params), |
| 188 | 199 | }); |
| 189 | const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); | |
| 190 | const diffOpen = diffSearch.diff === "1"; | |
| 200 | const diffSelection = useDiffPanelStore((state) => | |
| 201 | selectThreadDiffPanelSelection(state.byThreadKey, routeThreadRef), | |
| 202 | ); | |
| 191 | 203 | const activeThreadId = routeThreadRef?.threadId ?? null; |
| 192 | 204 | const activeThread = useThread(routeThreadRef); |
| 193 | 205 | const activeProjectId = activeThread?.projectId ?? null; |
| ... | ... | @@ -233,8 +245,20 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff |
| 233 | 245 | [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], |
| 234 | 246 | ); |
| 235 | 247 | |
| 236 | const selectedTurnId = diffSearch.diffTurnId ?? null; | |
| 237 | const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; | |
| 248 | useEffect(() => { | |
| 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 | 262 | const selectedTurn = |
| 239 | 263 | selectedTurnId === null |
| 240 | 264 | ? undefined |
| ... | ... | @@ -243,7 +267,16 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff |
| 243 | 267 | const selectedCheckpointTurnCount = |
| 244 | 268 | selectedTurn && |
| 245 | 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 | 280 | const collapseScopeKey = routeThreadRef |
| 248 | 281 | ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}` |
| 249 | 282 | : null; |
| ... | ... | @@ -253,7 +286,9 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff |
| 253 | 286 | : EMPTY_COLLAPSED_DIFF_FILE_KEYS; |
| 254 | 287 | const reviewSectionTitle = selectedTurn |
| 255 | 288 | ? `Turn ${selectedCheckpointTurnCount ?? "?"}` |
| 256 | : "All turns"; | |
| 289 | : selectedGitScope === "unstaged" | |
| 290 | ? "Working tree" | |
| 291 | : "Branch changes"; | |
| 257 | 292 | const selectedCheckpointRange = useMemo( |
| 258 | 293 | () => |
| 259 | 294 | typeof selectedCheckpointTurnCount === "number" |
| ... | ... | @@ -264,62 +299,116 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff |
| 264 | 299 | : null, |
| 265 | 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 | 302 | const activeCheckpointDiff = useCheckpointDiff( |
| 302 | 303 | { |
| 303 | 304 | environmentId: activeThread?.environmentId ?? null, |
| 304 | 305 | threadId: activeThreadId, |
| 305 | fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, | |
| 306 | toTurnCount: activeCheckpointRange?.toTurnCount ?? null, | |
| 306 | fromTurnCount: selectedCheckpointRange?.fromTurnCount ?? null, | |
| 307 | toTurnCount: selectedCheckpointRange?.toTurnCount ?? null, | |
| 307 | 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 conversationCheckpointDiff = selectedTurn ? undefined : activeCheckpointDiff.data?.diff; | |
| 314 | const isLoadingCheckpointDiff = activeCheckpointDiff.isPending; | |
| 315 | const checkpointDiffError = activeCheckpointDiff.error; | |
| 316 | ||
| 317 | const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff; | |
| 313 | const primaryBranchDiffPreview = useEnvironmentQuery( | |
| 314 | selectedTurnId === null && activeThread && activeCwd | |
| 315 | ? reviewEnvironment.diffPreview({ | |
| 316 | environmentId: activeThread.environmentId, | |
| 317 | input: { | |
| 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 | 404 | const hasResolvedPatch = typeof selectedPatch === "string"; |
| 319 | 405 | const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; |
| 320 | 406 | const renderablePatch = useMemo( |
| 321 | () => getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`), | |
| 322 | [resolvedTheme, selectedPatch], | |
| 407 | () => | |
| 408 | getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`, { | |
| 409 | compactPartialHunkOffsets: selectedTurnId === null, | |
| 410 | }), | |
| 411 | [resolvedTheme, selectedPatch, selectedTurnId], | |
| 323 | 412 | ); |
| 324 | 413 | const renderableFiles = useMemo(() => { |
| 325 | 414 | if (!renderablePatch || renderablePatch.kind !== "files") { |
| ... | ... | @@ -332,24 +421,26 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff |
| 332 | 421 | }), |
| 333 | 422 | ); |
| 334 | 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 | ); | |
| 335 | 437 | |
| 336 | 438 | useEffect(() => { |
| 337 | if (diffOpen && !previousDiffOpenRef.current) { | |
| 338 | setDiffWordWrap(settings.diffWordWrap); | |
| 339 | setDiffIgnoreWhitespace(settings.diffIgnoreWhitespace); | |
| 340 | } | |
| 341 | previousDiffOpenRef.current = diffOpen; | |
| 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]); | |
| 439 | if (!selectedFilePath) return; | |
| 440 | const file = codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath); | |
| 441 | if (!file) return; | |
| 442 | codeViewRef.current?.scrollTo({ type: "item", id: file.fileKey, align: "start" }); | |
| 443 | }, [codeViewFiles, selectedFilePath, selectedFileRevealRequestId]); | |
| 353 | 444 | |
| 354 | 445 | const openDiffFile = useCallback( |
| 355 | 446 | (filePath: string) => { |
| ... | ... | @@ -385,186 +476,190 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff |
| 385 | 476 | ); |
| 386 | 477 | |
| 387 | 478 | const selectTurn = (turnId: TurnId) => { |
| 388 | if (!activeThread) return; | |
| 389 | void navigate({ | |
| 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 | }); | |
| 479 | if (!routeThreadRef) return; | |
| 480 | useDiffPanelStore.getState().selectTurn(routeThreadRef, turnId); | |
| 397 | 481 | }; |
| 398 | const selectWholeConversation = () => { | |
| 399 | if (!activeThread) return; | |
| 400 | void navigate({ | |
| 401 | to: "/$environmentId/$threadId", | |
| 402 | params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), | |
| 403 | search: (previous) => { | |
| 404 | const rest = stripDiffSearchParams(previous); | |
| 405 | return { ...rest, diff: "1" }; | |
| 406 | }, | |
| 407 | }); | |
| 482 | const selectGitScope = (scope: "branch" | "unstaged") => { | |
| 483 | if (!routeThreadRef) return; | |
| 484 | useDiffPanelStore.getState().selectGitScope(routeThreadRef, scope); | |
| 485 | }; | |
| 486 | const selectBranchBaseRef = (baseRef: string | null) => { | |
| 487 | if (!routeThreadRef) return; | |
| 488 | useDiffPanelStore.getState().selectBranchBaseRef(routeThreadRef, baseRef); | |
| 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]); | |
| 469 | 490 | |
| 470 | 491 | const headerRow = ( |
| 471 | 492 | <> |
| 472 | <div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]"> | |
| 473 | <button | |
| 474 | type="button" | |
| 475 | className={cn( | |
| 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", | |
| 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} | |
| 493 | <div className="flex min-w-0 flex-1 items-center gap-3 [-webkit-app-region:no-drag]"> | |
| 494 | <DropdownMenu> | |
| 495 | <DropdownMenuTrigger | |
| 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" | |
| 497 | aria-label={`Diff scope: ${selectedScopeLabel}`} | |
| 518 | 498 | > |
| 519 | <div | |
| 520 | className={cn( | |
| 521 | "rounded-md border px-2 py-1 text-left transition-colors", | |
| 522 | selectedTurnId === null | |
| 523 | ? "border-border bg-accent text-accent-foreground" | |
| 524 | : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80", | |
| 499 | <span className="truncate">{selectedScopeLabel}</span> | |
| 500 | <ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground" /> | |
| 501 | </DropdownMenuTrigger> | |
| 502 | <DropdownMenuContent align="start" className="w-60"> | |
| 503 | <DropdownMenuItem onClick={() => selectGitScope("unstaged")}> | |
| 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> | |
| 528 | </div> | |
| 529 | </button> | |
| 530 | {orderedTurnDiffSummaries.map((summary) => ( | |
| 531 | <Tooltip key={summary.turnId}> | |
| 532 | <TooltipTrigger | |
| 533 | render={ | |
| 534 | <button | |
| 535 | type="button" | |
| 536 | aria-label={summary.turnId} | |
| 537 | className="shrink-0 rounded-md" | |
| 538 | onClick={() => selectTurn(summary.turnId)} | |
| 539 | data-turn-chip-selected={summary.turnId === selectedTurn?.turnId} | |
| 540 | /> | |
| 541 | } | |
| 520 | <span>Latest turn</span> | |
| 521 | {selectedTurnId !== null && selectedTurn?.turnId === latestTurn?.turnId && ( | |
| 522 | <CheckIcon className="ml-auto" /> | |
| 523 | )} | |
| 524 | </DropdownMenuItem> | |
| 525 | <DropdownMenuSub> | |
| 526 | <DropdownMenuSubTrigger>Turn</DropdownMenuSubTrigger> | |
| 527 | <DropdownMenuSubContent className="w-64"> | |
| 528 | {orderedTurnDiffSummaries.map((summary) => { | |
| 529 | const turnCount = | |
| 530 | summary.checkpointTurnCount ?? | |
| 531 | inferredCheckpointTurnCountByTurnId[summary.turnId] ?? | |
| 532 | "?"; | |
| 533 | return ( | |
| 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 | <div | |
| 544 | className={cn( | |
| 545 | "rounded-md border px-2 py-1 text-left transition-colors", | |
| 546 | summary.turnId === selectedTurn?.turnId | |
| 547 | ? "border-border bg-accent text-accent-foreground" | |
| 548 | : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80", | |
| 549 | )} | |
| 550 | > | |
| 551 | <div className="flex items-center gap-1"> | |
| 552 | <span className="text-[10px] leading-tight font-medium"> | |
| 553 | Turn{" "} | |
| 554 | {summary.checkpointTurnCount ?? | |
| 555 | inferredCheckpointTurnCountByTurnId[summary.turnId] ?? | |
| 556 | "?"} | |
| 557 | </span> | |
| 558 | <span className="text-[9px] leading-tight opacity-70 tabular-nums"> | |
| 559 | {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} | |
| 560 | </span> | |
| 581 | <div className="min-w-0 shrink-0 px-3 pt-2.5"> | |
| 582 | <div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring"> | |
| 583 | <SearchIcon | |
| 584 | aria-hidden="true" | |
| 585 | className="pointer-events-none absolute top-1.5 left-0 size-4 shrink-0 text-muted-foreground/55" | |
| 586 | /> | |
| 587 | <ComboboxInput | |
| 588 | className="[&_input]:h-6.5 [&_input]:ps-5 [&_input]:font-sans [&_input]:leading-6.5" | |
| 589 | inputClassName="rounded-none bg-transparent text-sm" | |
| 590 | placeholder="Search refs..." | |
| 591 | showTrigger={false} | |
| 592 | size="sm" | |
| 593 | unstyled | |
| 594 | value={baseRefQuery} | |
| 595 | onChange={(event) => setBaseRefQuery(event.target.value)} | |
| 596 | /> | |
| 561 | 597 | </div> |
| 562 | 598 | </div> |
| 563 | </TooltipTrigger> | |
| 564 | <TooltipPopup side="top">{summary.turnId}</TooltipPopup> | |
| 565 | </Tooltip> | |
| 566 | ))} | |
| 567 | </div> | |
| 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"> | |
| 600 | <span aria-hidden="true" /> | |
| 601 | <div className="grid min-w-0 grid-cols-[minmax(0,1fr)_2rem] items-center"> | |
| 602 | <span>Branch</span> | |
| 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 | 663 | </div> |
| 569 | 664 | <div className="flex shrink-0 items-center gap-1 [-webkit-app-region:no-drag]"> |
| 570 | 665 | <ToggleGroup |
| ... | ... | @@ -644,24 +739,35 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff |
| 644 | 739 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> |
| 645 | 740 | Turn diffs are unavailable because this project is not a git repository. |
| 646 | 741 | </div> |
| 647 | ) : orderedTurnDiffSummaries.length === 0 ? ( | |
| 742 | ) : selectedTurnId !== null && orderedTurnDiffSummaries.length === 0 ? ( | |
| 648 | 743 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> |
| 649 | 744 | No completed turns yet. |
| 650 | 745 | </div> |
| 651 | 746 | ) : ( |
| 652 | 747 | <> |
| 653 | <div | |
| 654 | ref={patchViewportRef} | |
| 655 | className="diff-panel-viewport min-h-0 min-w-0 flex-1 overflow-hidden" | |
| 656 | > | |
| 657 | {checkpointDiffError && !renderablePatch && ( | |
| 748 | <div className="diff-panel-viewport flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"> | |
| 749 | {isSelectedPatchTruncated && ( | |
| 750 | <p className="shrink-0 border-b border-border/70 bg-muted/40 px-3 py-1.5 text-[11px] text-muted-foreground"> | |
| 751 | This diff was truncated because it exceeded the preview limit. The changes shown are | |
| 752 | incomplete. | |
| 753 | </p> | |
| 754 | )} | |
| 755 | {selectedPatchError && !renderablePatch && ( | |
| 658 | 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 | 758 | </div> |
| 661 | 759 | )} |
| 662 | 760 | {!renderablePatch ? ( |
| 663 | isLoadingCheckpointDiff ? ( | |
| 664 | <DiffPanelLoadingState label="Loading checkpoint diff..." /> | |
| 761 | isLoadingSelectedPatch ? ( | |
| 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 | 772 | <div className="flex h-full items-center justify-center px-3 py-2 text-xs text-muted-foreground/70"> |
| 667 | 773 | <p> |
| ... | ... | @@ -672,88 +778,73 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff |
| 672 | 778 | </div> |
| 673 | 779 | ) |
| 674 | 780 | ) : renderablePatch.kind === "files" ? ( |
| 675 | <Virtualizer | |
| 676 | className="diff-render-surface h-full min-h-0 overflow-auto px-2 pb-2" | |
| 677 | config={{ | |
| 678 | overscrollSize: 600, | |
| 679 | intersectionObserverMargin: 1200, | |
| 781 | <div | |
| 782 | className="min-h-0 flex-1" | |
| 783 | onClickCapture={(event) => { | |
| 784 | const composedPath = event.nativeEvent.composedPath?.() ?? []; | |
| 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) => { | |
| 683 | const filePath = resolveFileDiffPath(fileDiff); | |
| 684 | const fileKey = buildFileDiffRenderKey(fileDiff); | |
| 685 | const themedFileKey = `${fileKey}:${resolvedTheme}`; | |
| 686 | const collapsed = collapsedDiffFileKeys.has(fileKey); | |
| 687 | return ( | |
| 688 | <div | |
| 689 | key={themedFileKey} | |
| 690 | data-diff-file-path={filePath} | |
| 691 | className="diff-render-file group/diff-file mb-2 rounded-md first:mt-2 last:mb-0" | |
| 692 | onClickCapture={(event) => { | |
| 693 | const nativeEvent = event.nativeEvent as MouseEvent; | |
| 694 | const composedPath = nativeEvent.composedPath?.() ?? []; | |
| 695 | const clickedHeader = composedPath.some((node) => { | |
| 696 | if (!(node instanceof Element)) return false; | |
| 697 | return node.hasAttribute("data-title"); | |
| 698 | }); | |
| 699 | if (!clickedHeader) return; | |
| 700 | openDiffFile(filePath); | |
| 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" /> | |
| 793 | <AnnotatableCodeView | |
| 794 | viewerRef={codeViewRef} | |
| 795 | key={collapseScopeKey ?? reviewSectionId} | |
| 796 | className="diff-render-surface h-full min-h-0 overflow-auto" | |
| 797 | files={codeViewFiles} | |
| 798 | sectionId={reviewSectionId} | |
| 799 | sectionTitle={reviewSectionTitle} | |
| 800 | composerDraftTarget={composerDraftTarget} | |
| 801 | renderHeaderPrefix={(fileDiff, fileKey, collapsed) => { | |
| 802 | const filePath = resolveFileDiffPath(fileDiff); | |
| 803 | return ( | |
| 804 | <Tooltip> | |
| 805 | <TooltipTrigger | |
| 806 | render={ | |
| 807 | <button | |
| 808 | type="button" | |
| 809 | className={cn( | |
| 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", | |
| 811 | getDiffCollapseIconClassName(fileDiff), | |
| 734 | 812 | )} |
| 735 | </TooltipTrigger> | |
| 736 | <TooltipPopup side="top"> | |
| 737 | {collapsed ? "Expand diff" : "Collapse diff"} | |
| 738 | </TooltipPopup> | |
| 739 | </Tooltip> | |
| 740 | )} | |
| 741 | options={{ | |
| 742 | collapsed, | |
| 743 | diffStyle: diffRenderMode === "split" ? "split" : "unified", | |
| 744 | lineDiffType: "none", | |
| 745 | overflow: diffWordWrap ? "wrap" : "scroll", | |
| 746 | theme: resolveDiffThemeName(resolvedTheme), | |
| 747 | themeType: resolvedTheme as DiffThemeType, | |
| 748 | unsafeCSS: DIFF_PANEL_UNSAFE_CSS, | |
| 749 | }} | |
| 750 | /> | |
| 751 | </div> | |
| 752 | ); | |
| 753 | })} | |
| 754 | </Virtualizer> | |
| 813 | aria-label={collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`} | |
| 814 | aria-expanded={!collapsed} | |
| 815 | onClick={(event) => { | |
| 816 | event.stopPropagation(); | |
| 817 | toggleDiffFileCollapsed(fileKey); | |
| 818 | }} | |
| 819 | /> | |
| 820 | } | |
| 821 | > | |
| 822 | {collapsed ? ( | |
| 823 | <ChevronRightIcon className="size-4" /> | |
| 824 | ) : ( | |
| 825 | <ChevronDownIcon className="size-4" /> | |
| 826 | )} | |
| 827 | </TooltipTrigger> | |
| 828 | <TooltipPopup side="top"> | |
| 829 | {collapsed ? "Expand diff" : "Collapse diff"} | |
| 830 | </TooltipPopup> | |
| 831 | </Tooltip> | |
| 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 | 848 | <div className="space-y-2"> |
| 758 | 849 | <p className="text-[11px] text-muted-foreground/75">{renderablePatch.reason}</p> |
| 759 | 850 | <pre |
apps/web/src/components/DiffPanelShell.tsx+2-8| ... | ... | @@ -48,14 +48,8 @@ export function DiffPanelShell(props: { |
| 48 | 48 | export function DiffPanelHeaderSkeleton() { |
| 49 | 49 | return ( |
| 50 | 50 | <> |
| 51 | <div className="relative 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" /> | |
| 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> | |
| 51 | <div className="min-w-0 flex-1"> | |
| 52 | <Skeleton className="h-8 w-32 rounded-lg" /> | |
| 59 | 53 | </div> |
| 60 | 54 | <div className="flex shrink-0 gap-1"> |
| 61 | 55 | <Skeleton className="size-7 rounded-md" /> |
apps/web/src/components/diffs/AnnotatableFileDiff.tsx+209-2| ... | ... | @@ -1,14 +1,23 @@ |
| 1 | 1 | import type { |
| 2 | 2 | AnnotationSide, |
| 3 | CodeViewDiffItem, | |
| 4 | CodeViewItem, | |
| 3 | 5 | DiffLineAnnotation, |
| 4 | 6 | FileDiffMetadata, |
| 5 | 7 | SelectedLineRange, |
| 6 | 8 | } from "@pierre/diffs"; |
| 7 | import { FileDiff, type FileDiffProps } from "@pierre/diffs/react"; | |
| 9 | import { | |
| 10 | CodeView, | |
| 11 | type CodeViewHandle, | |
| 12 | type CodeViewProps, | |
| 13 | FileDiff, | |
| 14 | type FileDiffProps, | |
| 15 | } from "@pierre/diffs/react"; | |
| 8 | 16 | import type { ScopedThreadRef } from "@t3tools/contracts"; |
| 9 | import { useCallback, useMemo, useState, type ReactNode } from "react"; | |
| 17 | import { useCallback, useMemo, useState, type ReactNode, type Ref } from "react"; | |
| 10 | 18 | |
| 11 | 19 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
| 20 | import { fnv1a32 } from "~/lib/diffRendering"; | |
| 12 | 21 | import { |
| 13 | 22 | buildDiffReviewComment, |
| 14 | 23 | restoreDiffReviewCommentRange, |
| ... | ... | @@ -31,6 +40,7 @@ interface DiffCommentAnnotationGroup { |
| 31 | 40 | } |
| 32 | 41 | |
| 33 | 42 | type DiffCommentLineAnnotation = DiffLineAnnotation<DiffCommentAnnotationGroup>; |
| 43 | export type AnnotatableCodeViewHandle = CodeViewHandle<DiffCommentAnnotationGroup>; | |
| 34 | 44 | const EMPTY_REVIEW_COMMENTS: ReadonlyArray<ReviewCommentContext> = []; |
| 35 | 45 | |
| 36 | 46 | function annotationSide(range: SelectedLineRange): AnnotationSide { |
| ... | ... | @@ -237,3 +247,200 @@ export function AnnotatableFileDiff({ |
| 237 | 247 | /> |
| 238 | 248 | ); |
| 239 | 249 | } |
| 250 | ||
| 251 | interface 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 | ||
| 271 | interface DiffSelectionContext { | |
| 272 | item: CodeViewItem<DiffCommentAnnotationGroup>; | |
| 273 | } | |
| 274 | ||
| 275 | export 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 @@ |
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 2 | import { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts"; | |
| 3 | import { beforeEach, describe, expect, it } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { selectThreadDiffPanelSelection, useDiffPanelStore } from "./diffPanelStore"; | |
| 6 | ||
| 7 | const THREAD_REF = scopeThreadRef(EnvironmentId.make("environment-1"), ThreadId.make("thread-1")); | |
| 8 | ||
| 9 | describe("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 @@ |
| 1 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; | |
| 2 | import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; | |
| 3 | import { create } from "zustand"; | |
| 4 | import { createJSONStorage, persist } from "zustand/middleware"; | |
| 5 | ||
| 6 | import { resolveStorage } from "./lib/storage"; | |
| 7 | ||
| 8 | export type DiffPanelSelection = | |
| 9 | | { kind: "branch"; baseRef: string | null } | |
| 10 | | { kind: "unstaged" } | |
| 11 | | { kind: "turn"; turnId: TurnId; filePath: string | null; revealRequestId: number }; | |
| 12 | ||
| 13 | const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null }; | |
| 14 | ||
| 15 | interface 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 | ||
| 25 | function normalizeBaseRef(baseRef: string | null): string | null { | |
| 26 | const normalized = baseRef?.trim(); | |
| 27 | return normalized ? normalized : null; | |
| 28 | } | |
| 29 | ||
| 30 | export 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 | ||
| 133 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { parseDiffRouteSearch } from "./diffRouteSearch"; | |
| 4 | ||
| 5 | describe("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 @@ |
| 1 | import { TurnId } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export interface DiffRouteSearch { | |
| 4 | diff?: "1" | undefined; | |
| 5 | diffTurnId?: TurnId | undefined; | |
| 6 | diffFilePath?: string | undefined; | |
| 7 | } | |
| 8 | ||
| 9 | function isDiffOpenValue(value: unknown): boolean { | |
| 10 | return value === "1" || value === 1 || value === true; | |
| 11 | } | |
| 12 | ||
| 13 | function 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 | ||
| 21 | export 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 | ||
| 28 | export 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 | 689 | background: color-mix(in srgb, var(--background) 94%, var(--card)); |
| 690 | 690 | } |
| 691 | 691 | |
| 692 | .diff-render-file { | |
| 692 | .diff-render-file, | |
| 693 | .diff-render-surface > diffs-container { | |
| 693 | 694 | border: 1px solid var(--border); |
| 694 | 695 | border-radius: 0.5rem; |
| 695 | 696 | overflow: clip; |
apps/web/src/lib/baseRefChoices.test.ts created+52| ... | ... | @@ -0,0 +1,52 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import type { VcsRef } from "@t3tools/contracts"; | |
| 3 | import { buildBaseRefChoices, filterBaseRefChoices } from "./baseRefChoices"; | |
| 4 | ||
| 5 | function 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 | ||
| 16 | describe("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 | ||
| 38 | describe("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 @@ |
| 1 | import type { VcsRef } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export interface BaseRefChoice { | |
| 4 | readonly id: string; | |
| 5 | readonly label: string; | |
| 6 | readonly local: VcsRef | null; | |
| 7 | readonly remote: VcsRef | null; | |
| 8 | } | |
| 9 | ||
| 10 | function 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 | ||
| 17 | export 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 | ||
| 49 | export 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 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { buildPatchCacheKey } from "./diffRendering"; | |
| 2 | import { buildPatchCacheKey, getRenderablePatch } from "./diffRendering"; | |
| 3 | 3 | |
| 4 | 4 | describe("buildPatchCacheKey", () => { |
| 5 | 5 | it("returns a stable cache key for identical content", () => { |
| ... | ... | @@ -29,3 +29,56 @@ describe("buildPatchCacheKey", () => { |
| 29 | 29 | ); |
| 30 | 30 | }); |
| 31 | 31 | }); |
| 32 | ||
| 33 | describe("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 | 52 | reason: string; |
| 53 | 53 | }; |
| 54 | 54 | |
| 55 | interface 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 | ||
| 65 | export 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 | ||
| 55 | 90 | export function getRenderablePatch( |
| 56 | 91 | patch: string | undefined, |
| 57 | 92 | cacheScope = "diff-panel", |
| 93 | options: RenderablePatchOptions = {}, | |
| 58 | 94 | ): RenderablePatch | null { |
| 59 | 95 | if (!patch) return null; |
| 60 | 96 | const normalizedPatch = patch.trim(); |
| ... | ... | @@ -65,7 +101,11 @@ export function getRenderablePatch( |
| 65 | 101 | normalizedPatch, |
| 66 | 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 | 109 | if (files.length > 0) { |
| 70 | 110 | return { kind: "files", files }; |
| 71 | 111 | } |
apps/web/src/rightPanelStore.test.ts-11| ... | ... | @@ -6,7 +6,6 @@ import { |
| 6 | 6 | migratePersistedRightPanelState, |
| 7 | 7 | selectActiveRightPanel, |
| 8 | 8 | selectActiveRightPanelSurface, |
| 9 | selectActiveRightPanelKindWithUrl, | |
| 10 | 9 | selectThreadRightPanelState, |
| 11 | 10 | useRightPanelStore, |
| 12 | 11 | } from "./rightPanelStore"; |
| ... | ... | @@ -254,16 +253,6 @@ describe("rightPanelStore", () => { |
| 254 | 253 | expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("plan"); |
| 255 | 254 | }); |
| 256 | 255 | |
| 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 | 256 | it("removeThread clears persisted state", () => { |
| 268 | 257 | useRightPanelStore.getState().open(refA, "plan"); |
| 269 | 258 | useRightPanelStore.getState().removeThread(refA); |
apps/web/src/rightPanelStore.ts-10| ... | ... | @@ -550,13 +550,3 @@ export function selectActiveRightPanelSurface( |
| 550 | 550 | if (!state.isOpen) return null; |
| 551 | 551 | return state.surfaces.find((surface) => surface.id === state.activeSurfaceId) ?? null; |
| 552 | 552 | } |
| 553 | ||
| 554 | export 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 | import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; | |
| 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; | |
| 2 | 2 | import { useEffect } from "react"; |
| 3 | 3 | |
| 4 | 4 | import ChatView from "../components/ChatView"; |
| 5 | 5 | import { threadHasStarted } from "../components/ChatView.logic"; |
| 6 | 6 | import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; |
| 7 | import { type DiffRouteSearch, parseDiffRouteSearch } from "../diffRouteSearch"; | |
| 8 | 7 | import { resolveThreadRouteRef } from "../threadRoutes"; |
| 9 | 8 | import { SidebarInset } from "~/components/ui/sidebar"; |
| 10 | 9 | import { useEnvironmentThreadRefs, useThreadDetail, useThreadShell } from "../state/entities"; |
| ... | ... | @@ -74,9 +73,5 @@ function ChatThreadRouteView() { |
| 74 | 73 | } |
| 75 | 74 | |
| 76 | 75 | export const Route = createFileRoute("/_chat/$environmentId/$threadId")({ |
| 77 | validateSearch: (search) => parseDiffRouteSearch(search), | |
| 78 | search: { | |
| 79 | middlewares: [retainSearchParams<DiffRouteSearch>(["diff"])], | |
| 80 | }, | |
| 81 | 76 | component: ChatThreadRouteView, |
| 82 | 77 | }); |
packages/contracts/src/git.ts+2| ... | ... | @@ -125,6 +125,8 @@ export const VcsListRefsInput = Schema.Struct({ |
| 125 | 125 | cwd: TrimmedNonEmptyStringSchema, |
| 126 | 126 | query: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(256))), |
| 127 | 127 | cursor: Schema.optional(NonNegativeInt), |
| 128 | includeMatchingRemoteRefs: Schema.optional(Schema.Boolean), | |
| 129 | refKind: Schema.optional(Schema.Literals(["all", "local", "remote"])), | |
| 128 | 130 | limit: Schema.optional( |
| 129 | 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 | export const ReviewDiffPreviewInput = Schema.Struct({ |
| 7 | 7 | cwd: TrimmedNonEmptyString, |
| 8 | 8 | baseRef: Schema.optional(TrimmedNonEmptyString), |
| 9 | ignoreWhitespace: Schema.optionalKey(Schema.Boolean), | |
| 9 | 10 | }); |
| 10 | 11 | export type ReviewDiffPreviewInput = typeof ReviewDiffPreviewInput.Type; |
| 11 | 12 |