authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-03 09:30:18-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-03 10:01:18-08:00
log9ca9a862a5d31f4f7dcf0d0fe263ed54026347d5
tree7db70037e2a01c9e82780e1a2c2ad49d277e4a7b
parent827dc9ff9fce243c48125201c26bed81c4042573

Fix branch selector default and loading behavior in worktree mode

- Default new worktree branch selection to the current checked-out branch when unset - Extract branch value resolution into shared logic with tests - Stabilize virtualized branch list scroll/measurement to avoid loading glitches

3 files changed, 131 insertions(+), 41 deletions(-)

apps/web/src/components/BranchToolbar.logic.test.ts+36
......@@ -3,8 +3,44 @@ import { describe, expect, it } from "vitest";
33import {
44 dedupeRemoteBranchesWithLocalMatches,
55 deriveLocalBranchNameFromRemoteRef,
6 resolveBranchToolbarValue,
67} from "./BranchToolbar.logic";
78
9describe("resolveBranchToolbarValue", () => {
10 it("defaults new-worktree mode to current git branch when no explicit base branch is set", () => {
11 expect(
12 resolveBranchToolbarValue({
13 envMode: "worktree",
14 activeWorktreePath: null,
15 activeThreadBranch: null,
16 currentGitBranch: "main",
17 }),
18 ).toBe("main");
19 });
20
21 it("keeps an explicitly selected worktree base branch", () => {
22 expect(
23 resolveBranchToolbarValue({
24 envMode: "worktree",
25 activeWorktreePath: null,
26 activeThreadBranch: "feature/base",
27 currentGitBranch: "main",
28 }),
29 ).toBe("feature/base");
30 });
31
32 it("shows the actual checked-out branch when not selecting a new worktree base", () => {
33 expect(
34 resolveBranchToolbarValue({
35 envMode: "local",
36 activeWorktreePath: null,
37 activeThreadBranch: "feature/base",
38 currentGitBranch: "main",
39 }),
40 ).toBe("main");
41 });
42});
43
844describe("deriveLocalBranchNameFromRemoteRef", () => {
945 it("strips the remote prefix from a remote ref", () => {
1046 expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo");
apps/web/src/components/BranchToolbar.logic.ts+13
......@@ -1,5 +1,18 @@
11import type { GitBranch } from "@t3tools/contracts";
22
3export function resolveBranchToolbarValue(input: {
4 envMode: "local" | "worktree";
5 activeWorktreePath: string | null;
6 activeThreadBranch: string | null;
7 currentGitBranch: string | null;
8}): string | null {
9 const { envMode, activeWorktreePath, activeThreadBranch, currentGitBranch } = input;
10 if (envMode === "worktree" && !activeWorktreePath) {
11 return activeThreadBranch ?? currentGitBranch;
12 }
13 return currentGitBranch ?? activeThreadBranch;
14}
15
316export function deriveLocalBranchNameFromRemoteRef(branchName: string): string {
417 const firstSeparatorIndex = branchName.indexOf("/");
518 if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) {
apps/web/src/components/BranchToolbar.tsx+82-41
......@@ -1,7 +1,7 @@
11import type { GitBranch, ThreadId } from "@t3tools/contracts";
22import { useQuery, useQueryClient } from "@tanstack/react-query";
33import { useVirtualizer } from "@tanstack/react-virtual";
4import { useMemo, useOptimistic, useRef, useState, useTransition } from "react";
4import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState, useTransition } from "react";
55
66import { newCommandId } from "../lib/utils";
77import { gitBranchesQueryOptions, invalidateGitQueries } from "../lib/gitReactQuery";
......@@ -11,6 +11,7 @@ import { useStore } from "../store";
1111import {
1212 dedupeRemoteBranchesWithLocalMatches,
1313 deriveLocalBranchNameFromRemoteRef,
14 resolveBranchToolbarValue,
1415} from "./BranchToolbar.logic";
1516import { Button } from "./ui/button";
1617import {
......@@ -49,7 +50,7 @@ export default function BranchToolbar({
4950
5051 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
5152 const [branchQuery, setBranchQuery] = useState("");
52 const branchListRef = useRef<HTMLDivElement | null>(null);
53 const branchListScrollElementRef = useRef<HTMLDivElement | null>(null);
5354
5455 const serverThread = threads.find((thread) => thread.id === threadId);
5556 const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null;
......@@ -73,10 +74,12 @@ export default function BranchToolbar({
7374 [branchesQuery.data?.branches],
7475 );
7576 const currentGitBranch = branches.find((branch) => branch.current)?.name ?? null;
76 const canonicalActiveBranch =
77 effectiveEnvMode === "worktree" && !activeWorktreePath
78 ? activeThreadBranch
79 : (currentGitBranch ?? activeThreadBranch);
77 const canonicalActiveBranch = resolveBranchToolbarValue({
78 envMode: effectiveEnvMode,
79 activeWorktreePath,
80 activeThreadBranch,
81 currentGitBranch,
82 });
8083 const branchNames = useMemo(() => branches.map((branch) => branch.name), [branches]);
8184 const branchByName = useMemo(
8285 () => new Map(branches.map((branch) => [branch.name, branch] as const)),
......@@ -121,50 +124,89 @@ export default function BranchToolbar({
121124 });
122125 };
123126
127 const setThreadBranch = useCallback(
128 (branch: string | null, worktreePath: string | null) => {
129 if (!activeThreadId) return;
130 const api = readNativeApi();
131 // If the effective cwd is about to change, stop the running session so the
132 // next message creates a new one with the correct cwd.
133 const sessionId = serverThread?.session?.sessionId;
134 if (sessionId && worktreePath !== activeWorktreePath && api) {
135 void api.orchestration
136 .dispatchCommand({
137 type: "thread.session.stop",
138 commandId: newCommandId(),
139 threadId: activeThreadId,
140 createdAt: new Date().toISOString(),
141 })
142 .catch(() => undefined);
143 }
144 if (api && hasServerThread) {
145 void api.orchestration.dispatchCommand({
146 type: "thread.meta.update",
147 commandId: newCommandId(),
148 threadId: activeThreadId,
149 branch,
150 worktreePath,
151 });
152 }
153 if (hasServerThread) {
154 setThreadBranchAction(activeThreadId, branch, worktreePath);
155 return;
156 }
157 setDraftThreadContext(threadId, {
158 branch,
159 worktreePath,
160 envMode: effectiveEnvMode,
161 });
162 },
163 [
164 activeThreadId,
165 serverThread?.session?.sessionId,
166 activeWorktreePath,
167 hasServerThread,
168 setThreadBranchAction,
169 setDraftThreadContext,
170 threadId,
171 effectiveEnvMode,
172 ],
173 );
174
124175 const branchListVirtualizer = useVirtualizer({
125176 count: filteredBranchPickerItems.length,
126177 estimateSize: () => 28,
127 getScrollElement: () => branchListRef.current?.parentElement ?? null,
178 getScrollElement: () => branchListScrollElementRef.current,
128179 overscan: 12,
129180 enabled: isBranchMenuOpen,
181 initialRect: {
182 height: 224,
183 width: 0,
184 },
130185 });
131186 const virtualBranchRows = branchListVirtualizer.getVirtualItems();
187 const setBranchListRef = useCallback(
188 (element: HTMLDivElement | null) => {
189 branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null;
190 if (element) {
191 branchListVirtualizer.measure();
192 }
193 },
194 [branchListVirtualizer],
195 );
132196
133 const setThreadBranch = (branch: string | null, worktreePath: string | null) => {
134 if (!activeThreadId) return;
135 const api = readNativeApi();
136 // If the effective cwd is about to change, stop the running session so the
137 // next message creates a new one with the correct cwd.
138 const sessionId = serverThread?.session?.sessionId;
139 if (sessionId && worktreePath !== activeWorktreePath && api) {
140 void api.orchestration
141 .dispatchCommand({
142 type: "thread.session.stop",
143 commandId: newCommandId(),
144 threadId: activeThreadId,
145 createdAt: new Date().toISOString(),
146 })
147 .catch(() => undefined);
148 }
149 if (api && hasServerThread) {
150 void api.orchestration.dispatchCommand({
151 type: "thread.meta.update",
152 commandId: newCommandId(),
153 threadId: activeThreadId,
154 branch,
155 worktreePath,
156 });
157 }
158 if (hasServerThread) {
159 setThreadBranchAction(activeThreadId, branch, worktreePath);
197 useEffect(() => {
198 if (effectiveEnvMode !== "worktree" || activeWorktreePath || activeThreadBranch || !currentGitBranch) {
160199 return;
161200 }
162 setDraftThreadContext(threadId, {
163 branch,
164 worktreePath,
165 envMode: effectiveEnvMode,
201 setThreadBranch(currentGitBranch, null);
202 }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]);
203
204 useEffect(() => {
205 if (!isBranchMenuOpen) return;
206 queueMicrotask(() => {
207 branchListVirtualizer.measure();
166208 });
167 };
209 }, [branchListVirtualizer, filteredBranchPickerItems.length, isBranchMenuOpen]);
168210
169211 const selectBranch = (branch: GitBranch) => {
170212 const api = readNativeApi();
......@@ -296,7 +338,6 @@ export default function BranchToolbar({
296338 onOpenChange={(open) => {
297339 setIsBranchMenuOpen(open);
298340 if (!open) setBranchQuery("");
299 else void invalidateGitQueries(queryClient);
300341 }}
301342 open={isBranchMenuOpen}
302343 value={resolvedActiveBranch}
......@@ -329,7 +370,7 @@ export default function BranchToolbar({
329370 </div>
330371 <ComboboxEmpty>No branches found.</ComboboxEmpty>
331372
332 <ComboboxList ref={branchListRef} className="max-h-56">
373 <ComboboxList ref={setBranchListRef} className="max-h-56">
333374 <div
334375 className="relative"
335376 style={{