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..7c9eb50ee1ee9 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,17 @@ 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" + ? [] + : // Like the driver, hide origin refs with a same-named local branch unless asked. + remoteRefs.filter( + (ref) => + input.includeMatchingRemoteRefs === true || + !localRefs.some((local) => `origin/${local.name}` === ref.name), + )), + ].filter((ref) => ref.name.includes(input.query ?? "")); const cursor = input.cursor ?? 0; return Effect.succeed({ refs: matches.slice(cursor, cursor + 1), @@ -1305,9 +1338,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: "refs/remotes/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 +1380,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 +1440,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", @@ -1581,6 +1642,33 @@ it.effect("fetches the origin base from the remote it already resolved", () => { }).pipe(Effect.provide(harness.layer)); }); +it.effect("fails instead of checking out a remote branch deleted since the last fetch", () => { + const harness = makeHarness({ fetchWorktreeBaseCommit: () => Effect.succeed(null) }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:deleted-remote", + thread: "thread:launch:deleted-remote", + message: "Continue the feature", + workspace: { type: "worktree", baseRef: "origin/feature", createBranch: false }, + }), + ); + yield* awaitRunStatus(launched.threadId, "failed"); + assert.deepEqual(yield* fetchStage(launched.threadId), { + status: "failed", + detail: "origin/feature not found", + }); + const projection = yield* threads.getThreadProjection(launched.threadId); + assert.include( + projection.turnItems.find((item) => item.type === "error")?.failure.message ?? "", + "Remote branch origin/feature no longer exists.", + ); + assert.equal(harness.createWorktree.mock.calls.length, 0); + }).pipe(Effect.provide(harness.layer)); +}); + it.effect("shows the fetch diagnosis when preparing a worktree from origin fails", () => { const detail = "Git could not authenticate with the remote. Check Git credentials or SSH access on the server, then retry."; @@ -2513,7 +2601,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..4efcdd988a931 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -252,16 +252,19 @@ 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, + // listRefs hides origin refs that have a same-named local branch by default. + includeMatchingRemoteRefs: true, refresh: cursor === 0, cursor, }); @@ -272,40 +275,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 +481,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 +515,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 @@ -502,6 +543,15 @@ const make = Effect.gen(function* () { "done", `${remoteBase.remoteRefName} at ${remoteBase.commitSha.slice(0, 7)}`, ); + } else if (checkout !== null) { + // A stale tracking ref would resurrect a branch deleted on the remote. + const missing = `${remote.remoteName}/${remote.branchName}`; + yield* setupTracker.stageStatus(threadId, "fetch", "failed", `${missing} not found`); + return yield* mapError( + input, + "provision-worktree", + threadId, + )(`Remote branch ${missing} no longer exists.`); } else { yield* setupTracker.stageStatus( threadId, @@ -527,7 +577,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 +585,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: `refs/remotes/${input.workspaceStrategy.baseRef}`, + newRefName: checkout.branch, + path: null, + } + : { cwd: project.workspaceRoot, refName: checkout.branch, path: null }, { progress: { onWorktreeClaimed: (path) => @@ -934,9 +992,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 +1054,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..298b80d5e24b7 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 ? ( @@ -631,7 +648,14 @@ export const BranchToolbar = memo(function BranchToolbar({ const activeProject = useProject(activeProjectRef); const activeWorktreePath = forceNewWorktree ? null : worktreePath; const effectiveEnvMode = forceNewWorktree ? "worktree" : envMode; + // Fan-out starts every model on its own new branch. + const effectiveCreateNewBranch = forceNewWorktree || createNewBranch; 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 +734,10 @@ export const BranchToolbar = memo(function BranchToolbar({ showEnvironmentIndicator={activeEnvironmentOption !== null} onEnvironmentChange={onEnvironmentChange} effectiveEnvMode={effectiveEnvMode} + createNewBranch={effectiveCreateNewBranch} + canCheckoutWorktree={canCheckoutWorktree} activeWorktreePath={activeWorktreePath} - onEnvModeChange={onEnvModeChange} + onEnvModeChange={onWorkspaceChoiceChange} previousWorktreeLabel={previousWorktreeLabel} previousWorktreeBranch={previousWorktreeSeed?.branch ?? null} onUsePreviousWorktree={onUsePreviousWorktree} @@ -731,8 +757,7 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvModeOverride={effectiveEnvMode} {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})} {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} - createNewBranch={createNewBranch} - onCreateNewBranchChange={onCreateNewBranchChange} + createNewBranch={effectiveCreateNewBranch} startFromOrigin={startFromOrigin} onStartFromOriginChange={onStartFromOriginChange} {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} @@ -763,8 +788,10 @@ export const BranchToolbar = memo(function BranchToolbar({ showEnvironmentIndicator={showEnvironmentIndicator} onEnvironmentChange={onEnvironmentChange} effectiveEnvMode={effectiveEnvMode} + createNewBranch={effectiveCreateNewBranch} + canCheckoutWorktree={canCheckoutWorktree} activeWorktreePath={activeWorktreePath} - onEnvModeChange={onEnvModeChange} + onEnvModeChange={onWorkspaceChoiceChange} previousWorktreeLabel={previousWorktreeLabel} previousWorktreeBranch={previousWorktreeSeed?.branch ?? null} onUsePreviousWorktree={onUsePreviousWorktree} @@ -806,8 +833,10 @@ export const BranchToolbar = memo(function BranchToolbar({ forceNewWorktree={forceNewWorktree} envLocked={envModeLocked} effectiveEnvMode={effectiveEnvMode} + createNewBranch={effectiveCreateNewBranch} + canCheckoutWorktree={canCheckoutWorktree} activeWorktreePath={activeWorktreePath} - onEnvModeChange={onEnvModeChange} + onEnvModeChange={onWorkspaceChoiceChange} previousWorktreeLabel={previousWorktreeLabel} previousWorktreeBranch={previousWorktreeSeed?.branch ?? null} onUsePreviousWorktree={onUsePreviousWorktree} @@ -828,8 +857,7 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvModeOverride={effectiveEnvMode} {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})} {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} - createNewBranch={createNewBranch} - onCreateNewBranchChange={onCreateNewBranchChange} + createNewBranch={effectiveCreateNewBranch} 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 (