From b7c4f413e1aeba811431e51ce4181c663ecef213 Mon Sep 17 00:00:00 2001 From: Luiz Ferraz Date: Fri, 18 Sep 2026 18:12:14 +0000 Subject: [PATCH] feat(web,mobile): per-project default base branch for new worktrees - Add defaultThreadBaseBranch project override, editable in source control settings - Automatic worktree drafts prefer the override, then the repo default, then the current branch - Mobile settings gains the field and honors it in the new task flow --- .../SettingsServerControlsRouteScreen.tsx | 143 ++++++++++++++++-- .../settings/settings-scoped-server.test.ts | 33 ++++ .../settings/settings-scoped-server.ts | 40 ++++- .../threads/new-task-branch-default.test.ts | 60 ++++++++ .../threads/new-task-branch-default.ts | 23 +++ .../threads/new-task-flow-provider.tsx | 21 +-- .../components/BranchToolbar.logic.test.ts | 37 +++++ .../web/src/components/BranchToolbar.logic.ts | 8 + .../BranchToolbarBranchSelector.tsx | 30 +++- .../settings/ProjectDefaultsSettings.tsx | 33 ++++ .../settings/scopedSettings.test.ts | 30 ++++ .../src/components/settings/scopedSettings.ts | 29 ++++ .../components/settings/useScopedSettings.ts | 12 ++ apps/web/src/hooks/useHandleNewThread.test.ts | 51 ++++++- apps/web/src/hooks/useHandleNewThread.ts | 10 +- docs/user/project-settings.md | 6 + packages/contracts/src/settings.test.ts | 39 +++++ packages/contracts/src/settings.ts | 17 ++- packages/shared/src/projectSettings.test.ts | 22 +++ packages/shared/src/projectSettings.ts | 2 +- 20 files changed, 598 insertions(+), 48 deletions(-) create mode 100644 apps/mobile/src/features/threads/new-task-branch-default.test.ts create mode 100644 apps/mobile/src/features/threads/new-task-branch-default.ts diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 6e7cef0de93d..715f4849a03c 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -1,6 +1,6 @@ import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { SymbolView } from "../../components/AppSymbol"; -import { AppText as Text } from "../../components/AppText"; +import { AppText as Text, AppTextInput } from "../../components/AppText"; import { type ResponseStreamingMode, type ServerSettings, @@ -9,7 +9,7 @@ import { PROJECT_SCOPED_SERVER_SETTING_KEYS, type ProjectScopedServerSettingKey, } from "@t3tools/contracts"; -import { useRef, useState, type ComponentProps } from "react"; +import { useEffect, useRef, useState, type ComponentProps } from "react"; import { Alert, Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -26,6 +26,7 @@ import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; import { + planMobileProjectOverridePatch, planMobileScopedSettingsClear, planMobileScopedSettingsPatch, resolveMobileSettingsTargets, @@ -148,7 +149,45 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { }; const clearProjectOverrides = () => { if (writeInFlight.current) return; - const writes = planMobileScopedSettingsClear(targets, PAGE_PROJECT_KEYS[props.page]); + const keys = + props.page === "source-control" + ? ([...PAGE_PROJECT_KEYS[props.page], "defaultThreadBaseBranch"] as const) + : PAGE_PROJECT_KEYS[props.page]; + const writes = planMobileScopedSettingsClear(targets, keys); + if (writes.length === 0) return; + writeInFlight.current = true; + setPendingTargets(targets); + setPendingWrites((count) => count + 1); + void Promise.allSettled( + writes.map((entry) => + updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }), + ), + ).finally(() => { + writeInFlight.current = false; + setPendingTargets(null); + setPendingWrites((count) => count - 1); + }); + }; + const writeProjectOverrides = (patch: { readonly defaultThreadBaseBranch?: string }) => { + if (writeInFlight.current || !projectSelected || !hasConnectedSelection) return; + const writes = planMobileProjectOverridePatch(targets, patch); + if (writes.length === 0) return; + writeInFlight.current = true; + setPendingTargets(targets); + setPendingWrites((count) => count + 1); + void Promise.allSettled( + writes.map((entry) => + updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }), + ), + ).finally(() => { + writeInFlight.current = false; + setPendingTargets(null); + setPendingWrites((count) => count - 1); + }); + }; + const clearDefaultThreadBaseBranch = () => { + if (writeInFlight.current || !projectSelected) return; + const writes = planMobileScopedSettingsClear(targets, ["defaultThreadBaseBranch"]); if (writes.length === 0) return; writeInFlight.current = true; setPendingTargets(targets); @@ -205,8 +244,13 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { {projectSelected ? ( - PAGE_PROJECT_KEYS[props.page].some((key) => target.sources[key] === "project"), + hasOverrides={targets.some( + (target) => + PAGE_PROJECT_KEYS[props.page].some( + (key) => target.sources[key] === "project", + ) || + (props.page === "source-control" && + Object.hasOwn(target.overrides, "defaultThreadBaseBranch")), )} supportsOverrides={supportsProjectOverrides} pending={pendingWrites > 0} @@ -271,14 +315,35 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { /> - write({ newWorktreesStartFromOrigin: value })} - /> + {projectSelected ? ( + + target.overrides.defaultThreadBaseBranch === + reference.overrides.defaultThreadBaseBranch, + ) + ? (reference.overrides.defaultThreadBaseBranch ?? "") + : null + } + disabled={disabled} + onSave={(value) => + writeProjectOverrides({ defaultThreadBaseBranch: value }) + } + onClear={clearDefaultThreadBaseBranch} + /> + ) : null} + + write({ newWorktreesStartFromOrigin: value })} + /> + ) : null} @@ -374,6 +439,58 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { ); } +function DefaultBaseBranchField(props: { + readonly value: string | null; + readonly disabled: boolean; + readonly onSave: (value: string) => void; + readonly onClear: () => void; +}) { + const [draft, setDraft] = useState(props.value ?? ""); + useEffect(() => setDraft(props.value ?? ""), [props.value]); + const current = props.value ?? ""; + const trimmed = draft.trim(); + const changed = trimmed !== current; + const commit = () => { + if (props.disabled || !changed) return; + if (trimmed.length === 0) { + if (props.value !== null) props.onClear(); + } else props.onSave(trimmed); + }; + return ( + + Default base branch + + New worktree threads use this branch. Clear it to use the repository default. + + + + {changed ? ( + + Save + + ) : null} + + + ); +} + function ChoiceRow(props: { readonly label: string; readonly description: string; diff --git a/apps/mobile/src/features/settings/settings-scoped-server.test.ts b/apps/mobile/src/features/settings/settings-scoped-server.test.ts index dd589cd2038c..12a5c6536599 100644 --- a/apps/mobile/src/features/settings/settings-scoped-server.test.ts +++ b/apps/mobile/src/features/settings/settings-scoped-server.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { SettingsTarget } from "./settings-environment-filter"; import { + planMobileProjectOverridePatch, planMobileScopedSettingsClear, planMobileScopedSettingsPatch, resolveMobileSettingsTargets, @@ -91,4 +92,36 @@ describe("mobile project settings scope", () => { planMobileScopedSettingsPatch(targets, true, { enableProviderUpdateChecks: false }), ).toEqual([]); }); + + it("saves and clears the project-only base branch without dropping sibling overrides", () => { + const settings: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [firstProject]: { defaultAutoPull: true, defaultThreadBaseBranch: "dev" }, + }, + }; + const targets = resolveMobileSettingsTargets( + [environment(firstId, settings)], + [{ environmentId: firstId, id: firstProject }], + ); + + expect(planMobileProjectOverridePatch(targets, { defaultThreadBaseBranch: "release" })).toEqual( + [ + { + environmentId: firstId, + patch: { + projectSettingsOverrides: { + [firstProject]: { defaultAutoPull: true, defaultThreadBaseBranch: "release" }, + }, + }, + }, + ], + ); + expect(planMobileScopedSettingsClear(targets, ["defaultThreadBaseBranch"])).toEqual([ + { + environmentId: firstId, + patch: { projectSettingsOverrides: { [firstProject]: { defaultAutoPull: true } } }, + }, + ]); + }); }); diff --git a/apps/mobile/src/features/settings/settings-scoped-server.ts b/apps/mobile/src/features/settings/settings-scoped-server.ts index 53cc44a7ee1c..69ad331602c8 100644 --- a/apps/mobile/src/features/settings/settings-scoped-server.ts +++ b/apps/mobile/src/features/settings/settings-scoped-server.ts @@ -3,12 +3,13 @@ import { type EnvironmentId, type ProjectId, type ProjectScopedServerSettingKey, + type ProjectSettingsOverrides, type ServerSettings, type ServerSettingsPatch, } from "@t3tools/contracts"; import { - clearProjectSettingsOverrides, resolveProjectSettings, + type ResolvedProjectSettings, } from "@t3tools/shared/projectSettings"; import type { SettingsTarget } from "./settings-environment-filter"; @@ -17,7 +18,8 @@ export interface ScopedMobileSettingsTarget { readonly environment: SettingsTarget; readonly projectId: ProjectId | null; readonly settings: ServerSettings; - readonly sources: ReturnType["sources"]; + readonly sources: ResolvedProjectSettings["sources"]; + readonly overrides: ProjectSettingsOverrides; } export function resolveMobileSettingsTargets( @@ -79,10 +81,32 @@ export function planMobileScopedSettingsPatch( patch: { projectSettingsOverrides } as ServerSettingsPatch, })); } +export function planMobileProjectOverridePatch( + targets: readonly ScopedMobileSettingsTarget[], + patch: Partial, +) { + const writes = new Map>(); + for (const target of targets) { + if ( + target.projectId === null || + target.environment.serverConfig.environment.capabilities.projectSettingsOverrides !== true + ) + continue; + const current = + target.environment.serverConfig.settings.projectSettingsOverrides[target.projectId] ?? {}; + const overrides = writes.get(target.environment.environmentId) ?? {}; + overrides[target.projectId] = { ...current, ...patch }; + writes.set(target.environment.environmentId, overrides); + } + return [...writes].map(([environmentId, projectSettingsOverrides]) => ({ + environmentId, + patch: { projectSettingsOverrides } as ServerSettingsPatch, + })); +} export function planMobileScopedSettingsClear( targets: readonly ScopedMobileSettingsTarget[], - keys: readonly ProjectScopedServerSettingKey[], + keys: readonly (keyof ProjectSettingsOverrides)[], ) { const writes = new Map>(); for (const target of targets) { @@ -91,12 +115,12 @@ export function planMobileScopedSettingsClear( target.environment.serverConfig.environment.capabilities.projectSettingsOverrides !== true ) continue; + const current = + target.environment.serverConfig.settings.projectSettingsOverrides[target.projectId] ?? {}; + const next = { ...current }; + for (const key of keys) delete next[key]; const overrides = writes.get(target.environment.environmentId) ?? {}; - overrides[target.projectId] = clearProjectSettingsOverrides( - target.environment.serverConfig.settings, - target.projectId, - keys, - ); + overrides[target.projectId] = Object.keys(next).length === 0 ? null : next; writes.set(target.environment.environmentId, overrides); } return [...writes].map(([environmentId, projectSettingsOverrides]) => ({ diff --git a/apps/mobile/src/features/threads/new-task-branch-default.test.ts b/apps/mobile/src/features/threads/new-task-branch-default.test.ts new file mode 100644 index 000000000000..2886412c543c --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-branch-default.test.ts @@ -0,0 +1,60 @@ +import type { VcsRef } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveAutomaticWorktreeBaseBranch } from "./new-task-branch-default"; + +const ref = (name: string, options: Partial = {}): VcsRef => ({ + name, + current: false, + isDefault: false, + worktreePath: null, + ...options, +}); + +describe("automatic new-task worktree base branch", () => { + it("prefers the project override over git defaults and current checkout", () => { + const branches = [ + ref("main", { isDefault: true }), + ref("current", { current: true }), + ref("dev"), + ]; + + expect( + resolveAutomaticWorktreeBaseBranch({ + configuredBranch: "dev", + refs: branches, + localRefs: branches, + }), + ).toEqual(branches[2]); + }); + + it("retains a configured branch missing from refs instead of silently falling back", () => { + expect( + resolveAutomaticWorktreeBaseBranch({ + configuredBranch: "release/next", + refs: [ref("main", { isDefault: true })], + localRefs: [ref("main", { isDefault: true })], + }), + ).toEqual(ref("release/next")); + }); + + it("keeps existing git-default then current-checkout fallback when unset", () => { + const current = ref("dev", { current: true }); + const defaultBranch = ref("main", { isDefault: true }); + + expect( + resolveAutomaticWorktreeBaseBranch({ + configuredBranch: undefined, + refs: [current, defaultBranch], + localRefs: [current, defaultBranch], + }), + ).toEqual(defaultBranch); + expect( + resolveAutomaticWorktreeBaseBranch({ + configuredBranch: undefined, + refs: [current], + localRefs: [current], + }), + ).toEqual(current); + }); +}); diff --git a/apps/mobile/src/features/threads/new-task-branch-default.ts b/apps/mobile/src/features/threads/new-task-branch-default.ts new file mode 100644 index 000000000000..288db3fcb672 --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-branch-default.ts @@ -0,0 +1,23 @@ +import type { VcsRef } from "@t3tools/contracts"; + +export function resolveAutomaticWorktreeBaseBranch(input: { + readonly configuredBranch: string | undefined; + readonly refs: ReadonlyArray; + readonly localRefs: ReadonlyArray; +}): VcsRef | null { + if (input.configuredBranch !== undefined) { + return ( + input.refs.find((branch) => branch.name === input.configuredBranch) ?? { + name: input.configuredBranch, + current: false, + isDefault: false, + worktreePath: null, + } + ); + } + return ( + input.refs.find((branch) => branch.isDefault) ?? + input.localRefs.find((branch) => branch.current) ?? + null + ); +} 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 5c74e52cfac1..9bc9c99ab5ae 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -100,6 +100,7 @@ import { resolveNewTaskLocalWorkspaceSelection, } from "./new-task-context-presentation"; import { resolveEnvironmentProjectMatch } from "./new-task-project-selection"; +import { resolveAutomaticWorktreeBaseBranch } from "./new-task-branch-default"; import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; type WorkspaceMode = "local" | "worktree"; @@ -472,6 +473,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin; const startFromOrigin = draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin; + const defaultThreadBaseBranch = projectSettings.overrides.defaultThreadBaseBranch; const defaultRuntimeMode = editingPendingTask ? (editingPendingTask.runtimeMode ?? DEFAULT_RUNTIME_MODE) : projectSettings.settings.defaultRuntimeMode; @@ -879,18 +881,19 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ) { return; } - // The default may only exist as origin/ (isRemote), which - // availableBranches filters out — search the unfiltered refs for it. - const preferredBranch = - allBranchRefs.find((branch) => branch.isDefault) ?? - availableBranches.find((branch) => branch.current) ?? - null; - if (preferredBranch) { - selectBranch(preferredBranch); - } + // The configured name remains authoritative even when it is absent from + // the current refs page: creation should surface the normal git error, + // never silently start work from another branch. + const preferredBranch = resolveAutomaticWorktreeBaseBranch({ + configuredBranch: defaultThreadBaseBranch, + refs: allBranchRefs, + localRefs: availableBranches, + }); + if (preferredBranch) selectBranch(preferredBranch); }, [ allBranchRefs, availableBranches, + defaultThreadBaseBranch, defaultWorkspaceModeSettled, selectBranch, selectedBranchName, diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index ff4bb76bf12a..2b847962bd7f 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -5,6 +5,7 @@ import { deriveLocalBranchNameFromRemoteRef, resolveEnvironmentOptionLabel, resolveBranchSelectionTarget, + resolveAutomaticWorktreeBaseBranch, resolveCurrentWorkspaceLabel, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, @@ -518,6 +519,42 @@ describe("resolveLockedWorkspaceLabel", () => { }); }); +describe("resolveAutomaticWorktreeBaseBranch", () => { + it("prefers the project override, then Git default, then current branch", () => { + expect( + resolveAutomaticWorktreeBaseBranch({ + projectOverride: "dev", + gitDefault: "main", + currentBranch: "feature/current", + }), + ).toBe("dev"); + expect( + resolveAutomaticWorktreeBaseBranch({ + projectOverride: undefined, + gitDefault: "main", + currentBranch: "feature/current", + }), + ).toBe("main"); + expect( + resolveAutomaticWorktreeBaseBranch({ + projectOverride: undefined, + gitDefault: null, + currentBranch: "feature/current", + }), + ).toBe("feature/current"); + }); + + it("retains an explicitly configured name even when Git refs do not list it", () => { + expect( + resolveAutomaticWorktreeBaseBranch({ + projectOverride: "release/next", + gitDefault: "main", + currentBranch: "main", + }), + ).toBe("release/next"); + }); +}); + describe("deriveLocalBranchNameFromRemoteRef", () => { it("strips the remote prefix from a remote ref", () => { expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo"); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 0577f5e8dd1f..7f53c2878fd4 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -94,6 +94,14 @@ export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): return activeWorktreePath ? "Worktree" : "Local checkout"; } +export function resolveAutomaticWorktreeBaseBranch(input: { + projectOverride: string | null | undefined; + gitDefault: string | null; + currentBranch: string | null; +}): string | null { + return input.projectOverride ?? input.gitDefault ?? input.currentBranch; +} + export interface PreviousWorktreeSeed { branch: string | null; worktreePath: string; diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index db448990a4c3..731b07913b71 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -7,7 +7,14 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { ContextMenuItem, EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + type ContextMenuItem, + type EnvironmentId, + type ThreadId, + type VcsRef, +} from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { ChevronDownIcon, GitBranchIcon, SearchIcon } from "lucide-react"; import { @@ -32,7 +39,7 @@ import { readLocalApi } from "../localApi"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { shouldLoadNextBranchPageAfterScroll } from "../state/paginatedBranches"; import { usePaginatedBranches } from "../state/queries"; -import { useProject, useThreadShell } from "../state/entities"; +import { useProject, useServerConfigs, useThreadShell } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { threadEnvironment } from "../state/threads"; import { useAtomCommand } from "../state/use-atom-command"; @@ -44,6 +51,7 @@ import { useComposerMenuProps } from "./chat/composerEventScope"; import { deriveLocalBranchNameFromRemoteRef, resolveBranchTriggerLabel, + resolveAutomaticWorktreeBaseBranch, resolveBranchToolbarPrBranch, resolveBranchSelectionTarget, resolveBranchToolbarValue, @@ -148,6 +156,14 @@ export function BranchToolbarBranchSelector({ ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; const activeProject = useProject(activeProjectRef); + const serverConfigs = useServerConfigs(); + const projectDefaultThreadBaseBranch = activeProject + ? resolveProjectSettings( + serverConfigs.get(environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS, + activeProject.id, + activeProject, + ).overrides.defaultThreadBaseBranch + : undefined; const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); const activeThreadBranch = @@ -507,15 +523,19 @@ export function BranchToolbarBranchSelector({ }); }; - // Default the worktree base to the repo default branch (origin/HEAD), only - // falling back to the checked-out branch when no default is known. + // Automatic worktree bases honor the project-only override before Git's + // default ref, then retain the existing current-checkout fallback. const defaultBranchName = useMemo( () => refs.find((refName) => refName.isDefault)?.name ?? null, [refs], ); const worktreeBaseBranchCandidate = isInitialBranchesLoadPending ? null - : (defaultBranchName ?? currentGitBranch); + : resolveAutomaticWorktreeBaseBranch({ + projectOverride: projectDefaultThreadBaseBranch, + gitDefault: defaultBranchName, + currentBranch: currentGitBranch, + }); useEffect(() => { if ( diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index a08b918be4c5..ab45fe274a82 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -6,6 +6,7 @@ import { } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { useNavigate } from "@tanstack/react-router"; +import { DraftInput } from "../ui/draft-input"; import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; import { getCustomModelOptionsByInstance } from "../../modelSelection"; @@ -38,6 +39,7 @@ import { useScopedSettings, useScopedSettingsMixed, useScopedSettingSource, + useUpdateProjectDefaultThreadBaseBranch, useUpdateScopedSettings, } from "./useScopedSettings"; @@ -50,6 +52,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting const { scope, target, targets, connectedEnvironments } = useSettingsScope(); const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); + const updateDefaultThreadBaseBranch = useUpdateProjectDefaultThreadBaseBranch(); const navigate = useNavigate(); const { environments } = useEnvironments(); const representative = target @@ -78,6 +81,9 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting const workspaceSource = useScopedSettingSource(["defaultThreadEnvMode"]); const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; const unavailable = connectedEnvironments.length === 0; + const defaultThreadBaseBranch = isProjectScope + ? (target?.overrides.defaultThreadBaseBranch ?? "") + : ""; // A checkout's t3.json wins over the environment default when the project // has no override of its own; show which one "inherit" resolves to. @@ -339,6 +345,33 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting ) : category === "source-control" ? ( <> + {isProjectScope ? ( + updateDefaultThreadBaseBranch(null)} + /> + ) : null + } + control={ + { + const branch = value.trim(); + updateDefaultThreadBaseBranch(branch.length > 0 ? branch : null); + }} + /> + } + /> + ) : null} { }); }); + it("writes and clears the project-only default base branch without losing sibling overrides", () => { + const withExisting = environment("Server", { + settings: { + projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false } }, + }, + }); + expect( + planProjectDefaultThreadBaseBranchPatch(checkout, [withExisting], "dev").serverWrites[0] + ?.patch, + ).toEqual({ + projectSettingsOverrides: { + [projectId]: { enableAgentBrowserAccess: false, defaultThreadBaseBranch: "dev" }, + }, + }); + + const configured = environment("Server", { + settings: { + projectSettingsOverrides: { + [projectId]: { enableAgentBrowserAccess: false, defaultThreadBaseBranch: "dev" }, + }, + }, + }); + expect( + planProjectDefaultThreadBaseBranchPatch(checkout, [configured], null).serverWrites[0]?.patch, + ).toEqual({ + projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false } }, + }); + }); + it("keeps each project's other cleanup rules when changing one rule across machines", () => { const machine = environment("Laptop", { settings: { diff --git a/apps/web/src/components/settings/scopedSettings.ts b/apps/web/src/components/settings/scopedSettings.ts index 0ef90dbacd4f..3df6a9228174 100644 --- a/apps/web/src/components/settings/scopedSettings.ts +++ b/apps/web/src/components/settings/scopedSettings.ts @@ -79,6 +79,7 @@ export interface ScopedSettingsTarget { readonly projectId: ProjectId | null; readonly settings: ServerSettings; readonly sources: Readonly>; + readonly overrides: ProjectSettingsOverrides; } /** Effective settings per connected target: members at project scope, environments otherwise. */ @@ -101,6 +102,7 @@ export function resolveScopedSettingsTargets( projectId: member.id, settings: resolved.settings, sources: resolved.sources, + overrides: resolved.overrides, }, ]; }); @@ -114,6 +116,7 @@ export function resolveScopedSettingsTargets( projectId: null, settings: environment.serverConfig.settings, sources: resolveProjectSettings(environment.serverConfig.settings, null).sources, + overrides: {}, }, ] : [], @@ -280,6 +283,32 @@ export function planScopedSettingsPatch( return { clientPatch, hasClientWrite, serverWrites, unavailableReason }; } +/** Write or clear the project-only default branch without treating it as an inheritable setting. */ +export function planProjectDefaultThreadBaseBranchPatch( + scope: ResolvedSettingsScope, + environments: readonly ScopedSettingsEnvironment[], + branch: string | null, +) { + const serverWrites = + scope.kind === "project" || scope.kind === "checkout" + ? projectOverrideWrites(scope, environments, (current) => { + const next = { ...current }; + if (branch === null) delete next.defaultThreadBaseBranch; + else next.defaultThreadBaseBranch = branch; + return Object.keys(next).length === 0 ? null : next; + }) + : []; + return { + clientPatch: {} as ClientSettingsPatch, + hasClientWrite: false, + serverWrites, + unavailableReason: + serverWrites.length > 0 + ? null + : "Connect the selected checkouts, or update their environments, to save this project setting.", + }; +} + /** Remove the keys' project overrides so each member inherits its environment value again. */ export function planScopedSettingsClear( scope: ResolvedSettingsScope, diff --git a/apps/web/src/components/settings/useScopedSettings.ts b/apps/web/src/components/settings/useScopedSettings.ts index ddf6b60b9aac..2e10758566b9 100644 --- a/apps/web/src/components/settings/useScopedSettings.ts +++ b/apps/web/src/components/settings/useScopedSettings.ts @@ -18,6 +18,7 @@ import { useOptionalSettingsScope, useSettingsScope } from "./SettingsScopeConte import { persistScopedSettingsPatch, planProjectOverridesClear, + planProjectDefaultThreadBaseBranchPatch, planScopedSettingsClear, planScopedSettingsPatch, scopedSettingsAreMixed, @@ -90,6 +91,17 @@ export function useUpdateScopedSettings() { ); } +/** Save the project-only worktree base branch, preserving every sibling override field. */ +export function useUpdateProjectDefaultThreadBaseBranch() { + const { scope, environments } = useSettingsScope(); + const run = useRunScopedPlan(); + return useCallback( + (branch: string | null) => + run(planProjectDefaultThreadBaseBranchPatch(scope, environments, branch)), + [environments, run, scope], + ); +} + /** * Drop the project overrides for `keys` so the selected checkouts inherit * again. Rows also render outside the settings layout (provider cards, diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 6cae988e87c6..c7065f158ad8 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -9,6 +9,7 @@ const testState = vi.hoisted(() => { newWorktreesStartFromOrigin: false, defaultModelSelection: null, defaultRuntimeMode: "full-access" as RuntimeMode, + projectSettingsOverrides: {} as Record, }; let storedDraft: { readonly draftId: string; @@ -57,6 +58,7 @@ const testState = vi.hoisted(() => { defaultThreadEnvMode: workspaceDefaults.envMode, newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin, defaultModelSelection: null, + projectSettingsOverrides: {}, defaultRuntimeMode: "full-access", }; router.state.location.href = "/"; @@ -98,12 +100,10 @@ vi.mock("@t3tools/contracts", () => ({ DEFAULT_SERVER_SETTINGS: {}, })); vi.mock("@t3tools/shared/projectSettings", () => ({ - // Environment settings pass through; the tests set project fields on the - // project record, which the hook still honors until the server folds them. - resolveProjectSettings: (settings: Record) => ({ + resolveProjectSettings: (settings: typeof testState.targetSettings) => ({ settings, sources: { defaultModelSelection: "environment", defaultThreadEnvMode: "environment" }, - overrides: {}, + overrides: settings.projectSettingsOverrides["project-remote"] ?? {}, }), })); vi.mock("@t3tools/shared/threadEnvMode", () => ({ @@ -259,6 +259,49 @@ describe.each([ }, ); + it("uses the project base branch for automatic worktree drafts", async () => { + testState.reset(draft, { envMode: "worktree", startFromOrigin: false }); + testState.targetSettings.projectSettingsOverrides = { + "project-remote": { defaultThreadBaseBranch: "dev" }, + }; + const projectRef = { + environmentId: "environment-ssh", + projectId: "project-remote", + } as never; + const pendingOpen = useNewThreadHandler()(projectRef); + testState.completeProjectFileRead(null); + const opened = await pendingOpen; + + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith( + "remote-project", + projectRef, + opened!.draftId, + expect.objectContaining({ envMode: "worktree", branch: "dev" }), + ); + }); + + it("keeps an explicit branch ahead of the project base branch", async () => { + testState.reset(draft, { envMode: "worktree", startFromOrigin: false }); + testState.targetSettings.projectSettingsOverrides = { + "project-remote": { defaultThreadBaseBranch: "dev" }, + }; + const projectRef = { + environmentId: "environment-ssh", + projectId: "project-remote", + } as never; + const opened = await useNewThreadHandler()(projectRef, { + envMode: "worktree", + branch: "release", + }); + + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith( + "remote-project", + projectRef, + opened!.draftId, + expect.objectContaining({ envMode: "worktree", branch: "release" }), + ); + }); + it.each([true, false])( "preserves an explicit start-from-origin choice of %s", async (startFromOrigin) => { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 65ac2307ce89..ffe194fc1ab3 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -142,6 +142,7 @@ export function useNewThreadHandler() { projectSettings.sources.defaultThreadEnvMode === "project" ? projectSettings.settings.defaultThreadEnvMode : undefined; + const projectDefaultThreadBaseBranch = projectSettings.overrides.defaultThreadBaseBranch; const resolveModelSelectionOverride = (destinationDraftId: DraftId) => resolveNewThreadModelSelectionOverride({ projectDefaultSelection: projectDefaultModelSelection ?? null, @@ -255,7 +256,8 @@ export function useNewThreadHandler() { return null; } workspaceContext = { - branch: null, + branch: + defaultEnvMode === "worktree" ? (projectDefaultThreadBaseBranch ?? null) : null, worktreePath: null, envMode: defaultEnvMode, startFromOrigin: resolveNewDraftStartFromOrigin({ @@ -408,7 +410,11 @@ export function useNewThreadHandler() { setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { threadId, createdAt, - branch: options?.branch ?? null, + branch: hasBranchOption + ? (options?.branch ?? null) + : initialEnvMode === "worktree" + ? (projectDefaultThreadBaseBranch ?? null) + : null, worktreePath: options?.worktreePath ?? null, envMode: initialEnvMode, startFromOrigin: diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 0d5d7e66b1fb..ddbd81316994 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -52,6 +52,12 @@ on each selected environment, and reset returns to the environment's shared list For workspace mode, a project's `t3.json` preference applies when the project has no override. Browser access changes apply when an agent session next starts. +To start new worktree threads from a branch such as `dev` instead of the repository's default +branch, select the project and set **Source Control → Default base branch**. This setting is +available only for a specific project, not **All projects**. An explicit branch choice takes +precedence; clearing the setting restores the repository default. It does not change Git's default +branch or switch the current checkout for local-mode threads. + ## Storage cleanup Open **Settings → Storage** to enable automatic cleanup on one machine or all connected diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 3a9fc35c4c82..2caea01c31d9 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; +import { ProjectId } from "./baseSchemas.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ClientSettingsSchema, @@ -106,6 +107,44 @@ describe("ServerSettings default permissions", () => { }); }); +describe("project default thread base branch", () => { + const projectId = ProjectId.make("project"); + it("round-trips only inside a project override and trims the branch name", () => { + const input = { + projectSettingsOverrides: { + project: { defaultThreadBaseBranch: " dev ", defaultAutoPull: true }, + }, + }; + const settings = decodeServerSettings(input); + expect(settings.projectSettingsOverrides[projectId]).toEqual({ + defaultThreadBaseBranch: "dev", + defaultAutoPull: true, + }); + expect(encodeServerSettings(settings).projectSettingsOverrides?.[projectId]).toEqual({ + defaultThreadBaseBranch: "dev", + defaultAutoPull: true, + }); + expect(decodeServerSettingsPatch(input).projectSettingsOverrides?.[projectId]).toEqual({ + defaultThreadBaseBranch: "dev", + defaultAutoPull: true, + }); + }); + + it("rejects an empty project branch and does not expose a global setting", () => { + expect(() => + decodeServerSettingsPatch({ + projectSettingsOverrides: { project: { defaultThreadBaseBranch: " " } }, + }), + ).toThrow(); + expect(decodeServerSettings({ defaultThreadBaseBranch: "dev" })).not.toHaveProperty( + "defaultThreadBaseBranch", + ); + expect(decodeServerSettingsPatch({ defaultThreadBaseBranch: "dev" })).not.toHaveProperty( + "defaultThreadBaseBranch", + ); + }); +}); + describe("ServerSettings usage price overrides", () => { const prices = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 885e214d4382..a7ba0a66c8ed 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1034,15 +1034,17 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number]; /** - * One project's overrides. An absent key inherits the environment value; - * `null` is a real value where the environment type is nullable (no default - * model, no dedicated writer model, never auto-settle). + * One project's overrides. An absent inheritable key uses the environment + * value; `null` is a real value where the environment type is nullable (no + * default model, no dedicated writer model, never auto-settle). + * `defaultThreadBaseBranch` is project-only and has no environment value. */ export const ProjectSettingsOverrides = Schema.Struct({ worktreeCleanup: Schema.optionalKey(WorktreeCleanup), defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), defaultRuntimeMode: Schema.optionalKey(RuntimeMode), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), + defaultThreadBaseBranch: Schema.optionalKey(TrimmedNonEmptyString), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), defaultAutoPull: Schema.optionalKey(Schema.Boolean), defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)), @@ -1056,7 +1058,9 @@ export const ProjectSettingsOverrides = Schema.Struct({ sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), -} satisfies Record); +} satisfies Record & { + readonly defaultThreadBaseBranch: unknown; +}); export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; export const StorageCleanupSettings = Schema.Struct({ @@ -1122,8 +1126,9 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)), ), /** - * Per-project overrides of the keys in `PROJECT_SCOPED_SERVER_SETTING_KEYS`. - * The source of truth for project settings; `projectAgentBrowserAccessOverrides`, + * Per-project overrides of the inheritable keys in + * `PROJECT_SCOPED_SERVER_SETTING_KEYS` plus project-only settings. The source + * of truth for project settings; `projectAgentBrowserAccessOverrides`, * `projectAutoPullOverrides` and `projectScriptOverrides` are derived views * kept for one release so older clients keep reading them. */ diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts index e08be45807cb..1ce97084884d 100644 --- a/packages/shared/src/projectSettings.test.ts +++ b/packages/shared/src/projectSettings.test.ts @@ -147,6 +147,28 @@ describe("projectSettingsOverrides patches", () => { expect(hasProjectSettingsOverrides(DEFAULT_SERVER_SETTINGS)).toBe(false); }); + it("persists and clears the project-only default base branch by entry replacement", () => { + const written = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + projectSettingsOverrides: { + [projectId]: { defaultThreadBaseBranch: "dev", defaultAutoPull: true }, + }, + }); + expect(written.projectSettingsOverrides[projectId]).toEqual({ + defaultThreadBaseBranch: "dev", + defaultAutoPull: true, + }); + expect(resolveProjectSettings(written, projectId).overrides.defaultThreadBaseBranch).toBe( + "dev", + ); + + const cleared = applyServerSettingsPatch(written, { + projectSettingsOverrides: { + [projectId]: clearProjectSettingsOverrides(written, projectId, ["defaultThreadBaseBranch"]), + }, + }); + expect(cleared.projectSettingsOverrides[projectId]).toEqual({ defaultAutoPull: true }); + }); + it("derives the legacy per-key maps from the generic record", () => { const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { projectSettingsOverrides: { diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index fd137545666f..11294123101b 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -117,7 +117,7 @@ export function withProjectSettingsOverrides( export function clearProjectSettingsOverrides( settings: Pick, projectId: ProjectId, - keys: readonly ProjectScopedServerSettingKey[], + keys: readonly (keyof ProjectSettingsOverrides)[], ): ProjectSettingsOverrides | null { const current = settings.projectSettingsOverrides[projectId]; if (current === undefined) return null;