From af15157fa0e354ed2f73354f8946e27135b60c9a Mon Sep 17 00:00:00 2001 From: Luiz Ferraz Date: Wed, 4 Nov 2026 04:37:06 +0000 Subject: [PATCH 1/5] feat: check out existing local or remote branches in a new worktree from the workspace selector --- .../threads/NewTaskContextPickerScreens.tsx | 16 +-- .../features/threads/NewTaskDraftScreen.tsx | 9 +- .../threads/new-task-context-presentation.ts | 21 ++- .../threads/new-task-flow-provider.tsx | 41 ++---- .../src/mcp/toolkits/project/handlers.test.ts | 2 +- .../ThreadLaunchService.test.ts | 88 +++++++++--- .../orchestration-v2/ThreadLaunchService.ts | 127 ++++++++++++------ apps/web/src/components/BranchPicker.tsx | 41 ------ .../web/src/components/BranchToolbar.logic.ts | 16 ++- apps/web/src/components/BranchToolbar.tsx | 46 +++++-- .../BranchToolbarBranchSelector.tsx | 15 --- .../BranchToolbarEnvModeSelector.tsx | 33 ++++- ...0008-start-threads-on-existing-branches.md | 57 +++++--- .../0009-choose-where-new-worktrees-start.md | 2 +- docs/user/thread-sidebar.md | 15 ++- .../client-runtime/src/state/vcsRef.test.ts | 13 +- packages/client-runtime/src/state/vcsRef.ts | 9 +- 17 files changed, 337 insertions(+), 214 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index 9c6406167b4d6..e9aae1216d20b 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -345,7 +345,6 @@ export function NewTaskBranchPickerRouteScreen() { startFromOrigin: flow.startFromOrigin, onChangeStartFromOrigin: flow.setStartFromOrigin, createNewBranch: flow.createNewBranch, - onChangeCreateNewBranch: flow.setCreateNewBranch, } : undefined } @@ -371,9 +370,8 @@ export function BranchPickerScreen(props: { readonly worktree?: { readonly startFromOrigin: boolean; readonly onChangeStartFromOrigin: (value: boolean) => void; - /** Scheduled tasks omit these: a recurring run would find its branch still checked out. */ + /** Scheduled tasks omit this: a recurring run would find its branch still checked out. */ readonly createNewBranch?: boolean; - readonly onChangeCreateNewBranch?: ((value: boolean) => void) | null; }; }) { const searchRef = useRef(null); @@ -422,16 +420,10 @@ export function BranchPickerScreen(props: { Platform.OS === "android" ? "rounded-[28px]" : "rounded-2xl", )} > - {worktree.onChangeCreateNewBranch ? ( - - ) : null} {checksOutExistingBranch ? ( - - Select a local branch that is not already checked out. + + Select a branch that is not already checked out. A remote branch is checked out as a local + branch that tracks it. ) : ( ( @@ -834,13 +837,13 @@ export function NewTaskDraftScreen(props: { )} label={workspaceLabel} maxWidth="100%" - onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")} + onPress={() => flow.setWorkspaceMode(nextWorkspace.mode, nextWorkspace.createNewBranch)} showChevron={false} /> ComposerDraftWorkspaceSelection; readonly startFromOrigin: boolean; - /** False checks out the selected local branch itself in the new worktree. */ + /** False checks out the selected branch itself in the new worktree. */ readonly createNewBranch: boolean; + /** Whether the server can check out an existing branch in a new worktree. */ + readonly canCheckoutWorktree: boolean; readonly pullRequestReference: string | undefined; readonly draftKey: string | null; readonly prompt: string; @@ -191,11 +193,10 @@ type NewTaskFlowContextValue = { key: string | null, options?: ReadonlyArray, ) => void; - readonly setWorkspaceMode: (mode: WorkspaceMode) => void; + /** `createNewBranch` false checks out the selected branch in the new worktree. */ + readonly setWorkspaceMode: (mode: WorkspaceMode, createNewBranch?: boolean) => void; readonly selectBranch: (branch: VcsRef) => void; readonly setStartFromOrigin: (value: boolean) => void; - /** Null when the server cannot check out an existing branch, which hides the toggle. */ - readonly setCreateNewBranch: ((value: boolean) => void) | null; readonly buildPendingTaskMessage: ( metadata: TurnCommandMetadata, options?: { @@ -648,7 +649,7 @@ export function NewTaskFlowProvider( return false; }; - const setWorkspaceMode = (mode: WorkspaceMode) => { + const setWorkspaceMode = (mode: WorkspaceMode, nextCreateNewBranch?: boolean) => { if (!selectedProjectDraftKey) { return; } @@ -666,13 +667,15 @@ export function NewTaskFlowProvider( mode === "local" ? localSelection.branch : resolveNewTaskWorktreeBranch({ - createNewBranch, + createNewBranch: nextCreateNewBranch ?? createNewBranch, selectedBranchName, - branches: availableBranches, + branches: allBranchRefs, }), worktreePath: mode === "local" ? localSelection.worktreePath : selectedWorktreePath, ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), - ...(draftCreateNewBranch !== undefined ? { createNewBranch: draftCreateNewBranch } : {}), + ...((nextCreateNewBranch ?? draftCreateNewBranch) !== undefined + ? { createNewBranch: nextCreateNewBranch ?? draftCreateNewBranch } + : {}), // Refs have not named the current branch yet; the selection follows it. ...(mode === "local" && localSelection.awaitsCurrentBranch ? { followsCurrentCheckout: true } @@ -718,26 +721,6 @@ export function NewTaskFlowProvider( }); }; - const updateCreateNewBranch = (value: boolean) => { - if (!selectedProjectDraftKey) { - return; - } - updateComposerDraftSettings(selectedProjectDraftKey, { - workspaceSelection: { - mode: workspaceMode, - branch: resolveNewTaskWorktreeBranch({ - createNewBranch: value, - selectedBranchName, - branches: availableBranches, - }), - worktreePath: selectedWorktreePath, - ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), - createNewBranch: value, - }, - }); - }; - const setCreateNewBranch = supportsExistingBranchWorktree ? updateCreateNewBranch : null; - const refreshBranches = branchState.refresh; const loadBranches = () => { if (!selectedProject) { @@ -915,6 +898,7 @@ export function NewTaskFlowProvider( resolveWorkspaceSelection, startFromOrigin, createNewBranch, + canCheckoutWorktree: supportsExistingBranchWorktree, draftKey: selectedProjectDraftKey, pullRequestReference: selectedProjectDraft.workspaceSelection?.pullRequestReference, prompt, @@ -947,7 +931,6 @@ export function NewTaskFlowProvider( setWorkspaceMode, selectBranch, setStartFromOrigin, - setCreateNewBranch, buildPendingTaskMessage, setPrompt, replaceAttachments, diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index fcee3b3d8ba88..895800e9fa3a9 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -57,7 +57,7 @@ it.effect("reports a rejected existing-branch launch as an invalid request", () deletedAt: null, } as OrchestrationV2ThreadShell; const message = - 'Branch "main" is already checked out at /repo. Select another branch, or enable Create new branch.'; + 'Branch "main" is already checked out at /repo. Select another branch, or choose New worktree.'; const dependencies = Layer.mergeAll( Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), ServerSettings.layerTest(), diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 9ad7888aedc4d..1e27349b08192 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -104,7 +104,24 @@ const localRefs = [ { name: "archive/feature/existing", current: false, isDefault: false, worktreePath: null }, { name: "feature/existing", current: false, isDefault: false, worktreePath: null }, { name: "t3code/abcd1234", current: false, isDefault: false, worktreePath: null }, + { + name: "tracked", + current: false, + isDefault: false, + worktreePath: null, + upstreamRef: "refs/remotes/origin/tracked", + }, ]; +const remoteRefs = ["origin/feature", "origin/tracked", "upstream/feature/existing"].map( + (name) => ({ + name, + current: false, + isDefault: false, + worktreePath: null, + isRemote: true, + remoteName: name.slice(0, name.indexOf("/")), + }), +); const adapter = { instanceId: modelSelection.instanceId, @@ -217,7 +234,13 @@ function makeHarness(options: HarnessOptions = {}) { renameBranch, fetchWorktreeBaseCommit: options.fetchWorktreeBaseCommit ?? - (() => Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" })), + ((input) => + Effect.succeed({ + commitSha: "remote-main-sha", + remoteRefName: input.remote + ? `${input.remote.remoteName}/${input.remote.branchName}` + : "origin/main", + })), hasCommit: options.hasCommit ?? (() => Effect.succeed(false)), resolveWorktreeBaseRemote: () => Effect.succeed({ remoteName: "origin", branchName: "main" }), remoteExists: options.remoteExists ?? (() => Effect.succeed(true)), @@ -227,7 +250,10 @@ function makeHarness(options: HarnessOptions = {}) { listRefs: options.listRefs ?? ((input) => { - const matches = localRefs.filter((ref) => ref.name.includes(input.query ?? "")); + const matches = [ + ...(input.refKind === "remote" ? [] : localRefs), + ...(input.refKind === "local" ? [] : remoteRefs), + ].filter((ref) => ref.name.includes(input.query ?? "")); const cursor = input.cursor ?? 0; return Effect.succeed({ refs: matches.slice(cursor, cursor + 1), @@ -1305,9 +1331,35 @@ it.effect.each(["stable", "upstream/release"])( ), ); -it.effect.each(["feature/existing", "t3code/abcd1234"])( - "checks out the existing branch %s without creating or renaming one", - (baseRef) => +const skippedFetch = { status: "skipped" as const, detail: null }; +it.effect.each([ + { + baseRef: "feature/existing", + branch: "feature/existing", + worktree: { refName: "feature/existing" }, + fetch: skippedFetch, + }, + { + baseRef: "t3code/abcd1234", + branch: "t3code/abcd1234", + worktree: { refName: "t3code/abcd1234" }, + fetch: skippedFetch, + }, + { + baseRef: "origin/feature", + branch: "feature", + worktree: { refName: "origin/feature", newRefName: "feature" }, + fetch: { status: "done" as const, detail: "origin/feature at remote-" }, + }, + { + baseRef: "origin/tracked", + branch: "tracked", + worktree: { refName: "tracked" }, + fetch: skippedFetch, + }, +])( + "checks out the existing branch $baseRef without creating or renaming one", + ({ baseRef, branch, worktree, fetch }) => Effect.gen(function* () { const harness = makeHarness(); yield* Effect.gen(function* () { @@ -1321,19 +1373,16 @@ it.effect.each(["feature/existing", "t3code/abcd1234"])( workspace: { type: "worktree", baseRef, createBranch: false }, }), ); - assert.equal(launched.projection.thread.branch, baseRef); + assert.equal(launched.projection.thread.branch, branch); // The run starts only after preparation, which is where a rename would be forked. yield* awaitRunStatus(launched.threadId, "starting"); assert.deepEqual( harness.createWorktree.mock.calls.map(([input]) => input), - [{ cwd: "/repo", refName: baseRef, path: null }], + [{ cwd: "/repo", ...worktree, path: null }], ); - assert.deepEqual(yield* fetchStage(launched.threadId), { - status: "skipped", - detail: null, - }); + assert.deepEqual(yield* fetchStage(launched.threadId), fetch); const projection = yield* threads.getThreadProjection(launched.threadId); - assert.equal(projection.thread.branch, baseRef); + assert.equal(projection.thread.branch, branch); assert.equal(projection.thread.worktreePath, "/repo-worktrees/feature"); assert.equal(harness.generateBranchName.mock.calls.length, 0); assert.equal(harness.renameBranch.mock.calls.length, 0); @@ -1384,14 +1433,19 @@ it.effect("checks out a branch still held by a deleted worktree git has not prun it.effect.each([ { - reason: "a remote-only ref", - workspace: { baseRef: "origin/feature" }, - message: /"origin\/feature" is not a local branch/u, + reason: "a missing ref", + workspace: { baseRef: "origin/missing" }, + message: /"origin\/missing" is not a branch/u, }, { reason: "a partial branch name", workspace: { baseRef: "feature" }, - message: /"feature" is not a local branch/u, + message: /"feature" is not a branch/u, + }, + { + reason: "a remote ref whose local name tracks something else", + workspace: { baseRef: "upstream/feature/existing" }, + message: /local branch "feature\/existing" already exists and does not track/u, }, { reason: "a branch checked out elsewhere", @@ -2513,7 +2567,7 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch const rejectedWorkspace = yield* ThreadMessageIntake.launchThread({ ...input, commandId: CommandId.make("intake-rejected-workspace"), - workspaceStrategy: { type: "worktree", baseRef: "origin/feature", createBranch: false }, + workspaceStrategy: { type: "worktree", baseRef: "origin/missing", createBranch: false }, }).pipe(Effect.flip); assert.equal(rejectedWorkspace._tag, "ThreadLaunchWorkspaceError"); assert.equal((yield* claimedFiles).length, 1); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 3bbcdac9dae81..be96dc4914f74 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -252,16 +252,17 @@ const make = Effect.gen(function* () { .getByCommandId(commandId) .pipe(Effect.mapError(mapError(input, "read-receipt", input.threadId))); - const findLocalBranch = Effect.fn("ThreadLaunchService.findLocalBranch")(function* ( + const findRef = Effect.fn("ThreadLaunchService.findRef")(function* ( cwd: string, name: string, + refKind: "local" | "remote", ) { let cursor: number | null = 0; while (cursor !== null) { const page: VcsListRefsResult = yield* git.listRefs({ cwd, query: name, - refKind: "local", + refKind, refresh: cursor === 0, cursor, }); @@ -272,40 +273,70 @@ const make = Effect.gen(function* () { return null; }); - // Checking out an existing branch skips branch creation, so the branch must - // already exist locally and be free: git refuses a branch another worktree - // (or the project checkout) has checked out. listRefs ignores worktrees whose - // directory is gone, but git still holds their branches until a prune. - const validateExistingBranchCheckout = Effect.fn( - "ThreadLaunchService.validateExistingBranchCheckout", - )(function* (input: ThreadLaunchInput, cwd: string) { - const strategy = input.workspaceStrategy; - if (strategy.type !== "worktree" || strategy.createBranch !== false) return; + // A remote ref such as `origin/feature` checks out the local `feature` that tracks it, + // created on first use. A same-named local branch tracking anything else is refused. + // Rerunning after the branch was created resolves to it, so retries stay idempotent. + const resolveExistingBranchCheckout = Effect.fn( + "ThreadLaunchService.resolveExistingBranchCheckout", + )(function* (input: PreparationInput, cwd: string, baseRef: string) { const reject = (message: string) => new ThreadLaunchWorkspaceError({ commandId: input.commandId, projectId: input.projectId, message, }); - if (strategy.branch !== undefined || strategy.startFromOrigin === true) { + const local = yield* findRef(cwd, baseRef, "local"); + if (local !== null) return { branch: baseRef, ref: local, remote: null }; + const remoteRef = yield* findRef(cwd, baseRef, "remote"); + if (remoteRef?.remoteName === undefined) { return yield* reject( - "Checking out an existing branch cannot also name a new branch or start from a remote.", + `"${baseRef}" is not a branch. Select an existing branch, or choose New worktree to start a new branch from it.`, ); } - yield* git.pruneWorktrees({ cwd }).pipe(Effect.ignore({ log: true })); - const ref = yield* findLocalBranch(cwd, strategy.baseRef).pipe( - Effect.mapError(mapError(input, "provision-worktree")), - ); - if (ref === null) { + const branch = baseRef.slice(remoteRef.remoteName.length + 1); + const remote = { remoteName: remoteRef.remoteName, branchName: branch }; + const tracking = yield* findRef(cwd, branch, "local"); + if (tracking === null) return { branch, ref: null, remote }; + if (tracking.upstreamRef !== `refs/remotes/${baseRef}`) { return yield* reject( - `"${strategy.baseRef}" is not a local branch. Select an existing local branch, or enable Create new branch to start from a remote ref.`, + `A local branch "${branch}" already exists and does not track ${baseRef}. Select "${branch}" instead.`, ); } - if (ref.worktreePath !== null) { - return yield* reject( - `Branch "${strategy.baseRef}" is already checked out at ${ref.worktreePath}. Select another branch, or enable Create new branch.`, - ); + return { branch, ref: tracking, remote: null }; + }); + + // Checking out an existing branch skips branch creation, so the branch must + // be free: git refuses a branch another worktree (or the project checkout) + // has checked out. listRefs ignores worktrees whose directory is gone, but git + // still holds their branches until a prune. Returns the branch to check out. + const validateExistingBranchCheckout = Effect.fn( + "ThreadLaunchService.validateExistingBranchCheckout", + )(function* (input: ThreadLaunchInput, cwd: string) { + const strategy = input.workspaceStrategy; + if (strategy.type !== "worktree" || strategy.createBranch !== false) return undefined; + if (strategy.branch !== undefined || strategy.startFromOrigin === true) { + return yield* new ThreadLaunchWorkspaceError({ + commandId: input.commandId, + projectId: input.projectId, + message: + "Checking out an existing branch cannot also name a new branch or start from a remote.", + }); + } + yield* git.pruneWorktrees({ cwd }).pipe(Effect.ignore({ log: true })); + const checkout = yield* resolveExistingBranchCheckout(input, cwd, strategy.baseRef).pipe( + Effect.catchTags({ + GitCommandError: (cause) => Effect.fail(mapError(input, "provision-worktree")(cause)), + }), + ); + const worktreePath = checkout.ref?.worktreePath ?? null; + if (worktreePath !== null) { + return yield* new ThreadLaunchWorkspaceError({ + commandId: input.commandId, + projectId: input.projectId, + message: `Branch "${checkout.branch}" is already checked out at ${worktreePath}. Select another branch, or choose New worktree.`, + }); } + return checkout.branch; }); const validateReusableThread = Effect.fn("ThreadLaunchService.validateReusableThread")(function* ( @@ -448,9 +479,15 @@ const make = Effect.gen(function* () { ); const requestedBranch = input.workspaceStrategy.branch; + const checkout = + existingBranch === null + ? null + : yield* resolveExistingBranchCheckout(input, project.workspaceRoot, existingBranch).pipe( + Effect.mapError(mapError(input, "provision-worktree", threadId)), + ); let branch: string | null; - if (existingBranch !== null) { - branch = existingBranch; + if (checkout !== null) { + branch = checkout.branch; } else if (input.workspaceStrategy.type === "worktree" && requestedBranch === undefined) { const uuid = yield* randomUuidV4; branch = buildTemporaryWorktreeBranchName(() => uuid.replaceAll("-", "")); @@ -476,14 +513,16 @@ const make = Effect.gen(function* () { let startRef = input.workspaceStrategy.baseRef; let baseRefName = input.workspaceStrategy.baseRef; const remote = - existingBranch === null && input.workspaceStrategy.startFromOrigin === true - ? yield* git - .resolveWorktreeBaseRemote({ - cwd: project.workspaceRoot, - refName: input.workspaceStrategy.baseRef, - }) - .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))) - : null; + checkout !== null + ? checkout.remote + : input.workspaceStrategy.startFromOrigin === true + ? yield* git + .resolveWorktreeBaseRemote({ + cwd: project.workspaceRoot, + refName: input.workspaceStrategy.baseRef, + }) + .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))) + : null; yield* setupTracker.stageStatus(threadId, "fetch", remote !== null ? "running" : "skipped"); if (remote !== null) { const remoteBase = yield* git @@ -507,7 +546,7 @@ const make = Effect.gen(function* () { threadId, "fetch", "warning", - `${remote.remoteName}/${remote.branchName} not found, using local branch`, + `${remote.remoteName}/${remote.branchName} not found, using ${checkout !== null ? "last fetched commit" : "local branch"}`, ); } } @@ -527,7 +566,7 @@ const make = Effect.gen(function* () { yield* setupTracker.stageStatus(threadId, "checkout", "running"); const worktree = yield* git .createWorktree( - existingBranch === null + checkout === null ? { cwd: project.workspaceRoot, refName: startRef, @@ -535,7 +574,15 @@ const make = Effect.gen(function* () { baseRefName, path: null, } - : { cwd: project.workspaceRoot, refName: existingBranch, path: null }, + : checkout.remote !== null + ? // The remote ref name, not the fetched commit, so the new branch tracks it. + { + cwd: project.workspaceRoot, + refName: input.workspaceStrategy.baseRef, + newRefName: checkout.branch, + path: null, + } + : { cwd: project.workspaceRoot, refName: checkout.branch, path: null }, { progress: { onWorktreeClaimed: (path) => @@ -934,9 +981,9 @@ const make = Effect.gen(function* () { const launchReceipt = yield* readReceipt(input, input.commandId); // A retry replays a launch this check already passed, and would find // the branch occupied by its own first attempt. - if (Option.isNone(launchReceipt)) { - yield* validateExistingBranchCheckout(input, project.workspaceRoot); - } + const checkoutBranch = Option.isNone(launchReceipt) + ? yield* validateExistingBranchCheckout(input, project.workspaceRoot) + : undefined; yield* threads.assertProviderInstanceEnabled({ projectId: input.projectId, commandId: input.commandId, @@ -996,7 +1043,7 @@ const make = Effect.gen(function* () { : input.workspaceStrategy; const initialBranch = workspaceStrategy.type === "worktree" && workspaceStrategy.createBranch === false - ? workspaceStrategy.baseRef + ? (checkoutBranch ?? workspaceStrategy.baseRef) : (workspaceStrategy.branch ?? null); const initialWorktreePath = workspaceStrategy.type === "existing_worktree" ? workspaceStrategy.worktreePath : null; diff --git a/apps/web/src/components/BranchPicker.tsx b/apps/web/src/components/BranchPicker.tsx index d35a33361938f..cfa082368a974 100644 --- a/apps/web/src/components/BranchPicker.tsx +++ b/apps/web/src/components/BranchPicker.tsx @@ -1,6 +1,5 @@ import type { VcsRef } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; -import { GitBranchIcon } from "lucide-react"; import { useId, useRef, useState, type ComponentProps, type ReactNode } from "react"; import { MiddleTruncate } from "./ui/middle-truncate"; @@ -34,7 +33,6 @@ export function BranchPicker({ isFetchingNextPage, onLoadNext, statusText, - createBranchControl, originControl, popupProps, renderItem, @@ -53,14 +51,6 @@ export function BranchPicker({ isFetchingNextPage: boolean; onLoadNext: () => void; statusText: string | null; - createBranchControl?: - | { - checked: boolean; - disabled: boolean; - tooltip: string; - onCheckedChange: (checked: boolean) => void; - } - | undefined; originControl?: { checked: boolean; onCheckedChange: (checked: boolean) => void } | undefined; popupProps: Omit, "children">; renderItem: (value: string, index: number) => ReactNode; @@ -69,7 +59,6 @@ export function BranchPicker({ }) { const highlightedValueRef = useRef(null); const startFromOriginSwitchId = useId(); - const createBranchSwitchId = useId(); const branchListScrollElementRef = useRef(null); const previousBranchListScrollTopRef = useRef(null); const handleOpenChange = (nextOpen: boolean) => { @@ -214,36 +203,6 @@ export function BranchPicker({ /> - {createBranchControl ? ( - - - - - - createBranchControl.onCheckedChange(Boolean(checked)) - } - /> - - } - /> - - {createBranchControl.tooltip} - - - ) : null} {originControl ? ( = { recursive: "Recursive", "top-level": "Top level only", @@ -104,10 +116,10 @@ export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): export function resolveLockedWorkspaceLabel( activeWorktreePath: string | null, - effectiveEnvMode: EnvMode, + choice: WorkspaceChoice, ): string { if (activeWorktreePath) return "Worktree"; - return effectiveEnvMode === "worktree" ? resolveEnvModeLabel("worktree") : "Local checkout"; + return choice === "local" ? "Local checkout" : resolveNewWorkspaceLabel(choice); } export interface PreviousWorktreeSeed { diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index b0f6fe1f34c36..de3dc9461b9e3 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -22,15 +22,19 @@ import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { useProject, useThreadShellsForProjectRefs } from "../state/entities"; import { ADD_TO_MACHINE_LABEL_CLASS, + CHECKOUT_WORKTREE_LABEL, type EnvMode, type EnvironmentOption, resolveContextStripLabelsCompact, resolveCurrentWorkspaceLabel, resolveEnvModeLabel, resolveLockedWorkspaceLabel, + resolveNewWorkspaceLabel, resolvePreviousWorktreeLabel, resolvePreviousWorktreeSeed, + resolveWorkspaceChoice, shouldShowEnvironmentIndicator, + type WorkspaceChoice, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector, @@ -155,8 +159,10 @@ interface RunContextSelectorProps { showEnvironmentIndicator: boolean; onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined; effectiveEnvMode: EnvMode; + createNewBranch: boolean; + canCheckoutWorktree: boolean; activeWorktreePath: string | null; - onEnvModeChange: (mode: EnvMode) => void; + onEnvModeChange: (choice: WorkspaceChoice) => void; previousWorktreeLabel: string | null; previousWorktreeBranch: string | null; onUsePreviousWorktree: () => void; @@ -178,6 +184,8 @@ const RunContextSelector = memo(function RunContextSelector({ showEnvironmentIndicator, onEnvironmentChange, effectiveEnvMode, + createNewBranch, + canCheckoutWorktree, activeWorktreePath, onEnvModeChange, previousWorktreeLabel, @@ -195,12 +203,13 @@ const RunContextSelector = memo(function RunContextSelector({ : activeWorktreePath ? FolderGitIcon : FolderIcon; + const workspaceChoice = resolveWorkspaceChoice(effectiveEnvMode, createNewBranch); const workspaceLabel = forceNewWorktree ? resolveEnvModeLabel("worktree") : envModeLocked - ? resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode) + ? resolveLockedWorkspaceLabel(activeWorktreePath, workspaceChoice) : effectiveEnvMode === "worktree" - ? resolveEnvModeLabel("worktree") + ? resolveNewWorkspaceLabel(workspaceChoice) : resolveCurrentWorkspaceLabel(activeWorktreePath); const isPanel = displayMode === "panel"; const isLocked = envLocked || (envModeLocked && (!isPanel || !showEnvironmentPicker)); @@ -396,13 +405,13 @@ const RunContextSelector = memo(function RunContextSelector({ Workspace { if (value === "previous-worktree") { onUsePreviousWorktree(); return; } - onEnvModeChange(value as EnvMode); + onEnvModeChange(value as WorkspaceChoice); }} > @@ -421,6 +430,14 @@ const RunContextSelector = memo(function RunContextSelector({ {resolveEnvModeLabel("worktree")} + {canCheckoutWorktree ? ( + + + + {CHECKOUT_WORKTREE_LABEL} + + + ) : null} {previousWorktreeLabel ? ( @@ -632,6 +649,11 @@ export const BranchToolbar = memo(function BranchToolbar({ const activeWorktreePath = forceNewWorktree ? null : worktreePath; const effectiveEnvMode = forceNewWorktree ? "worktree" : envMode; const envModeLocked = envLocked || (isServerThread && activeWorktreePath !== null); + const canCheckoutWorktree = onCreateNewBranchChange !== undefined && !forceNewWorktree; + const onWorkspaceChoiceChange = (choice: WorkspaceChoice) => { + if (choice !== "local") onCreateNewBranchChange?.(choice === "worktree"); + onEnvModeChange(choice === "local" ? "local" : "worktree"); + }; const canUsePreviousWorktree = !isServerThread && !envModeLocked && !forceNewWorktree; const projectRefsForWorktreeLookup = canUsePreviousWorktree ? [activeProjectRef] : []; @@ -710,8 +732,10 @@ export const BranchToolbar = memo(function BranchToolbar({ showEnvironmentIndicator={activeEnvironmentOption !== null} onEnvironmentChange={onEnvironmentChange} effectiveEnvMode={effectiveEnvMode} + createNewBranch={createNewBranch} + canCheckoutWorktree={canCheckoutWorktree} activeWorktreePath={activeWorktreePath} - onEnvModeChange={onEnvModeChange} + onEnvModeChange={onWorkspaceChoiceChange} previousWorktreeLabel={previousWorktreeLabel} previousWorktreeBranch={previousWorktreeSeed?.branch ?? null} onUsePreviousWorktree={onUsePreviousWorktree} @@ -732,7 +756,6 @@ export const BranchToolbar = memo(function BranchToolbar({ {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})} {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} createNewBranch={createNewBranch} - onCreateNewBranchChange={onCreateNewBranchChange} startFromOrigin={startFromOrigin} onStartFromOriginChange={onStartFromOriginChange} {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} @@ -763,8 +786,10 @@ export const BranchToolbar = memo(function BranchToolbar({ showEnvironmentIndicator={showEnvironmentIndicator} onEnvironmentChange={onEnvironmentChange} effectiveEnvMode={effectiveEnvMode} + createNewBranch={createNewBranch} + canCheckoutWorktree={canCheckoutWorktree} activeWorktreePath={activeWorktreePath} - onEnvModeChange={onEnvModeChange} + onEnvModeChange={onWorkspaceChoiceChange} previousWorktreeLabel={previousWorktreeLabel} previousWorktreeBranch={previousWorktreeSeed?.branch ?? null} onUsePreviousWorktree={onUsePreviousWorktree} @@ -806,8 +831,10 @@ export const BranchToolbar = memo(function BranchToolbar({ forceNewWorktree={forceNewWorktree} envLocked={envModeLocked} effectiveEnvMode={effectiveEnvMode} + createNewBranch={createNewBranch} + canCheckoutWorktree={canCheckoutWorktree} activeWorktreePath={activeWorktreePath} - onEnvModeChange={onEnvModeChange} + onEnvModeChange={onWorkspaceChoiceChange} previousWorktreeLabel={previousWorktreeLabel} previousWorktreeBranch={previousWorktreeSeed?.branch ?? null} onUsePreviousWorktree={onUsePreviousWorktree} @@ -829,7 +856,6 @@ export const BranchToolbar = memo(function BranchToolbar({ {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})} {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} createNewBranch={createNewBranch} - onCreateNewBranchChange={onCreateNewBranchChange} startFromOrigin={startFromOrigin} onStartFromOriginChange={onStartFromOriginChange} {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 2563f9d35cb7a..156320934e19f 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -90,8 +90,6 @@ interface BranchToolbarBranchSelectorProps { activeThreadBranchOverride?: string | null; onActiveThreadBranchOverrideChange?: (refName: string | null) => void; createNewBranch: boolean; - /** Omitted when the server cannot check out an existing branch, which hides the toggle. */ - onCreateNewBranchChange?: ((createNewBranch: boolean) => void) | undefined; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; onCheckoutPullRequestRequest?: (reference: string) => void; @@ -115,7 +113,6 @@ export function BranchToolbarBranchSelector({ activeThreadBranchOverride, onActiveThreadBranchOverrideChange, createNewBranch: createNewBranchProp, - onCreateNewBranchChange, startFromOrigin, onStartFromOriginChange, onCheckoutPullRequestRequest, @@ -710,18 +707,6 @@ export function BranchToolbarBranchSelector({ ? "create-branch" : "branch" } - createBranchControl={ - isSelectingWorktreeBase && onCreateNewBranchChange - ? { - checked: createNewBranch, - disabled: forceNewWorktree, - tooltip: forceNewWorktree - ? "Each model starts in its own worktree, so each one needs its own new branch." - : "Turn off to check out the selected local branch in a new worktree. The branch must not already be checked out.", - onCheckedChange: onCreateNewBranchChange, - } - : undefined - } originControl={ isSelectingWorktreeBase && createNewBranch ? { checked: startFromOrigin, onCheckedChange: onStartFromOriginChange } diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index ca511de43642c..c20e9187316d9 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -4,10 +4,14 @@ import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react"; import { memo, type MouseEvent as ReactMouseEvent } from "react"; import { + CHECKOUT_WORKTREE_LABEL, resolveCurrentWorkspaceLabel, resolveEnvModeLabel, resolveLockedWorkspaceLabel, + resolveNewWorkspaceLabel, + resolveWorkspaceChoice, type EnvMode, + type WorkspaceChoice, } from "./BranchToolbar.logic"; import { useComposerMenuProps } from "./chat/composerEventScope"; import { PreviousWorktreeItemContent } from "./PreviousWorktreeItemContent"; @@ -30,8 +34,11 @@ interface BranchToolbarEnvModeSelectorProps { forceNewWorktree?: boolean; envLocked: boolean; effectiveEnvMode: EnvMode; + createNewBranch: boolean; + /** Whether the server can check out an existing branch in a new worktree. */ + canCheckoutWorktree: boolean; activeWorktreePath: string | null; - onEnvModeChange: (mode: EnvMode) => void; + onEnvModeChange: (choice: WorkspaceChoice) => void; previousWorktreeLabel?: string | null; previousWorktreeBranch?: string | null; onUsePreviousWorktree?: () => void; @@ -41,6 +48,8 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe forceNewWorktree = false, envLocked, effectiveEnvMode, + createNewBranch, + canCheckoutWorktree, activeWorktreePath, onEnvModeChange, previousWorktreeLabel, @@ -53,12 +62,16 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe "composer.workspace", ); const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); + const workspaceChoice = resolveWorkspaceChoice(effectiveEnvMode, createNewBranch); const envModeItems = [ { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath), }, { value: "worktree", label: resolveEnvModeLabel("worktree") }, + ...(canCheckoutWorktree + ? [{ value: "checkout-worktree", label: CHECKOUT_WORKTREE_LABEL }] + : []), ...(showPreviousWorktree && previousWorktreeLabel ? [{ value: PREVIOUS_WORKTREE_SELECT_VALUE, label: previousWorktreeLabel }] : []), @@ -86,7 +99,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe {forceNewWorktree ? resolveEnvModeLabel("worktree") - : resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode)} + : resolveLockedWorkspaceLabel(activeWorktreePath, workspaceChoice)} ); @@ -97,7 +110,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe {forceNewWorktree ? "Each model starts in its own worktree." - : resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode)} + : resolveLockedWorkspaceLabel(activeWorktreePath, workspaceChoice)} ); @@ -106,13 +119,13 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe return (