Skip to content

Add diff scope switching and provider update settings#3169

Open
juliusmarminge wants to merge 4 commits into
mainfrom
t3code/diff-turn-dropdown
Open

Add diff scope switching and provider update settings#3169
juliusmarminge wants to merge 4 commits into
mainfrom
t3code/diff-turn-dropdown

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Jun 19, 2026

Copy link
Copy Markdown
Member

Summary

  • Add a diff-scope dropdown so users can switch between branch and unstaged-change views.
  • Wire provider snapshot settings through server state so update checks can be disabled and refreshed when settings change.
  • Convert multiple server and client codepaths to Option-based secret handling and Effect schema decoding for more explicit nullability.
  • Improve session and sidebar UI signals, including worktree indicators, PR link behavior, and thread status badges.

Testing

  • vp check
  • vp run typecheck
  • vp test
  • Not run: additional manual UI verification of the new diff-scope dropdown and PR link interactions

Note

Medium Risk
Large UI and state-model change (no URL deep links, new git diff data paths) touches review/VCS APIs, but behavior is covered by new integration and store tests.

Overview
Moves diff panel state off the URL (?diff, turn/file search params) into a persisted per-thread store (diffPanelStore), so opening the panel and choosing a scope no longer updates route search. ChatView only toggles the right panel; turn diffs from the timeline write selection into that store instead of navigating.

The diff panel adds a scope dropdown: Unstaged, Branch changes (via reviewEnvironment.diffPreview working-tree / branch-range sources), Latest turn, or a specific turn (checkpoint diff unchanged). Branch view gets a searchable base ref combobox backed by listRefs with includeMatchingRemoteRefs and local/remote refKind filters, plus buildBaseRefChoices pairing. Rendering switches from per-file Virtualizer to AnnotatableCodeView (CodeView) with sticky headers; git scopes use compactPartialHunkOffsets so partial hunks virtualize correctly.

Server: getReviewDiffPreview passes --ignore-all-space when ignoreWhitespace is set; listRefs can include matching remotes and filter by ref kind. Contracts extend ReviewDiffPreviewInput and VcsListRefsInput accordingly. Removes diffRouteSearch and thread-route search validation.

Reviewed by Cursor Bugbot for commit 104bb7a. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add diff scope switching and store-driven selection to the diff panel

  • Replaces the URL-based diff panel state (search params, navigation side-effects) with a persisted Zustand store (diffPanelStore.ts) that tracks per-thread selection across three scopes: unstaged changes, branch diff (with selectable base ref), and individual turn diffs.
  • The diff panel gains a scope dropdown and a branch base-ref combobox with local/remote branch filtering via new helpers in baseRefChoices.ts.
  • Adds a new AnnotatableCodeView component (AnnotatableFileDiff.tsx) that renders multiple diff files with inline comment annotations and supports sticky headers and programmatic file scrolling.
  • Extends the server-side getReviewDiffPreview and listRefs handlers to support ignoreWhitespace and ref-kind/remote-ref filtering respectively.
  • Deletes diffRouteSearch.ts and removes the diff search param from the chat route; the diff panel open/close state is no longer reflected in the URL.
  • Behavioral Change: navigating directly to a URL with ?diff=1 no longer opens the diff panel; bookmarks or shared links containing diff search params will not restore diff panel state.

Macroscope summarized 104bb7a.

- Replace the turn strip with a scope menu
- Support unstaged diff routing and parsing
- Update diff header skeleton and tests
@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f863d07c-5c3d-4251-acd9-2cd08b48f464

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/diff-turn-dropdown

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Jun 19, 2026
@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

🚀 Expo continuous deployment is ready!

  • Project → t3-code
  • Platforms → android, ios
  • Scheme → t3code-preview
  🤖 Android 🍎 iOS
Fingerprint fe5a51f2e189da69dfc4c2cd458e6cfb5fdff2ea ae3bd597809dfd7771d0898f735d172973d4c1c8
Build Details Build Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: fe5a51f2e189da69dfc4c2cd458e6cfb5fdff2ea
App version: 0.1.0
Git commit: 5a7182ae66b2070fc19716b30aef093443628423
Build Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: ae3bd597809dfd7771d0898f735d172973d4c1c8
App version: 0.1.0
Git commit: 5a7182ae66b2070fc19716b30aef093443628423
Update Details Update Permalink
DetailsBranch: pr-3169
Runtime version: fe5a51f2e189da69dfc4c2cd458e6cfb5fdff2ea
Git commit: 5a7182ae66b2070fc19716b30aef093443628423
Update Permalink
DetailsBranch: pr-3169
Runtime version: ae3bd597809dfd7771d0898f735d172973d4c1c8
Git commit: 5a7182ae66b2070fc19716b30aef093443628423
Update QR

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Whitespace toggle ignores git scopes
    • Added ignoreWhitespace to ReviewDiffPreviewInput schema, passed -w flag in server git diff commands when enabled, and included the setting in the client's diffPreview query so the toggle triggers a refetch with whitespace filtering.
  • ✅ Fixed: Unstaged label shows worktree diff
    • Renamed all user-facing 'Unstaged' labels to 'Working tree' to accurately describe the scope which includes staged, unstaged, and untracked changes compared to HEAD.
  • ✅ Fixed: Truncated git diffs unhandled
    • Added extraction of the source's truncated flag, stripping of the [truncated] marker from diff text before parsing, and a visible warning banner when a truncated source is displayed.
  • ✅ Fixed: Diff list drops virtualizer
    • Re-added the Virtualizer import from @pierre/diffs/react and restored the Virtualizer wrapper with overscrollSize and intersectionObserverMargin config around the file diff list.

Create PR

Or push these changes by commenting:

@cursor push 2fee8036e0
Preview (2fee8036e0)
diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts
--- a/apps/server/src/vcs/GitVcsDriverCore.ts
+++ b/apps/server/src/vcs/GitVcsDriverCore.ts
@@ -1814,10 +1814,12 @@
           )
         : null);
 
+    const ignoreWsArgs = input.ignoreWhitespace ? ["-w"] : [];
+
     const dirtyTrackedResult = yield* executeGit(
       "GitVcsDriver.getReviewDiffPreview.dirtyTracked",
       input.cwd,
-      ["diff", "--patch", "--minimal", "HEAD", "--"],
+      ["diff", "--patch", "--minimal", ...ignoreWsArgs, "HEAD", "--"],
       {
         maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES,
         appendTruncationMarker: true,
@@ -1843,7 +1845,7 @@
         ? yield* executeGit(
             "GitVcsDriver.getReviewDiffPreview.base",
             input.cwd,
-            ["diff", "--patch", "--minimal", `${baseRef}...HEAD`],
+            ["diff", "--patch", "--minimal", ...ignoreWsArgs, `${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
--- a/apps/web/src/components/DiffPanel.tsx
+++ b/apps/web/src/components/DiffPanel.tsx
@@ -1,4 +1,5 @@
 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 {
@@ -247,7 +248,7 @@
   const selectedScopeLabel =
     selectedTurnId === null
       ? selectedGitScope === "unstaged"
-        ? "Unstaged"
+        ? "Working tree"
         : "Branch changes"
       : selectedTurn?.turnId === latestTurn?.turnId
         ? "Latest turn"
@@ -263,7 +264,7 @@
   const reviewSectionTitle = selectedTurn
     ? `Turn ${selectedCheckpointTurnCount ?? "?"}`
     : selectedGitScope === "unstaged"
-      ? "Unstaged"
+      ? "Working tree"
       : "Branch changes";
   const selectedCheckpointRange = useMemo(
     () =>
@@ -290,7 +291,7 @@
     selectedTurnId === null && activeThread && activeCwd
       ? reviewEnvironment.diffPreview({
           environmentId: activeThread.environmentId,
-          input: { cwd: activeCwd },
+          input: { cwd: activeCwd, ignoreWhitespace: diffIgnoreWhitespace || undefined },
         })
       : null,
   );
@@ -303,18 +304,24 @@
     shouldRetryBranchDiffAtEnvironmentCwd && activeThread && serverConfig
       ? reviewEnvironment.diffPreview({
           environmentId: activeThread.environmentId,
-          input: { cwd: serverConfig.cwd },
+          input: { cwd: serverConfig.cwd, ignoreWhitespace: diffIgnoreWhitespace || undefined },
         })
       : null,
   );
   const branchDiffPreview = shouldRetryBranchDiffAtEnvironmentCwd
     ? fallbackBranchDiffPreview
     : primaryBranchDiffPreview;
-  const gitDiff = branchDiffPreview.data?.sources.find(
+  const gitDiffSource = branchDiffPreview.data?.sources.find(
     (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"),
-  )?.diff;
+  );
+  const gitDiff = gitDiffSource?.diff;
+  const gitDiffTruncated = gitDiffSource?.truncated ?? false;
 
-  const selectedPatch = selectedTurn ? activeCheckpointDiff.data?.diff : gitDiff;
+  const selectedPatch = selectedTurn
+    ? activeCheckpointDiff.data?.diff
+    : gitDiff != null
+      ? gitDiff.replace(/\n*\[truncated\]\s*$/, "")
+      : gitDiff;
   const isLoadingSelectedPatch = selectedTurn
     ? activeCheckpointDiff.isPending
     : branchDiffPreview.isPending;
@@ -424,7 +431,7 @@
           </DropdownMenuTrigger>
           <DropdownMenuContent align="start" className="w-60">
             <DropdownMenuItem onClick={() => selectGitScope("unstaged")}>
-              <span>Unstaged</span>
+              <span>Working tree</span>
               {selectedTurnId === null && selectedGitScope === "unstaged" && (
                 <CheckIcon className="ml-auto" />
               )}
@@ -564,6 +571,13 @@
                 <p className="mb-2 text-[11px] text-red-500/80">{selectedPatchError}</p>
               </div>
             )}
+            {gitDiffTruncated && !selectedTurn && renderablePatch && (
+              <div className="border-b border-amber-200 bg-amber-50 px-3 py-1.5 dark:border-amber-900/60 dark:bg-amber-950/40">
+                <p className="text-[11px] text-amber-700 dark:text-amber-300">
+                  Diff output was truncated by the server. Showing available excerpt.
+                </p>
+              </div>
+            )}
             {!renderablePatch ? (
               isLoadingSelectedPatch ? (
                 <DiffPanelLoadingState
@@ -571,7 +585,7 @@
                     selectedTurn
                       ? "Loading checkpoint diff..."
                       : selectedGitScope === "unstaged"
-                        ? "Loading unstaged diff..."
+                        ? "Loading working tree diff..."
                         : "Loading branch diff..."
                   }
                 />
@@ -585,9 +599,13 @@
                 </div>
               )
             ) : renderablePatch.kind === "files" ? (
-              <div
+              <Virtualizer
                 key={collapseScopeKey ?? reviewSectionId}
                 className="diff-render-surface h-full min-h-0 overflow-auto px-2 pb-2"
+                config={{
+                  overscrollSize: 600,
+                  intersectionObserverMargin: 1200,
+                }}
               >
                 {renderableFiles.map((fileDiff) => {
                   const filePath = resolveFileDiffPath(fileDiff);
@@ -661,7 +679,7 @@
                     </div>
                   );
                 })}
-              </div>
+              </Virtualizer>
             ) : (
               <div className="h-full overflow-auto p-2">
                 <div className="space-y-2">

diff --git a/packages/contracts/src/review.ts b/packages/contracts/src/review.ts
--- a/packages/contracts/src/review.ts
+++ b/packages/contracts/src/review.ts
@@ -6,6 +6,7 @@
 export const ReviewDiffPreviewInput = Schema.Struct({
   cwd: TrimmedNonEmptyString,
   baseRef: Schema.optional(TrimmedNonEmptyString),
+  ignoreWhitespace: Schema.optional(Schema.Boolean),
 });
 export type ReviewDiffPreviewInput = typeof ReviewDiffPreviewInput.Type;

You can send follow-ups to the cloud agent here.

Comment thread apps/web/src/components/DiffPanel.tsx
Comment thread apps/web/src/components/DiffPanel.tsx Outdated
: primaryBranchDiffPreview;
const gitDiff = branchDiffPreview.data?.sources.find(
(source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"),
)?.diff;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unstaged label shows worktree diff

Medium Severity

The “Unstaged” scope and diffScope=unstaged route state select the working-tree preview source, which elsewhere represents dirty worktree changes (staged, tracked, and untracked), not git unstaged-only diffs. Users can believe they are reviewing unstaged files while seeing a broader worktree patch.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d134318. Configure here.

Comment thread apps/web/src/components/DiffPanel.tsx
Comment thread apps/web/src/components/DiffPanel.tsx
@macroscopeapp

macroscopeapp Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR introduces significant new user-facing functionality (diff scope switching, base ref selection) with substantial UI and state management changes. Multiple unresolved review comments identify potential bugs including state mismatches and missing feature parity that should be addressed before merging.

You can customize Macroscope's approvability policy. Learn more.

- 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
@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Jun 20, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

There are 6 total unresolved issues (including 3 from previous reviews).

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Scope switch clears base ref
    • The selectGitScope function now extracts the previous diffBaseRef via parseDiffRouteSearch before stripping params and re-adds it when scope is "branch".
  • ✅ Fixed: Turn file scroll target missing
    • The scroll useEffect now queries [data-title] elements and matches by textContent (consistent with the click handler) instead of the non-existent [data-diff-file-path] attribute.
  • ✅ Fixed: Base ref search shows stale
    • filteredBaseRefItems now applies a client-side .filter() on choice.label against the baseRefQuery so stale/unrelated refs are hidden while refetches are in flight.

Create PR

Or push these changes by commenting:

@cursor push 6ea65f1c5b
Preview (6ea65f1c5b)
diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx
--- a/apps/web/src/components/DiffPanel.tsx
+++ b/apps/web/src/components/DiffPanel.tsx
@@ -380,7 +380,13 @@
   const baseRefItems = [AUTOMATIC_BASE_REF, ...baseRefChoices.map(valueForBaseRefChoice)];
   const filteredBaseRefItems = [
     ...(baseRefQuery.trim().length === 0 ? [AUTOMATIC_BASE_REF] : []),
-    ...baseRefChoices.map(valueForBaseRefChoice),
+    ...baseRefChoices
+      .filter(
+        (choice) =>
+          baseRefQuery.trim().length === 0 ||
+          choice.label.toLowerCase().includes(baseRefQuery.trim().toLowerCase()),
+      )
+      .map(valueForBaseRefChoice),
   ];
   const gitDiff = selectedGitSource?.diff;
 
@@ -436,8 +442,8 @@
       return;
     }
     const target = Array.from(
-      patchViewportRef.current.querySelectorAll<HTMLElement>("[data-diff-file-path]"),
-    ).find((element) => element.dataset.diffFilePath === selectedFilePath);
+      patchViewportRef.current.querySelectorAll<HTMLElement>("[data-title]"),
+    ).find((element) => element.textContent?.trim() === selectedFilePath);
     target?.scrollIntoView({ block: "nearest" });
   }, [selectedFilePath, renderableFiles]);
 
@@ -491,8 +497,15 @@
       to: "/$environmentId/$threadId",
       params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)),
       search: (previous) => {
+        const prevBaseRef =
+          scope === "branch" ? parseDiffRouteSearch(previous).diffBaseRef : undefined;
         const rest = stripDiffSearchParams(previous);
-        return { ...rest, diff: "1", ...(scope === "unstaged" ? { diffScope: scope } : {}) };
+        return {
+          ...rest,
+          diff: "1",
+          ...(scope === "unstaged" ? { diffScope: scope } : {}),
+          ...(prevBaseRef ? { diffBaseRef: prevBaseRef } : {}),
+        };
       },
     });
   };

You can send follow-ups to the cloud agent here.

Comment thread apps/web/src/components/DiffPanel.tsx
Comment thread apps/web/src/components/DiffPanel.tsx
Comment thread apps/web/src/components/DiffPanel.tsx
Comment thread apps/web/src/components/DiffPanel.tsx
- 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
@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Jun 20, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

There are 6 total unresolved issues (including 4 from previous reviews).

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Stale turn id loads wrong diff
    • Added a useEffect that detects when the persisted selectedTurnId is absent from orderedTurnDiffSummaries and updates the store to select the latest available turn, keeping internal state in sync with the displayed diff.
  • ✅ Fixed: Branch scope clears base ref
    • Changed selectGitScope to preserve the existing baseRef when the current selection is already 'branch' scope, instead of unconditionally resetting it to null via DEFAULT_SELECTION.

Create PR

Or push these changes by commenting:

@cursor push ea8244b85f
Preview (ea8244b85f)
diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx
--- a/apps/web/src/components/DiffPanel.tsx
+++ b/apps/web/src/components/DiffPanel.tsx
@@ -256,6 +256,21 @@
       ? undefined
       : (orderedTurnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ??
         orderedTurnDiffSummaries[0]);
+
+  useEffect(() => {
+    if (
+      selectedTurnId !== null &&
+      orderedTurnDiffSummaries.length > 0 &&
+      !orderedTurnDiffSummaries.some((summary) => summary.turnId === selectedTurnId)
+    ) {
+      if (routeThreadRef) {
+        useDiffPanelStore
+          .getState()
+          .selectTurn(routeThreadRef, orderedTurnDiffSummaries[0]!.turnId);
+      }
+    }
+  }, [selectedTurnId, orderedTurnDiffSummaries, routeThreadRef]);
+
   const selectedCheckpointTurnCount =
     selectedTurn &&
     (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]);

diff --git a/apps/web/src/diffPanelStore.ts b/apps/web/src/diffPanelStore.ts
--- a/apps/web/src/diffPanelStore.ts
+++ b/apps/web/src/diffPanelStore.ts
@@ -30,12 +30,27 @@
     (set) => ({
       byThreadKey: {},
       selectGitScope: (ref, scope) =>
-        set((state) => ({
-          byThreadKey: {
-            ...state.byThreadKey,
-            [scopedThreadKey(ref)]: scope === "branch" ? DEFAULT_SELECTION : { kind: "unstaged" },
-          },
-        })),
+        set((state) => {
+          const threadKey = scopedThreadKey(ref);
+          if (scope === "branch") {
+            const current = state.byThreadKey[threadKey];
+            return {
+              byThreadKey: {
+                ...state.byThreadKey,
+                [threadKey]: {
+                  kind: "branch",
+                  baseRef: current?.kind === "branch" ? current.baseRef : null,
+                },
+              },
+            };
+          }
+          return {
+            byThreadKey: {
+              ...state.byThreadKey,
+              [threadKey]: { kind: "unstaged" },
+            },
+          };
+        }),
       selectBranchBaseRef: (ref, baseRef) =>
         set((state) => ({
           byThreadKey: {

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit c966b5b. Configure here.

Comment thread apps/web/src/components/DiffPanel.tsx
Comment thread apps/web/src/diffPanelStore.ts Outdated
- 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant