From d13431801636a7c71c4e517456b27c1e3e4eb16e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 12:47:46 -0700 Subject: [PATCH 1/5] Add diff-scope dropdown for branch and unstaged changes - Replace the turn strip with a scope menu - Support unstaged diff routing and parsing - Update diff header skeleton and tests --- apps/web/src/components/DiffPanel.tsx | 360 ++++++++------------- apps/web/src/components/DiffPanelShell.tsx | 10 +- apps/web/src/diffRouteSearch.test.ts | 20 ++ apps/web/src/diffRouteSearch.ts | 15 +- 4 files changed, 169 insertions(+), 236 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 7ea2d588477..7f70b8dccc0 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,5 +1,4 @@ import { useAtomValue } from "@effect/atom-react"; -import { Virtualizer } from "@pierre/diffs/react"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { @@ -8,22 +7,15 @@ import { } from "@t3tools/client-runtime/state/runtime"; import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; import { + CheckIcon, ChevronDownIcon, - ChevronLeftIcon, ChevronRightIcon, Columns2Icon, PilcrowIcon, Rows3Icon, TextWrapIcon, } from "lucide-react"; -import { - type WheelEvent as ReactWheelEvent, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useOpenInPreferredEditor } from "../editorPreferences"; import { type DraftId } from "../composerDraftStore"; import { openDiffFilePrimaryAction } from "../diffFileActions"; @@ -46,9 +38,19 @@ import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; import { AnnotatableFileDiff } from "./diffs/AnnotatableFileDiff"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { useEnvironmentQuery } from "../state/query"; import { serverEnvironment } from "../state/server"; +import { reviewEnvironment } from "../state/review"; import { vcsEnvironment } from "../state/vcs"; type DiffRenderMode = "stacked" | "split"; @@ -178,10 +180,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS, })); const patchViewportRef = useRef(null); - const turnStripRef = useRef(null); const previousDiffOpenRef = useRef(false); - const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); - const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); const routeThreadRef = useParams({ strict: false, select: (params) => resolveThreadRouteRef(params), @@ -234,6 +233,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff ); const selectedTurnId = diffSearch.diffTurnId ?? null; + const selectedGitScope = diffSearch.diffScope === "unstaged" ? "unstaged" : "branch"; const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; const selectedTurn = selectedTurnId === null @@ -243,7 +243,16 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff const selectedCheckpointTurnCount = selectedTurn && (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); - const reviewSectionId = selectedTurn ? `turn:${selectedTurn.turnId}` : "conversation"; + const latestTurn = orderedTurnDiffSummaries[0]; + const selectedScopeLabel = + selectedTurnId === null + ? selectedGitScope === "unstaged" + ? "Unstaged" + : "Branch changes" + : selectedTurn?.turnId === latestTurn?.turnId + ? "Latest turn" + : `Turn ${selectedCheckpointTurnCount ?? "?"}`; + const reviewSectionId = selectedTurn ? `turn:${selectedTurn.turnId}` : selectedGitScope; const collapseScopeKey = routeThreadRef ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}` : null; @@ -253,7 +262,9 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff : EMPTY_COLLAPSED_DIFF_FILE_KEYS; const reviewSectionTitle = selectedTurn ? `Turn ${selectedCheckpointTurnCount ?? "?"}` - : "All turns"; + : selectedGitScope === "unstaged" + ? "Unstaged" + : "Branch changes"; const selectedCheckpointRange = useMemo( () => typeof selectedCheckpointTurnCount === "number" @@ -264,57 +275,50 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff : null, [selectedCheckpointTurnCount], ); - const conversationCheckpointTurnCount = useMemo(() => { - const turnCounts: Array = []; - for (const summary of orderedTurnDiffSummaries) { - const value = - summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; - if (typeof value === "number") { - turnCounts.push(value); - } - } - if (turnCounts.length === 0) { - return undefined; - } - const latest = Math.max(...turnCounts); - return latest > 0 ? latest : undefined; - }, [inferredCheckpointTurnCountByTurnId, orderedTurnDiffSummaries]); - const conversationCheckpointRange = useMemo( - () => - !selectedTurn && typeof conversationCheckpointTurnCount === "number" - ? { - fromTurnCount: 0, - toTurnCount: conversationCheckpointTurnCount, - } - : null, - [conversationCheckpointTurnCount, selectedTurn], - ); - const activeCheckpointRange = selectedTurn - ? selectedCheckpointRange - : conversationCheckpointRange; - const conversationCacheScope = useMemo(() => { - if (selectedTurn || orderedTurnDiffSummaries.length === 0) { - return null; - } - return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`; - }, [orderedTurnDiffSummaries, selectedTurn]); const activeCheckpointDiff = useCheckpointDiff( { environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, - fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, - toTurnCount: activeCheckpointRange?.toTurnCount ?? null, + fromTurnCount: selectedCheckpointRange?.fromTurnCount ?? null, + toTurnCount: selectedCheckpointRange?.toTurnCount ?? null, ignoreWhitespace: diffIgnoreWhitespace, - cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, + cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : null, }, - { enabled: isGitRepo }, + { enabled: isGitRepo && selectedTurn !== undefined }, ); - const selectedTurnCheckpointDiff = selectedTurn ? activeCheckpointDiff.data?.diff : undefined; - const conversationCheckpointDiff = selectedTurn ? undefined : activeCheckpointDiff.data?.diff; - const isLoadingCheckpointDiff = activeCheckpointDiff.isPending; - const checkpointDiffError = activeCheckpointDiff.error; - - const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff; + const primaryBranchDiffPreview = useEnvironmentQuery( + selectedTurnId === null && activeThread && activeCwd + ? reviewEnvironment.diffPreview({ + environmentId: activeThread.environmentId, + input: { cwd: activeCwd }, + }) + : null, + ); + const shouldRetryBranchDiffAtEnvironmentCwd = + selectedTurnId === null && + primaryBranchDiffPreview.error?.includes("configured workspace root") === true && + serverConfig?.cwd !== undefined && + serverConfig.cwd !== activeCwd; + const fallbackBranchDiffPreview = useEnvironmentQuery( + shouldRetryBranchDiffAtEnvironmentCwd && activeThread && serverConfig + ? reviewEnvironment.diffPreview({ + environmentId: activeThread.environmentId, + input: { cwd: serverConfig.cwd }, + }) + : null, + ); + const branchDiffPreview = shouldRetryBranchDiffAtEnvironmentCwd + ? fallbackBranchDiffPreview + : primaryBranchDiffPreview; + const gitDiff = branchDiffPreview.data?.sources.find( + (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"), + )?.diff; + + const selectedPatch = selectedTurn ? activeCheckpointDiff.data?.diff : gitDiff; + const isLoadingSelectedPatch = selectedTurn + ? activeCheckpointDiff.isPending + : branchDiffPreview.isPending; + const selectedPatchError = selectedTurn ? activeCheckpointDiff.error : branchDiffPreview.error; const hasResolvedPatch = typeof selectedPatch === "string"; const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; const renderablePatch = useMemo( @@ -395,176 +399,77 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff }, }); }; - const selectWholeConversation = () => { + const selectGitScope = (scope: "branch" | "unstaged") => { if (!activeThread) return; void navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), search: (previous) => { const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1" }; + return { ...rest, diff: "1", ...(scope === "unstaged" ? { diffScope: scope } : {}) }; }, }); }; - const updateTurnStripScrollState = useCallback(() => { - const element = turnStripRef.current; - if (!element) { - setCanScrollTurnStripLeft(false); - setCanScrollTurnStripRight(false); - return; - } - - const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth); - setCanScrollTurnStripLeft(element.scrollLeft > 4); - setCanScrollTurnStripRight(element.scrollLeft < maxScrollLeft - 4); - }, []); - const scrollTurnStripBy = useCallback((offset: number) => { - const element = turnStripRef.current; - if (!element) return; - element.scrollBy({ left: offset, behavior: "smooth" }); - }, []); - const onTurnStripWheel = useCallback((event: ReactWheelEvent) => { - const element = turnStripRef.current; - if (!element) return; - if (element.scrollWidth <= element.clientWidth + 1) return; - if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return; - - event.preventDefault(); - element.scrollBy({ left: event.deltaY, behavior: "auto" }); - }, []); - - useEffect(() => { - const element = turnStripRef.current; - if (!element) return; - - const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState()); - const onScroll = () => updateTurnStripScrollState(); - - element.addEventListener("scroll", onScroll, { passive: true }); - - const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState()); - resizeObserver.observe(element); - - return () => { - window.cancelAnimationFrame(frameId); - element.removeEventListener("scroll", onScroll); - resizeObserver.disconnect(); - }; - }, [updateTurnStripScrollState]); - - useEffect(() => { - const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState()); - return () => { - window.cancelAnimationFrame(frameId); - }; - }, [orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); - - useEffect(() => { - const element = turnStripRef.current; - if (!element) return; - - const selectedChip = element.querySelector("[data-turn-chip-selected='true']"); - selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); - }, [selectedTurn?.turnId, selectedTurnId]); const headerRow = ( <> -
- - -
- - {orderedTurnDiffSummaries.map((summary) => ( - - selectTurn(summary.turnId)} - data-turn-chip-selected={summary.turnId === selectedTurn?.turnId} - /> - } - > -
-
- - Turn{" "} - {summary.checkpointTurnCount ?? - inferredCheckpointTurnCountByTurnId[summary.turnId] ?? - "?"} - - - {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} - -
-
-
- {summary.turnId} -
- ))} -
+ Latest turn + {selectedTurnId !== null && selectedTurn?.turnId === latestTurn?.turnId && ( + + )} + + + Turn + + {orderedTurnDiffSummaries.map((summary) => { + const turnCount = + summary.checkpointTurnCount ?? + inferredCheckpointTurnCountByTurnId[summary.turnId] ?? + "?"; + return ( + selectTurn(summary.turnId)} + > + Turn {turnCount} + + {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} + + {summary.turnId === selectedTurn?.turnId && } + + ); + })} + + + +
Turn diffs are unavailable because this project is not a git repository.
- ) : orderedTurnDiffSummaries.length === 0 ? ( + ) : selectedTurnId !== null && orderedTurnDiffSummaries.length === 0 ? (
No completed turns yet.
@@ -654,14 +559,22 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff ref={patchViewportRef} className="diff-panel-viewport min-h-0 min-w-0 flex-1 overflow-hidden" > - {checkpointDiffError && !renderablePatch && ( + {selectedPatchError && !renderablePatch && (
-

{checkpointDiffError}

+

{selectedPatchError}

)} {!renderablePatch ? ( - isLoadingCheckpointDiff ? ( - + isLoadingSelectedPatch ? ( + ) : (

@@ -672,12 +585,9 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff

) ) : renderablePatch.kind === "files" ? ( - {renderableFiles.map((fileDiff) => { const filePath = resolveFileDiffPath(fileDiff); @@ -751,7 +661,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff ); })} - + ) : (
diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index 4dd569d2823..e727a80055d 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -48,14 +48,8 @@ export function DiffPanelShell(props: { export function DiffPanelHeaderSkeleton() { return ( <> -
- - -
- - - -
+
+
diff --git a/apps/web/src/diffRouteSearch.test.ts b/apps/web/src/diffRouteSearch.test.ts index c80368eeea4..db39534429c 100644 --- a/apps/web/src/diffRouteSearch.test.ts +++ b/apps/web/src/diffRouteSearch.test.ts @@ -60,6 +60,26 @@ describe("parseDiffRouteSearch", () => { }); }); + it("parses the unstaged diff scope without a selected turn", () => { + expect(parseDiffRouteSearch({ diff: "1", diffScope: "unstaged" })).toEqual({ + diff: "1", + diffScope: "unstaged", + }); + }); + + it("drops the git diff scope when a turn is selected", () => { + expect( + parseDiffRouteSearch({ diff: "1", diffScope: "unstaged", diffTurnId: "turn-1" }), + ).toEqual({ + diff: "1", + diffTurnId: "turn-1", + }); + }); + + it("drops unknown diff scopes", () => { + expect(parseDiffRouteSearch({ diff: "1", diffScope: "staged" })).toEqual({ diff: "1" }); + }); + it("normalizes whitespace-only values", () => { const parsed = parseDiffRouteSearch({ diff: "1", diff --git a/apps/web/src/diffRouteSearch.ts b/apps/web/src/diffRouteSearch.ts index d9b072f28e1..5f49533bed4 100644 --- a/apps/web/src/diffRouteSearch.ts +++ b/apps/web/src/diffRouteSearch.ts @@ -2,6 +2,7 @@ import { TurnId } from "@t3tools/contracts"; export interface DiffRouteSearch { diff?: "1" | undefined; + diffScope?: "unstaged" | undefined; diffTurnId?: TurnId | undefined; diffFilePath?: string | undefined; } @@ -20,19 +21,27 @@ function normalizeSearchString(value: unknown): string | undefined { export function stripDiffSearchParams>( params: T, -): Omit { - const { diff: _diff, diffTurnId: _diffTurnId, diffFilePath: _diffFilePath, ...rest } = params; - return rest as Omit; +): Omit { + const { + diff: _diff, + diffScope: _diffScope, + diffTurnId: _diffTurnId, + diffFilePath: _diffFilePath, + ...rest + } = params; + return rest as Omit; } export function parseDiffRouteSearch(search: Record): DiffRouteSearch { const diff = isDiffOpenValue(search.diff) ? "1" : undefined; const diffTurnIdRaw = diff ? normalizeSearchString(search.diffTurnId) : undefined; const diffTurnId = diffTurnIdRaw ? TurnId.make(diffTurnIdRaw) : undefined; + const diffScope = diff && !diffTurnId && search.diffScope === "unstaged" ? "unstaged" : undefined; const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; return { ...(diff ? { diff } : {}), + ...(diffScope ? { diffScope } : {}), ...(diffTurnId ? { diffTurnId } : {}), ...(diffFilePath ? { diffFilePath } : {}), }; From e2a658830e366bd5d8fe0c1821a02c5a777bcb04 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 17:26:38 -0700 Subject: [PATCH 2/5] Add branch base ref selection to diff view - Add diff base-ref routing and parsing - Pair local and remote refs in the branch picker - Update git ref listing and diff rendering for the new selector --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 38 ++ apps/server/src/vcs/GitVcsDriverCore.ts | 14 +- apps/web/src/components/DiffPanel.tsx | 363 ++++++++++++++---- .../components/diffs/AnnotatableFileDiff.tsx | 199 +++++++++- apps/web/src/diffRouteSearch.test.ts | 16 + apps/web/src/diffRouteSearch.ts | 9 +- apps/web/src/index.css | 3 +- apps/web/src/lib/baseRefChoices.test.ts | 36 ++ apps/web/src/lib/baseRefChoices.ts | 47 +++ apps/web/src/lib/diffRendering.test.ts | 55 ++- apps/web/src/lib/diffRendering.ts | 42 +- packages/contracts/src/git.ts | 2 + 12 files changed, 731 insertions(+), 93 deletions(-) create mode 100644 apps/web/src/lib/baseRefChoices.test.ts create mode 100644 apps/web/src/lib/baseRefChoices.ts diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 41f5d595f0a..8f8f50b14ab 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -342,6 +342,44 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("refName operations", () => { + it.effect("optionally includes remote refs that match local branches", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const deduplicated = yield* driver.listRefs({ cwd }); + assert.equal( + deduplicated.refs.some((ref) => ref.name === `origin/${initialBranch}`), + false, + ); + + const complete = yield* driver.listRefs({ cwd, includeMatchingRemoteRefs: true }); + assert.equal( + complete.refs.some((ref) => ref.name === initialBranch), + true, + ); + assert.equal( + complete.refs.some((ref) => ref.name === `origin/${initialBranch}`), + true, + ); + + const remoteOnly = yield* driver.listRefs({ + cwd, + includeMatchingRemoteRefs: true, + refKind: "remote", + limit: 1, + }); + assert.equal(remoteOnly.refs.length, 1); + assert.equal(remoteOnly.refs[0]?.name, `origin/${initialBranch}`); + assert.equal(remoteOnly.refs[0]?.isRemote, true); + }), + ); + it.effect("creates, checks out, renames, and lists refs", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 5c24072052d..a0955a318ed 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2127,11 +2127,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }) : []; + const allBranches = input.includeMatchingRemoteRefs + ? [...localBranches, ...remoteBranches] + : dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]); + const branchesForKind = + input.refKind === "local" + ? allBranches.filter((ref) => !ref.isRemote) + : input.refKind === "remote" + ? allBranches.filter((ref) => ref.isRemote) + : allBranches; const refs = paginateBranches({ - refs: filterBranchesForListQuery( - dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]), - input.query, - ), + refs: filterBranchesForListQuery(branchesForKind, input.query), cursor: input.cursor, limit: input.limit, }); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 7f70b8dccc0..5bf6ee09736 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -7,12 +7,14 @@ import { } from "@t3tools/client-runtime/state/runtime"; import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; import { + ArrowRightIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, Columns2Icon, PilcrowIcon, Rows3Icon, + SearchIcon, TextWrapIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -36,8 +38,18 @@ import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { useSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; -import { AnnotatableFileDiff } from "./diffs/AnnotatableFileDiff"; +import { AnnotatableCodeView } from "./diffs/AnnotatableFileDiff"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; +import { Switch } from "./ui/switch"; +import { + Combobox, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxTrigger, +} from "./ui/combobox"; import { DropdownMenu, DropdownMenuContent, @@ -52,9 +64,11 @@ import { useEnvironmentQuery } from "../state/query"; import { serverEnvironment } from "../state/server"; import { reviewEnvironment } from "../state/review"; import { vcsEnvironment } from "../state/vcs"; +import { buildBaseRefChoices } from "../lib/baseRefChoices"; type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; +const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; interface CollapsedDiffFilesState { readonly scopeKey: string | null; @@ -175,6 +189,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); + const [baseRefQuery, setBaseRefQuery] = useState(""); const [collapsedDiffFiles, setCollapsedDiffFiles] = useState(() => ({ scopeKey: null, fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS, @@ -234,6 +249,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff const selectedTurnId = diffSearch.diffTurnId ?? null; const selectedGitScope = diffSearch.diffScope === "unstaged" ? "unstaged" : "branch"; + const selectedBaseRef = diffSearch.diffBaseRef; const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; const selectedTurn = selectedTurnId === null @@ -290,7 +306,10 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff selectedTurnId === null && activeThread && activeCwd ? reviewEnvironment.diffPreview({ environmentId: activeThread.environmentId, - input: { cwd: activeCwd }, + input: { + cwd: activeCwd, + ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}), + }, }) : null, ); @@ -303,16 +322,67 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff shouldRetryBranchDiffAtEnvironmentCwd && activeThread && serverConfig ? reviewEnvironment.diffPreview({ environmentId: activeThread.environmentId, - input: { cwd: serverConfig.cwd }, + input: { + cwd: serverConfig.cwd, + ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}), + }, }) : null, ); const branchDiffPreview = shouldRetryBranchDiffAtEnvironmentCwd ? fallbackBranchDiffPreview : primaryBranchDiffPreview; - const gitDiff = branchDiffPreview.data?.sources.find( + const selectedGitSource = branchDiffPreview.data?.sources.find( (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"), - )?.diff; + ); + const localBranchRefs = useEnvironmentQuery( + selectedTurnId === null && + selectedGitScope === "branch" && + activeThread && + branchDiffPreview.data?.cwd + ? vcsEnvironment.listRefs({ + environmentId: activeThread.environmentId, + input: { + cwd: branchDiffPreview.data.cwd, + includeMatchingRemoteRefs: true, + refKind: "local", + ...(baseRefQuery.trim().length > 0 ? { query: baseRefQuery.trim() } : {}), + limit: 100, + }, + }) + : null, + ); + const remoteBranchRefs = useEnvironmentQuery( + selectedTurnId === null && + selectedGitScope === "branch" && + activeThread && + branchDiffPreview.data?.cwd + ? vcsEnvironment.listRefs({ + environmentId: activeThread.environmentId, + input: { + cwd: branchDiffPreview.data.cwd, + includeMatchingRemoteRefs: true, + refKind: "remote", + ...(baseRefQuery.trim().length > 0 ? { query: baseRefQuery.trim() } : {}), + limit: 100, + }, + }) + : null, + ); + const baseRefChoices = buildBaseRefChoices( + localBranchRefs.data?.refs.filter((ref) => ref.name !== selectedGitSource?.headRef) ?? [], + remoteBranchRefs.data?.refs ?? [], + ); + const valueForBaseRefChoice = (choice: (typeof baseRefChoices)[number]) => + selectedBaseRef && selectedBaseRef === choice.remote?.name + ? selectedBaseRef + : (choice.local?.name ?? choice.remote?.name ?? choice.id); + const baseRefItems = [AUTOMATIC_BASE_REF, ...baseRefChoices.map(valueForBaseRefChoice)]; + const filteredBaseRefItems = [ + ...(baseRefQuery.trim().length === 0 ? [AUTOMATIC_BASE_REF] : []), + ...baseRefChoices.map(valueForBaseRefChoice), + ]; + const gitDiff = selectedGitSource?.diff; const selectedPatch = selectedTurn ? activeCheckpointDiff.data?.diff : gitDiff; const isLoadingSelectedPatch = selectedTurn @@ -322,8 +392,11 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff const hasResolvedPatch = typeof selectedPatch === "string"; const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; const renderablePatch = useMemo( - () => getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`), - [resolvedTheme, selectedPatch], + () => + getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`, { + compactPartialHunkOffsets: selectedTurnId === null, + }), + [resolvedTheme, selectedPatch, selectedTurnId], ); const renderableFiles = useMemo(() => { if (!renderablePatch || renderablePatch.kind !== "files") { @@ -336,6 +409,19 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff }), ); }, [renderablePatch]); + const codeViewFiles = useMemo( + () => + renderableFiles.map((fileDiff) => { + const fileKey = buildFileDiffRenderKey(fileDiff); + return { + fileDiff, + filePath: resolveFileDiffPath(fileDiff), + fileKey, + collapsed: collapsedDiffFileKeys.has(fileKey), + }; + }), + [collapsedDiffFileKeys, renderableFiles], + ); useEffect(() => { if (diffOpen && !previousDiffOpenRef.current) { @@ -410,17 +496,28 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff }, }); }; + const selectBranchBaseRef = (baseRef: string | null) => { + if (!activeThread) return; + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), + search: (previous) => { + const rest = stripDiffSearchParams(previous); + return { ...rest, diff: "1", ...(baseRef ? { diffBaseRef: baseRef } : {}) }; + }, + }); + }; const headerRow = ( <> -
+
{selectedScopeLabel} - + selectGitScope("unstaged")}> @@ -470,6 +567,119 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff + {selectedTurnId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && ( +
+ {selectedGitSource.headRef ?? "HEAD"} + + { + if (!open) setBaseRefQuery(""); + }} + onValueChange={(value) => { + if (!value) return; + selectBranchBaseRef(value === AUTOMATIC_BASE_REF ? null : value); + }} + > + + {selectedGitSource.baseRef} + + + +
+
+
+
+
+
+ No matching refs. + + + Automatic + + {baseRefChoices.map((choice) => { + const item = valueForBaseRefChoice(choice); + const hasBoth = choice.local !== null && choice.remote !== null; + const useRemote = choice.remote?.name === item; + return ( + +
+ {choice.label} + {hasBoth ? ( +
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + { + const nextRef = checked + ? choice.remote?.name + : choice.local?.name; + if (nextRef) selectBranchBaseRef(nextRef); + }} + /> +
+ ) : choice.remote ? ( + + + ) : null} +
+
+ ); + })} +
+
+
+
+ )}
{ + const composedPath = event.nativeEvent.composedPath?.() ?? []; + const title = composedPath.find( + (node): node is HTMLElement => + node instanceof HTMLElement && node.hasAttribute("data-title"), + ); + const filePath = title?.textContent?.trim(); + if (filePath) openDiffFile(filePath); + }} > - {renderableFiles.map((fileDiff) => { - const filePath = resolveFileDiffPath(fileDiff); - const fileKey = buildFileDiffRenderKey(fileDiff); - const themedFileKey = `${fileKey}:${resolvedTheme}`; - const collapsed = collapsedDiffFileKeys.has(fileKey); - return ( -
{ - const nativeEvent = event.nativeEvent as MouseEvent; - const composedPath = nativeEvent.composedPath?.() ?? []; - const clickedHeader = composedPath.some((node) => { - if (!(node instanceof Element)) return false; - return node.hasAttribute("data-title"); - }); - if (!clickedHeader) return; - openDiffFile(filePath); - }} - > - ( - - { - event.stopPropagation(); - toggleDiffFileCollapsed(fileKey); - }} - /> - } - > - {collapsed ? ( - - ) : ( - + { + const filePath = resolveFileDiffPath(fileDiff); + return ( + + - - {collapsed ? "Expand diff" : "Collapse diff"} - - - )} - options={{ - collapsed, - diffStyle: diffRenderMode === "split" ? "split" : "unified", - lineDiffType: "none", - overflow: diffWordWrap ? "wrap" : "scroll", - theme: resolveDiffThemeName(resolvedTheme), - themeType: resolvedTheme as DiffThemeType, - unsafeCSS: DIFF_PANEL_UNSAFE_CSS, - }} - /> -
- ); - })} + aria-label={collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`} + aria-expanded={!collapsed} + onClick={(event) => { + event.stopPropagation(); + toggleDiffFileCollapsed(fileKey); + }} + /> + } + > + {collapsed ? ( + + ) : ( + + )} + + + {collapsed ? "Expand diff" : "Collapse diff"} + + + ); + }} + options={{ + diffStyle: diffRenderMode === "split" ? "split" : "unified", + lineDiffType: "none", + overflow: diffWordWrap ? "wrap" : "scroll", + theme: resolveDiffThemeName(resolvedTheme), + themeType: resolvedTheme as DiffThemeType, + unsafeCSS: DIFF_PANEL_UNSAFE_CSS, + stickyHeaders: true, + layout: { paddingTop: 8, paddingBottom: 8, gap: 8 }, + }} + />
) : (
diff --git a/apps/web/src/components/diffs/AnnotatableFileDiff.tsx b/apps/web/src/components/diffs/AnnotatableFileDiff.tsx index ceb2f87785a..4f8fc2b2ab0 100644 --- a/apps/web/src/components/diffs/AnnotatableFileDiff.tsx +++ b/apps/web/src/components/diffs/AnnotatableFileDiff.tsx @@ -1,14 +1,17 @@ import type { AnnotationSide, + CodeViewDiffItem, + CodeViewItem, DiffLineAnnotation, FileDiffMetadata, SelectedLineRange, } from "@pierre/diffs"; -import { FileDiff, type FileDiffProps } from "@pierre/diffs/react"; +import { CodeView, type CodeViewProps, FileDiff, type FileDiffProps } from "@pierre/diffs/react"; import type { ScopedThreadRef } from "@t3tools/contracts"; import { useCallback, useMemo, useState, type ReactNode } from "react"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import { fnv1a32 } from "~/lib/diffRendering"; import { buildDiffReviewComment, restoreDiffReviewCommentRange, @@ -237,3 +240,197 @@ export function AnnotatableFileDiff({ /> ); } + +interface AnnotatableCodeViewProps { + files: ReadonlyArray<{ + fileDiff: FileDiffMetadata; + filePath: string; + fileKey: string; + collapsed: boolean; + }>; + sectionId: string; + sectionTitle: string; + composerDraftTarget: ScopedThreadRef | DraftId; + options: NonNullable["options"]>; + className?: string; + renderHeaderPrefix: ( + fileDiff: FileDiffMetadata, + fileKey: string, + collapsed: boolean, + ) => ReactNode; +} + +interface DiffSelectionContext { + item: CodeViewItem; +} + +export function AnnotatableCodeView({ + files, + sectionId, + sectionTitle, + composerDraftTarget, + options, + className, + renderHeaderPrefix, +}: AnnotatableCodeViewProps) { + const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); + const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); + const reviewComments = useComposerDraftStore( + (store) => store.getComposerDraft(composerDraftTarget)?.reviewComments ?? EMPTY_REVIEW_COMMENTS, + ); + const [selectedLines, setSelectedLines] = useState<{ + id: string; + range: SelectedLineRange; + } | null>(null); + const [draft, setDraft] = useState<{ + fileKey: string; + annotation: DiffCommentLineAnnotation; + } | null>(null); + + const filesByKey = useMemo(() => new Map(files.map((file) => [file.fileKey, file])), [files]); + const items = useMemo[]>( + () => + files.map(({ fileDiff, filePath, fileKey, collapsed }) => { + const persisted = reviewComments + .filter( + (comment) => + comment.sectionId === sectionId && + comment.filePath === filePath && + (comment.fenceLanguage ?? "diff") === "diff", + ) + .reduce((annotations, comment) => { + const range = restoreDiffReviewCommentRange(fileDiff, comment); + if (!range) return annotations; + return appendAnnotationEntry(annotations, range, { + id: comment.id, + kind: "comment", + range, + rangeLabel: comment.rangeLabel, + text: comment.text, + }); + }, []); + const annotations = + draft?.fileKey === fileKey ? [...persisted, draft.annotation] : persisted; + return { + id: fileKey, + type: "diff", + fileDiff, + annotations, + collapsed, + version: fnv1a32( + `${collapsed ? "1" : "0"}:${annotations + .flatMap((annotation) => + annotation.metadata.entries.map( + (entry) => `${entry.id}:${entry.rangeLabel}:${entry.text}`, + ), + ) + .join(":")}`, + ), + }; + }), + [draft, files, reviewComments, sectionId], + ); + + const removeEntry = useCallback( + (entryId: string) => { + setSelectedLines(null); + if (draft?.annotation.metadata.entries.some((entry) => entry.id === entryId)) { + setDraft(null); + } else { + removeReviewComment(composerDraftTarget, entryId); + } + }, + [composerDraftTarget, draft, removeReviewComment], + ); + + const submitEntry = useCallback( + (entryId: string, text: string) => { + const entry = draft?.annotation.metadata.entries.find( + (candidate) => candidate.id === entryId, + ); + const file = draft ? filesByKey.get(draft.fileKey) : undefined; + if (!entry || !file) return; + const comment = buildDiffReviewComment({ + id: entry.id, + sectionId, + sectionTitle, + filePath: file.filePath, + fileDiff: file.fileDiff, + range: entry.range, + text, + }); + if (comment) addReviewComment(composerDraftTarget, comment); + setSelectedLines(null); + setDraft(null); + }, + [addReviewComment, composerDraftTarget, draft, filesByKey, sectionId, sectionTitle], + ); + + const beginComment = useCallback( + (range: SelectedLineRange | null, context: DiffSelectionContext) => { + if (!range) return; + const item = context.item; + if (item.type !== "diff") return; + const file = filesByKey.get(item.id); + if (!file) return; + const id = nextFileCommentId(); + const comment = buildDiffReviewComment({ + id, + sectionId, + sectionTitle, + filePath: file.filePath, + fileDiff: file.fileDiff, + range, + text: "", + }); + if (!comment) return; + setDraft({ + fileKey: item.id, + annotation: { + side: annotationSide(range), + lineNumber: range.end, + metadata: { + entries: [{ id, kind: "draft", range, rangeLabel: comment.rangeLabel, text: "" }], + }, + }, + }); + }, + [filesByKey, sectionId, sectionTitle], + ); + + const hasOpenComment = draft !== null; + return ( + + {...(className ? { className } : {})} + items={items} + selectedLines={selectedLines} + onSelectedLinesChange={setSelectedLines} + options={{ + ...options, + enableGutterUtility: !hasOpenComment, + enableLineSelection: !hasOpenComment, + onLineSelectionEnd: beginComment, + }} + renderHeaderPrefix={(item) => + item.type === "diff" + ? renderHeaderPrefix(item.fileDiff, item.id, item.collapsed === true) + : null + } + renderAnnotation={(annotation) => ( +
+ {annotation.metadata.entries.map((entry) => ( + removeEntry(entry.id)} + onComment={(text) => submitEntry(entry.id, text)} + onDelete={() => removeEntry(entry.id)} + /> + ))} +
+ )} + /> + ); +} diff --git a/apps/web/src/diffRouteSearch.test.ts b/apps/web/src/diffRouteSearch.test.ts index db39534429c..6b88f1374c8 100644 --- a/apps/web/src/diffRouteSearch.test.ts +++ b/apps/web/src/diffRouteSearch.test.ts @@ -80,6 +80,22 @@ describe("parseDiffRouteSearch", () => { expect(parseDiffRouteSearch({ diff: "1", diffScope: "staged" })).toEqual({ diff: "1" }); }); + it("parses a branch comparison target", () => { + expect(parseDiffRouteSearch({ diff: "1", diffBaseRef: "origin/main" })).toEqual({ + diff: "1", + diffBaseRef: "origin/main", + }); + }); + + it("drops the branch target for unstaged and turn diffs", () => { + expect( + parseDiffRouteSearch({ diff: "1", diffScope: "unstaged", diffBaseRef: "origin/main" }), + ).toEqual({ diff: "1", diffScope: "unstaged" }); + expect( + parseDiffRouteSearch({ diff: "1", diffTurnId: "turn-1", diffBaseRef: "origin/main" }), + ).toEqual({ diff: "1", diffTurnId: "turn-1" }); + }); + it("normalizes whitespace-only values", () => { const parsed = parseDiffRouteSearch({ diff: "1", diff --git a/apps/web/src/diffRouteSearch.ts b/apps/web/src/diffRouteSearch.ts index 5f49533bed4..1c706d02864 100644 --- a/apps/web/src/diffRouteSearch.ts +++ b/apps/web/src/diffRouteSearch.ts @@ -3,6 +3,7 @@ import { TurnId } from "@t3tools/contracts"; export interface DiffRouteSearch { diff?: "1" | undefined; diffScope?: "unstaged" | undefined; + diffBaseRef?: string | undefined; diffTurnId?: TurnId | undefined; diffFilePath?: string | undefined; } @@ -21,15 +22,16 @@ function normalizeSearchString(value: unknown): string | undefined { export function stripDiffSearchParams>( params: T, -): Omit { +): Omit { const { diff: _diff, diffScope: _diffScope, + diffBaseRef: _diffBaseRef, diffTurnId: _diffTurnId, diffFilePath: _diffFilePath, ...rest } = params; - return rest as Omit; + return rest as Omit; } export function parseDiffRouteSearch(search: Record): DiffRouteSearch { @@ -37,11 +39,14 @@ export function parseDiffRouteSearch(search: Record): DiffRoute const diffTurnIdRaw = diff ? normalizeSearchString(search.diffTurnId) : undefined; const diffTurnId = diffTurnIdRaw ? TurnId.make(diffTurnIdRaw) : undefined; const diffScope = diff && !diffTurnId && search.diffScope === "unstaged" ? "unstaged" : undefined; + const diffBaseRef = + diff && !diffTurnId && !diffScope ? normalizeSearchString(search.diffBaseRef) : undefined; const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; return { ...(diff ? { diff } : {}), ...(diffScope ? { diffScope } : {}), + ...(diffBaseRef ? { diffBaseRef } : {}), ...(diffTurnId ? { diffTurnId } : {}), ...(diffFilePath ? { diffFilePath } : {}), }; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 9048e2074ed..09ef006a638 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -689,7 +689,8 @@ label:has(> select#reasoning-effort) select { background: color-mix(in srgb, var(--background) 94%, var(--card)); } -.diff-render-file { +.diff-render-file, +.diff-render-surface > diffs-container { border: 1px solid var(--border); border-radius: 0.5rem; overflow: clip; diff --git a/apps/web/src/lib/baseRefChoices.test.ts b/apps/web/src/lib/baseRefChoices.test.ts new file mode 100644 index 00000000000..896a2daa119 --- /dev/null +++ b/apps/web/src/lib/baseRefChoices.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { VcsRef } from "@t3tools/contracts"; +import { buildBaseRefChoices } from "./baseRefChoices"; + +function ref(name: string, remoteName?: string): VcsRef { + return { + name, + current: false, + isDefault: false, + isRemote: remoteName !== undefined, + ...(remoteName ? { remoteName } : {}), + worktreePath: null, + }; +} + +describe("buildBaseRefChoices", () => { + it("pairs matching local and remote branches and prefers origin", () => { + const choices = buildBaseRefChoices( + [ref("main")], + [ref("upstream/main", "upstream"), ref("origin/main", "origin")], + ); + + expect(choices).toEqual([ + expect.objectContaining({ + label: "main", + local: expect.objectContaining({ name: "main" }), + remote: expect.objectContaining({ name: "origin/main" }), + }), + expect.objectContaining({ + label: "upstream/main", + local: null, + remote: expect.objectContaining({ name: "upstream/main" }), + }), + ]); + }); +}); diff --git a/apps/web/src/lib/baseRefChoices.ts b/apps/web/src/lib/baseRefChoices.ts new file mode 100644 index 00000000000..5364ea1e5d7 --- /dev/null +++ b/apps/web/src/lib/baseRefChoices.ts @@ -0,0 +1,47 @@ +import type { VcsRef } from "@t3tools/contracts"; + +export interface BaseRefChoice { + readonly id: string; + readonly label: string; + readonly local: VcsRef | null; + readonly remote: VcsRef | null; +} + +function remoteBranchName(ref: VcsRef): string { + if (ref.remoteName && ref.name.startsWith(`${ref.remoteName}/`)) { + return ref.name.slice(ref.remoteName.length + 1); + } + return ref.name; +} + +export function buildBaseRefChoices( + localRefs: ReadonlyArray, + remoteRefs: ReadonlyArray, +): ReadonlyArray { + const unusedRemoteRefs = new Set(remoteRefs); + const pairedChoices = localRefs.map((local) => { + const matches = remoteRefs.filter( + (remote) => unusedRemoteRefs.has(remote) && remoteBranchName(remote) === local.name, + ); + const remote = + matches.find((candidate) => candidate.remoteName === "origin") ?? matches[0] ?? null; + if (remote) unusedRemoteRefs.delete(remote); + return { + id: `local:${local.name}`, + label: local.name, + local, + remote, + }; + }); + + const remoteOnlyChoices = remoteRefs + .filter((remote) => unusedRemoteRefs.has(remote)) + .map((remote) => ({ + id: `remote:${remote.name}`, + label: remote.name, + local: null, + remote, + })); + + return [...pairedChoices, ...remoteOnlyChoices]; +} diff --git a/apps/web/src/lib/diffRendering.test.ts b/apps/web/src/lib/diffRendering.test.ts index c24f58b99dd..e75a893d6b3 100644 --- a/apps/web/src/lib/diffRendering.test.ts +++ b/apps/web/src/lib/diffRendering.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildPatchCacheKey } from "./diffRendering"; +import { buildPatchCacheKey, getRenderablePatch } from "./diffRendering"; describe("buildPatchCacheKey", () => { it("returns a stable cache key for identical content", () => { @@ -29,3 +29,56 @@ describe("buildPatchCacheKey", () => { ); }); }); + +describe("getRenderablePatch", () => { + it("compacts partial hunk render offsets for virtualized review diffs", () => { + const patch = [ + "diff --git a/example.ts b/example.ts", + "index 1111111..2222222 100644", + "--- a/example.ts", + "+++ b/example.ts", + "@@ -48,4 +48,4 @@", + " context", + "-before", + "+after", + " context", + " context", + "@@ -80,3 +80,4 @@", + " context", + "+added", + " context", + " context", + ].join("\n"); + + const parsed = getRenderablePatch(patch, "review", { + compactPartialHunkOffsets: true, + }); + expect(parsed?.kind).toBe("files"); + if (parsed?.kind !== "files") return; + + const file = parsed.files[0]; + expect(file?.hunks[0]?.collapsedBefore).toBe(47); + expect(file?.hunks[0]?.unifiedLineStart).toBe(0); + expect(file?.hunks[1]?.collapsedBefore).toBeGreaterThan(0); + expect(file?.hunks[1]?.unifiedLineStart).toBe(file?.hunks[0]?.unifiedLineCount); + expect(file?.unifiedLineCount).toBe( + file?.hunks.reduce((total, hunk) => total + hunk.unifiedLineCount, 0), + ); + }); + + it("retains source-file offsets for checkpoint diffs", () => { + const patch = [ + "diff --git a/example.ts b/example.ts", + "--- a/example.ts", + "+++ b/example.ts", + "@@ -48,1 +48,1 @@", + "-before", + "+after", + ].join("\n"); + + const parsed = getRenderablePatch(patch, "checkpoint"); + expect(parsed?.kind).toBe("files"); + if (parsed?.kind !== "files") return; + expect(parsed.files[0]?.hunks[0]?.unifiedLineStart).toBe(47); + }); +}); diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index cb57ec7e065..cb8318b3d2d 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -52,9 +52,45 @@ export type RenderablePatch = reason: string; }; +interface RenderablePatchOptions { + /** + * Pierre's partial-patch parser keeps hunk render starts in source-file + * coordinates. Its virtualizer iterates partial patches as compact rows, so + * review diffs need compact render starts while retaining collapsedBefore + * for the "N unmodified lines" separator. + */ + compactPartialHunkOffsets?: boolean; +} + +export function compactPartialHunkOffsets(file: FileDiffMetadata): FileDiffMetadata { + if (!file.isPartial) return file; + + let splitLineStart = 0; + let unifiedLineStart = 0; + const hunks = file.hunks.map((hunk) => { + const compactHunk = { + ...hunk, + splitLineStart, + unifiedLineStart, + }; + splitLineStart += hunk.splitLineCount; + unifiedLineStart += hunk.unifiedLineCount; + return compactHunk; + }); + + return { + ...file, + hunks, + splitLineCount: splitLineStart, + unifiedLineCount: unifiedLineStart, + ...(file.cacheKey ? { cacheKey: `${file.cacheKey}:compact-partial` } : {}), + }; +} + export function getRenderablePatch( patch: string | undefined, cacheScope = "diff-panel", + options: RenderablePatchOptions = {}, ): RenderablePatch | null { if (!patch) return null; const normalizedPatch = patch.trim(); @@ -65,7 +101,11 @@ export function getRenderablePatch( normalizedPatch, buildPatchCacheKey(normalizedPatch, cacheScope), ); - const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files); + const files = parsedPatches.flatMap((parsedPatch) => + options.compactPartialHunkOffsets + ? parsedPatch.files.map(compactPartialHunkOffsets) + : parsedPatch.files, + ); if (files.length > 0) { return { kind: "files", files }; } diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index e8e9a4ecc1a..3de6c84fa44 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -125,6 +125,8 @@ export const VcsListRefsInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, query: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(256))), cursor: Schema.optional(NonNegativeInt), + includeMatchingRemoteRefs: Schema.optional(Schema.Boolean), + refKind: Schema.optional(Schema.Literals(["all", "local", "remote"])), limit: Schema.optional( PositiveInt.check(Schema.isLessThanOrEqualTo(GIT_LIST_BRANCHES_MAX_LIMIT)), ), From c966b5b47fdbe2c638ddecd13daa34aad97ee713 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 17:57:32 -0700 Subject: [PATCH 3/5] Move diff panel selection into persisted local store - Store diff turn, scope, and file selection per thread - Remove diff state from route search params - Keep diff file reveal behavior stable across panel updates --- apps/web/src/components/ChatView.tsx | 133 ++---------------- apps/web/src/components/DiffPanel.tsx | 88 ++++-------- .../components/diffs/AnnotatableFileDiff.tsx | 14 +- apps/web/src/diffPanelStore.test.ts | 42 ++++++ apps/web/src/diffPanelStore.ts | 87 ++++++++++++ apps/web/src/diffRouteSearch.test.ts | 110 --------------- apps/web/src/diffRouteSearch.ts | 53 ------- apps/web/src/rightPanelStore.test.ts | 11 -- apps/web/src/rightPanelStore.ts | 10 -- .../routes/_chat.$environmentId.$threadId.tsx | 7 +- 10 files changed, 182 insertions(+), 373 deletions(-) create mode 100644 apps/web/src/diffPanelStore.test.ts create mode 100644 apps/web/src/diffPanelStore.ts delete mode 100644 apps/web/src/diffRouteSearch.test.ts delete mode 100644 apps/web/src/diffRouteSearch.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a1ef90c4309..63674076151 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -42,7 +42,7 @@ import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/ter import { Debouncer } from "@tanstack/react-pacer"; import { useAtomValue } from "@effect/atom-react"; import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useNavigate, useSearch } from "@tanstack/react-router"; +import { useNavigate } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { isAtomCommandInterrupted, @@ -55,7 +55,7 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; -import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; +import { useDiffPanelStore } from "../diffPanelStore"; import { collapseExpandedComposerCursor, parseStandaloneComposerSlashCommand, @@ -104,7 +104,7 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { - selectActiveRightPanelKindWithUrl, + selectActiveRightPanel, selectActiveRightPanelSurface, selectThreadRightPanelState, type RightPanelSurface, @@ -1034,10 +1034,6 @@ function ChatViewContent(props: ChatViewProps) { const timestampFormat = settings.timestampFormat; const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; const navigate = useNavigate(); - const rawSearch = useSearch({ - strict: false, - select: (params) => parseDiffRouteSearch(params), - }); const { resolvedTheme } = useTheme(); // Granular store selectors — avoid subscribing to prompt changes. const composerRuntimeMode = useComposerDraftStore( @@ -1217,7 +1213,6 @@ function ChatViewContent(props: ChatViewProps) { composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; - const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: activeThread?.environmentId ?? null, @@ -1259,8 +1254,9 @@ function ChatViewContent(props: ChatViewProps) { ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const activeRightPanelKind = useRightPanelStore((state) => - selectActiveRightPanelKindWithUrl(state.byThreadKey, activeThreadRef, diffOpen), + selectActiveRightPanel(state.byThreadKey, activeThreadRef), ); + const diffOpen = activeRightPanelKind === "diff"; const rightPanelState = useRightPanelStore((state) => selectThreadRightPanelState(state.byThreadKey, activeThreadRef), ); @@ -1295,11 +1291,6 @@ function ChatViewContent(props: ChatViewProps) { const planSidebarOpen = activeRightPanelKind === "plan"; - useEffect(() => { - if (!activeThreadRef || !diffOpen) return; - useRightPanelStore.getState().open(activeThreadRef, "diff"); - }, [activeThreadRef, diffOpen]); - const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); @@ -2151,27 +2142,7 @@ function ChatViewContent(props: ChatViewProps) { if (activeThreadRef) { useRightPanelStore.getState().toggle(activeThreadRef, "diff"); } - void navigate({ - to: "/$environmentId/$threadId", - params: { - environmentId, - threadId, - }, - replace: true, - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; - }, - }); - }, [ - activeThreadRef, - diffOpen, - environmentId, - isServerThread, - navigate, - onDiffPanelOpen, - threadId, - ]); + }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]); const envLocked = Boolean( activeThread && @@ -2757,21 +2728,7 @@ function ChatViewContent(props: ChatViewProps) { if (!activeThreadRef || !isServerThread || !isGitRepo) return; useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); - void navigate({ - to: "/$environmentId/$threadId", - params: { environmentId, threadId }, - replace: true, - search: (previous) => ({ ...stripDiffSearchParams(previous), diff: "1" }), - }); - }, [ - activeThreadRef, - environmentId, - isGitRepo, - isServerThread, - navigate, - onDiffPanelOpen, - threadId, - ]); + }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]); const addFilesSurface = useCallback(() => { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().open(activeThreadRef, "files"); @@ -2789,30 +2746,13 @@ function ChatViewContent(props: ChatViewProps) { useRightPanelStore.getState().close(activeThreadRef); return; } - if (diffOpen) { - void navigate({ - to: "/$environmentId/$threadId", - params: { environmentId, threadId }, - replace: true, - search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), - }); - } const activeTabId = activePreviewState.activeTabId; if (activeTabId) { useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId); } else { createBrowserSurface(); } - }, [ - activePreviewState.activeTabId, - activeThreadRef, - createBrowserSurface, - diffOpen, - environmentId, - navigate, - previewPanelOpen, - threadId, - ]); + }, [activePreviewState.activeTabId, activeThreadRef, createBrowserSurface, previewPanelOpen]); const closePreviewPanel = useCallback(() => { if (activeThreadRef) { setMaximizedRightPanelThreadKey(null); @@ -2936,31 +2876,9 @@ function ChatViewContent(props: ChatViewProps) { } if (surface.kind === "diff" && !diffOpen) { onDiffPanelOpen?.(); - void navigate({ - to: "/$environmentId/$threadId", - params: { environmentId, threadId }, - replace: true, - search: (previous) => ({ ...stripDiffSearchParams(previous), diff: "1" }), - }); - } else if (surface.kind !== "diff" && diffOpen) { - void navigate({ - to: "/$environmentId/$threadId", - params: { environmentId, threadId }, - replace: true, - search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), - }); } }, - [ - activeThreadRef, - diffOpen, - dismissPlanSidebarForCurrentTurn, - environmentId, - navigate, - onDiffPanelOpen, - planSidebarOpen, - threadId, - ], + [activeThreadRef, diffOpen, dismissPlanSidebarForCurrentTurn, onDiffPanelOpen, planSidebarOpen], ); const toggleRightPanel = useCallback(() => { if (!activeThreadRef) return; @@ -3006,26 +2924,14 @@ function ChatViewContent(props: ChatViewProps) { } } } - if (diffOpen && surfaces.some((surface) => surface.kind === "diff")) { - void navigate({ - to: "/$environmentId/$threadId", - params: { environmentId, threadId }, - replace: true, - search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), - }); - } }, [ activeThreadRef, activePreviewState.sessions, closePreview, closeTerminalMutation, - diffOpen, dismissPlanSidebarForCurrentTurn, - environmentId, - navigate, storeCloseTerminal, - threadId, ], ); const syncActivePreviewSurface = useCallback(() => { @@ -4631,25 +4537,12 @@ function ChatViewContent(props: ChatViewProps) { }, []); const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { - if (!isServerThread) { - return; - } + if (!isServerThread || !activeThreadRef) return; + useDiffPanelStore.getState().selectTurn(activeThreadRef, turnId, filePath); + useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); - void navigate({ - to: "/$environmentId/$threadId", - params: { - environmentId, - threadId, - }, - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return filePath - ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath } - : { ...rest, diff: "1", diffTurnId: turnId }; - }, - }); }, - [environmentId, isServerThread, navigate, onDiffPanelOpen, threadId], + [activeThreadRef, isServerThread, onDiffPanelOpen], ); // Both the Map and the revert handler are read from refs at call-time so // the callback reference is fully stable and never busts context identity. diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 5bf6ee09736..6adf4804122 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,6 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { useParams } from "@tanstack/react-router"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -23,7 +22,7 @@ import { type DraftId } from "../composerDraftStore"; import { openDiffFilePrimaryAction } from "../diffFileActions"; import { useCheckpointDiff } from "~/lib/checkpointDiffState"; import { cn } from "~/lib/utils"; -import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; +import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; import { useTheme } from "../hooks/useTheme"; import { buildFileDiffRenderKey, @@ -34,11 +33,11 @@ import { } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useProject, useThread } from "../state/entities"; -import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; +import { resolveThreadRouteRef } from "../threadRoutes"; import { useSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; -import { AnnotatableCodeView } from "./diffs/AnnotatableFileDiff"; +import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableFileDiff"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; import { Switch } from "./ui/switch"; import { @@ -183,7 +182,6 @@ interface DiffPanelProps { export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; export default function DiffPanel({ mode = "inline", composerDraftTarget }: DiffPanelProps) { - const navigate = useNavigate(); const { resolvedTheme } = useTheme(); const settings = useSettings(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); @@ -194,14 +192,14 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff scopeKey: null, fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS, })); - const patchViewportRef = useRef(null); - const previousDiffOpenRef = useRef(false); + const codeViewRef = useRef(null); const routeThreadRef = useParams({ strict: false, select: (params) => resolveThreadRouteRef(params), }); - const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); - const diffOpen = diffSearch.diff === "1"; + const diffSelection = useDiffPanelStore((state) => + selectThreadDiffPanelSelection(state.byThreadKey, routeThreadRef), + ); const activeThreadId = routeThreadRef?.threadId ?? null; const activeThread = useThread(routeThreadRef); const activeProjectId = activeThread?.projectId ?? null; @@ -247,10 +245,12 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], ); - const selectedTurnId = diffSearch.diffTurnId ?? null; - const selectedGitScope = diffSearch.diffScope === "unstaged" ? "unstaged" : "branch"; - const selectedBaseRef = diffSearch.diffBaseRef; - const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; + const selectedTurnId = diffSelection.kind === "turn" ? diffSelection.turnId : null; + const selectedGitScope = diffSelection.kind === "unstaged" ? "unstaged" : "branch"; + const selectedBaseRef = diffSelection.kind === "branch" ? diffSelection.baseRef : null; + const selectedFilePath = diffSelection.kind === "turn" ? diffSelection.filePath : null; + const selectedFileRevealRequestId = + diffSelection.kind === "turn" ? diffSelection.revealRequestId : 0; const selectedTurn = selectedTurnId === null ? undefined @@ -424,22 +424,11 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff ); useEffect(() => { - if (diffOpen && !previousDiffOpenRef.current) { - setDiffWordWrap(settings.diffWordWrap); - setDiffIgnoreWhitespace(settings.diffIgnoreWhitespace); - } - previousDiffOpenRef.current = diffOpen; - }, [diffOpen, settings.diffIgnoreWhitespace, settings.diffWordWrap]); - - useEffect(() => { - if (!selectedFilePath || !patchViewportRef.current) { - return; - } - const target = Array.from( - patchViewportRef.current.querySelectorAll("[data-diff-file-path]"), - ).find((element) => element.dataset.diffFilePath === selectedFilePath); - target?.scrollIntoView({ block: "nearest" }); - }, [selectedFilePath, renderableFiles]); + if (!selectedFilePath) return; + const file = codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath); + if (!file) return; + codeViewRef.current?.scrollTo({ type: "item", id: file.fileKey, align: "start" }); + }, [codeViewFiles, selectedFilePath, selectedFileRevealRequestId]); const openDiffFile = useCallback( (filePath: string) => { @@ -475,37 +464,16 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff ); const selectTurn = (turnId: TurnId) => { - if (!activeThread) return; - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1", diffTurnId: turnId }; - }, - }); + if (!routeThreadRef) return; + useDiffPanelStore.getState().selectTurn(routeThreadRef, turnId); }; const selectGitScope = (scope: "branch" | "unstaged") => { - if (!activeThread) return; - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1", ...(scope === "unstaged" ? { diffScope: scope } : {}) }; - }, - }); + if (!routeThreadRef) return; + useDiffPanelStore.getState().selectGitScope(routeThreadRef, scope); }; const selectBranchBaseRef = (baseRef: string | null) => { - if (!activeThread) return; - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1", ...(baseRef ? { diffBaseRef: baseRef } : {}) }; - }, - }); + if (!routeThreadRef) return; + useDiffPanelStore.getState().selectBranchBaseRef(routeThreadRef, baseRef); }; const headerRow = ( @@ -765,10 +733,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
) : ( <> -
+
{selectedPatchError && !renderablePatch && (

{selectedPatchError}

@@ -808,6 +773,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff }} > ; +export type AnnotatableCodeViewHandle = CodeViewHandle; const EMPTY_REVIEW_COMMENTS: ReadonlyArray = []; function annotationSide(range: SelectedLineRange): AnnotationSide { @@ -252,6 +259,7 @@ interface AnnotatableCodeViewProps { sectionTitle: string; composerDraftTarget: ScopedThreadRef | DraftId; options: NonNullable["options"]>; + viewerRef?: Ref; className?: string; renderHeaderPrefix: ( fileDiff: FileDiffMetadata, @@ -270,6 +278,7 @@ export function AnnotatableCodeView({ sectionTitle, composerDraftTarget, options, + viewerRef, className, renderHeaderPrefix, }: AnnotatableCodeViewProps) { @@ -401,6 +410,7 @@ export function AnnotatableCodeView({ const hasOpenComment = draft !== null; return ( + {...(viewerRef ? { ref: viewerRef } : {})} {...(className ? { className } : {})} items={items} selectedLines={selectedLines} diff --git a/apps/web/src/diffPanelStore.test.ts b/apps/web/src/diffPanelStore.test.ts new file mode 100644 index 00000000000..61f61ac202c --- /dev/null +++ b/apps/web/src/diffPanelStore.test.ts @@ -0,0 +1,42 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { selectThreadDiffPanelSelection, useDiffPanelStore } from "./diffPanelStore"; + +const THREAD_REF = scopeThreadRef(EnvironmentId.make("environment-1"), ThreadId.make("thread-1")); + +describe("diffPanelStore", () => { + beforeEach(() => useDiffPanelStore.setState({ byThreadKey: {} })); + + it("defaults each thread to branch changes with automatic base selection", () => { + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ kind: "branch", baseRef: null }); + }); + + it("clears incompatible selection fields when changing scopes", () => { + const store = useDiffPanelStore.getState(); + store.selectTurn(THREAD_REF, TurnId.make("turn-1"), "src/app.ts"); + store.selectGitScope(THREAD_REF, "unstaged"); + + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ kind: "unstaged" }); + + useDiffPanelStore.getState().selectBranchBaseRef(THREAD_REF, " origin/main "); + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ kind: "branch", baseRef: "origin/main" }); + }); + + it("increments the reveal request when opening the same turn file again", () => { + const turnId = TurnId.make("turn-1"); + useDiffPanelStore.getState().selectTurn(THREAD_REF, turnId, "src/app.ts"); + useDiffPanelStore.getState().selectTurn(THREAD_REF, turnId, "src/app.ts"); + + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ kind: "turn", turnId, filePath: "src/app.ts", revealRequestId: 2 }); + }); +}); diff --git a/apps/web/src/diffPanelStore.ts b/apps/web/src/diffPanelStore.ts new file mode 100644 index 00000000000..ab792bb7987 --- /dev/null +++ b/apps/web/src/diffPanelStore.ts @@ -0,0 +1,87 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { resolveStorage } from "./lib/storage"; + +export type DiffPanelSelection = + | { kind: "branch"; baseRef: string | null } + | { kind: "unstaged" } + | { kind: "turn"; turnId: TurnId; filePath: string | null; revealRequestId: number }; + +const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null }; + +interface DiffPanelStoreState { + byThreadKey: Record; + selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void; + selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void; + selectTurn: (ref: ScopedThreadRef, turnId: TurnId, filePath?: string) => void; + removeThread: (ref: ScopedThreadRef) => void; +} + +function normalizeBaseRef(baseRef: string | null): string | null { + const normalized = baseRef?.trim(); + return normalized ? normalized : null; +} + +export const useDiffPanelStore = create()( + persist( + (set) => ({ + byThreadKey: {}, + selectGitScope: (ref, scope) => + set((state) => ({ + byThreadKey: { + ...state.byThreadKey, + [scopedThreadKey(ref)]: scope === "branch" ? DEFAULT_SELECTION : { kind: "unstaged" }, + }, + })), + selectBranchBaseRef: (ref, baseRef) => + set((state) => ({ + byThreadKey: { + ...state.byThreadKey, + [scopedThreadKey(ref)]: { kind: "branch", baseRef: normalizeBaseRef(baseRef) }, + }, + })), + selectTurn: (ref, turnId, filePath) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const previous = state.byThreadKey[threadKey]; + return { + byThreadKey: { + ...state.byThreadKey, + [threadKey]: { + kind: "turn", + turnId, + filePath: filePath?.trim() || null, + revealRequestId: previous?.kind === "turn" ? previous.revealRequestId + 1 : 1, + }, + }, + }; + }), + removeThread: (ref) => + set((state) => { + const threadKey = scopedThreadKey(ref); + if (!(threadKey in state.byThreadKey)) return state; + const { [threadKey]: _removed, ...byThreadKey } = state.byThreadKey; + return { byThreadKey }; + }), + }), + { + name: "t3code:diff-panel-state:v1", + version: 1, + storage: createJSONStorage(() => + resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), + ), + partialize: (state) => ({ byThreadKey: state.byThreadKey }), + }, + ), +); + +export function selectThreadDiffPanelSelection( + byThreadKey: Record, + ref: ScopedThreadRef | null | undefined, +): DiffPanelSelection { + if (!ref) return DEFAULT_SELECTION; + return byThreadKey[scopedThreadKey(ref)] ?? DEFAULT_SELECTION; +} diff --git a/apps/web/src/diffRouteSearch.test.ts b/apps/web/src/diffRouteSearch.test.ts deleted file mode 100644 index 6b88f1374c8..00000000000 --- a/apps/web/src/diffRouteSearch.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { parseDiffRouteSearch } from "./diffRouteSearch"; - -describe("parseDiffRouteSearch", () => { - it("parses valid diff search values", () => { - const parsed = parseDiffRouteSearch({ - diff: "1", - diffTurnId: "turn-1", - diffFilePath: "src/app.ts", - }); - - expect(parsed).toEqual({ - diff: "1", - diffTurnId: "turn-1", - diffFilePath: "src/app.ts", - }); - }); - - it("treats numeric and boolean diff toggles as open", () => { - expect( - parseDiffRouteSearch({ - diff: 1, - diffTurnId: "turn-1", - }), - ).toEqual({ - diff: "1", - diffTurnId: "turn-1", - }); - - expect( - parseDiffRouteSearch({ - diff: true, - diffTurnId: "turn-1", - }), - ).toEqual({ - diff: "1", - diffTurnId: "turn-1", - }); - }); - - it("drops turn and file values when diff is closed", () => { - const parsed = parseDiffRouteSearch({ - diff: "0", - diffTurnId: "turn-1", - diffFilePath: "src/app.ts", - }); - - expect(parsed).toEqual({}); - }); - - it("drops file value when turn is not selected", () => { - const parsed = parseDiffRouteSearch({ - diff: "1", - diffFilePath: "src/app.ts", - }); - - expect(parsed).toEqual({ - diff: "1", - }); - }); - - it("parses the unstaged diff scope without a selected turn", () => { - expect(parseDiffRouteSearch({ diff: "1", diffScope: "unstaged" })).toEqual({ - diff: "1", - diffScope: "unstaged", - }); - }); - - it("drops the git diff scope when a turn is selected", () => { - expect( - parseDiffRouteSearch({ diff: "1", diffScope: "unstaged", diffTurnId: "turn-1" }), - ).toEqual({ - diff: "1", - diffTurnId: "turn-1", - }); - }); - - it("drops unknown diff scopes", () => { - expect(parseDiffRouteSearch({ diff: "1", diffScope: "staged" })).toEqual({ diff: "1" }); - }); - - it("parses a branch comparison target", () => { - expect(parseDiffRouteSearch({ diff: "1", diffBaseRef: "origin/main" })).toEqual({ - diff: "1", - diffBaseRef: "origin/main", - }); - }); - - it("drops the branch target for unstaged and turn diffs", () => { - expect( - parseDiffRouteSearch({ diff: "1", diffScope: "unstaged", diffBaseRef: "origin/main" }), - ).toEqual({ diff: "1", diffScope: "unstaged" }); - expect( - parseDiffRouteSearch({ diff: "1", diffTurnId: "turn-1", diffBaseRef: "origin/main" }), - ).toEqual({ diff: "1", diffTurnId: "turn-1" }); - }); - - it("normalizes whitespace-only values", () => { - const parsed = parseDiffRouteSearch({ - diff: "1", - diffTurnId: " ", - diffFilePath: " ", - }); - - expect(parsed).toEqual({ - diff: "1", - }); - }); -}); diff --git a/apps/web/src/diffRouteSearch.ts b/apps/web/src/diffRouteSearch.ts deleted file mode 100644 index 1c706d02864..00000000000 --- a/apps/web/src/diffRouteSearch.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { TurnId } from "@t3tools/contracts"; - -export interface DiffRouteSearch { - diff?: "1" | undefined; - diffScope?: "unstaged" | undefined; - diffBaseRef?: string | undefined; - diffTurnId?: TurnId | undefined; - diffFilePath?: string | undefined; -} - -function isDiffOpenValue(value: unknown): boolean { - return value === "1" || value === 1 || value === true; -} - -function normalizeSearchString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const normalized = value.trim(); - return normalized.length > 0 ? normalized : undefined; -} - -export function stripDiffSearchParams>( - params: T, -): Omit { - const { - diff: _diff, - diffScope: _diffScope, - diffBaseRef: _diffBaseRef, - diffTurnId: _diffTurnId, - diffFilePath: _diffFilePath, - ...rest - } = params; - return rest as Omit; -} - -export function parseDiffRouteSearch(search: Record): DiffRouteSearch { - const diff = isDiffOpenValue(search.diff) ? "1" : undefined; - const diffTurnIdRaw = diff ? normalizeSearchString(search.diffTurnId) : undefined; - const diffTurnId = diffTurnIdRaw ? TurnId.make(diffTurnIdRaw) : undefined; - const diffScope = diff && !diffTurnId && search.diffScope === "unstaged" ? "unstaged" : undefined; - const diffBaseRef = - diff && !diffTurnId && !diffScope ? normalizeSearchString(search.diffBaseRef) : undefined; - const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; - - return { - ...(diff ? { diff } : {}), - ...(diffScope ? { diffScope } : {}), - ...(diffBaseRef ? { diffBaseRef } : {}), - ...(diffTurnId ? { diffTurnId } : {}), - ...(diffFilePath ? { diffFilePath } : {}), - }; -} diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 3b6dcc347e4..fb6d56f98c7 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -6,7 +6,6 @@ import { migratePersistedRightPanelState, selectActiveRightPanel, selectActiveRightPanelSurface, - selectActiveRightPanelKindWithUrl, selectThreadRightPanelState, useRightPanelStore, } from "./rightPanelStore"; @@ -254,16 +253,6 @@ describe("rightPanelStore", () => { expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("plan"); }); - it("?diff=1 always wins over persisted state", () => { - useRightPanelStore.getState().open(refA, "preview"); - expect( - selectActiveRightPanelKindWithUrl(useRightPanelStore.getState().byThreadKey, refA, true), - ).toBe("diff"); - expect( - selectActiveRightPanelKindWithUrl(useRightPanelStore.getState().byThreadKey, refA, false), - ).toBe("preview"); - }); - it("removeThread clears persisted state", () => { useRightPanelStore.getState().open(refA, "plan"); useRightPanelStore.getState().removeThread(refA); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 36fa82f9ff8..26dfe8c5153 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -550,13 +550,3 @@ export function selectActiveRightPanelSurface( if (!state.isOpen) return null; return state.surfaces.find((surface) => surface.id === state.activeSurfaceId) ?? null; } - -export function selectActiveRightPanelKindWithUrl( - byThreadKey: Record, - ref: ScopedThreadRef | null | undefined, - diffSearchActive: boolean, -): RightPanelKind | null { - if (!selectThreadRightPanelState(byThreadKey, ref).isOpen) return null; - if (diffSearchActive) return "diff"; - return selectActiveRightPanel(byThreadKey, ref); -} diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 5640487b31b..7dc6702b4ec 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -1,10 +1,9 @@ -import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; -import { type DiffRouteSearch, parseDiffRouteSearch } from "../diffRouteSearch"; import { resolveThreadRouteRef } from "../threadRoutes"; import { SidebarInset } from "~/components/ui/sidebar"; import { useEnvironmentThreadRefs, useThreadDetail, useThreadShell } from "../state/entities"; @@ -74,9 +73,5 @@ function ChatThreadRouteView() { } export const Route = createFileRoute("/_chat/$environmentId/$threadId")({ - validateSearch: (search) => parseDiffRouteSearch(search), - search: { - middlewares: [retainSearchParams(["diff"])], - }, component: ChatThreadRouteView, }); From 104bb7a7894be70c980721cf109b8ac636b39dee Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 19:13:06 -0700 Subject: [PATCH 4/5] Improve diff preview selection and whitespace handling - Persist branch base refs per thread and restore them after scope changes - Reconcile missing turn selections to the latest available turn - Filter stale base-ref choices and honor ignore-whitespace diffs --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 35 +++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 17 +++- apps/web/src/components/DiffPanel.tsx | 28 +++++-- apps/web/src/diffPanelStore.test.ts | 28 ++++++- apps/web/src/diffPanelStore.ts | 82 ++++++++++++++++---- apps/web/src/lib/baseRefChoices.test.ts | 18 ++++- apps/web/src/lib/baseRefChoices.ts | 14 ++++ packages/contracts/src/review.ts | 1 + 8 files changed, 199 insertions(+), 24 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 8f8f50b14ab..5be6427fe73 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -100,6 +100,41 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.deepStrictEqual(paths, ["complete.txt", "final.txt"]); }), ); + + it.effect("honors whitespace filtering for worktree and branch previews", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "-b", "feature/whitespace"]); + yield* writeTextFile(cwd, "README.md", "# test\n"); + yield* git(cwd, ["add", "README.md"]); + yield* git(cwd, ["commit", "-m", "change whitespace"]); + yield* writeTextFile(cwd, "README.md", "# test\n"); + + const included = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + ignoreWhitespace: false, + }); + const ignored = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + ignoreWhitespace: true, + }); + + assert.isNotEmpty(included.sources.find((source) => source.kind === "working-tree")?.diff); + assert.isNotEmpty(included.sources.find((source) => source.kind === "branch-range")?.diff); + assert.strictEqual( + ignored.sources.find((source) => source.kind === "working-tree")?.diff, + "", + ); + assert.strictEqual( + ignored.sources.find((source) => source.kind === "branch-range")?.diff, + "", + ); + }), + ); }); describe("repository status", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a0955a318ed..b78fba1030e 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1817,7 +1817,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const dirtyTrackedResult = yield* executeGit( "GitVcsDriver.getReviewDiffPreview.dirtyTracked", input.cwd, - ["diff", "--patch", "--minimal", "HEAD", "--"], + [ + "diff", + "--patch", + "--minimal", + ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), + "HEAD", + "--", + ], { maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -1843,7 +1850,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? yield* executeGit( "GitVcsDriver.getReviewDiffPreview.base", input.cwd, - ["diff", "--patch", "--minimal", `${baseRef}...HEAD`], + [ + "diff", + "--patch", + "--minimal", + ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), + `${baseRef}...HEAD`, + ], { maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, appendTruncationMarker: true, diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 6adf4804122..ae8ae9b636c 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -63,7 +63,7 @@ import { useEnvironmentQuery } from "../state/query"; import { serverEnvironment } from "../state/server"; import { reviewEnvironment } from "../state/review"; import { vcsEnvironment } from "../state/vcs"; -import { buildBaseRefChoices } from "../lib/baseRefChoices"; +import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices"; type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; @@ -245,6 +245,14 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], ); + useEffect(() => { + if (!routeThreadRef || diffSelection.kind !== "turn") return; + useDiffPanelStore.getState().reconcileTurnSelection( + routeThreadRef, + orderedTurnDiffSummaries.map((summary) => summary.turnId), + ); + }, [diffSelection, orderedTurnDiffSummaries, routeThreadRef]); + const selectedTurnId = diffSelection.kind === "turn" ? diffSelection.turnId : null; const selectedGitScope = diffSelection.kind === "unstaged" ? "unstaged" : "branch"; const selectedBaseRef = diffSelection.kind === "branch" ? diffSelection.baseRef : null; @@ -309,6 +317,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff input: { cwd: activeCwd, ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}), + ignoreWhitespace: diffIgnoreWhitespace, }, }) : null, @@ -325,6 +334,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff input: { cwd: serverConfig.cwd, ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}), + ignoreWhitespace: diffIgnoreWhitespace, }, }) : null, @@ -373,6 +383,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff localBranchRefs.data?.refs.filter((ref) => ref.name !== selectedGitSource?.headRef) ?? [], remoteBranchRefs.data?.refs ?? [], ); + const matchingBaseRefChoices = filterBaseRefChoices(baseRefChoices, baseRefQuery); const valueForBaseRefChoice = (choice: (typeof baseRefChoices)[number]) => selectedBaseRef && selectedBaseRef === choice.remote?.name ? selectedBaseRef @@ -380,11 +391,12 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff const baseRefItems = [AUTOMATIC_BASE_REF, ...baseRefChoices.map(valueForBaseRefChoice)]; const filteredBaseRefItems = [ ...(baseRefQuery.trim().length === 0 ? [AUTOMATIC_BASE_REF] : []), - ...baseRefChoices.map(valueForBaseRefChoice), + ...matchingBaseRefChoices.map(valueForBaseRefChoice), ]; const gitDiff = selectedGitSource?.diff; const selectedPatch = selectedTurn ? activeCheckpointDiff.data?.diff : gitDiff; + const isSelectedPatchTruncated = !selectedTurn && selectedGitSource?.truncated === true; const isLoadingSelectedPatch = selectedTurn ? activeCheckpointDiff.isPending : branchDiffPreview.isPending; @@ -733,7 +745,13 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
) : ( <> -
+
+ {isSelectedPatchTruncated && ( +

+ This diff was truncated because it exceeded the preview limit. The changes shown are + incomplete. +

+ )} {selectedPatchError && !renderablePatch && (

{selectedPatchError}

@@ -761,7 +779,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff ) ) : renderablePatch.kind === "files" ? (
{ const composedPath = event.nativeEvent.composedPath?.() ?? []; const title = composedPath.find( @@ -826,7 +844,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff />
) : ( -
+

{renderablePatch.reason}

 {
-  beforeEach(() => useDiffPanelStore.setState({ byThreadKey: {} }));
+  beforeEach(() => useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {} }));
 
   it("defaults each thread to branch changes with automatic base selection", () => {
     expect(
@@ -39,4 +39,30 @@ describe("diffPanelStore", () => {
       selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),
     ).toEqual({ kind: "turn", turnId, filePath: "src/app.ts", revealRequestId: 2 });
   });
+
+  it("restores the selected branch base after visiting another scope", () => {
+    useDiffPanelStore.getState().selectBranchBaseRef(THREAD_REF, "origin/main");
+    useDiffPanelStore.getState().selectGitScope(THREAD_REF, "unstaged");
+    useDiffPanelStore.getState().selectGitScope(THREAD_REF, "branch");
+
+    expect(
+      selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),
+    ).toEqual({ kind: "branch", baseRef: "origin/main" });
+  });
+
+  it("reconciles a missing turn selection to the latest available turn", () => {
+    const missingTurnId = TurnId.make("turn-missing");
+    const latestTurnId = TurnId.make("turn-latest");
+    useDiffPanelStore.getState().selectTurn(THREAD_REF, missingTurnId, "src/app.ts");
+    useDiffPanelStore.getState().reconcileTurnSelection(THREAD_REF, [latestTurnId]);
+
+    expect(
+      selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),
+    ).toEqual({
+      kind: "turn",
+      turnId: latestTurnId,
+      filePath: "src/app.ts",
+      revealRequestId: 1,
+    });
+  });
 });
diff --git a/apps/web/src/diffPanelStore.ts b/apps/web/src/diffPanelStore.ts
index ab792bb7987..c946b286d1b 100644
--- a/apps/web/src/diffPanelStore.ts
+++ b/apps/web/src/diffPanelStore.ts
@@ -14,9 +14,11 @@ const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null };
 
 interface DiffPanelStoreState {
   byThreadKey: Record;
+  branchBaseRefByThreadKey: Record;
   selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void;
   selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void;
   selectTurn: (ref: ScopedThreadRef, turnId: TurnId, filePath?: string) => void;
+  reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray) => void;
   removeThread: (ref: ScopedThreadRef) => void;
 }
 
@@ -29,20 +31,44 @@ export const useDiffPanelStore = create()(
   persist(
     (set) => ({
       byThreadKey: {},
+      branchBaseRefByThreadKey: {},
       selectGitScope: (ref, scope) =>
-        set((state) => ({
-          byThreadKey: {
-            ...state.byThreadKey,
-            [scopedThreadKey(ref)]: scope === "branch" ? DEFAULT_SELECTION : { kind: "unstaged" },
-          },
-        })),
+        set((state) => {
+          const threadKey = scopedThreadKey(ref);
+          const previous = state.byThreadKey[threadKey];
+          const previousBaseRef =
+            previous?.kind === "branch"
+              ? previous.baseRef
+              : (state.branchBaseRefByThreadKey[threadKey] ?? null);
+          return {
+            byThreadKey: {
+              ...state.byThreadKey,
+              [threadKey]:
+                scope === "branch"
+                  ? { kind: "branch", baseRef: previousBaseRef }
+                  : { kind: "unstaged" },
+            },
+            branchBaseRefByThreadKey:
+              previous?.kind === "branch"
+                ? { ...state.branchBaseRefByThreadKey, [threadKey]: previous.baseRef }
+                : state.branchBaseRefByThreadKey,
+          };
+        }),
       selectBranchBaseRef: (ref, baseRef) =>
-        set((state) => ({
-          byThreadKey: {
-            ...state.byThreadKey,
-            [scopedThreadKey(ref)]: { kind: "branch", baseRef: normalizeBaseRef(baseRef) },
-          },
-        })),
+        set((state) => {
+          const threadKey = scopedThreadKey(ref);
+          const normalizedBaseRef = normalizeBaseRef(baseRef);
+          return {
+            byThreadKey: {
+              ...state.byThreadKey,
+              [threadKey]: { kind: "branch", baseRef: normalizedBaseRef },
+            },
+            branchBaseRefByThreadKey: {
+              ...state.branchBaseRefByThreadKey,
+              [threadKey]: normalizedBaseRef,
+            },
+          };
+        }),
       selectTurn: (ref, turnId, filePath) =>
         set((state) => {
           const threadKey = scopedThreadKey(ref);
@@ -59,12 +85,35 @@ export const useDiffPanelStore = create()(
             },
           };
         }),
+      reconcileTurnSelection: (ref, availableTurnIds) =>
+        set((state) => {
+          const threadKey = scopedThreadKey(ref);
+          const previous = state.byThreadKey[threadKey];
+          const latestTurnId = availableTurnIds[0];
+          if (
+            previous?.kind !== "turn" ||
+            latestTurnId === undefined ||
+            availableTurnIds.includes(previous.turnId)
+          ) {
+            return state;
+          }
+          return {
+            byThreadKey: {
+              ...state.byThreadKey,
+              [threadKey]: { ...previous, turnId: latestTurnId },
+            },
+          };
+        }),
       removeThread: (ref) =>
         set((state) => {
           const threadKey = scopedThreadKey(ref);
-          if (!(threadKey in state.byThreadKey)) return state;
+          if (!(threadKey in state.byThreadKey) && !(threadKey in state.branchBaseRefByThreadKey)) {
+            return state;
+          }
           const { [threadKey]: _removed, ...byThreadKey } = state.byThreadKey;
-          return { byThreadKey };
+          const { [threadKey]: _removedBaseRef, ...branchBaseRefByThreadKey } =
+            state.branchBaseRefByThreadKey;
+          return { byThreadKey, branchBaseRefByThreadKey };
         }),
     }),
     {
@@ -73,7 +122,10 @@ export const useDiffPanelStore = create()(
       storage: createJSONStorage(() =>
         resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
       ),
-      partialize: (state) => ({ byThreadKey: state.byThreadKey }),
+      partialize: (state) => ({
+        byThreadKey: state.byThreadKey,
+        branchBaseRefByThreadKey: state.branchBaseRefByThreadKey,
+      }),
     },
   ),
 );
diff --git a/apps/web/src/lib/baseRefChoices.test.ts b/apps/web/src/lib/baseRefChoices.test.ts
index 896a2daa119..90f84d900f4 100644
--- a/apps/web/src/lib/baseRefChoices.test.ts
+++ b/apps/web/src/lib/baseRefChoices.test.ts
@@ -1,6 +1,6 @@
 import { describe, expect, it } from "vite-plus/test";
 import type { VcsRef } from "@t3tools/contracts";
-import { buildBaseRefChoices } from "./baseRefChoices";
+import { buildBaseRefChoices, filterBaseRefChoices } from "./baseRefChoices";
 
 function ref(name: string, remoteName?: string): VcsRef {
   return {
@@ -34,3 +34,19 @@ describe("buildBaseRefChoices", () => {
     ]);
   });
 });
+
+describe("filterBaseRefChoices", () => {
+  it("filters stale server results against the current query", () => {
+    const choices = buildBaseRefChoices(
+      [ref("main"), ref("feature/search")],
+      [ref("origin/main", "origin"), ref("origin/feature/search", "origin")],
+    );
+
+    expect(filterBaseRefChoices(choices, "SEARCH").map((choice) => choice.label)).toEqual([
+      "feature/search",
+    ]);
+    expect(filterBaseRefChoices(choices, "origin/main").map((choice) => choice.label)).toEqual([
+      "main",
+    ]);
+  });
+});
diff --git a/apps/web/src/lib/baseRefChoices.ts b/apps/web/src/lib/baseRefChoices.ts
index 5364ea1e5d7..2be010040a3 100644
--- a/apps/web/src/lib/baseRefChoices.ts
+++ b/apps/web/src/lib/baseRefChoices.ts
@@ -45,3 +45,17 @@ export function buildBaseRefChoices(
 
   return [...pairedChoices, ...remoteOnlyChoices];
 }
+
+export function filterBaseRefChoices(
+  choices: ReadonlyArray,
+  query: string,
+): ReadonlyArray {
+  const normalizedQuery = query.trim().toLocaleLowerCase();
+  if (normalizedQuery.length === 0) return choices;
+  return choices.filter(
+    (choice) =>
+      choice.label.toLocaleLowerCase().includes(normalizedQuery) ||
+      choice.local?.name.toLocaleLowerCase().includes(normalizedQuery) === true ||
+      choice.remote?.name.toLocaleLowerCase().includes(normalizedQuery) === true,
+  );
+}
diff --git a/packages/contracts/src/review.ts b/packages/contracts/src/review.ts
index 363b124bf22..a6b879a0c7f 100644
--- a/packages/contracts/src/review.ts
+++ b/packages/contracts/src/review.ts
@@ -6,6 +6,7 @@ import { VcsError } from "./vcs.ts";
 export const ReviewDiffPreviewInput = Schema.Struct({
   cwd: TrimmedNonEmptyString,
   baseRef: Schema.optional(TrimmedNonEmptyString),
+  ignoreWhitespace: Schema.optionalKey(Schema.Boolean),
 });
 export type ReviewDiffPreviewInput = typeof ReviewDiffPreviewInput.Type;
 

From d5bbbb7b2529a77499e9645dd5a4466034a9e206 Mon Sep 17 00:00:00 2001
From: Julius Marminge 
Date: Fri, 19 Jun 2026 19:39:33 -0700
Subject: [PATCH 5/5] Rename unstaged diffs to working tree

- Update diff panel labels from "Unstaged" to "Working tree"
- Adjust loading text to match the new terminology
---
 apps/web/src/components/DiffPanel.tsx | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx
index ae8ae9b636c..a7309b44f4c 100644
--- a/apps/web/src/components/DiffPanel.tsx
+++ b/apps/web/src/components/DiffPanel.tsx
@@ -271,7 +271,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
   const selectedScopeLabel =
     selectedTurnId === null
       ? selectedGitScope === "unstaged"
-        ? "Unstaged"
+        ? "Working tree"
         : "Branch changes"
       : selectedTurn?.turnId === latestTurn?.turnId
         ? "Latest turn"
@@ -287,7 +287,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
   const reviewSectionTitle = selectedTurn
     ? `Turn ${selectedCheckpointTurnCount ?? "?"}`
     : selectedGitScope === "unstaged"
-      ? "Unstaged"
+      ? "Working tree"
       : "Branch changes";
   const selectedCheckpointRange = useMemo(
     () =>
@@ -501,7 +501,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
           
           
              selectGitScope("unstaged")}>
-              Unstaged
+              Working tree
               {selectedTurnId === null && selectedGitScope === "unstaged" && (
                 
               )}
@@ -764,7 +764,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
                     selectedTurn
                       ? "Loading checkpoint diff..."
                       : selectedGitScope === "unstaged"
-                        ? "Loading unstaged diff..."
+                        ? "Loading working tree diff..."
                         : "Loading branch diff..."
                   }
                 />