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";...@@ -3,8 +3,44 @@ import { describe, expect, it } from "vitest";
3import {3import {
4 dedupeRemoteBranchesWithLocalMatches,4 dedupeRemoteBranchesWithLocalMatches,
5 deriveLocalBranchNameFromRemoteRef,5 deriveLocalBranchNameFromRemoteRef,
6 resolveBranchToolbarValue,
6} from "./BranchToolbar.logic";7} 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
8describe("deriveLocalBranchNameFromRemoteRef", () => {44describe("deriveLocalBranchNameFromRemoteRef", () => {
9 it("strips the remote prefix from a remote ref", () => {45 it("strips the remote prefix from a remote ref", () => {
10 expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo");46 expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo");
apps/web/src/components/BranchToolbar.logic.ts+13
...@@ -1,5 +1,18 @@...@@ -1,5 +1,18 @@
1import type { GitBranch } from "@t3tools/contracts";1import 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
3export function deriveLocalBranchNameFromRemoteRef(branchName: string): string {16export function deriveLocalBranchNameFromRemoteRef(branchName: string): string {
4 const firstSeparatorIndex = branchName.indexOf("/");17 const firstSeparatorIndex = branchName.indexOf("/");
5 if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) {18 if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) {
apps/web/src/components/BranchToolbar.tsx+82-41
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import type { GitBranch, ThreadId } from "@t3tools/contracts";1import type { GitBranch, ThreadId } from "@t3tools/contracts";
2import { useQuery, useQueryClient } from "@tanstack/react-query";2import { useQuery, useQueryClient } from "@tanstack/react-query";
3import { useVirtualizer } from "@tanstack/react-virtual";3import { useVirtualizer } from "@tanstack/react-virtual";
4import { useMemo, useOptimistic, useRef, useState, useTransition } from "react";4import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState, useTransition } from "react";
55
6import { newCommandId } from "../lib/utils";6import { newCommandId } from "../lib/utils";
7import { gitBranchesQueryOptions, invalidateGitQueries } from "../lib/gitReactQuery";7import { gitBranchesQueryOptions, invalidateGitQueries } from "../lib/gitReactQuery";
...@@ -11,6 +11,7 @@ import { useStore } from "../store";...@@ -11,6 +11,7 @@ import { useStore } from "../store";
11import {11import {
12 dedupeRemoteBranchesWithLocalMatches,12 dedupeRemoteBranchesWithLocalMatches,
13 deriveLocalBranchNameFromRemoteRef,13 deriveLocalBranchNameFromRemoteRef,
14 resolveBranchToolbarValue,
14} from "./BranchToolbar.logic";15} from "./BranchToolbar.logic";
15import { Button } from "./ui/button";16import { Button } from "./ui/button";
16import {17import {
...@@ -49,7 +50,7 @@ export default function BranchToolbar({...@@ -49,7 +50,7 @@ export default function BranchToolbar({
4950
50 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);51 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
51 const [branchQuery, setBranchQuery] = useState("");52 const [branchQuery, setBranchQuery] = useState("");
52 const branchListRef = useRef<HTMLDivElement | null>(null);53 const branchListScrollElementRef = useRef<HTMLDivElement | null>(null);
5354
54 const serverThread = threads.find((thread) => thread.id === threadId);55 const serverThread = threads.find((thread) => thread.id === threadId);
55 const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null;56 const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null;
...@@ -73,10 +74,12 @@ export default function BranchToolbar({...@@ -73,10 +74,12 @@ export default function BranchToolbar({
73 [branchesQuery.data?.branches],74 [branchesQuery.data?.branches],
74 );75 );
75 const currentGitBranch = branches.find((branch) => branch.current)?.name ?? null;76 const currentGitBranch = branches.find((branch) => branch.current)?.name ?? null;
76 const canonicalActiveBranch =77 const canonicalActiveBranch = resolveBranchToolbarValue({
77 effectiveEnvMode === "worktree" && !activeWorktreePath78 envMode: effectiveEnvMode,
78 ? activeThreadBranch79 activeWorktreePath,
79 : (currentGitBranch ?? activeThreadBranch);80 activeThreadBranch,
81 currentGitBranch,
82 });
80 const branchNames = useMemo(() => branches.map((branch) => branch.name), [branches]);83 const branchNames = useMemo(() => branches.map((branch) => branch.name), [branches]);
81 const branchByName = useMemo(84 const branchByName = useMemo(
82 () => new Map(branches.map((branch) => [branch.name, branch] as const)),85 () => new Map(branches.map((branch) => [branch.name, branch] as const)),
...@@ -121,50 +124,89 @@ export default function BranchToolbar({...@@ -121,50 +124,89 @@ export default function BranchToolbar({
121 });124 });
122 };125 };
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
124 const branchListVirtualizer = useVirtualizer({175 const branchListVirtualizer = useVirtualizer({
125 count: filteredBranchPickerItems.length,176 count: filteredBranchPickerItems.length,
126 estimateSize: () => 28,177 estimateSize: () => 28,
127 getScrollElement: () => branchListRef.current?.parentElement ?? null,178 getScrollElement: () => branchListScrollElementRef.current,
128 overscan: 12,179 overscan: 12,
129 enabled: isBranchMenuOpen,180 enabled: isBranchMenuOpen,
181 initialRect: {
182 height: 224,
183 width: 0,
184 },
130 });185 });
131 const virtualBranchRows = branchListVirtualizer.getVirtualItems();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 );
132196
133 const setThreadBranch = (branch: string | null, worktreePath: string | null) => {197 useEffect(() => {
134 if (!activeThreadId) return;198 if (effectiveEnvMode !== "worktree" || activeWorktreePath || activeThreadBranch || !currentGitBranch) {
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);
160 return;199 return;
161 }200 }
162 setDraftThreadContext(threadId, {201 setThreadBranch(currentGitBranch, null);
163 branch,202 }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]);
164 worktreePath,203
165 envMode: effectiveEnvMode,204 useEffect(() => {
205 if (!isBranchMenuOpen) return;
206 queueMicrotask(() => {
207 branchListVirtualizer.measure();
166 });208 });
167 };209 }, [branchListVirtualizer, filteredBranchPickerItems.length, isBranchMenuOpen]);
168210
169 const selectBranch = (branch: GitBranch) => {211 const selectBranch = (branch: GitBranch) => {
170 const api = readNativeApi();212 const api = readNativeApi();
...@@ -296,7 +338,6 @@ export default function BranchToolbar({...@@ -296,7 +338,6 @@ export default function BranchToolbar({
296 onOpenChange={(open) => {338 onOpenChange={(open) => {
297 setIsBranchMenuOpen(open);339 setIsBranchMenuOpen(open);
298 if (!open) setBranchQuery("");340 if (!open) setBranchQuery("");
299 else void invalidateGitQueries(queryClient);
300 }}341 }}
301 open={isBranchMenuOpen}342 open={isBranchMenuOpen}
302 value={resolvedActiveBranch}343 value={resolvedActiveBranch}
...@@ -329,7 +370,7 @@ export default function BranchToolbar({...@@ -329,7 +370,7 @@ export default function BranchToolbar({
329 </div>370 </div>
330 <ComboboxEmpty>No branches found.</ComboboxEmpty>371 <ComboboxEmpty>No branches found.</ComboboxEmpty>
331372
332 <ComboboxList ref={branchListRef} className="max-h-56">373 <ComboboxList ref={setBranchListRef} className="max-h-56">
333 <div374 <div
334 className="relative"375 className="relative"
335 style={{376 style={{