From 48d2c04fbb26d7c9cd217da15eb0262e39255818 Mon Sep 17 00:00:00 2001 From: Ayush Kaithwas Date: Tue, 27 Oct 2026 18:07:57 +0530 Subject: [PATCH 1/2] feat(settings): new worktrees can start from a configured base branch Projects whose work branches from something other than the repository default, such as dev, had to pick that base by hand on every new worktree thread. Settings -> Worktrees -> Base branch sets it per environment or per project; empty keeps the repository default. Web and mobile drafts use it wherever they used the default branch. Switching a draft from the current checkout to a new worktree drops the branch synced from the checkout so the configured base applies; a branch the user picked still wins. Co-Authored-By: Claude Opus 5.5 --- ...SettingsServerControlsRouteScreen.test.tsx | 3 ++ .../SettingsServerControlsRouteScreen.tsx | 8 +++ .../components/WorktreeBaseBranchSetting.tsx | 50 +++++++++++++++++ .../new-task-context-presentation.test.ts | 44 +++++++++++++++ .../threads/new-task-context-presentation.ts | 29 +++++++--- .../threads/new-task-flow-provider.tsx | 13 ++++- .../features/threads/thread-list-v2-items.tsx | 1 + .../src/state/pending-new-tasks-model.test.ts | 9 ++++ .../src/state/pending-new-tasks-model.ts | 2 + .../components/BranchToolbar.logic.test.ts | 39 ++++++++++++++ .../web/src/components/BranchToolbar.logic.ts | 11 ++++ .../BranchToolbarBranchSelector.tsx | 23 ++++++-- .../components/chat/useComposerSelection.ts | 18 ++++++- .../components/settings/WorktreesSettings.tsx | 53 ++++++++++++++++++- .../src/components/settings/settingsSearch.ts | 7 +++ apps/web/src/lib/chatThreadActions.test.ts | 28 +++++++++- apps/web/src/lib/chatThreadActions.ts | 15 ++++++ docs/user/project-settings.md | 8 +++ packages/contracts/src/settings.ts | 5 ++ packages/shared/src/projectSettings.test.ts | 12 +++++ 20 files changed, 362 insertions(+), 16 deletions(-) create mode 100644 apps/mobile/src/features/settings/components/WorktreeBaseBranchSetting.tsx diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx index 179eceba6c6f..ae22e4233a92 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx @@ -115,6 +115,9 @@ vi.mock("./components/BranchNamingSettings", () => ({ return null; }, })); +vi.mock("./components/WorktreeBaseBranchSetting", () => ({ + WorktreeBaseBranchSetting: () => null, +})); vi.mock("./settings-environment-filter", () => ({ useSettingsEnvironmentFilter: () => ({ selectedTargets: mocks.targets, diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 94a54db718db..626b8f615e9b 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -29,6 +29,7 @@ import { SettingsChoiceRow } from "./components/SettingsChoiceRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; +import { WorktreeBaseBranchSetting } from "./components/WorktreeBaseBranchSetting"; import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; import { useSettingsWriter } from "./useSettingsWriter"; import { @@ -62,6 +63,7 @@ const PAGE_PROJECT_KEYS: Record + void; +}) { + // Save on exit too: Android Back hides the keyboard without ending editing. + const draft = useRef<{ text: string; over: string | null } | null>(null); + const save = () => { + const current = draft.current; + draft.current = null; + // Drop a draft typed over an older saved value: the updated value has already remounted the input. + if (current === null || current.over !== props.value) return; + const value = current.text.trim(); + if (props.value === null || value !== props.value) { + props.onChange({ newWorktreesBaseBranch: value }); + } + }; + const saveOnExit = useEffectEvent(save); + // oxlint-disable-next-line t3code/react-effect-budget -- leaving the screen must flush an unsaved edit + useEffect(() => () => saveOnExit(), []); + return ( + + Base branch + + The branch, tag, or commit new worktrees start from, such as dev. Leave empty to use the + repository’s default branch. + + { + draft.current = { text, over: props.value }; + }} + defaultValue={props.value ?? ""} + placeholder={props.value === null ? "Mixed" : "Repository default"} + editable={!props.disabled} + autoCapitalize="none" + autoCorrect={false} + className="min-h-10 rounded-xl px-3 py-2 text-base text-foreground" + onEndEditing={save} + /> + + ); +} diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.test.ts b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts index 2bb2659dc6d7..1f97230d89db 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.test.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { fillNewTaskWorkspaceBranch, + resolveWorktreeSwitchBranch, resolveNewTaskBranchLabel, resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection, @@ -116,6 +117,7 @@ describe("fillNewTaskWorkspaceBranch", () => { branches, projectCwd: "/repo", worktreeDefaultSettled: true, + worktreeBaseBranch: "", }; it("bases a new worktree on the default branch, even when only the remote has it", () => { @@ -127,6 +129,32 @@ describe("fillNewTaskWorkspaceBranch", () => { ).toEqual({ mode: "worktree", branch: "origin/main", worktreePath: null }); }); + it("bases a new worktree on the configured branch, even when refs have not listed it", () => { + expect( + fillNewTaskWorkspaceBranch({ + ...base, + branches: [], + worktreeBaseBranch: "dev", + selection: { mode: "worktree", branch: null, worktreePath: null }, + }), + ).toEqual({ mode: "worktree", branch: "dev", worktreePath: null }); + }); + + it("keeps a configured base out of local selections", () => { + expect( + fillNewTaskWorkspaceBranch({ + ...base, + worktreeBaseBranch: "dev", + selection: { + mode: "local", + branch: null, + worktreePath: null, + followsCurrentCheckout: true, + }, + }), + ).toMatchObject({ branch: "feature" }); + }); + it("waits for the workspace default to settle before picking a base", () => { const selection = { mode: "worktree", branch: null, worktreePath: null } as const; expect(fillNewTaskWorkspaceBranch({ ...base, selection, worktreeDefaultSettled: false })).toBe( @@ -167,5 +195,21 @@ describe("fillNewTaskWorkspaceBranch", () => { it("keeps an explicit branch", () => { const selection = { mode: "worktree", branch: "release", worktreePath: null } as const; expect(fillNewTaskWorkspaceBranch({ ...base, selection })).toBe(selection); + expect(fillNewTaskWorkspaceBranch({ ...base, worktreeBaseBranch: "dev", selection })).toBe( + selection, + ); + }); +}); + +describe("resolveWorktreeSwitchBranch", () => { + const input = { selectedBranch: "feature", checkoutBranch: "feature", worktreeBaseBranch: "dev" }; + + it("drops the checkout branch so the configured base applies", () => { + expect(resolveWorktreeSwitchBranch(input)).toBeNull(); + }); + + it("keeps another branch the user chose, and today's behavior without a base", () => { + expect(resolveWorktreeSwitchBranch({ ...input, selectedBranch: "release" })).toBe("release"); + expect(resolveWorktreeSwitchBranch({ ...input, worktreeBaseBranch: "" })).toBe("feature"); }); }); diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.ts b/apps/mobile/src/features/threads/new-task-context-presentation.ts index 44efe4cf0484..3e85a08e7836 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.ts @@ -72,6 +72,17 @@ export function resolveNewTaskLocalWorkspaceSelection(input: { }; } +/** Leaving the checkout for a new worktree drops the checkout's branch when a base is configured. */ +export function resolveWorktreeSwitchBranch(input: { + readonly selectedBranch: string | null; + readonly checkoutBranch: string | null; + readonly worktreeBaseBranch: string; +}): string | null { + return input.worktreeBaseBranch !== "" && input.selectedBranch === input.checkoutBranch + ? null + : input.selectedBranch; +} + export function resolveNewTaskBranchLabel(input: { readonly branchName: string | null; readonly startFromOrigin: boolean; @@ -113,10 +124,9 @@ export function filterNewTaskBranches( } /** - * A worktree with no chosen base starts from the default branch, and a local - * selection the user just switched to follows the current checkout until refs - * load. Both are derived, never written back, so a late ref list or t3.json - * default still wins. + * A worktree with no chosen base starts from the configured base, else the default branch, + * and a local selection the user just switched to follows the current checkout until refs + * load. Both are derived, never written back, so a late ref list or t3.json default still wins. */ export function fillNewTaskWorkspaceBranch(input: { readonly selection: ComposerDraftWorkspaceSelection; @@ -129,17 +139,18 @@ export function fillNewTaskWorkspaceBranch(input: { }>; readonly projectCwd: string | null; readonly worktreeDefaultSettled: boolean; + readonly worktreeBaseBranch: string; }): ComposerDraftWorkspaceSelection { const { selection } = input; if (selection.branch !== null) return selection; if (selection.mode === "worktree") { // The default may only exist as origin/, so search remote refs too. - const base = + const fallback = input.branches.find((branch) => branch.isDefault) ?? - input.branches.find((branch) => !branch.isRemote && branch.current) ?? - null; + input.branches.find((branch) => !branch.isRemote && branch.current); + const base = input.worktreeBaseBranch || fallback?.name; return input.worktreeDefaultSettled && base - ? { ...selection, branch: base.name, worktreePath: null } + ? { ...selection, branch: base, worktreePath: null } : selection; } if (!selection.followsCurrentCheckout || input.projectCwd === null) return selection; @@ -166,6 +177,7 @@ export function resolveNewTaskDraftWorkspace(input: { readonly isGitRepo: boolean; readonly branches: Parameters[0]["branches"]; readonly projectCwd: string | null; + readonly worktreeBaseBranch: string; }): ComposerDraftWorkspaceSelection { const selection = input.canChooseWorkspace ? input.selection : undefined; return fillNewTaskWorkspaceBranch({ @@ -177,5 +189,6 @@ export function resolveNewTaskDraftWorkspace(input: { branches: input.branches, projectCwd: input.projectCwd, worktreeDefaultSettled: selection?.mode !== undefined || input.defaultModeSettled, + worktreeBaseBranch: input.worktreeBaseBranch, }); } diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 87e2c5254dbc..70c162895709 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -87,6 +87,7 @@ import { import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; import { resolveNewTaskDraftWorkspace, + resolveWorktreeSwitchBranch, resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection, } from "./new-task-context-presentation"; @@ -460,6 +461,7 @@ export function NewTaskFlowProvider( isGitRepo, branches: allBranchRefs, projectCwd: selectedProject?.workspaceRoot ?? null, + worktreeBaseBranch: projectSettings.settings.newWorktreesBaseBranch, }); const workspaceSelection = resolveWorkspaceSelection(selectedProjectDraft.workspaceSelection); const workspaceMode = workspaceSelection.mode; @@ -687,7 +689,16 @@ export function NewTaskFlowProvider( updateComposerDraftSettings(selectedProjectDraftKey, { workspaceSelection: { mode, - branch: mode === "local" ? localSelection.branch : selectedBranchName, + branch: + mode === "local" + ? localSelection.branch + : workspaceMode === "local" + ? resolveWorktreeSwitchBranch({ + selectedBranch: selectedBranchName, + checkoutBranch: localSelection.branch, + worktreeBaseBranch: projectSettings.settings.newWorktreesBaseBranch, + }) + : selectedBranchName, worktreePath: mode === "local" ? localSelection.worktreePath : selectedWorktreePath, ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), // Refs have not named the current branch yet; the selection follows it. diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 031b2bd52906..ccc656549c12 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -387,6 +387,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props canChooseWorkspace: !projectDefaults.isScratch, defaultMode: projectDefaults.defaultWorkspaceMode, defaultModeSettled: projectDefaults.defaultWorkspaceModeSettled, + worktreeBaseBranch: projectDefaults.projectSettings.settings.newWorktreesBaseBranch, }; const draftBranchRefs = useBranches({ environmentId: pendingTask.environmentId, diff --git a/apps/mobile/src/state/pending-new-tasks-model.test.ts b/apps/mobile/src/state/pending-new-tasks-model.test.ts index 6c039d7254c0..d35df7d701ff 100644 --- a/apps/mobile/src/state/pending-new-tasks-model.test.ts +++ b/apps/mobile/src/state/pending-new-tasks-model.test.ts @@ -13,6 +13,7 @@ const localDefault: PendingDraftProjectDefaults = { canChooseWorkspace: true, defaultMode: "local", defaultModeSettled: true, + worktreeBaseBranch: "", }; const worktreeDefault: PendingDraftProjectDefaults = { ...localDefault, defaultMode: "worktree" }; const resolve = ( @@ -63,6 +64,14 @@ describe("resolvePendingDraftBranch", () => { const worktree = draft({ mode: "worktree", branch: null, worktreePath: null }); expect(pendingDraftBranchNeedsRefs(worktree, localDefault)).toBe(true); expect(resolve({ draft: worktree, branches, projectCwd })).toBe("origin/main"); + expect( + resolve({ + draft: worktree, + defaults: { ...localDefault, worktreeBaseBranch: "dev" }, + branches, + projectCwd, + }), + ).toBe("dev"); }); it("shows the current branch for a local draft that follows the checkout", () => { diff --git a/apps/mobile/src/state/pending-new-tasks-model.ts b/apps/mobile/src/state/pending-new-tasks-model.ts index b7fec4183df1..352e26b57d59 100644 --- a/apps/mobile/src/state/pending-new-tasks-model.ts +++ b/apps/mobile/src/state/pending-new-tasks-model.ts @@ -52,6 +52,7 @@ export interface PendingDraftProjectDefaults { readonly canChooseWorkspace: boolean; readonly defaultMode: "local" | "worktree"; readonly defaultModeSettled: boolean; + readonly worktreeBaseBranch: string; } /** @@ -82,6 +83,7 @@ export function resolvePendingDraftBranch(input: { isGitRepo: input.isGitRepo, branches: input.branches, projectCwd: input.projectCwd, + worktreeBaseBranch: input.defaults.worktreeBaseBranch, }).branch; } diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index d1ca6d01e4c3..8a4ff87ffd46 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -8,6 +8,7 @@ import { resolveEffectiveEnvMode, resolveBranchToolbarPrBranch, resolveLocalCheckoutBranchMismatch, + resolveWorktreeBaseBranchCandidate, sanitizeNewRefName, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; @@ -45,6 +46,44 @@ describe("resolveBranchToolbarPrBranch", () => { }); }); +describe("resolveWorktreeBaseBranchCandidate", () => { + const input = { + configuredBaseBranch: "", + isRepo: true, + defaultBranchName: "main", + currentGitBranch: "feature/open-thread", + }; + + it("uses the repository default, not the open thread's branch, when no base is configured", () => { + expect(resolveWorktreeBaseBranchCandidate(input)).toBe("main"); + expect(resolveWorktreeBaseBranchCandidate({ ...input, defaultBranchName: null })).toBe( + "feature/open-thread", + ); + }); + + it("uses the configured base even when the loaded refs do not include it", () => { + expect( + resolveWorktreeBaseBranchCandidate({ + ...input, + configuredBaseBranch: "dev", + defaultBranchName: null, + currentGitBranch: null, + }), + ).toBe("dev"); + }); + + it("ignores a configured base outside a Git repository", () => { + expect( + resolveWorktreeBaseBranchCandidate({ + configuredBaseBranch: "dev", + isRepo: false, + defaultBranchName: null, + currentGitBranch: null, + }), + ).toBeNull(); + }); +}); + describe("resolveLocalCheckoutBranchMismatch", () => { it("detects when a local thread is associated with a different branch than the checkout", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 01b9155617a4..8c17e60021b8 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -200,6 +200,17 @@ export function resolveBranchToolbarValue(input: { return currentGitBranch ?? activeThreadBranch; } +/** A configured base is used as typed; it may sit outside the loaded ref page or exist only on a remote. */ +export function resolveWorktreeBaseBranchCandidate(input: { + configuredBaseBranch: string; + isRepo: boolean; + defaultBranchName: string | null; + currentGitBranch: string | null; +}): string | null { + if (input.isRepo && input.configuredBaseBranch !== "") return input.configuredBaseBranch; + return input.defaultBranchName ?? input.currentGitBranch; +} + export function resolveBranchTriggerLabel(input: { activeWorktreePath: string | null; effectiveEnvMode: EnvMode; diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 8a55b276a64e..ac5a0a7022ad 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,3 +1,5 @@ +import { useAtomValue } from "@effect/atom-react"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; import { ComposerContextLabel } from "./ComposerContextLabel"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; @@ -36,6 +38,7 @@ import { usePaginatedBranches } from "../state/queries"; import { useProject, useQuietThreadShell } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; +import { serverEnvironment } from "~/state/server"; import { threadEnvironment } from "../state/threads"; import { useAtomCommand } from "../state/use-atom-command"; import { vcsEnvironment } from "../state/vcs"; @@ -53,6 +56,7 @@ import { resolveBranchToolbarValue, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, + resolveWorktreeBaseBranchCandidate, sanitizeNewRefName, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; @@ -146,6 +150,9 @@ export function BranchToolbarBranchSelector({ ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; const activeProject = useProject(activeProjectRef); + const environmentServerSettings = useAtomValue( + serverEnvironment.settingsValueAtom(environmentId), + ); const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); const activeThreadBranch = @@ -478,9 +485,19 @@ export function BranchToolbarBranchSelector({ }); }; - const worktreeBaseBranchCandidate = isInitialBranchesLoadPending - ? null - : (defaultBranchName ?? currentGitBranch); + // Wait for settings too, so the repository default cannot claim the draft before a configured base arrives. + const worktreeBaseBranchCandidate = + isInitialBranchesLoadPending || environmentServerSettings === null + ? null + : resolveWorktreeBaseBranchCandidate({ + configuredBaseBranch: resolveProjectSettings( + environmentServerSettings, + activeProject?.id ?? null, + ).settings.newWorktreesBaseBranch, + isRepo: branchRefState.data?.isRepo ?? false, + defaultBranchName, + currentGitBranch, + }); // The server sets started threads' branches; defaulting here would detach the agent from its worktree. const threadStarted = serverSession !== null; diff --git a/apps/web/src/components/chat/useComposerSelection.ts b/apps/web/src/components/chat/useComposerSelection.ts index 61992327d361..62eeae93d0a7 100644 --- a/apps/web/src/components/chat/useComposerSelection.ts +++ b/apps/web/src/components/chat/useComposerSelection.ts @@ -18,7 +18,10 @@ import { type SetStateAction } from "react"; import { useNavigate } from "@tanstack/react-router"; import { toastManager } from "../ui/toast"; import { resolveAppModelSelectionForInstance } from "../../modelSelection"; -import { resolveNewDraftStartFromOrigin } from "../../lib/chatThreadActions"; +import { + resolveNewDraftStartFromOrigin, + shouldClearSyncedDraftBranch, +} from "../../lib/chatThreadActions"; import { type DraftThreadEnvMode, useComposerDraftStore } from "../../composerDraftStore"; import { getStartedThreadModelChangeBlockReason, @@ -32,7 +35,10 @@ interface ComposerSelectionInput { readonly activeThread: EnvironmentThreadShell | undefined; readonly activeRuntime: ReturnType; readonly activeProjectSettings: { - readonly settings: { readonly newWorktreesStartFromOrigin: boolean }; + readonly settings: { + readonly newWorktreesStartFromOrigin: boolean; + readonly newWorktreesBaseBranch: string; + }; }; readonly settings: UnifiedSettings; readonly providerStatuses: ReadonlyArray; @@ -231,6 +237,14 @@ export function useComposerSelection(input: ComposerSelectionInput) { newWorktreesStartFromOrigin: activeProjectSettings.settings.newWorktreesStartFromOrigin, }), ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), + ...(shouldClearSyncedDraftBranch({ + nextEnvMode: mode, + worktreeBaseBranch: activeProjectSettings.settings.newWorktreesBaseBranch, + draftBranch: draftThread?.branch ?? null, + environmentSelection: draftThread?.environmentSelection, + }) + ? { branch: null } + : {}), }); } scheduleComposerFocus(); diff --git a/apps/web/src/components/settings/WorktreesSettings.tsx b/apps/web/src/components/settings/WorktreesSettings.tsx index a771e92ceca4..0d643643e837 100644 --- a/apps/web/src/components/settings/WorktreesSettings.tsx +++ b/apps/web/src/components/settings/WorktreesSettings.tsx @@ -1,5 +1,7 @@ +import { useRef } from "react"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { Input } from "../ui/input"; import { AdvancedSettings } from "./AdvancedSettings"; import { BranchNamingSettings } from "./BranchNamingSettings"; import { WorktreeSubmodulesRow } from "./ProjectDefaultsSettings"; @@ -13,7 +15,55 @@ import { } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { WorktreeCleanupSection, WorktreesDirectoryRow } from "./StorageSettings"; -import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; +import { + useScopedSettings, + useScopedSettingsMixed, + useUpdateScopedSettings, +} from "./useScopedSettings"; + +function BaseBranchRow() { + const settings = useScopedSettings(); + const { targets } = useSettingsScope(); + const scopeKey = targets.map((target) => `${target.environmentId}:${target.projectId}`).join(","); + const edited = useRef(false); + const updateSettings = useUpdateScopedSettings(); + const mixed = useScopedSettingsMixed(["newWorktreesBaseBranch"]); + return ( + updateSettings({ newWorktreesBaseBranch: "" })} + /> + ) : null + } + control={ + { + edited.current = true; + }} + placeholder={mixed ? "Mixed" : "Repository default"} + defaultValue={mixed ? "" : settings.newWorktreesBaseBranch} + onBlur={(event) => { + const value = event.target.value.trim(); + if (edited.current && (mixed || value !== settings.newWorktreesBaseBranch)) + updateSettings({ newWorktreesBaseBranch: value }); + edited.current = false; + }} + /> + } + /> + ); +} function StartFromRemoteRow() { const settings = useScopedSettings(); @@ -59,6 +109,7 @@ export function WorktreesSettingsPanel() { {!isProjectScope && } + diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 5840566dc1ca..7ad788399c20 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -575,6 +575,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "project-defaults", searchTerms: ["git submodule init recursive top-level none worktree t3.json"], }, + { + id: "worktree-base-branch", + title: "Base branch", + to: "/settings/worktrees", + scope: "project-defaults", + searchTerms: ["new worktrees start from default branch dev main base ref"], + }, { id: "start-from-origin", title: "Start from remote", diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index ad465cd06b2b..6ef132bfc533 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -1,6 +1,9 @@ import { ProviderInstanceId, type ModelSelection } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { resolveNewThreadModelSelectionOverride } from "./chatThreadActions"; +import { + resolveNewThreadModelSelectionOverride, + shouldClearSyncedDraftBranch, +} from "./chatThreadActions"; const PROJECT_DEFAULT_SELECTION: ModelSelection = { instanceId: ProviderInstanceId.make("codex"), @@ -34,3 +37,26 @@ describe("chatThreadActions", () => { ).toEqual(PROJECT_DEFAULT_SELECTION); }); }); + +describe("shouldClearSyncedDraftBranch", () => { + const input = { + nextEnvMode: "worktree", + worktreeBaseBranch: "dev", + draftBranch: "feature/checked-out", + environmentSelection: "auto", + } as const; + + it("drops the checkout branch so a new worktree starts from the configured base", () => { + expect(shouldClearSyncedDraftBranch(input)).toBe(true); + expect(shouldClearSyncedDraftBranch({ ...input, environmentSelection: undefined })).toBe(true); + }); + + it("keeps a branch the user picked", () => { + expect(shouldClearSyncedDraftBranch({ ...input, environmentSelection: "manual" })).toBe(false); + }); + + it("keeps today's behavior without a configured base or outside worktree mode", () => { + expect(shouldClearSyncedDraftBranch({ ...input, worktreeBaseBranch: "" })).toBe(false); + expect(shouldClearSyncedDraftBranch({ ...input, nextEnvMode: "local" })).toBe(false); + }); +}); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index ac5412c23975..a4188fd41526 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -43,6 +43,21 @@ export function resolveNewDraftStartFromOrigin(input: { return input.envMode === "worktree" && input.newWorktreesStartFromOrigin; } +/** A configured base replaces the branch a draft synced from its checkout, never one the user picked. */ +export function shouldClearSyncedDraftBranch(input: { + nextEnvMode: DraftThreadEnvMode; + worktreeBaseBranch: string; + draftBranch: string | null; + environmentSelection: "auto" | "manual" | undefined; +}): boolean { + return ( + input.nextEnvMode === "worktree" && + input.worktreeBaseBranch !== "" && + input.draftBranch !== null && + input.environmentSelection !== "manual" + ); +} + export function resolveNewThreadModelSelectionOverride(input: { readonly projectDefaultSelection: ModelSelection | null; readonly carrySelection: ModelSelection | null; diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 718f8723468a..2eb6fc260301 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -46,6 +46,14 @@ Settings that are environment-wide stay read-only while a project is selected. W targets disagree, a control shows **Mixed** until you choose one value. Appearance, keyboard, and other phone-only settings ignore the filter. +## Worktree base branch + +New worktrees start from the repository's default branch. If a project's work branches +from somewhere else, such as `dev`, select the project and enter that branch, tag, or +commit in **Settings → Worktrees → Base branch**. Leave it empty to go back to the +repository default. You can still choose a different base for any single thread +from the branch picker. + ## Worktree branch names In **Settings → Worktrees → Worktree branch naming**, choose a static prefix, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 649e0e0ab0ec..04e68ce742b6 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -956,6 +956,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "defaultRuntimeMode", "defaultThreadEnvMode", "newWorktreesStartFromOrigin", + "newWorktreesBaseBranch", "worktreeSubmodules", "defaultAutoPull", "defaultProjectScripts", @@ -993,6 +994,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ defaultRuntimeMode: Schema.optionalKey(RuntimeMode), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), + newWorktreesBaseBranch: Schema.optionalKey(TrimmedString), worktreeSubmodules: ForwardCompatibleOptional(WorktreeSubmodules), defaultAutoPull: Schema.optionalKey(Schema.Boolean), defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)), @@ -1198,6 +1200,8 @@ export const ServerSettings = Schema.Struct({ newWorktreesStartFromOrigin: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), + /** Empty uses the repository's default branch. */ + newWorktreesBaseBranch: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), /** Null inherits t3.json, then recursive; unknown values decode to null for older clients. */ worktreeSubmodules: ForwardCompatibleNullable(WorktreeSubmodules).pipe( Schema.withDecodingDefault(Effect.succeed(null)), @@ -1525,6 +1529,7 @@ export const ServerSettingsPatch = Schema.Struct({ environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)), defaultThreadEnvMode: Schema.optionalKey(Schema.NullOr(ThreadEnvMode)), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), + newWorktreesBaseBranch: Schema.optionalKey(TrimmedString), worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts index 67aaf2186e7a..2b5321418782 100644 --- a/packages/shared/src/projectSettings.test.ts +++ b/packages/shared/src/projectSettings.test.ts @@ -34,6 +34,18 @@ describe("resolveSendEnvMode", () => { }); describe("resolveProjectSettings", () => { + it("lets a project return to the repository default when its environment sets a base branch", () => { + const settings = { + ...DEFAULT_SERVER_SETTINGS, + newWorktreesBaseBranch: "dev", + projectSettingsOverrides: { [projectId]: { newWorktreesBaseBranch: "" } }, + }; + expect(resolveProjectSettings(settings, projectId).settings.newWorktreesBaseBranch).toBe(""); + expect(resolveProjectSettings(settings, otherProjectId).settings.newWorktreesBaseBranch).toBe( + "dev", + ); + }); + it("ignores an override left undefined by a forward-compatible decode", () => { const resolved = resolveProjectSettings( { From 097cde0b165acdedc3bbe21f08aec07b7affec6a Mon Sep 17 00:00:00 2001 From: Ayush Kaithwas Date: Tue, 27 Oct 2026 18:29:53 +0530 Subject: [PATCH 2/2] fix(settings): a configured base always applies when leaving the checkout Leaving Current checkout for New worktree guessed whether the draft's branch was synced or picked: web read environmentSelection, which machine picks also set, and mobile compared branch names, which a checked-out pick also matches. In Current checkout the picker checks a branch out rather than choosing a worktree base, so with a base configured the switch now always starts from it, shown in the picker before sending. Re-selecting New worktree keeps a base already picked there. Co-Authored-By: Claude Opus 5.5 --- .../new-task-context-presentation.test.ts | 15 +++++----- .../threads/new-task-context-presentation.ts | 7 ++--- .../threads/new-task-flow-provider.tsx | 1 - .../components/chat/useComposerSelection.ts | 7 ++--- apps/web/src/lib/chatThreadActions.test.ts | 28 +++++++++++-------- apps/web/src/lib/chatThreadActions.ts | 12 ++++---- 6 files changed, 34 insertions(+), 36 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.test.ts b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts index 1f97230d89db..a373b4010613 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.test.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts @@ -202,14 +202,15 @@ describe("fillNewTaskWorkspaceBranch", () => { }); describe("resolveWorktreeSwitchBranch", () => { - const input = { selectedBranch: "feature", checkoutBranch: "feature", worktreeBaseBranch: "dev" }; - - it("drops the checkout branch so the configured base applies", () => { - expect(resolveWorktreeSwitchBranch(input)).toBeNull(); + it("starts a new worktree from the configured base, not the checkout's branch", () => { + expect( + resolveWorktreeSwitchBranch({ selectedBranch: "release", worktreeBaseBranch: "dev" }), + ).toBeNull(); }); - it("keeps another branch the user chose, and today's behavior without a base", () => { - expect(resolveWorktreeSwitchBranch({ ...input, selectedBranch: "release" })).toBe("release"); - expect(resolveWorktreeSwitchBranch({ ...input, worktreeBaseBranch: "" })).toBe("feature"); + it("keeps today's behavior without a configured base", () => { + expect(resolveWorktreeSwitchBranch({ selectedBranch: "release", worktreeBaseBranch: "" })).toBe( + "release", + ); }); }); diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.ts b/apps/mobile/src/features/threads/new-task-context-presentation.ts index 3e85a08e7836..f246bf45a89e 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.ts @@ -72,15 +72,12 @@ export function resolveNewTaskLocalWorkspaceSelection(input: { }; } -/** Leaving the checkout for a new worktree drops the checkout's branch when a base is configured. */ +/** A checkout's branch is not a worktree base, so leaving it for a new worktree starts from the configured base. */ export function resolveWorktreeSwitchBranch(input: { readonly selectedBranch: string | null; - readonly checkoutBranch: string | null; readonly worktreeBaseBranch: string; }): string | null { - return input.worktreeBaseBranch !== "" && input.selectedBranch === input.checkoutBranch - ? null - : input.selectedBranch; + return input.worktreeBaseBranch === "" ? input.selectedBranch : null; } export function resolveNewTaskBranchLabel(input: { diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 70c162895709..fc1b6a689b68 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -695,7 +695,6 @@ export function NewTaskFlowProvider( : workspaceMode === "local" ? resolveWorktreeSwitchBranch({ selectedBranch: selectedBranchName, - checkoutBranch: localSelection.branch, worktreeBaseBranch: projectSettings.settings.newWorktreesBaseBranch, }) : selectedBranchName, diff --git a/apps/web/src/components/chat/useComposerSelection.ts b/apps/web/src/components/chat/useComposerSelection.ts index 62eeae93d0a7..1241eaaf70e7 100644 --- a/apps/web/src/components/chat/useComposerSelection.ts +++ b/apps/web/src/components/chat/useComposerSelection.ts @@ -20,7 +20,7 @@ import { toastManager } from "../ui/toast"; import { resolveAppModelSelectionForInstance } from "../../modelSelection"; import { resolveNewDraftStartFromOrigin, - shouldClearSyncedDraftBranch, + shouldResetBranchForNewWorktree, } from "../../lib/chatThreadActions"; import { type DraftThreadEnvMode, useComposerDraftStore } from "../../composerDraftStore"; import { @@ -237,11 +237,10 @@ export function useComposerSelection(input: ComposerSelectionInput) { newWorktreesStartFromOrigin: activeProjectSettings.settings.newWorktreesStartFromOrigin, }), ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), - ...(shouldClearSyncedDraftBranch({ + ...(shouldResetBranchForNewWorktree({ + currentEnvMode: draftThread?.envMode, nextEnvMode: mode, worktreeBaseBranch: activeProjectSettings.settings.newWorktreesBaseBranch, - draftBranch: draftThread?.branch ?? null, - environmentSelection: draftThread?.environmentSelection, }) ? { branch: null } : {}), diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index 6ef132bfc533..b6e99f8c70a0 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -2,7 +2,7 @@ import { ProviderInstanceId, type ModelSelection } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { resolveNewThreadModelSelectionOverride, - shouldClearSyncedDraftBranch, + shouldResetBranchForNewWorktree, } from "./chatThreadActions"; const PROJECT_DEFAULT_SELECTION: ModelSelection = { @@ -38,25 +38,29 @@ describe("chatThreadActions", () => { }); }); -describe("shouldClearSyncedDraftBranch", () => { +describe("shouldResetBranchForNewWorktree", () => { const input = { + currentEnvMode: "local", nextEnvMode: "worktree", worktreeBaseBranch: "dev", - draftBranch: "feature/checked-out", - environmentSelection: "auto", } as const; - it("drops the checkout branch so a new worktree starts from the configured base", () => { - expect(shouldClearSyncedDraftBranch(input)).toBe(true); - expect(shouldClearSyncedDraftBranch({ ...input, environmentSelection: undefined })).toBe(true); + it("starts a new worktree from the configured base instead of the checkout's branch", () => { + expect(shouldResetBranchForNewWorktree(input)).toBe(true); }); - it("keeps a branch the user picked", () => { - expect(shouldClearSyncedDraftBranch({ ...input, environmentSelection: "manual" })).toBe(false); + it("keeps a base already picked in worktree mode", () => { + expect(shouldResetBranchForNewWorktree({ ...input, currentEnvMode: "worktree" })).toBe(false); }); - it("keeps today's behavior without a configured base or outside worktree mode", () => { - expect(shouldClearSyncedDraftBranch({ ...input, worktreeBaseBranch: "" })).toBe(false); - expect(shouldClearSyncedDraftBranch({ ...input, nextEnvMode: "local" })).toBe(false); + it("keeps today's behavior without a configured base or when leaving worktree mode", () => { + expect(shouldResetBranchForNewWorktree({ ...input, worktreeBaseBranch: "" })).toBe(false); + expect( + shouldResetBranchForNewWorktree({ + currentEnvMode: "worktree", + nextEnvMode: "local", + worktreeBaseBranch: "dev", + }), + ).toBe(false); }); }); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index a4188fd41526..4b7c7fa79ce7 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -43,18 +43,16 @@ export function resolveNewDraftStartFromOrigin(input: { return input.envMode === "worktree" && input.newWorktreesStartFromOrigin; } -/** A configured base replaces the branch a draft synced from its checkout, never one the user picked. */ -export function shouldClearSyncedDraftBranch(input: { +/** A checkout's branch is not a worktree base, so leaving it for a new worktree starts from the configured base. */ +export function shouldResetBranchForNewWorktree(input: { + currentEnvMode: DraftThreadEnvMode | undefined; nextEnvMode: DraftThreadEnvMode; worktreeBaseBranch: string; - draftBranch: string | null; - environmentSelection: "auto" | "manual" | undefined; }): boolean { return ( + input.currentEnvMode === "local" && input.nextEnvMode === "worktree" && - input.worktreeBaseBranch !== "" && - input.draftBranch !== null && - input.environmentSelection !== "manual" + input.worktreeBaseBranch !== "" ); }