diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 58d24845a1a7ca94ab96f007c73ff773b769173d..cf55fa4575297246ca5b2c26c50cf182523abbaa 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -3,8 +3,44 @@ import { describe, expect, it } from "vitest"; import { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, + resolveBranchToolbarValue, } from "./BranchToolbar.logic"; +describe("resolveBranchToolbarValue", () => { + it("defaults new-worktree mode to current git branch when no explicit base branch is set", () => { + expect( + resolveBranchToolbarValue({ + envMode: "worktree", + activeWorktreePath: null, + activeThreadBranch: null, + currentGitBranch: "main", + }), + ).toBe("main"); + }); + + it("keeps an explicitly selected worktree base branch", () => { + expect( + resolveBranchToolbarValue({ + envMode: "worktree", + activeWorktreePath: null, + activeThreadBranch: "feature/base", + currentGitBranch: "main", + }), + ).toBe("feature/base"); + }); + + it("shows the actual checked-out branch when not selecting a new worktree base", () => { + expect( + resolveBranchToolbarValue({ + envMode: "local", + activeWorktreePath: null, + activeThreadBranch: "feature/base", + currentGitBranch: "main", + }), + ).toBe("main"); + }); +}); + describe("deriveLocalBranchNameFromRemoteRef", () => { it("strips the remote prefix from a remote ref", () => { expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo"); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 030f735321279c2179e0613fd81dfc79b7c12ea7..eabe23afc145f0c592da282e61d64f744ea2ed15 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,5 +1,18 @@ import type { GitBranch } from "@t3tools/contracts"; +export function resolveBranchToolbarValue(input: { + envMode: "local" | "worktree"; + activeWorktreePath: string | null; + activeThreadBranch: string | null; + currentGitBranch: string | null; +}): string | null { + const { envMode, activeWorktreePath, activeThreadBranch, currentGitBranch } = input; + if (envMode === "worktree" && !activeWorktreePath) { + return activeThreadBranch ?? currentGitBranch; + } + return currentGitBranch ?? activeThreadBranch; +} + export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { const firstSeparatorIndex = branchName.indexOf("/"); if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) { diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 80b147d129ee7c608e1509d8dc1038955a352172..288c7ae35e7e860d8160b8b39c52efa6ffcdb7a4 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,7 +1,7 @@ import type { GitBranch, ThreadId } from "@t3tools/contracts"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { useMemo, useOptimistic, useRef, useState, useTransition } from "react"; +import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState, useTransition } from "react"; import { newCommandId } from "../lib/utils"; import { gitBranchesQueryOptions, invalidateGitQueries } from "../lib/gitReactQuery"; @@ -11,6 +11,7 @@ import { useStore } from "../store"; import { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, + resolveBranchToolbarValue, } from "./BranchToolbar.logic"; import { Button } from "./ui/button"; import { @@ -49,7 +50,7 @@ export default function BranchToolbar({ const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); const [branchQuery, setBranchQuery] = useState(""); - const branchListRef = useRef(null); + const branchListScrollElementRef = useRef(null); const serverThread = threads.find((thread) => thread.id === threadId); const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; @@ -73,10 +74,12 @@ export default function BranchToolbar({ [branchesQuery.data?.branches], ); const currentGitBranch = branches.find((branch) => branch.current)?.name ?? null; - const canonicalActiveBranch = - effectiveEnvMode === "worktree" && !activeWorktreePath - ? activeThreadBranch - : (currentGitBranch ?? activeThreadBranch); + const canonicalActiveBranch = resolveBranchToolbarValue({ + envMode: effectiveEnvMode, + activeWorktreePath, + activeThreadBranch, + currentGitBranch, + }); const branchNames = useMemo(() => branches.map((branch) => branch.name), [branches]); const branchByName = useMemo( () => new Map(branches.map((branch) => [branch.name, branch] as const)), @@ -121,50 +124,89 @@ export default function BranchToolbar({ }); }; + const setThreadBranch = useCallback( + (branch: string | null, worktreePath: string | null) => { + if (!activeThreadId) return; + const api = readNativeApi(); + // If the effective cwd is about to change, stop the running session so the + // next message creates a new one with the correct cwd. + const sessionId = serverThread?.session?.sessionId; + if (sessionId && worktreePath !== activeWorktreePath && api) { + void api.orchestration + .dispatchCommand({ + type: "thread.session.stop", + commandId: newCommandId(), + threadId: activeThreadId, + createdAt: new Date().toISOString(), + }) + .catch(() => undefined); + } + if (api && hasServerThread) { + void api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: activeThreadId, + branch, + worktreePath, + }); + } + if (hasServerThread) { + setThreadBranchAction(activeThreadId, branch, worktreePath); + return; + } + setDraftThreadContext(threadId, { + branch, + worktreePath, + envMode: effectiveEnvMode, + }); + }, + [ + activeThreadId, + serverThread?.session?.sessionId, + activeWorktreePath, + hasServerThread, + setThreadBranchAction, + setDraftThreadContext, + threadId, + effectiveEnvMode, + ], + ); + const branchListVirtualizer = useVirtualizer({ count: filteredBranchPickerItems.length, estimateSize: () => 28, - getScrollElement: () => branchListRef.current?.parentElement ?? null, + getScrollElement: () => branchListScrollElementRef.current, overscan: 12, enabled: isBranchMenuOpen, + initialRect: { + height: 224, + width: 0, + }, }); const virtualBranchRows = branchListVirtualizer.getVirtualItems(); + const setBranchListRef = useCallback( + (element: HTMLDivElement | null) => { + branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null; + if (element) { + branchListVirtualizer.measure(); + } + }, + [branchListVirtualizer], + ); - const setThreadBranch = (branch: string | null, worktreePath: string | null) => { - if (!activeThreadId) return; - const api = readNativeApi(); - // If the effective cwd is about to change, stop the running session so the - // next message creates a new one with the correct cwd. - const sessionId = serverThread?.session?.sessionId; - if (sessionId && worktreePath !== activeWorktreePath && api) { - void api.orchestration - .dispatchCommand({ - type: "thread.session.stop", - commandId: newCommandId(), - threadId: activeThreadId, - createdAt: new Date().toISOString(), - }) - .catch(() => undefined); - } - if (api && hasServerThread) { - void api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: activeThreadId, - branch, - worktreePath, - }); - } - if (hasServerThread) { - setThreadBranchAction(activeThreadId, branch, worktreePath); + useEffect(() => { + if (effectiveEnvMode !== "worktree" || activeWorktreePath || activeThreadBranch || !currentGitBranch) { return; } - setDraftThreadContext(threadId, { - branch, - worktreePath, - envMode: effectiveEnvMode, + setThreadBranch(currentGitBranch, null); + }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]); + + useEffect(() => { + if (!isBranchMenuOpen) return; + queueMicrotask(() => { + branchListVirtualizer.measure(); }); - }; + }, [branchListVirtualizer, filteredBranchPickerItems.length, isBranchMenuOpen]); const selectBranch = (branch: GitBranch) => { const api = readNativeApi(); @@ -296,7 +338,6 @@ export default function BranchToolbar({ onOpenChange={(open) => { setIsBranchMenuOpen(open); if (!open) setBranchQuery(""); - else void invalidateGitQueries(queryClient); }} open={isBranchMenuOpen} value={resolvedActiveBranch} @@ -329,7 +370,7 @@ export default function BranchToolbar({ No branches found. - +