authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-03 09:52:47-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-03 10:01:18-08:00
logfabb3e07348eb864d6a2c33234db1afb59c7e310
tree95fbb0fd75bfbc620fdc02924a5be298cd4ad28a
parent9ca9a862a5d31f4f7dcf0d0fe263ed54026347d5

Extract branch selector and refresh branches on menu open

- Move branch combobox logic into `BranchToolbarBranchSelector` to simplify `BranchToolbar` - Re-fetch git branches when opening the selector so options are current - Preserve draft env mode correctly when switching between main and worktree branches

4 files changed, 462 insertions(+), 340 deletions(-)

apps/web/src/components/BranchToolbar.logic.test.ts+33
......@@ -3,9 +3,42 @@ import { describe, expect, it } from "vitest";
33import {
44 dedupeRemoteBranchesWithLocalMatches,
55 deriveLocalBranchNameFromRemoteRef,
6 resolveDraftEnvModeAfterBranchChange,
67 resolveBranchToolbarValue,
78} from "./BranchToolbar.logic";
89
10describe("resolveDraftEnvModeAfterBranchChange", () => {
11 it("switches to local mode when returning from an existing worktree to the main worktree", () => {
12 expect(
13 resolveDraftEnvModeAfterBranchChange({
14 nextWorktreePath: null,
15 currentWorktreePath: "/repo/.t3/worktrees/feature-a",
16 effectiveEnvMode: "worktree",
17 }),
18 ).toBe("local");
19 });
20
21 it("keeps new-worktree mode when selecting a base branch before worktree creation", () => {
22 expect(
23 resolveDraftEnvModeAfterBranchChange({
24 nextWorktreePath: null,
25 currentWorktreePath: null,
26 effectiveEnvMode: "worktree",
27 }),
28 ).toBe("worktree");
29 });
30
31 it("uses worktree mode when selecting a branch already attached to a worktree", () => {
32 expect(
33 resolveDraftEnvModeAfterBranchChange({
34 nextWorktreePath: "/repo/.t3/worktrees/feature-a",
35 currentWorktreePath: null,
36 effectiveEnvMode: "local",
37 }),
38 ).toBe("worktree");
39 });
40});
41
942describe("resolveBranchToolbarValue", () => {
1043 it("defaults new-worktree mode to current git branch when no explicit base branch is set", () => {
1144 expect(
apps/web/src/components/BranchToolbar.logic.ts+15
......@@ -1,5 +1,20 @@
11import type { GitBranch } from "@t3tools/contracts";
22
3export function resolveDraftEnvModeAfterBranchChange(input: {
4 nextWorktreePath: string | null;
5 currentWorktreePath: string | null;
6 effectiveEnvMode: "local" | "worktree";
7}): "local" | "worktree" {
8 const { nextWorktreePath, currentWorktreePath, effectiveEnvMode } = input;
9 if (nextWorktreePath) {
10 return "worktree";
11 }
12 if (effectiveEnvMode === "worktree" && !currentWorktreePath) {
13 return "worktree";
14 }
15 return "local";
16}
17
318export function resolveBranchToolbarValue(input: {
419 envMode: "local" | "worktree";
520 activeWorktreePath: string | null;
apps/web/src/components/BranchToolbar.tsx+20-340
......@@ -1,30 +1,13 @@
1import type { GitBranch, ThreadId } from "@t3tools/contracts";
2import { useQuery, useQueryClient } from "@tanstack/react-query";
3import { useVirtualizer } from "@tanstack/react-virtual";
4import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState, useTransition } from "react";
1import type { ThreadId } from "@t3tools/contracts";
2import { useCallback } from "react";
53
64import { newCommandId } from "../lib/utils";
7import { gitBranchesQueryOptions, invalidateGitQueries } from "../lib/gitReactQuery";
85import { readNativeApi } from "../nativeApi";
96import { useComposerDraftStore } from "../composerDraftStore";
107import { useStore } from "../store";
11import {
12 dedupeRemoteBranchesWithLocalMatches,
13 deriveLocalBranchNameFromRemoteRef,
14 resolveBranchToolbarValue,
15} from "./BranchToolbar.logic";
8import { resolveDraftEnvModeAfterBranchChange } from "./BranchToolbar.logic";
9import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector";
1610import { Button } from "./ui/button";
17import {
18 Combobox,
19 ComboboxEmpty,
20 ComboboxInput,
21 ComboboxItem,
22 ComboboxList,
23 ComboboxPopup,
24 ComboboxTrigger,
25} from "./ui/combobox";
26import { ChevronDownIcon } from "lucide-react";
27import { toastManager } from "./ui/toast";
2811
2912interface BranchToolbarProps {
3013 threadId: ThreadId;
......@@ -46,11 +29,6 @@ export default function BranchToolbar({
4629 const setThreadBranchAction = useStore((store) => store.setThreadBranch);
4730 const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId));
4831 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
49 const queryClient = useQueryClient();
50
51 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
52 const [branchQuery, setBranchQuery] = useState("");
53 const branchListScrollElementRef = useRef<HTMLDivElement | null>(null);
5432
5533 const serverThread = threads.find((thread) => thread.id === threadId);
5634 const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null;
......@@ -65,65 +43,6 @@ export default function BranchToolbar({
6543 ? "worktree"
6644 : envMode;
6745
68 // ── Queries ───────────────────────────────────────────────────────────
69
70 const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd));
71
72 const branches = useMemo(
73 () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []),
74 [branchesQuery.data?.branches],
75 );
76 const currentGitBranch = branches.find((branch) => branch.current)?.name ?? null;
77 const canonicalActiveBranch = resolveBranchToolbarValue({
78 envMode: effectiveEnvMode,
79 activeWorktreePath,
80 activeThreadBranch,
81 currentGitBranch,
82 });
83 const branchNames = useMemo(() => branches.map((branch) => branch.name), [branches]);
84 const branchByName = useMemo(
85 () => new Map(branches.map((branch) => [branch.name, branch] as const)),
86 [branches],
87 );
88 const trimmedBranchQuery = branchQuery.trim();
89 const normalizedBranchQuery = trimmedBranchQuery.toLowerCase();
90 const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0;
91 const hasExactBranchMatch = branchByName.has(trimmedBranchQuery);
92 const createBranchItemValue = canCreateBranch
93 ? `__create_new_branch__:${trimmedBranchQuery}`
94 : null;
95 const branchPickerItems = useMemo(
96 () =>
97 createBranchItemValue && !hasExactBranchMatch
98 ? [...branchNames, createBranchItemValue]
99 : branchNames,
100 [branchNames, createBranchItemValue, hasExactBranchMatch],
101 );
102 const filteredBranchPickerItems = useMemo(
103 () =>
104 normalizedBranchQuery.length === 0
105 ? branchPickerItems
106 : branchPickerItems.filter((itemValue) => {
107 if (createBranchItemValue && itemValue === createBranchItemValue) return true;
108 return itemValue.toLowerCase().includes(normalizedBranchQuery);
109 }),
110 [branchPickerItems, createBranchItemValue, normalizedBranchQuery],
111 );
112
113 // ── Helpers ───────────────────────────────────────────────────────────
114
115 const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic(
116 canonicalActiveBranch,
117 (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch,
118 );
119 const [isBranchActionPending, startBranchActionTransition] = useTransition();
120 const runBranchAction = (action: () => Promise<void>) => {
121 startBranchActionTransition(async () => {
122 await action().catch(() => undefined);
123 await invalidateGitQueries(queryClient).catch(() => undefined);
124 });
125 };
126
12746 const setThreadBranch = useCallback(
12847 (branch: string | null, worktreePath: string | null) => {
12948 if (!activeThreadId) return;
......@@ -154,10 +73,15 @@ export default function BranchToolbar({
15473 setThreadBranchAction(activeThreadId, branch, worktreePath);
15574 return;
15675 }
76 const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({
77 nextWorktreePath: worktreePath,
78 currentWorktreePath: activeWorktreePath,
79 effectiveEnvMode,
80 });
15781 setDraftThreadContext(threadId, {
15882 branch,
15983 worktreePath,
160 envMode: effectiveEnvMode,
84 envMode: nextDraftEnvMode,
16185 });
16286 },
16387 [
......@@ -172,138 +96,6 @@ export default function BranchToolbar({
17296 ],
17397 );
17498
175 const branchListVirtualizer = useVirtualizer({
176 count: filteredBranchPickerItems.length,
177 estimateSize: () => 28,
178 getScrollElement: () => branchListScrollElementRef.current,
179 overscan: 12,
180 enabled: isBranchMenuOpen,
181 initialRect: {
182 height: 224,
183 width: 0,
184 },
185 });
186 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 );
196
197 useEffect(() => {
198 if (effectiveEnvMode !== "worktree" || activeWorktreePath || activeThreadBranch || !currentGitBranch) {
199 return;
200 }
201 setThreadBranch(currentGitBranch, null);
202 }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]);
203
204 useEffect(() => {
205 if (!isBranchMenuOpen) return;
206 queueMicrotask(() => {
207 branchListVirtualizer.measure();
208 });
209 }, [branchListVirtualizer, filteredBranchPickerItems.length, isBranchMenuOpen]);
210
211 const selectBranch = (branch: GitBranch) => {
212 const api = readNativeApi();
213 if (!api || !activeThreadId || !branchCwd || isBranchActionPending) return;
214
215 // For new worktree mode, selecting a branch picks the base branch.
216 if (effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath) {
217 setThreadBranch(branch.name, null);
218 setIsBranchMenuOpen(false);
219 onComposerFocusRequest?.();
220 return;
221 }
222
223 // If the branch already lives in a worktree, redirect there instead of
224 // trying to checkout (which git would reject with "already used by worktree").
225 if (branch.worktreePath) {
226 const isMainWorktree = branch.worktreePath === activeProject?.cwd;
227 // Main worktree → switch back to local (project cwd, worktreePath=null).
228 // Secondary worktree → point the thread at that worktree path.
229 setThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath);
230 setIsBranchMenuOpen(false);
231 onComposerFocusRequest?.();
232 return;
233 }
234
235 const selectedBranchName = branch.isRemote
236 ? deriveLocalBranchNameFromRemoteRef(branch.name)
237 : branch.name;
238
239 setIsBranchMenuOpen(false);
240 onComposerFocusRequest?.();
241
242 runBranchAction(async () => {
243 setOptimisticBranch(selectedBranchName);
244 try {
245 await api.git.checkout({ cwd: branchCwd, branch: branch.name });
246 await invalidateGitQueries(queryClient);
247 } catch (error) {
248 toastManager.add({
249 type: "error",
250 title: "Failed to checkout branch.",
251 description: error instanceof Error ? error.message : "An error occurred.",
252 });
253 return;
254 }
255
256 let nextBranchName = selectedBranchName;
257 if (branch.isRemote) {
258 const status = await api.git.status({ cwd: branchCwd }).catch(() => null);
259 if (status?.branch) {
260 nextBranchName = status.branch;
261 }
262 }
263
264 setOptimisticBranch(nextBranchName);
265 setThreadBranch(nextBranchName, activeWorktreePath);
266 });
267 };
268
269 const createBranch = (rawName: string) => {
270 const name = rawName.trim();
271 const api = readNativeApi();
272 if (!api || !activeThreadId || !branchCwd || !name || isBranchActionPending) return;
273
274 setIsBranchMenuOpen(false);
275 onComposerFocusRequest?.();
276
277 runBranchAction(async () => {
278 setOptimisticBranch(name);
279
280 try {
281 await api.git.createBranch({ cwd: branchCwd, branch: name });
282 try {
283 await api.git.checkout({ cwd: branchCwd, branch: name });
284 } catch (error) {
285 toastManager.add({
286 type: "error",
287 title: "Failed to checkout branch.",
288 description: error instanceof Error ? error.message : "An error occurred.",
289 });
290 return;
291 }
292 } catch (error) {
293 toastManager.add({
294 type: "error",
295 title: "Failed to create branch.",
296 description: error instanceof Error ? error.message : "An error occurred.",
297 });
298 return;
299 }
300
301 setOptimisticBranch(name);
302 setThreadBranch(name, activeWorktreePath);
303 setBranchQuery("");
304 });
305 };
306
30799 if (!activeThreadId || !activeProject) return null;
308100
309101 return (
......@@ -326,128 +118,16 @@ export default function BranchToolbar({
326118 )}
327119 </div>
328120
329 <Combobox
330 items={branchPickerItems}
331 filteredItems={filteredBranchPickerItems}
332 autoHighlight
333 virtualized
334 onItemHighlighted={(_value, eventDetails) => {
335 if (!isBranchMenuOpen || eventDetails.index < 0) return;
336 branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" });
337 }}
338 onOpenChange={(open) => {
339 setIsBranchMenuOpen(open);
340 if (!open) setBranchQuery("");
341 }}
342 open={isBranchMenuOpen}
343 value={resolvedActiveBranch}
344 >
345 <ComboboxTrigger
346 render={<Button variant="ghost" size="xs" />}
347 className="text-muted-foreground/70 hover:text-foreground/80"
348 disabled={branchesQuery.isLoading || isBranchActionPending}
349 >
350 <span className="max-w-[240px] truncate">
351 {resolvedActiveBranch
352 ? effectiveEnvMode === "worktree" && !activeWorktreePath
353 ? `From ${resolvedActiveBranch}`
354 : resolvedActiveBranch
355 : "Select branch"}
356 </span>
357 <ChevronDownIcon />
358 </ComboboxTrigger>
359 <ComboboxPopup align="end" side="top" className="w-64">
360 <div className="border-b p-1">
361 <ComboboxInput
362 className="[&_input]:font-sans rounded-md"
363 inputClassName="ring-0"
364 placeholder="Search branches..."
365 showTrigger={false}
366 size="sm"
367 value={branchQuery}
368 onChange={(event) => setBranchQuery(event.target.value)}
369 />
370 </div>
371 <ComboboxEmpty>No branches found.</ComboboxEmpty>
372
373 <ComboboxList ref={setBranchListRef} className="max-h-56">
374 <div
375 className="relative"
376 style={{
377 height: `${branchListVirtualizer.getTotalSize()}px`,
378 }}
379 >
380 {virtualBranchRows.map((virtualRow) => {
381 const itemValue = filteredBranchPickerItems[virtualRow.index];
382 if (!itemValue) return null;
383 if (createBranchItemValue && itemValue === createBranchItemValue) {
384 return (
385 <ComboboxItem
386 hideIndicator
387 key={itemValue}
388 index={virtualRow.index}
389 value={itemValue}
390 style={{
391 position: "absolute",
392 top: 0,
393 left: 0,
394 width: "100%",
395 transform: `translateY(${virtualRow.start}px)`,
396 }}
397 onClick={() => createBranch(trimmedBranchQuery)}
398 >
399 <span className="truncate">Create new branch "{trimmedBranchQuery}"</span>
400 </ComboboxItem>
401 );
402 }
403
404 const branch = branchByName.get(itemValue);
405 if (!branch) return null;
406
407 const hasSecondaryWorktree =
408 branch.worktreePath && branch.worktreePath !== activeProject.cwd;
409 const badge = branch.current
410 ? "current"
411 : hasSecondaryWorktree
412 ? "worktree"
413 : branch.isRemote
414 ? "remote"
415 : branch.isDefault
416 ? "default"
417 : null;
418 return (
419 <ComboboxItem
420 hideIndicator
421 key={itemValue}
422 index={virtualRow.index}
423 value={itemValue}
424 className={
425 itemValue === resolvedActiveBranch ? "bg-accent text-foreground" : undefined
426 }
427 style={{
428 position: "absolute",
429 top: 0,
430 left: 0,
431 width: "100%",
432 transform: `translateY(${virtualRow.start}px)`,
433 }}
434 onClick={() => selectBranch(branch)}
435 >
436 <div className="flex w-full items-center justify-between gap-2">
437 <span className="truncate">{itemValue}</span>
438 {badge && (
439 <span className="shrink-0 text-[10px] text-muted-foreground/45">
440 {badge}
441 </span>
442 )}
443 </div>
444 </ComboboxItem>
445 );
446 })}
447 </div>
448 </ComboboxList>
449 </ComboboxPopup>
450 </Combobox>
121 <BranchToolbarBranchSelector
122 activeProjectCwd={activeProject.cwd}
123 activeThreadBranch={activeThreadBranch}
124 activeWorktreePath={activeWorktreePath}
125 branchCwd={branchCwd}
126 effectiveEnvMode={effectiveEnvMode}
127 envLocked={envLocked}
128 onSetThreadBranch={setThreadBranch}
129 {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}
130 />
451131 </div>
452132 );
453133}
apps/web/src/components/BranchToolbarBranchSelector.tsx created+394
......@@ -0,0 +1,394 @@
1import type { GitBranch } from "@t3tools/contracts";
2import { useQuery, useQueryClient } from "@tanstack/react-query";
3import { useVirtualizer } from "@tanstack/react-virtual";
4import { ChevronDownIcon } from "lucide-react";
5import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState, useTransition } from "react";
6
7import { gitBranchesQueryOptions, gitQueryKeys, invalidateGitQueries } from "../lib/gitReactQuery";
8import { readNativeApi } from "../nativeApi";
9import {
10 dedupeRemoteBranchesWithLocalMatches,
11 deriveLocalBranchNameFromRemoteRef,
12 resolveBranchToolbarValue,
13} from "./BranchToolbar.logic";
14import { Button } from "./ui/button";
15import {
16 Combobox,
17 ComboboxEmpty,
18 ComboboxInput,
19 ComboboxItem,
20 ComboboxList,
21 ComboboxPopup,
22 ComboboxTrigger,
23} from "./ui/combobox";
24import { toastManager } from "./ui/toast";
25
26interface BranchToolbarBranchSelectorProps {
27 activeProjectCwd: string;
28 activeThreadBranch: string | null;
29 activeWorktreePath: string | null;
30 branchCwd: string | null;
31 effectiveEnvMode: "local" | "worktree";
32 envLocked: boolean;
33 onSetThreadBranch: (branch: string | null, worktreePath: string | null) => void;
34 onComposerFocusRequest?: () => void;
35}
36
37function toBranchActionErrorMessage(error: unknown): string {
38 return error instanceof Error ? error.message : "An error occurred.";
39}
40
41function getBranchTriggerLabel(input: {
42 activeWorktreePath: string | null;
43 effectiveEnvMode: "local" | "worktree";
44 resolvedActiveBranch: string | null;
45}): string {
46 const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input;
47 if (!resolvedActiveBranch) {
48 return "Select branch";
49 }
50 if (effectiveEnvMode === "worktree" && !activeWorktreePath) {
51 return `From ${resolvedActiveBranch}`;
52 }
53 return resolvedActiveBranch;
54}
55
56export function BranchToolbarBranchSelector({
57 activeProjectCwd,
58 activeThreadBranch,
59 activeWorktreePath,
60 branchCwd,
61 effectiveEnvMode,
62 envLocked,
63 onSetThreadBranch,
64 onComposerFocusRequest,
65}: BranchToolbarBranchSelectorProps) {
66 const queryClient = useQueryClient();
67 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
68 const [branchQuery, setBranchQuery] = useState("");
69
70 const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd));
71 const branches = useMemo(
72 () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []),
73 [branchesQuery.data?.branches],
74 );
75 const currentGitBranch = branches.find((branch) => branch.current)?.name ?? null;
76 const canonicalActiveBranch = resolveBranchToolbarValue({
77 envMode: effectiveEnvMode,
78 activeWorktreePath,
79 activeThreadBranch,
80 currentGitBranch,
81 });
82 const branchNames = useMemo(() => branches.map((branch) => branch.name), [branches]);
83 const branchByName = useMemo(
84 () => new Map(branches.map((branch) => [branch.name, branch] as const)),
85 [branches],
86 );
87 const trimmedBranchQuery = branchQuery.trim();
88 const normalizedBranchQuery = trimmedBranchQuery.toLowerCase();
89 const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0;
90 const hasExactBranchMatch = branchByName.has(trimmedBranchQuery);
91 const createBranchItemValue = canCreateBranch
92 ? `__create_new_branch__:${trimmedBranchQuery}`
93 : null;
94 const branchPickerItems = useMemo(
95 () =>
96 createBranchItemValue && !hasExactBranchMatch
97 ? [...branchNames, createBranchItemValue]
98 : branchNames,
99 [branchNames, createBranchItemValue, hasExactBranchMatch],
100 );
101 const filteredBranchPickerItems = useMemo(
102 () =>
103 normalizedBranchQuery.length === 0
104 ? branchPickerItems
105 : branchPickerItems.filter((itemValue) => {
106 if (createBranchItemValue && itemValue === createBranchItemValue) return true;
107 return itemValue.toLowerCase().includes(normalizedBranchQuery);
108 }),
109 [branchPickerItems, createBranchItemValue, normalizedBranchQuery],
110 );
111 const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic(
112 canonicalActiveBranch,
113 (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch,
114 );
115 const [isBranchActionPending, startBranchActionTransition] = useTransition();
116
117 const runBranchAction = (action: () => Promise<void>) => {
118 startBranchActionTransition(async () => {
119 await action().catch(() => undefined);
120 await invalidateGitQueries(queryClient).catch(() => undefined);
121 });
122 };
123
124 const selectBranch = (branch: GitBranch) => {
125 const api = readNativeApi();
126 if (!api || !branchCwd || isBranchActionPending) return;
127
128 // In new-worktree mode, selecting a branch sets the base branch.
129 if (effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath) {
130 onSetThreadBranch(branch.name, null);
131 setIsBranchMenuOpen(false);
132 onComposerFocusRequest?.();
133 return;
134 }
135
136 // If the branch already lives in a worktree, point the thread there.
137 if (branch.worktreePath) {
138 const isMainWorktree = branch.worktreePath === activeProjectCwd;
139 onSetThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath);
140 setIsBranchMenuOpen(false);
141 onComposerFocusRequest?.();
142 return;
143 }
144
145 const selectedBranchName = branch.isRemote
146 ? deriveLocalBranchNameFromRemoteRef(branch.name)
147 : branch.name;
148
149 setIsBranchMenuOpen(false);
150 onComposerFocusRequest?.();
151
152 runBranchAction(async () => {
153 setOptimisticBranch(selectedBranchName);
154 try {
155 await api.git.checkout({ cwd: branchCwd, branch: branch.name });
156 await invalidateGitQueries(queryClient);
157 } catch (error) {
158 toastManager.add({
159 type: "error",
160 title: "Failed to checkout branch.",
161 description: toBranchActionErrorMessage(error),
162 });
163 return;
164 }
165
166 let nextBranchName = selectedBranchName;
167 if (branch.isRemote) {
168 const status = await api.git.status({ cwd: branchCwd }).catch(() => null);
169 if (status?.branch) {
170 nextBranchName = status.branch;
171 }
172 }
173
174 setOptimisticBranch(nextBranchName);
175 onSetThreadBranch(nextBranchName, activeWorktreePath);
176 });
177 };
178
179 const createBranch = (rawName: string) => {
180 const name = rawName.trim();
181 const api = readNativeApi();
182 if (!api || !branchCwd || !name || isBranchActionPending) return;
183
184 setIsBranchMenuOpen(false);
185 onComposerFocusRequest?.();
186
187 runBranchAction(async () => {
188 setOptimisticBranch(name);
189
190 try {
191 await api.git.createBranch({ cwd: branchCwd, branch: name });
192 try {
193 await api.git.checkout({ cwd: branchCwd, branch: name });
194 } catch (error) {
195 toastManager.add({
196 type: "error",
197 title: "Failed to checkout branch.",
198 description: toBranchActionErrorMessage(error),
199 });
200 return;
201 }
202 } catch (error) {
203 toastManager.add({
204 type: "error",
205 title: "Failed to create branch.",
206 description: toBranchActionErrorMessage(error),
207 });
208 return;
209 }
210
211 setOptimisticBranch(name);
212 onSetThreadBranch(name, activeWorktreePath);
213 setBranchQuery("");
214 });
215 };
216
217 useEffect(() => {
218 if (effectiveEnvMode !== "worktree" || activeWorktreePath || activeThreadBranch || !currentGitBranch) {
219 return;
220 }
221 onSetThreadBranch(currentGitBranch, null);
222 }, [
223 activeThreadBranch,
224 activeWorktreePath,
225 currentGitBranch,
226 effectiveEnvMode,
227 onSetThreadBranch,
228 ]);
229
230 const handleOpenChange = useCallback(
231 (open: boolean) => {
232 setIsBranchMenuOpen(open);
233 if (!open) {
234 setBranchQuery("");
235 return;
236 }
237 void queryClient.invalidateQueries({
238 queryKey: gitQueryKeys.branches(branchCwd),
239 });
240 },
241 [branchCwd, queryClient],
242 );
243
244 const branchListScrollElementRef = useRef<HTMLDivElement | null>(null);
245 const branchListVirtualizer = useVirtualizer({
246 count: filteredBranchPickerItems.length,
247 estimateSize: () => 28,
248 getScrollElement: () => branchListScrollElementRef.current,
249 overscan: 12,
250 enabled: isBranchMenuOpen,
251 initialRect: {
252 height: 224,
253 width: 0,
254 },
255 });
256 const virtualBranchRows = branchListVirtualizer.getVirtualItems();
257 const setBranchListRef = useCallback(
258 (element: HTMLDivElement | null) => {
259 branchListScrollElementRef.current =
260 (element?.parentElement as HTMLDivElement | null) ?? null;
261 if (element) {
262 branchListVirtualizer.measure();
263 }
264 },
265 [branchListVirtualizer],
266 );
267
268 useEffect(() => {
269 if (!isBranchMenuOpen) return;
270 queueMicrotask(() => {
271 branchListVirtualizer.measure();
272 });
273 }, [branchListVirtualizer, filteredBranchPickerItems.length, isBranchMenuOpen]);
274
275 const triggerLabel = getBranchTriggerLabel({
276 activeWorktreePath,
277 effectiveEnvMode,
278 resolvedActiveBranch,
279 });
280
281 return (
282 <Combobox
283 items={branchPickerItems}
284 filteredItems={filteredBranchPickerItems}
285 autoHighlight
286 virtualized
287 onItemHighlighted={(_value, eventDetails) => {
288 if (!isBranchMenuOpen || eventDetails.index < 0) return;
289 branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" });
290 }}
291 onOpenChange={handleOpenChange}
292 open={isBranchMenuOpen}
293 value={resolvedActiveBranch}
294 >
295 <ComboboxTrigger
296 render={<Button variant="ghost" size="xs" />}
297 className="text-muted-foreground/70 hover:text-foreground/80"
298 disabled={branchesQuery.isLoading || isBranchActionPending}
299 >
300 <span className="max-w-[240px] truncate">{triggerLabel}</span>
301 <ChevronDownIcon />
302 </ComboboxTrigger>
303 <ComboboxPopup align="end" side="top" className="w-64">
304 <div className="border-b p-1">
305 <ComboboxInput
306 className="[&_input]:font-sans rounded-md"
307 inputClassName="ring-0"
308 placeholder="Search branches..."
309 showTrigger={false}
310 size="sm"
311 value={branchQuery}
312 onChange={(event) => setBranchQuery(event.target.value)}
313 />
314 </div>
315 <ComboboxEmpty>No branches found.</ComboboxEmpty>
316
317 <ComboboxList ref={setBranchListRef} className="max-h-56">
318 <div
319 className="relative"
320 style={{
321 height: `${branchListVirtualizer.getTotalSize()}px`,
322 }}
323 >
324 {virtualBranchRows.map((virtualRow) => {
325 const itemValue = filteredBranchPickerItems[virtualRow.index];
326 if (!itemValue) return null;
327 if (createBranchItemValue && itemValue === createBranchItemValue) {
328 return (
329 <ComboboxItem
330 hideIndicator
331 key={itemValue}
332 index={virtualRow.index}
333 value={itemValue}
334 style={{
335 position: "absolute",
336 top: 0,
337 left: 0,
338 width: "100%",
339 transform: `translateY(${virtualRow.start}px)`,
340 }}
341 onClick={() => createBranch(trimmedBranchQuery)}
342 >
343 <span className="truncate">Create new branch "{trimmedBranchQuery}"</span>
344 </ComboboxItem>
345 );
346 }
347
348 const branch = branchByName.get(itemValue);
349 if (!branch) return null;
350
351 const hasSecondaryWorktree =
352 branch.worktreePath && branch.worktreePath !== activeProjectCwd;
353 const badge = branch.current
354 ? "current"
355 : hasSecondaryWorktree
356 ? "worktree"
357 : branch.isRemote
358 ? "remote"
359 : branch.isDefault
360 ? "default"
361 : null;
362 return (
363 <ComboboxItem
364 hideIndicator
365 key={itemValue}
366 index={virtualRow.index}
367 value={itemValue}
368 className={
369 itemValue === resolvedActiveBranch ? "bg-accent text-foreground" : undefined
370 }
371 style={{
372 position: "absolute",
373 top: 0,
374 left: 0,
375 width: "100%",
376 transform: `translateY(${virtualRow.start}px)`,
377 }}
378 onClick={() => selectBranch(branch)}
379 >
380 <div className="flex w-full items-center justify-between gap-2">
381 <span className="truncate">{itemValue}</span>
382 {badge && (
383 <span className="shrink-0 text-[10px] text-muted-foreground/45">{badge}</span>
384 )}
385 </div>
386 </ComboboxItem>
387 );
388 })}
389 </div>
390 </ComboboxList>
391 </ComboboxPopup>
392 </Combobox>
393 );
394}