diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 94a3909a961..16cbc30bfb9 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -2,6 +2,7 @@ import { EnvironmentId, type VcsRef } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { dedupeRemoteBranchesWithLocalMatches, + deriveExistingWorktreeOptions, deriveLocalBranchNameFromRemoteRef, resolveEnvironmentOptionLabel, resolveBranchSelectionTarget, @@ -17,6 +18,72 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); const remoteEnvironmentId = EnvironmentId.make("environment-remote"); +describe("deriveExistingWorktreeOptions", () => { + const ref = ( + name: string, + worktreePath: string | null, + ): Pick => ({ + name, + worktreePath, + }); + + it("lists worktrees from refs, excluding main checkout and the current worktree", () => { + const options = deriveExistingWorktreeOptions({ + refs: [ + ref("main", "/repo"), // main checkout — excluded + ref("feature/current", "/home/.t3/worktrees/repo/t3code-current"), // active — excluded + ref("t3code/aaa", "/home/.t3/worktrees/repo/t3code-aaa"), + ref("fix/bbb", "/home/.t3/worktrees/repo/t3code-bbb"), + ref("origin/remote-only", null), // no worktree — excluded + ], + activeProjectCwd: "/repo", + activeWorktreePath: "/home/.t3/worktrees/repo/t3code-current", + }); + expect(options).toEqual([ + { + branch: "t3code/aaa", + worktreePath: "/home/.t3/worktrees/repo/t3code-aaa", + folderName: "t3code-aaa", + }, + { + branch: "fix/bbb", + worktreePath: "/home/.t3/worktrees/repo/t3code-bbb", + folderName: "t3code-bbb", + }, + ]); + }); + + it("dedupes by worktree path and returns empty when none qualify", () => { + expect( + deriveExistingWorktreeOptions({ + refs: [ref("main", "/repo"), ref("HEAD", "/repo")], + activeProjectCwd: "/repo", + activeWorktreePath: null, + }), + ).toEqual([]); + }); + + it("excludes and dedupes on a canonical path spelling (separators / trailing slash)", () => { + const options = deriveExistingWorktreeOptions({ + refs: [ + ref("main", "/repo/"), // main checkout, trailing slash — still excluded + ref("feature/current", "C:\\wt\\current"), // active, backslashes — still excluded + ref("t3code/aaa", "/home/wt/t3code-aaa"), + ref("t3code/aaa-dupe", "/home/wt/t3code-aaa/"), // same dir, trailing slash — deduped + ], + activeProjectCwd: "/repo", + activeWorktreePath: "C:/wt/current", + }); + expect(options).toEqual([ + { + branch: "t3code/aaa", + worktreePath: "/home/wt/t3code-aaa", + folderName: "t3code-aaa", + }, + ]); + }); +}); + describe("resolveDraftEnvModeAfterBranchChange", () => { it("switches to local mode when returning from an existing worktree to the main worktree", () => { expect( @@ -153,8 +220,10 @@ describe("resolveCurrentWorkspaceLabel", () => { expect(resolveCurrentWorkspaceLabel(null)).toBe("Current checkout"); }); - it("describes the active checkout as a worktree when one is attached", () => { - expect(resolveCurrentWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe("Current worktree"); + it("names the active worktree by its folder when one is attached", () => { + expect(resolveCurrentWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe( + "Current worktree · feature-a", + ); }); }); @@ -163,8 +232,10 @@ describe("resolveLockedWorkspaceLabel", () => { expect(resolveLockedWorkspaceLabel(null)).toBe("Local checkout"); }); - it("uses a shorter label for an attached worktree", () => { - expect(resolveLockedWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe("Worktree"); + it("names the attached worktree by its folder", () => { + expect(resolveLockedWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe( + "Worktree · feature-a", + ); }); }); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 65388962c08..066a79b1e3b 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,10 +1,64 @@ import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import { canonicalizeWorktreePath, formatWorktreePathForDisplay } from "../worktreeCleanup"; export { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, } from "@t3tools/shared/git"; +/** + * An existing git worktree that a thread can be started in. Derived from the + * branch ref list (each ref carries its `worktreePath` from `git worktree + * list`), so it surfaces worktrees created by t3code AND by other tools + * (JetBrains/git/etc.) as long as the worktree has a checked-out branch. + */ +/** Prefix used to encode an existing-worktree choice as a Select/Menu value. */ +export const EXISTING_WORKTREE_VALUE_PREFIX = "existing-worktree:"; + +export interface ExistingWorktreeOption { + /** The branch checked out in the worktree (used to bind the thread). */ + branch: string; + /** Absolute worktree path (used as the thread's cwd). */ + worktreePath: string; + /** Short label — the worktree's folder name (e.g. `t3code-4e609bb8`). */ + folderName: string; +} + +/** + * Collect the existing worktrees a thread could be started in, from the branch + * refs. Excludes the project's main checkout and the currently-active worktree + * (those are already covered by the "Current checkout" option). Deduped by + * worktree path and sorted by folder name. + */ +export function deriveExistingWorktreeOptions(input: { + refs: ReadonlyArray>; + activeProjectCwd: string | null; + activeWorktreePath: string | null; +}): ExistingWorktreeOption[] { + const { refs, activeProjectCwd, activeWorktreePath } = input; + // Compare on a canonical form (separators/trailing slash normalized) so a + // worktree spelled differently by `git worktree list` and client state is + // still excluded/deduped instead of leaking into the list. + const canonicalProjectCwd = canonicalizeWorktreePath(activeProjectCwd); + const canonicalActiveWorktree = canonicalizeWorktreePath(activeWorktreePath); + const byPath = new Map(); + for (const ref of refs) { + const worktreePath = ref.worktreePath; + if (!worktreePath) continue; + const canonical = canonicalizeWorktreePath(worktreePath); + if (!canonical) continue; + if (canonical === canonicalProjectCwd) continue; + if (canonical === canonicalActiveWorktree) continue; + if (byPath.has(canonical)) continue; + byPath.set(canonical, { + branch: ref.name, + worktreePath, + folderName: formatWorktreePathForDisplay(worktreePath), + }); + } + return [...byPath.values()].sort((a, b) => a.folderName.localeCompare(b.folderName)); +} + export interface EnvironmentOption { environmentId: EnvironmentId; projectId: ProjectId; @@ -47,11 +101,13 @@ export function resolveEnvModeLabel(mode: EnvMode): string { } export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): string { - return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local"); + if (!activeWorktreePath) return resolveEnvModeLabel("local"); + return `Current worktree · ${formatWorktreePathForDisplay(activeWorktreePath)}`; } export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): string { - return activeWorktreePath ? "Worktree" : "Local checkout"; + if (!activeWorktreePath) return "Local checkout"; + return `Worktree · ${formatWorktreePathForDisplay(activeWorktreePath)}`; } export function resolveEffectiveEnvMode(input: { diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 958cb3743c7..40fc68183ee 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -8,14 +8,20 @@ import { FolderIcon, MonitorIcon, } from "lucide-react"; -import { memo, useMemo } from "react"; +import { memo, useCallback, useMemo } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useProject, useThread } from "../state/entities"; +import { useBranches } from "../state/queries"; +import { threadEnvironment } from "../state/threads"; +import { useAtomCommand } from "../state/use-atom-command"; import { useIsMobile } from "../hooks/useMediaQuery"; import { + deriveExistingWorktreeOptions, type EnvMode, type EnvironmentOption, + EXISTING_WORKTREE_VALUE_PREFIX, + type ExistingWorktreeOption, resolveCurrentWorkspaceLabel, resolveEnvModeLabel, resolveEffectiveEnvMode, @@ -45,6 +51,8 @@ interface BranchToolbarProps { effectiveEnvModeOverride?: EnvMode; activeThreadBranchOverride?: string | null; onActiveThreadBranchOverrideChange?: (branch: string | null) => void; + activeThreadWorktreePathOverride?: string | null; + onActiveThreadWorktreePathOverrideChange?: (worktreePath: string | null) => void; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; envLocked: boolean; @@ -64,6 +72,8 @@ interface MobileRunContextSelectorProps { effectiveEnvMode: EnvMode; activeWorktreePath: string | null; onEnvModeChange: (mode: EnvMode) => void; + existingWorktrees: readonly ExistingWorktreeOption[]; + onSelectExistingWorktree: (option: ExistingWorktreeOption) => void; } const MobileRunContextSelector = memo(function MobileRunContextSelector({ @@ -76,6 +86,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ effectiveEnvMode, activeWorktreePath, onEnvModeChange, + existingWorktrees, + onSelectExistingWorktree, }: MobileRunContextSelectorProps) { const activeEnvironment = useMemo( () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, @@ -163,7 +175,17 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ Workspace onEnvModeChange(value as EnvMode)} + onValueChange={(value) => { + if (value.startsWith(EXISTING_WORKTREE_VALUE_PREFIX)) { + const worktreePath = value.slice(EXISTING_WORKTREE_VALUE_PREFIX.length); + const option = existingWorktrees.find( + (entry) => entry.worktreePath === worktreePath, + ); + if (option) onSelectExistingWorktree(option); + return; + } + onEnvModeChange(value as EnvMode); + }} > @@ -183,6 +205,21 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ {resolveEnvModeLabel("worktree")} + {existingWorktrees.map((option) => ( + + + + {option.branch} + + {option.folderName} + + + + ))} @@ -198,6 +235,8 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvModeOverride, activeThreadBranchOverride, onActiveThreadBranchOverrideChange, + activeThreadWorktreePathOverride, + onActiveThreadWorktreePathOverrideChange, startFromOrigin, onStartFromOriginChange, envLocked, @@ -221,7 +260,12 @@ export const BranchToolbar = memo(function BranchToolbar({ : null; const activeProject = useProject(activeProjectRef); const hasActiveThread = serverThread !== null || draftThread !== null; - const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + // Reflect an optimistic "existing worktree" pick immediately so the env-mode + // lock, labels and the branch picker's cwd don't lag the metadata round-trip. + const activeWorktreePath = + activeThreadWorktreePathOverride !== undefined + ? activeThreadWorktreePathOverride + : (serverThread?.worktreePath ?? draftThread?.worktreePath ?? null); const effectiveEnvMode = effectiveEnvModeOverride ?? resolveEffectiveEnvMode({ @@ -231,6 +275,90 @@ export const BranchToolbar = memo(function BranchToolbar({ }); const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null); + // Existing worktrees a fresh thread can be started in. Sourced from the + // branch refs (each ref carries its `worktreePath` from `git worktree list`, + // so t3code- and externally-created worktrees both appear). Only fetched + // while the workspace selector is interactive. + const activeProjectCwd = activeProject?.workspaceRoot ?? null; + const worktreeRefsQuery = useBranches({ + environmentId, + cwd: envModeLocked ? null : activeProjectCwd, + }); + const existingWorktrees = useMemo( + () => + deriveExistingWorktreeOptions({ + refs: worktreeRefsQuery.data?.refs ?? [], + activeProjectCwd, + activeWorktreePath, + }), + [worktreeRefsQuery.data?.refs, activeProjectCwd, activeWorktreePath], + ); + + const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); + const updateThreadMetadata = useAtomCommand( + threadEnvironment.updateMetadata, + "thread metadata update", + ); + const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); + + // Bind the thread (draft or server) to an existing worktree. Mirrors the + // branch selector's `setThreadBranch` reuse path: no git op runs — the + // server just adopts the provided worktree path as the thread's cwd. + const onSelectExistingWorktree = useCallback( + (option: ExistingWorktreeOption) => { + if (!activeProject) return; + const mutationThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); + if (!mutationThreadId) return; + if (serverThread?.session && option.worktreePath !== activeWorktreePath) { + void stopThreadSession({ environmentId, input: { threadId: mutationThreadId } }); + } + if (serverThread !== null) { + // Set the env-mode, branch and worktree-path overrides optimistically + // so a send that races the async metadata round-trip already runs in + // the chosen worktree instead of the thread's prior (local) mode. The + // worktree-path override is what stops that raced send from spawning a + // brand-new worktree and keeps the branch picker pointed at the chosen + // worktree until the metadata update lands. + onEnvModeChange("worktree"); + onActiveThreadWorktreePathOverrideChange?.(option.worktreePath); + void updateThreadMetadata({ + environmentId, + input: { + threadId: mutationThreadId, + branch: option.branch, + worktreePath: option.worktreePath, + }, + }); + onActiveThreadBranchOverrideChange?.(option.branch); + } else { + setDraftThreadContext(draftId ?? threadRef, { + branch: option.branch, + worktreePath: option.worktreePath, + envMode: "worktree", + projectRef: scopeProjectRef(environmentId, activeProject.id), + }); + } + onComposerFocusRequest?.(); + }, + [ + activeProject, + serverThread, + draftThread, + threadId, + activeWorktreePath, + environmentId, + stopThreadSession, + updateThreadMetadata, + onActiveThreadBranchOverrideChange, + onActiveThreadWorktreePathOverrideChange, + onEnvModeChange, + setDraftThreadContext, + draftId, + threadRef, + onComposerFocusRequest, + ], + ); + const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, ); @@ -251,6 +379,8 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} onEnvModeChange={onEnvModeChange} + existingWorktrees={existingWorktrees} + onSelectExistingWorktree={onSelectExistingWorktree} /> ) : (
@@ -270,6 +400,8 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} onEnvModeChange={onEnvModeChange} + existingWorktrees={existingWorktrees} + onSelectExistingWorktree={onSelectExistingWorktree} />
)} @@ -283,6 +415,12 @@ export const BranchToolbar = memo(function BranchToolbar({ {...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})} {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})} {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} + {...(activeThreadWorktreePathOverride !== undefined + ? { activeWorktreePathOverride: activeThreadWorktreePathOverride } + : {})} + {...(onActiveThreadWorktreePathOverrideChange + ? { onActiveWorktreePathOverrideChange: onActiveThreadWorktreePathOverrideChange } + : {})} startFromOrigin={startFromOrigin} onStartFromOriginChange={onStartFromOriginChange} {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index e2ee24c3608..007fddf5b4a 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -67,6 +67,8 @@ interface BranchToolbarBranchSelectorProps { effectiveEnvModeOverride?: "local" | "worktree"; activeThreadBranchOverride?: string | null; onActiveThreadBranchOverrideChange?: (refName: string | null) => void; + activeWorktreePathOverride?: string | null; + onActiveWorktreePathOverrideChange?: (worktreePath: string | null) => void; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; onCheckoutPullRequestRequest?: (reference: string) => void; @@ -101,6 +103,8 @@ export function BranchToolbarBranchSelector({ effectiveEnvModeOverride, activeThreadBranchOverride, onActiveThreadBranchOverrideChange, + activeWorktreePathOverride, + onActiveWorktreePathOverrideChange, startFromOrigin, onStartFromOriginChange, onCheckoutPullRequestRequest, @@ -144,7 +148,13 @@ export function BranchToolbarBranchSelector({ activeThreadBranchOverride !== undefined ? activeThreadBranchOverride : (serverThread?.branch ?? draftThread?.branch ?? null); - const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + // An optimistic "existing worktree" pick must steer branch git ops to the + // chosen worktree immediately; otherwise `branchCwd` falls back to the main + // checkout until the metadata round-trip lands. + const activeWorktreePath = + activeWorktreePathOverride !== undefined + ? activeWorktreePathOverride + : (serverThread?.worktreePath ?? draftThread?.worktreePath ?? null); const activeProjectCwd = activeProject?.workspaceRoot ?? null; const branchCwd = activeWorktreePath ?? activeProjectCwd; const hasServerThread = serverThread !== null; @@ -180,6 +190,11 @@ export function BranchToolbarBranchSelector({ } if (hasServerThread) { onActiveThreadBranchOverrideChange?.(branch); + // Keep the parent's optimistic worktree-path override in step with the + // branch action; otherwise picking a branch that retargets the main + // checkout (worktreePath === null) leaves a stale override pinning + // branchCwd and the next send to the previously-picked worktree. + onActiveWorktreePathOverrideChange?.(worktreePath); return; } const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ @@ -201,6 +216,7 @@ export function BranchToolbarBranchSelector({ activeWorktreePath, hasServerThread, onActiveThreadBranchOverrideChange, + onActiveWorktreePathOverrideChange, setDraftThreadContext, draftId, threadRef, diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 6d06882662f..89da40ad92e 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -2,6 +2,8 @@ import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react"; import { memo, useMemo } from "react"; import { + EXISTING_WORKTREE_VALUE_PREFIX, + type ExistingWorktreeOption, resolveCurrentWorkspaceLabel, resolveEnvModeLabel, resolveLockedWorkspaceLabel, @@ -22,6 +24,8 @@ interface BranchToolbarEnvModeSelectorProps { effectiveEnvMode: EnvMode; activeWorktreePath: string | null; onEnvModeChange: (mode: EnvMode) => void; + existingWorktrees: readonly ExistingWorktreeOption[]; + onSelectExistingWorktree: (option: ExistingWorktreeOption) => void; } export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ @@ -29,15 +33,32 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe effectiveEnvMode, activeWorktreePath, onEnvModeChange, + existingWorktrees, + onSelectExistingWorktree, }: BranchToolbarEnvModeSelectorProps) { const envModeItems = useMemo( () => [ { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) }, { value: "worktree", label: resolveEnvModeLabel("worktree") }, + ...existingWorktrees.map((option) => ({ + value: `${EXISTING_WORKTREE_VALUE_PREFIX}${option.worktreePath}`, + label: option.branch, + })), ], - [activeWorktreePath], + [activeWorktreePath, existingWorktrees], ); + const handleValueChange = (value: string | null) => { + if (value === null) return; + if (value.startsWith(EXISTING_WORKTREE_VALUE_PREFIX)) { + const worktreePath = value.slice(EXISTING_WORKTREE_VALUE_PREFIX.length); + const option = existingWorktrees.find((entry) => entry.worktreePath === worktreePath); + if (option) onSelectExistingWorktree(option); + return; + } + onEnvModeChange(value as EnvMode); + }; + if (envLocked) { return ( @@ -60,7 +81,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f5ea5bb1eba..b71ee3c54c9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1139,6 +1139,14 @@ function ChatViewContent(props: ChatViewProps) { const [pendingServerThreadEnvMode, setPendingServerThreadEnvMode] = useState(null); const [pendingServerThreadBranch, setPendingServerThreadBranch] = useState(); + // Optimistic worktree path for a fresh server thread that just picked an + // existing worktree, mirroring `pendingServerThreadBranch`. It closes the + // window before `updateThreadMetadata` resolves: without it a racing first + // send sees `worktreePath === null` and spawns a brand-new worktree, and the + // branch picker runs git in the main checkout instead of the chosen worktree. + const [pendingServerThreadWorktreePath, setPendingServerThreadWorktreePath] = useState< + string | null + >(); const [ pendingServerThreadStartFromOriginByThreadId, setPendingServerThreadStartFromOriginByThreadId, @@ -3582,6 +3590,14 @@ function ChatViewContent(props: ChatViewProps) { canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined ? pendingServerThreadBranch : (activeThread?.branch ?? null); + // Worktree path the thread will actually run in, reflecting an optimistic + // "existing worktree" pick before the metadata round-trip lands. Once the + // server persists the path, `canOverrideServerThreadEnvMode` flips false and + // this falls back to the real thread state. + const effectiveActiveWorktreePath = + canOverrideServerThreadEnvMode && pendingServerThreadWorktreePath !== undefined + ? pendingServerThreadWorktreePath + : (activeThread?.worktreePath ?? null); const startFromOrigin = isLocalDraftThread ? (draftThread?.startFromOrigin ?? false) : canOverrideServerThreadEnvMode @@ -3596,6 +3612,7 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { setPendingServerThreadEnvMode(null); setPendingServerThreadBranch(undefined); + setPendingServerThreadWorktreePath(undefined); }, [activeThread?.id]); useEffect(() => { @@ -3604,6 +3621,7 @@ function ChatViewContent(props: ChatViewProps) { } setPendingServerThreadEnvMode(null); setPendingServerThreadBranch(undefined); + setPendingServerThreadWorktreePath(undefined); }, [canOverrideServerThreadEnvMode]); useEffect(() => { @@ -3966,15 +3984,18 @@ function ChatViewContent(props: ChatViewProps) { if (!activeProject) return; const threadIdForSend = activeThread.id; const isFirstMessage = !isServerThread || activeThread.messages.length === 0; + // Respect an optimistically-picked existing worktree: if one is pending we + // must not spawn a fresh worktree while its metadata update is in flight. + const worktreePathForSend = effectiveActiveWorktreePath; const baseBranchForWorktree = - isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath + isFirstMessage && sendEnvMode === "worktree" && !worktreePathForSend ? activeThreadBranch : null; // In worktree mode, require an explicit base branch so we don't silently // fall back to local execution when branch selection is missing. const shouldCreateWorktree = - isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath; + isFirstMessage && sendEnvMode === "worktree" && !worktreePathForSend; if (shouldCreateWorktree && !activeThreadBranch) { setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); return; @@ -4098,13 +4119,23 @@ function ChatViewContent(props: ChatViewProps) { ); let failure: AtomCommandResult | null = null; - // Auto-title from first message + // Auto-title from first message. When an existing worktree was picked but + // its metadata update may still be in flight (optimistic override present + // while the thread's own worktreePath is still null), persist the branch + + // worktree path in this same *awaited* round-trip so the server binds the + // chosen worktree before startThreadTurn runs, instead of racing the + // fire-and-forget update and starting the turn with a null path. + const pendingWorktreeSync = + isFirstMessage && !activeThread.worktreePath && effectiveActiveWorktreePath + ? { branch: activeThreadBranch, worktreePath: effectiveActiveWorktreePath } + : {}; if (isFirstMessage && isServerThread) { const titleResult = await updateThreadMetadata({ environmentId, input: { threadId: threadIdForSend, title, + ...pendingWorktreeSync, }, }); if (titleResult._tag === "Failure") { @@ -4726,6 +4757,7 @@ function ChatViewContent(props: ChatViewProps) { activeProject, activeProposedPlan, activeThreadBranch, + effectiveActiveWorktreePath, activeThread, beginLocalDispatch, activeEnvironmentUnavailable, @@ -4840,6 +4872,11 @@ function ChatViewContent(props: ChatViewProps) { (mode: DraftThreadEnvMode) => { if (canOverrideServerThreadEnvMode) { setPendingServerThreadEnvMode(mode); + // Drop any optimistic existing-worktree pick: switching to Current + // checkout or New worktree abandons it. onSelectExistingWorktree calls + // onEnvModeChange("worktree") and then immediately re-sets the path + // override, so an actual existing-worktree pick is preserved. + setPendingServerThreadWorktreePath(undefined); scheduleComposerFocus(); return; } @@ -4862,6 +4899,7 @@ function ChatViewContent(props: ChatViewProps) { isLocalDraftThread, settings.newWorktreesStartFromOrigin, setPendingServerThreadEnvMode, + setPendingServerThreadWorktreePath, scheduleComposerFocus, setDraftThreadContext, ], @@ -5239,6 +5277,9 @@ function ChatViewContent(props: ChatViewProps) { ? { activeThreadBranchOverride: activeThreadBranch, onActiveThreadBranchOverrideChange: setPendingServerThreadBranch, + activeThreadWorktreePathOverride: effectiveActiveWorktreePath, + onActiveThreadWorktreePathOverrideChange: + setPendingServerThreadWorktreePath, } : {})} envLocked={envLocked} diff --git a/apps/web/src/worktreeCleanup.ts b/apps/web/src/worktreeCleanup.ts index 109f71ccd9a..0aa9055cad3 100644 --- a/apps/web/src/worktreeCleanup.ts +++ b/apps/web/src/worktreeCleanup.ts @@ -32,6 +32,20 @@ export function getOrphanedWorktreePathForThread( return isShared ? null : targetWorktreePath; } +/** + * Canonicalize an absolute worktree path for equality comparison: trim, unify + * separators to `/`, and strip trailing slashes. `git worktree list` and client + * state can spell the same directory differently (backslashes, trailing slash); + * comparing raw strings would then wrongly treat them as distinct. + */ +export function canonicalizeWorktreePath(path: string | null | undefined): string | null { + const trimmed = path?.trim(); + if (!trimmed) { + return null; + } + return trimmed.replace(/\\/g, "/").replace(/\/+$/, ""); +} + export function formatWorktreePathForDisplay(worktreePath: string): string { const trimmed = worktreePath.trim(); if (!trimmed) {