From bed31ca0d0f5648f61a13761b7802f6a0141bfc0 Mon Sep 17 00:00:00 2001 From: Ayush Kaithwas Date: Tue, 6 Oct 2026 16:54:23 +0530 Subject: [PATCH 1/3] feat(settings): choose a default worktree base ref --- .../SettingsServerControlsRouteScreen.tsx | 85 +++++++++++- .../settings/settings-scoped-server.test.ts | 45 ++++++ .../settings/settings-scoped-server.ts | 13 ++ .../threads/NewTaskContextPickerScreens.tsx | 13 +- .../new-task-context-presentation.test.ts | 33 +++++ .../threads/new-task-context-presentation.ts | 18 +++ .../threads/new-task-flow-provider.tsx | 129 +++++++++++++++-- .../persistence/mobile-preferences.test.ts | 52 +++++++ .../src/persistence/mobile-preferences.ts | 14 ++ apps/mobile/src/state/queries.ts | 6 +- apps/server/src/serverSettings.test.ts | 32 +++++ apps/web/src/components/BranchPicker.tsx | 3 + .../BranchToolbarBranchSelector.tsx | 72 ++++++++-- apps/web/src/components/ChatView.tsx | 12 +- .../settings/ProjectDefaultsSettings.tsx | 33 +++++ .../settings/SettingInheritance.test.ts | 19 +++ .../settings/SettingInheritance.tsx | 10 ++ .../settings/WorktreeBaseRefPicker.tsx | 131 ++++++++++++++++++ .../settings/scopedSettings.test.ts | 11 ++ .../src/components/settings/settingsSearch.ts | 7 + apps/web/src/lib/chatThreadActions.test.ts | 25 ++++ apps/web/src/lib/chatThreadActions.ts | 16 ++- apps/web/src/state/queries.ts | 5 +- apps/web/src/worktreePreferences.test.tsx | 45 ++++++ apps/web/src/worktreePreferences.ts | 16 +++ docs/user/project-settings.md | 14 ++ packages/client-runtime/src/state/vcsRef.ts | 1 + packages/contracts/src/settings.test.ts | 43 ++++++ packages/contracts/src/settings.ts | 12 ++ packages/shared/src/git.test.ts | 79 +++++++++++ packages/shared/src/git.ts | 23 +++ packages/shared/src/projectSettings.test.ts | 34 +++++ 32 files changed, 1019 insertions(+), 32 deletions(-) create mode 100644 apps/mobile/src/persistence/mobile-preferences.test.ts create mode 100644 apps/web/src/components/settings/WorktreeBaseRefPicker.tsx create mode 100644 apps/web/src/worktreePreferences.test.tsx create mode 100644 apps/web/src/worktreePreferences.ts diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 885f269251c8f..43a43b99ae118 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -1,13 +1,14 @@ import { useNavigation } from "@react-navigation/native"; import { SettingsRow } from "./components/SettingsRow"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; -import { AppText as Text } from "../../components/AppText"; +import { AppText as Text, AppTextInput } from "../../components/AppText"; import { type ResponseStreamingMode, type ServerSettings, type ServerSettingsPatch, type ThreadEnvMode, type WorktreeSubmodules, + type WorktreeBaseRef, PROJECT_SCOPED_SERVER_SETTING_KEYS, type ProjectScopedServerSettingKey, } from "@t3tools/contracts"; @@ -30,6 +31,7 @@ import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; import { + mobileSettingsAreMixed, planMobileScopedSettingsClear, planMobileScopedSettingsPatch, resolveMobileSettingsTargets, @@ -47,7 +49,12 @@ const PAGE_TITLES: Record = { }; const PAGE_PROJECT_KEYS: Record = { - "new-threads": ["defaultThreadEnvMode", "worktreeSubmodules", "defaultRuntimeMode"], + "new-threads": [ + "defaultThreadEnvMode", + "defaultWorktreeBaseRef", + "worktreeSubmodules", + "defaultRuntimeMode", + ], "source-control": [ "defaultAutoPull", "removeAgentCreditsOnMerge", @@ -157,9 +164,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { const uniform = (key: K) => uniformMobileSetting(displayTargets, key); // `uniform` folds a real null into "mixed"; nullable keys need the distinction. - const isMixed = (key: keyof ServerSettings) => - reference === null || - displayTargets.some((entry) => entry.settings[key] !== reference.settings[key]); + const isMixed = (key: keyof ServerSettings) => mobileSettingsAreMixed(displayTargets, key); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { label: "environment settings update", reportFailure: true, @@ -275,6 +280,48 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { /> ))} + + write({ defaultWorktreeBaseRef: null })} + /> + write({ defaultWorktreeBaseRef: { mode: "last-used" } })} + /> + + + Or enter a specific branch, tag, or commit. + + [ + target.environment.environmentId, + target.projectId, + ]), + )} + value={uniform("defaultWorktreeBaseRef")} + mixed={isMixed("defaultWorktreeBaseRef")} + disabled={disabledFor("defaultWorktreeBaseRef")} + onCommit={(value) => write({ defaultWorktreeBaseRef: value })} + /> + + ); } + +function WorktreeBaseRefField(props: { + readonly value: WorktreeBaseRef; + readonly mixed: boolean; + readonly disabled: boolean; + readonly onCommit: (value: string | null) => void; +}) { + const [draft, setDraft] = useState(null); + return ( + { + const value = event.nativeEvent.text.trim() || null; + setDraft(null); + if (draft !== null && !props.disabled && (props.mixed || value !== props.value)) + props.onCommit(value); + }} + /> + ); +} 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 1c2de9b662f10..083036e4c9522 100644 --- a/apps/mobile/src/features/settings/settings-scoped-server.test.ts +++ b/apps/mobile/src/features/settings/settings-scoped-server.test.ts @@ -3,11 +3,13 @@ import { type EnvironmentId, type ProjectId, type ServerSettings, + type WorktreeBaseRef, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import type { SettingsTarget } from "./settings-environment-filter"; import { + mobileSettingsAreMixed, planMobileScopedSettingsClear, planMobileScopedSettingsPatch, resolveMobileSettingsTargets, @@ -66,6 +68,28 @@ function environment(environmentId: EnvironmentId, settings: ServerSettings): Se } describe("mobile project settings scope", () => { + it.each<[WorktreeBaseRef, WorktreeBaseRef, boolean]>([ + [{ mode: "last-used" }, { mode: "last-used" }, false], + [{ mode: "last-used" }, "last-used", true], + [{ mode: "last-used" }, null, true], + [null, null, false], + ["dev", "dev", false], + ["dev", "main", true], + ])("compares base refs by value: %j and %j", (first, second, mixed) => { + const targets = resolveMobileSettingsTargets( + [ + environment(firstId, { ...DEFAULT_SERVER_SETTINGS, defaultWorktreeBaseRef: first }), + environment(secondId, { ...DEFAULT_SERVER_SETTINGS, defaultWorktreeBaseRef: second }), + ], + null, + ); + expect(mobileSettingsAreMixed(targets, "defaultWorktreeBaseRef")).toBe(mixed); + }); + + it("has no uniform value when no environments are selected", () => { + expect(mobileSettingsAreMixed([], "defaultWorktreeBaseRef")).toBe(true); + }); + it("edits each checkout's own override without changing either environment default", () => { const firstSettings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, @@ -139,6 +163,27 @@ describe("mobile project settings scope", () => { ]); }); + it("keeps an explicit repository default separate from inheriting the environment base", () => { + const settings = { + ...DEFAULT_SERVER_SETTINGS, + defaultWorktreeBaseRef: "origin/dev", + projectSettingsOverrides: { [firstProject]: { defaultWorktreeBaseRef: "release" } }, + }; + const targets = resolveMobileSettingsTargets( + [environment(firstId, settings)], + [{ environmentId: firstId, id: firstProject }], + ); + expect(planMobileScopedSettingsPatch(targets, true, { defaultWorktreeBaseRef: null })).toEqual([ + { + environmentId: firstId, + patch: { projectSettingsOverrides: { [firstProject]: { defaultWorktreeBaseRef: null } } }, + }, + ]); + expect(planMobileScopedSettingsClear(targets, ["defaultWorktreeBaseRef"])).toEqual([ + { environmentId: firstId, patch: { projectSettingsOverrides: { [firstProject]: null } } }, + ]); + }); + it("resets only the selected page's override and rejects environment-wide writes", () => { const settings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, diff --git a/apps/mobile/src/features/settings/settings-scoped-server.ts b/apps/mobile/src/features/settings/settings-scoped-server.ts index 81dd995fa02dc..976167cf9befc 100644 --- a/apps/mobile/src/features/settings/settings-scoped-server.ts +++ b/apps/mobile/src/features/settings/settings-scoped-server.ts @@ -11,6 +11,7 @@ import { clearProjectSettingsOverrides, resolveProjectSettings, } from "@t3tools/shared/projectSettings"; +import * as Equal from "effect/Equal"; import type { SettingsTarget } from "./settings-environment-filter"; @@ -21,6 +22,18 @@ export interface ScopedMobileSettingsTarget { readonly sources: ReturnType["sources"]; } +/** Compare decoded setting values across the selected environments. */ +export function mobileSettingsAreMixed( + targets: readonly Pick[], + key: keyof ServerSettings, +): boolean { + const reference = targets[0]; + return ( + reference === undefined || + targets.some((entry) => !Equal.equals(entry.settings[key], reference.settings[key])) + ); +} + export function resolveMobileSettingsTargets( environments: readonly SettingsTarget[], members: readonly { readonly environmentId: EnvironmentId; readonly id: ProjectId }[] | null, diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index f9ba9cf82c2f1..1945a824dc242 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -366,6 +366,7 @@ export function NewTaskBranchPickerRouteScreen() { onLoadMore={flow.loadMoreBranches} selectionDisabled={switchingBranchName !== null} onSelect={selectBranch} + lastWorktreeBaseBranch={flow.lastWorktreeBaseBranch} worktree={ flow.workspaceMode === "worktree" ? { @@ -394,6 +395,7 @@ export function BranchPickerScreen(props: { readonly onLoadMore: () => void; readonly selectionDisabled?: boolean; readonly onSelect: (branch: VcsRef) => void; + readonly lastWorktreeBaseBranch?: string | null; readonly worktree?: { readonly startFromOrigin: boolean; readonly onChangeStartFromOrigin: (value: boolean) => void; @@ -423,7 +425,14 @@ export function BranchPickerScreen(props: { const renderBranch = useCallback( ({ item, index }: { readonly item: VcsRef; readonly index: number }) => ( { @@ -54,6 +55,38 @@ describe("resolveNewTaskLocalWorkspaceSelection", () => { }); }); +describe("resolveNewTaskBranchAfterWorkspaceModeChange", () => { + const localSelection = { + branch: "feature/current", + worktreePath: null, + awaitsCurrentBranch: false, + }; + + it("applies the configured base after leaving the current checkout", () => { + expect( + resolveNewTaskBranchAfterWorkspaceModeChange({ + mode: "worktree", + previousMode: "local", + branch: "feature/current", + worktreePath: null, + localSelection, + }), + ).toBeNull(); + }); + + it("keeps a distinct branch chosen for the draft", () => { + expect( + resolveNewTaskBranchAfterWorkspaceModeChange({ + mode: "worktree", + previousMode: "local", + branch: "feature/chosen", + worktreePath: null, + localSelection, + }), + ).toBe("feature/chosen"); + }); +}); + describe("resolveNewTaskBranchWorktreePath", () => { it("moves Current checkout to the selected existing worktree", () => { expect( 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 87c9b2595f4f2..adb13e369b031 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.ts @@ -59,6 +59,24 @@ export function resolveNewTaskLocalWorkspaceSelection(input: { }; } +export function resolveNewTaskBranchAfterWorkspaceModeChange(input: { + readonly mode: WorkspaceMode; + readonly previousMode: WorkspaceMode; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly localSelection: ReturnType; +}): string | null { + if ( + input.previousMode === "local" && + input.mode === "worktree" && + input.branch === input.localSelection.branch && + input.worktreePath === input.localSelection.worktreePath + ) { + return null; + } + return input.branch; +} + export function resolveNewTaskBranchLabel(input: { readonly branchName: string | null; readonly startFromOrigin: boolean; 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 899015c2aae78..1933cd007f6b4 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1,3 +1,6 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/reactivity"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert } from "react-native"; @@ -20,7 +23,7 @@ import { T3_PROJECT_FILE_NAME, ThreadId, } from "@t3tools/contracts"; -import { sanitizeNewRefName } from "@t3tools/shared/git"; +import { resolveDefaultWorktreeBaseRef, sanitizeNewRefName } from "@t3tools/shared/git"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import * as Arr from "effect/Array"; @@ -115,6 +118,7 @@ import { filterNewTaskBranches, resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection, + resolveNewTaskBranchAfterWorkspaceModeChange, } from "./new-task-context-presentation"; import { resolveEnvironmentProjectMatch } from "./new-task-project-selection"; import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; @@ -168,6 +172,7 @@ type NewTaskFlowContextValue = { /** False for threads without a project: their folder has no branch or worktree. */ readonly canChooseWorkspace: boolean; readonly selectedBranchName: string | null; + readonly lastWorktreeBaseBranch: string | null; readonly selectedWorktreePath: string | null; readonly startFromOrigin: boolean; readonly draftKey: string | null; @@ -533,6 +538,21 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { : "local"; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const preferencesLoaded = AsyncResult.isSuccess(preferencesResult); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const lastWorktreeBaseBranch = + preferencesLoaded && selectedProject + ? (preferencesResult.value.lastWorktreeBaseBranchByProject?.[ + scopedProjectKey(selectedProject.environmentId, selectedProject.id) + ] ?? null) + : null; + const configuredBaseRef = selectedEnvironmentServerConfig + ? projectSettings.settings.defaultWorktreeBaseRef + : undefined; + const rememberedBranch = + configuredBaseRef && typeof configuredBaseRef === "object" ? lastWorktreeBaseBranch : null; + // Keep the user's explicit choice separate from the resolved display value: // only the explicit flag is ever written back to the draft, so the resolved // value keeps tracking the server setting when the config loads late. @@ -722,6 +742,50 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const hasMoreBranches = branchState.data?.nextCursor !== null && branchState.data?.nextCursor !== undefined; const allBranchRefs = branchState.refs; + const needsWorktreeBase = + workspaceMode === "worktree" && selectedBranchName === null && selectedWorktreePath === null; + const baseBranchesQuery = useEnvironmentQuery( + needsWorktreeBase && selectedProject?.workspaceRoot + ? vcsEnvironment.listRefs({ + environmentId: selectedProject.environmentId, + input: { cwd: selectedProject.workspaceRoot, limit: 100 }, + }) + : null, + ); + const rememberedBranchState = usePaginatedBranches({ + environmentId: selectedProject?.environmentId ?? null, + cwd: needsWorktreeBase && rememberedBranch ? selectedProject?.workspaceRoot || null : null, + query: rememberedBranch?.slice(0, 256) ?? null, + includeMatchingRemoteRefs: true, + }); + const rememberedRef = rememberedBranchState.refs.find((ref) => ref.name === rememberedBranch); + const hasMoreRememberedRefs = rememberedBranchState.data?.nextCursor != null; + const loadMoreRememberedRefs = rememberedBranchState.loadNext; + useEffect(() => { + if ( + needsWorktreeBase && + rememberedBranch && + !rememberedRef && + hasMoreRememberedRefs && + !rememberedBranchState.isPending && + !rememberedBranchState.error + ) + loadMoreRememberedRefs(); + }, [ + needsWorktreeBase, + rememberedBranch, + rememberedRef, + hasMoreRememberedRefs, + rememberedBranchState.isPending, + rememberedBranchState.error, + loadMoreRememberedRefs, + ]); + const rememberedBranchPending = + rememberedBranch !== null && + !rememberedRef && + !rememberedBranchState.error && + (rememberedBranchState.data === null || hasMoreRememberedRefs); + const availableBranches = useMemo( () => pipe( @@ -883,7 +947,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { updateComposerDraftSettings(selectedProjectDraftKey, { workspaceSelection: { mode, - branch: mode === "local" ? localSelection.branch : selectedBranchName, + branch: + mode === "local" + ? localSelection.branch + : resolveNewTaskBranchAfterWorkspaceModeChange({ + mode, + previousMode: workspaceMode, + branch: selectedBranchName, + worktreePath: selectedWorktreePath, + localSelection, + }), worktreePath: mode === "local" ? localSelection.worktreePath : selectedWorktreePath, ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), }, @@ -896,6 +969,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedProject, selectedProjectDraftKey, selectedWorktreePath, + workspaceMode, ], ); @@ -938,6 +1012,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (!selectedProject || !selectedProjectDraftKey) { return; } + if (workspaceMode === "worktree") { + savePreferences({ + transform: (current) => ({ + lastWorktreeBaseBranchByProject: { + ...current.lastWorktreeBaseBranchByProject, + [scopedProjectKey(selectedProject.environmentId, selectedProject.id)]: branch.name, + }, + }), + }); + } pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey); updateComposerDraftSettings(selectedProjectDraftKey, { workspaceSelection: { @@ -952,7 +1036,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { }, }); }, - [draftStartFromOrigin, selectedProject, selectedProjectDraftKey, workspaceMode], + [ + draftStartFromOrigin, + selectedProject, + selectedProjectDraftKey, + workspaceMode, + savePreferences, + ], ); const setStartFromOrigin = useCallback( @@ -986,6 +1076,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if ( !selectedProjectDraftKey || !defaultWorkspaceModeSettled || + (configuredBaseRef !== null && typeof configuredBaseRef === "object" && !preferencesLoaded) || + rememberedBranchPending || workspaceMode !== "worktree" || selectedBranchName !== null ) { @@ -1000,18 +1092,31 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { } // 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; + const preferredBranch = resolveDefaultWorktreeBaseRef({ + configuredRef: configuredBaseRef, + rememberedRef: rememberedRef?.name ?? null, + refs: baseBranchesQuery.data?.refs ?? null, + currentBranch: currentCheckoutBranchName, + }); if (preferredBranch) { - selectBranch(preferredBranch); + updateComposerDraftSettings(selectedProjectDraftKey, { + workspaceSelection: { + mode: "worktree", + branch: preferredBranch, + worktreePath: null, + ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), + }, + }); } }, [ - allBranchRefs, - availableBranches, + baseBranchesQuery.data, + preferencesLoaded, + rememberedBranchPending, + rememberedRef, + configuredBaseRef, + currentCheckoutBranchName, defaultWorkspaceModeSettled, - selectBranch, + draftStartFromOrigin, selectedBranchName, selectedProjectDraftKey, workspaceMode, @@ -1278,6 +1383,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { workspaceMode, canChooseWorkspace, selectedBranchName, + lastWorktreeBaseBranch, selectedWorktreePath, startFromOrigin, draftKey: selectedProjectDraftKey, @@ -1362,6 +1468,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { reset, runtimeMode, selectedBranchName, + lastWorktreeBaseBranch, hasMoreBranches, selectedEnvironmentId, selectedModel, diff --git a/apps/mobile/src/persistence/mobile-preferences.test.ts b/apps/mobile/src/persistence/mobile-preferences.test.ts new file mode 100644 index 0000000000000..67eb53c972031 --- /dev/null +++ b/apps/mobile/src/persistence/mobile-preferences.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { vi } from "vite-plus/test"; + +vi.mock("expo-secure-store", () => ({})); +vi.mock("react-native", () => ({ Platform: { OS: "ios" } })); + +import { MobileDatabase, type StoredPreferencesJson } from "./mobile-database"; +import { make } from "./mobile-preferences"; +import { MobileSecureStorage } from "./mobile-secure-storage"; + +describe("remembered worktree bases", () => { + it.effect("preserves project selections after reloading preferences and unrelated updates", () => + Effect.gen(function* () { + let stored: StoredPreferencesJson | null = null; + const database = MobileDatabase.of({ + listCache: () => Effect.succeed([]), + loadCache: () => Effect.succeed(Option.none()), + saveCache: () => Effect.void, + removeCache: () => Effect.void, + clearCacheKind: () => Effect.void, + clearEnvironmentCache: () => Effect.void, + clearAllCaches: Effect.void, + inspectCaches: Effect.succeed([]), + loadPreferencesJson: Effect.sync(() => Option.fromNullishOr(stored)), + savePreferencesJson: (payload, updatedAt) => + Effect.sync(() => { + stored = { payload, updatedAt }; + }), + }); + const createStore = make().pipe( + Effect.provideService(MobileDatabase, database), + Effect.provideService(MobileSecureStorage, { + getItem: () => Effect.succeed(null), + setItem: () => Effect.void, + removeItem: () => Effect.void, + }), + ); + const first = yield* createStore; + yield* first.savePatch({ + lastWorktreeBaseBranchByProject: { "env-a:project-a": "dev", "env-b:project-a": "release" }, + }); + const restarted = yield* createStore; + yield* restarted.savePatch({ baseFontSize: 18 }); + expect((yield* restarted.load).lastWorktreeBaseBranchByProject).toEqual({ + "env-a:project-a": "dev", + "env-b:project-a": "release", + }); + }), + ); +}); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index ee96bce2d5cec..afed3d8c80e0d 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -17,6 +17,7 @@ const PREFERENCES_KEY = "t3code.preferences"; const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; export interface Preferences { + readonly lastWorktreeBaseBranchByProject?: Readonly>; readonly liveActivitiesEnabled?: boolean; readonly themeId?: MobileThemeId; readonly lightThemeId?: MobileThemeId; @@ -93,6 +94,7 @@ export class MobilePreferencesStore extends Context.Service< function sanitizePreferences(parsed: Preferences): Preferences { const preferences: { + lastWorktreeBaseBranchByProject?: Readonly>; liveActivitiesEnabled?: boolean; themeId?: MobileThemeId; lightThemeId?: MobileThemeId; @@ -117,6 +119,18 @@ function sanitizePreferences(parsed: Preferences): Preferences { threadListWorkingShelfExpanded?: boolean; } = {}; + if ( + parsed.lastWorktreeBaseBranchByProject && + typeof parsed.lastWorktreeBaseBranchByProject === "object" && + !Array.isArray(parsed.lastWorktreeBaseBranchByProject) + ) { + preferences.lastWorktreeBaseBranchByProject = Object.fromEntries( + Object.entries(parsed.lastWorktreeBaseBranchByProject).filter( + ([, branch]) => typeof branch === "string" && branch.trim().length > 0, + ), + ); + } + if (typeof parsed.liveActivitiesEnabled === "boolean") { preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; } diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index 0671c9e7e74d3..342bca4214fdc 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -4,7 +4,6 @@ import type { EnvironmentId, OrchestrationV2ProjectedTurnItem, ProjectId, - ThreadId, VcsListRefsResult, VcsRef, } from "@t3tools/contracts"; @@ -196,7 +195,7 @@ export function usePaginatedBranches(target: VcsRefTarget) { const query = target.query?.trim() ?? ""; const targetKey = target.environmentId !== null && target.cwd !== null - ? JSON.stringify([target.environmentId, target.cwd, query]) + ? JSON.stringify([target.environmentId, target.cwd, query, target.includeMatchingRemoteRefs]) : null; const [pagination, setPagination] = useState<{ readonly targetKey: string | null; @@ -215,13 +214,14 @@ export function usePaginatedBranches(target: VcsRefTarget) { input: { cwd: target.cwd!, ...(query.length > 0 ? { query } : {}), + ...(target.includeMatchingRemoteRefs ? { includeMatchingRemoteRefs: true } : {}), ...(cursor === undefined ? {} : { cursor }), limit: VCS_REF_LIST_LIMIT, }, }), ) : [], - [cursors, query, target.cwd, target.environmentId], + [cursors, query, target.cwd, target.environmentId, target.includeMatchingRemoteRefs], ); const pagesAtom = useMemo( () => diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 4376d5d7ea69b..6fdf8cc4b43a6 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -563,6 +563,38 @@ it.layer(NodeServices.layer)("server settings", (it) => { ).pipe(Effect.provide(layerServerSettings())), ); + it.effect("persists and broadcasts project worktree bases and resets them", () => + Effect.scoped( + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const changes = yield* serverSettings.subscribeChanges; + const projectId = ProjectId.make("base-ref-project"); + const overrides = { [projectId]: { defaultWorktreeBaseRef: "origin/dev" } }; + yield* serverSettings.updateSettings({ projectSettingsOverrides: overrides }); + const change = Option.getOrUndefined(yield* Stream.runHead(changes)); + assert.deepEqual(change?.projectSettingsOverrides, overrides); + const persisted = yield* decodeServerSettingsJson( + yield* fileSystem.readFileString(serverConfig.settingsPath), + ); + assert.deepEqual(persisted.projectSettingsOverrides, overrides); + const lastUsed = { + [projectId]: { defaultWorktreeBaseRef: { mode: "last-used" as const } }, + }; + yield* serverSettings.updateSettings({ projectSettingsOverrides: lastUsed }); + const remembered = yield* decodeServerSettingsJson( + yield* fileSystem.readFileString(serverConfig.settingsPath), + ); + assert.deepEqual(remembered.projectSettingsOverrides, lastUsed); + const reset = yield* serverSettings.updateSettings({ + projectSettingsOverrides: { [projectId]: null }, + }); + assert.isUndefined(reset.projectSettingsOverrides[projectId]); + }), + ).pipe(Effect.provide(layerServerSettings())), + ); + it.effect("persists and broadcasts thread settlement settings", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/web/src/components/BranchPicker.tsx b/apps/web/src/components/BranchPicker.tsx index c8c37315b2dc9..7b5b658b5e923 100644 --- a/apps/web/src/components/BranchPicker.tsx +++ b/apps/web/src/components/BranchPicker.tsx @@ -266,6 +266,7 @@ export function BranchPickerRefItem({ value, onClick, onContextMenu, + hint, }: { branch: VcsRef; projectCwd: string | null; @@ -273,6 +274,7 @@ export function BranchPickerRefItem({ value?: string; onClick: ComponentProps["onClick"]; onContextMenu?: ComponentProps["onContextMenu"]; + hint?: string | null; }) { const itemValue = refName.name; const hasSecondaryWorktree = @@ -297,6 +299,7 @@ export function BranchPickerRefItem({ >
+ {hint && {hint}} {badge && {badge}}
diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 434516808aa12..cea1c94213de7 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,5 +1,8 @@ import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; import { ComposerContextLabel } from "./ComposerContextLabel"; +import { resolveDefaultWorktreeBaseRef } from "@t3tools/shared/git"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { useAtomValue } from "@effect/atom-react"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; import { useRightPanelStore } from "../rightPanelStore"; @@ -30,6 +33,8 @@ import { useOpenPrLink } from "../lib/openPullRequestLink"; import { usePaginatedBranches } from "../state/queries"; import { useProject, useThreadShell } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; +import { serverEnvironment } from "../state/server"; +import { useLastWorktreeBaseBranch } from "../worktreePreferences"; import { threadEnvironment } from "../state/threads"; import { useAtomCommand } from "../state/use-atom-command"; import { vcsEnvironment } from "../state/vcs"; @@ -141,6 +146,15 @@ export function BranchToolbarBranchSelector({ ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; const activeProject = useProject(activeProjectRef); + const [lastWorktreeBaseBranch, rememberWorktreeBaseBranch] = + useLastWorktreeBaseBranch(activeProjectRef); + const serverSettings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId)); + const configuredBaseRef = serverSettings + ? resolveProjectSettings( + serverSettings, + serverThread?.projectId ?? draftThread?.projectId ?? null, + ).settings.defaultWorktreeBaseRef + : undefined; const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); const activeThreadBranch = @@ -404,6 +418,7 @@ export function BranchToolbarBranchSelector({ if (isSelectingWorktreeBase) { setThreadBranch(refName.name, null); + if (activeProjectRef) rememberWorktreeBaseBranch(refName.name); setIsBranchMenuOpen(false); onComposerFocusRequest?.(); return; @@ -496,15 +511,55 @@ 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. - const defaultBranchName = useMemo( - () => refs.find((refName) => refName.isDefault)?.name ?? null, - [refs], + const needsWorktreeBase = + effectiveEnvMode === "worktree" && !activeWorktreePath && !activeThreadBranch; + const rememberedBranch = + configuredBaseRef && typeof configuredBaseRef === "object" ? lastWorktreeBaseBranch : null; + // Base defaults must not depend on a search currently open in the branch picker. + const baseBranchesQuery = useEnvironmentQuery( + needsWorktreeBase && activeProjectCwd + ? vcsEnvironment.listRefs({ environmentId, input: { cwd: activeProjectCwd, limit: 100 } }) + : null, ); - const worktreeBaseBranchCandidate = isInitialBranchesLoadPending - ? null - : (defaultBranchName ?? currentGitBranch); + const rememberedBranchState = usePaginatedBranches({ + environmentId, + cwd: needsWorktreeBase && rememberedBranch ? activeProjectCwd : null, + query: rememberedBranch?.slice(0, 256) ?? null, + includeMatchingRemoteRefs: true, + }); + const rememberedRef = rememberedBranchState.refs.find((ref) => ref.name === rememberedBranch); + const hasMoreRememberedRefs = rememberedBranchState.data?.nextCursor != null; + const loadMoreRememberedRefs = rememberedBranchState.loadNext; + useEffect(() => { + if ( + needsWorktreeBase && + rememberedBranch && + !rememberedRef && + hasMoreRememberedRefs && + !rememberedBranchState.isPending && + !rememberedBranchState.error + ) + loadMoreRememberedRefs(); + }, [ + needsWorktreeBase, + rememberedBranch, + rememberedRef, + hasMoreRememberedRefs, + rememberedBranchState.isPending, + rememberedBranchState.error, + loadMoreRememberedRefs, + ]); + const rememberedBranchPending = + rememberedBranch !== null && + !rememberedRef && + !rememberedBranchState.error && + (rememberedBranchState.data === null || hasMoreRememberedRefs); + const worktreeBaseBranchCandidate = resolveDefaultWorktreeBaseRef({ + configuredRef: configuredBaseRef, + rememberedRef: rememberedBranchPending ? undefined : (rememberedRef?.name ?? null), + refs: baseBranchesQuery.data?.refs ?? null, + currentBranch: currentGitBranch, + }); useEffect(() => { if ( @@ -649,6 +704,7 @@ export function BranchToolbarBranchSelector({ value={itemValue} onClick={() => selectPickerItem(itemValue)} onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} + hint={isSelectingWorktreeBase && itemValue === lastWorktreeBaseBranch ? "Last used" : null} /> ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 656b989e85955..dc0490c283f61 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -343,7 +343,10 @@ import { preventRepeatedTerminalCloseShortcut, preventTerminalCloseShortcut, } from "../lib/terminalCloseShortcut"; -import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; +import { + resolveDraftBranchAfterEnvModeChange, + resolveNewDraftStartFromOrigin, +} from "../lib/chatThreadActions"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, @@ -10424,6 +10427,11 @@ export default function ChatView(props: ChatViewProps) { if (isLocalDraftThread) { setDraftThreadContext(composerDraftTarget, { envMode: mode, + branch: resolveDraftBranchAfterEnvModeChange({ + mode, + branch: draftThread?.branch ?? null, + environmentSelection: draftThread?.environmentSelection, + }), startFromOrigin: resolveNewDraftStartFromOrigin({ envMode: mode, newWorktreesStartFromOrigin: activeProjectSettings.settings.newWorktreesStartFromOrigin, @@ -10437,6 +10445,8 @@ export default function ChatView(props: ChatViewProps) { canOverrideServerThreadEnvMode, composerDraftTarget, draftThread?.worktreePath, + draftThread?.branch, + draftThread?.environmentSelection, isLocalDraftThread, multipleModelSelections, activeProjectSettings.settings.newWorktreesStartFromOrigin, diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index bfd776855307c..624d4f5119e47 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -28,6 +28,7 @@ import { Switch } from "../ui/switch"; import type { ProjectSettingsCategory } from "./ProjectSettingsPanel"; import { searchableSetting } from "./settingsSearch"; import { useSettingsScope } from "./SettingsScopeContext"; +import { WorktreeBaseRefPicker } from "./WorktreeBaseRefPicker"; import { SETTINGS_PICKER_TRIGGER_CLASSNAME, SettingResetButton, @@ -76,6 +77,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting const mixedPermissions = useScopedSettingsMixed(["defaultRuntimeMode"]); const PermissionIcon = runtimeModeConfig[settings.defaultRuntimeMode].icon; const mixedWorkspace = useScopedSettingsMixed(["defaultThreadEnvMode"]); + const mixedBaseRef = useScopedSettingsMixed(["defaultWorktreeBaseRef"]); const mixedSubmodules = useScopedSettingsMixed(["worktreeSubmodules"]); const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]); const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]); @@ -200,6 +202,35 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting } /> ); + const baseRefRow = ( + updateSettings({ defaultWorktreeBaseRef: null })} + /> + ) : null + } + control={ +
+ [environmentId, projectId]), + )} + value={settings.defaultWorktreeBaseRef} + mixed={mixedBaseRef} + onChange={(value) => updateSettings({ defaultWorktreeBaseRef: value })} + /> +
+ } + /> + ); const workspaceRow = ( {modelRow} {workspaceRow} + {baseRefRow} ) : category === "general" ? ( <> @@ -335,6 +367,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting } /> {workspaceRow} + {baseRefRow} { + it("labels remembered and repository base choices in the inheritance chain", () => { + const settings = { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [projectId]: { defaultWorktreeBaseRef: { mode: "last-used" as const } }, + }, + }; + const layers = settingInheritanceLayers( + { environmentId, label: "Laptop", projectId, ...resolveProjectSettings(settings, projectId) }, + settings, + "defaultWorktreeBaseRef", + ); + expect(layers.map((layer) => layer.value)).toEqual([ + "Last used", + "Inherits", + "Repository default", + ]); + }); + it("marks the built-in default effective when nothing is set", () => { const resolved = resolveProjectSettings(DEFAULT_SERVER_SETTINGS, null); const layers = settingInheritanceLayers( diff --git a/apps/web/src/components/settings/SettingInheritance.tsx b/apps/web/src/components/settings/SettingInheritance.tsx index e08ca680649fd..401865e549c02 100644 --- a/apps/web/src/components/settings/SettingInheritance.tsx +++ b/apps/web/src/components/settings/SettingInheritance.tsx @@ -36,6 +36,16 @@ const WRITING_STYLE_LABELS: Record = { /** Human labels for the values the chain can show; falls back to a type summary. */ function formatValue(key: keyof ServerSettings, value: unknown): string { + if (key === "defaultWorktreeBaseRef") { + if (value === null) return "Repository default"; + if ( + typeof value === "object" && + value !== null && + "mode" in value && + value.mode === "last-used" + ) + return "Last used"; + } if (value === null || value === undefined) { return key === "pullRequestMergeMethod" ? "Last selected" diff --git a/apps/web/src/components/settings/WorktreeBaseRefPicker.tsx b/apps/web/src/components/settings/WorktreeBaseRefPicker.tsx new file mode 100644 index 0000000000000..32549fbabbc91 --- /dev/null +++ b/apps/web/src/components/settings/WorktreeBaseRefPicker.tsx @@ -0,0 +1,131 @@ +import type { WorktreeBaseRef } from "@t3tools/contracts"; +import { useDeferredValue, useState } from "react"; + +import { usePaginatedBranches } from "../../state/queries"; +import { Button } from "../ui/button"; +import { + Combobox, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxSearchInput, + ComboboxStatus, + ComboboxTrigger, +} from "../ui/combobox"; +import { SelectButton } from "../ui/select"; +import { useSettingsScope } from "./SettingsScopeContext"; + +// Git refs cannot contain a colon, so this picker value cannot collide with a branch name. +const LAST_USED_OPTION = ":last-used"; + +export function WorktreeBaseRefPicker({ + value, + mixed, + onChange, +}: { + value: WorktreeBaseRef; + mixed: boolean; + onChange: (value: WorktreeBaseRef) => void; +}) { + const { scope, target } = useSettingsScope(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const trimmedQuery = query.trim(); + const deferredQuery = useDeferredValue(trimmedQuery); + const member = scope.members.find( + (candidate) => + candidate.environmentId === target?.environmentId && candidate.id === target?.projectId, + ); + const branches = usePaginatedBranches({ + environmentId: open && member ? member.environmentId : null, + cwd: open && member ? member.workspaceRoot : null, + query: deferredQuery, + }); + const refNames = branches.refs.map((ref) => ref.name); + const matchingRefs = [ + ...new Set([...(typeof value === "string" ? [value] : []), ...refNames]), + ].filter((ref) => ref.toLowerCase().includes(trimmedQuery.toLowerCase())); + const customRef = trimmedQuery !== "" && !matchingRefs.includes(trimmedQuery); + const items = [ + ...("repository default".includes(trimmedQuery.toLowerCase()) ? [""] : []), + ...("last used".includes(trimmedQuery.toLowerCase()) ? [LAST_USED_OPTION] : []), + ...(customRef ? [trimmedQuery] : []), + ...matchingRefs, + ]; + + return ( + { + setOpen(next); + setQuery(""); + }} + onValueChange={(next) => { + if (next !== null) + onChange(next === LAST_USED_OPTION ? { mode: "last-used" } : next || null); + }} + > + }> + {mixed + ? "Mixed" + : typeof value === "object" && value !== null + ? "Last used" + : (value ?? "Repository default")} + + + setQuery(event.target.value)} + /> + + {items.map((ref) => ( + + + {ref === "" + ? "Repository default" + : ref === LAST_USED_OPTION + ? "Last used" + : customRef && ref === trimmedQuery + ? `Use “${ref}”` + : ref} + + + ))} + + {branches.data?.nextCursor != null ? ( + + ) : null} + + {branches.error + ? "Could not load refs. You can still enter a branch, tag, or commit." + : branches.isPending && branches.data === null + ? "Loading refs…" + : !member + ? "Select a project for suggestions, or enter a branch, tag, or commit." + : value !== null && typeof value === "object" + ? "Reuses your last base in this project on this device." + : "Choose a ref, or enter a branch, tag, or commit."} + + + + ); +} diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index 7e15b4e5a26f0..0205b5eb63f5d 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -242,6 +242,17 @@ describe("scoped settings writes", () => { }); }); + it("stores a worktree base or explicit repository default on every selected project checkout", () => { + for (const defaultWorktreeBaseRef of ["origin/dev", null]) { + const plan = planScopedSettingsPatch(project, environments, { defaultWorktreeBaseRef }); + expect(plan.unavailableReason).toBeNull(); + expect(plan.serverWrites.map((write) => write.patch)).toEqual([ + { projectSettingsOverrides: { [projectId]: { defaultWorktreeBaseRef } } }, + { projectSettingsOverrides: { [laptopProjectId]: { defaultWorktreeBaseRef } } }, + ]); + } + }); + it("writes project overrides into each member's entry on its environment", () => { const withExisting = environment("Server", { settings: { diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 4797c9b64ddee..a6eede8329f0c 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -450,6 +450,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "project-defaults", searchTerms: ["default workspace mode draft local worktree"], }, + { + id: "worktree-base-ref", + title: "Worktree base ref", + to: "/settings/general", + scope: "project-defaults", + searchTerms: ["default branch base ref git tag commit new worktree"], + }, { id: "worktree-submodules", title: "Submodules", diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index c145404a74e2d..738e2299d2bc4 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -10,6 +10,7 @@ import { resolveThreadActionProjectRef, hasExplicitComposerModelSelection, resolveNewDraftStartFromOrigin, + resolveDraftBranchAfterEnvModeChange, resolveNewThreadModelSelectionOverride, startNewThreadFromContext, type ChatThreadActionContext, @@ -102,6 +103,30 @@ describe("chatThreadActions", () => { ).toBe(false); }); + it("clears an automatically synced checkout branch when switching to a new worktree", () => { + expect( + resolveDraftBranchAfterEnvModeChange({ + mode: "worktree", + branch: "feature/current-checkout", + environmentSelection: "auto", + }), + ).toBeNull(); + expect( + resolveDraftBranchAfterEnvModeChange({ + mode: "worktree", + branch: "feature/chosen-base", + environmentSelection: "manual", + }), + ).toBe("feature/chosen-base"); + expect( + resolveDraftBranchAfterEnvModeChange({ + mode: "local", + branch: "feature/current-checkout", + environmentSelection: "auto", + }), + ).toBe("feature/current-checkout"); + }); + it("prefers the active thread project when resolving thread actions", () => { const projectRef = resolveThreadActionProjectRef( createContext({ diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index c14a26d03d1c0..55f3b292b6df8 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -5,7 +5,11 @@ import type { ProjectId, ScopedProjectRef, } from "@t3tools/contracts"; -import type { ComposerThreadDraftState, DraftThreadEnvMode } from "../composerDraftStore"; +import type { + ComposerThreadDraftState, + DraftThreadEnvMode, + DraftThreadState, +} from "../composerDraftStore"; type ComposerModelSelectionState = Pick< ComposerThreadDraftState, @@ -44,6 +48,16 @@ export function resolveNewDraftStartFromOrigin(input: { return input.envMode === "worktree" && input.newWorktreesStartFromOrigin; } +export function resolveDraftBranchAfterEnvModeChange(input: { + mode: DraftThreadEnvMode; + branch: string | null; + environmentSelection: DraftThreadState["environmentSelection"]; +}): string | null { + // The local checkout branch is synced into a fresh draft automatically. + // Let the configured worktree base win when that draft switches modes. + return input.mode === "worktree" && input.environmentSelection === "auto" ? null : input.branch; +} + export function resolveNewThreadModelSelectionOverride(input: { readonly projectDefaultSelection: ModelSelection | null; readonly carrySelection: ModelSelection | null; diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 2b5a96ab4cc9e..d09bb0b0bc52d 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -103,7 +103,7 @@ export function usePaginatedBranches(target: VcsRefTarget) { const query = target.query?.trim() ?? ""; const targetKey = target.environmentId !== null && target.cwd !== null - ? JSON.stringify([target.environmentId, target.cwd, query]) + ? JSON.stringify([target.environmentId, target.cwd, query, target.includeMatchingRemoteRefs]) : null; const [pagination, setPagination] = useState<{ readonly targetKey: string | null; @@ -122,13 +122,14 @@ export function usePaginatedBranches(target: VcsRefTarget) { input: { cwd: target.cwd!, ...(query.length > 0 ? { query } : {}), + ...(target.includeMatchingRemoteRefs ? { includeMatchingRemoteRefs: true } : {}), ...(cursor === undefined ? {} : { cursor }), limit: VCS_REF_LIST_LIMIT, }, }), ) : [], - [cursors, query, target.cwd, target.environmentId], + [cursors, query, target.cwd, target.environmentId, target.includeMatchingRemoteRefs], ); const pagesAtom = useMemo( () => diff --git a/apps/web/src/worktreePreferences.test.tsx b/apps/web/src/worktreePreferences.test.tsx new file mode 100644 index 0000000000000..ee050634be191 --- /dev/null +++ b/apps/web/src/worktreePreferences.test.tsx @@ -0,0 +1,45 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ProjectId, type ScopedProjectRef } from "@t3tools/contracts"; +import { renderToString } from "react-dom/server"; +import { afterEach, expect, it, vi } from "vite-plus/test"; + +const project = scopeProjectRef(EnvironmentId.make("env-a"), ProjectId.make("project-a")); + +afterEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); +}); + +it("restores explicit selections after remounting and isolates projects and environments", async () => { + const entries = new Map(); + vi.stubGlobal("window", { + localStorage: { + getItem: (key: string) => entries.get(key) ?? null, + setItem: (key: string, value: string) => entries.set(key, value), + removeItem: (key: string) => entries.delete(key), + }, + dispatchEvent: () => true, + }); + let usePreference = (await import("./worktreePreferences")).useLastWorktreeBaseBranch; + const observe = vi.fn<(branch: string | null, select: (branch: string) => void) => void>(); + function Composer({ projectRef }: { projectRef: ScopedProjectRef }) { + const [selected, selectBranch] = usePreference(projectRef); + observe(selected, selectBranch); + return null; + } + renderToString(); + expect(observe.mock.lastCall?.[0]).toBeNull(); + observe.mock.lastCall?.[1]("dev"); + vi.resetModules(); + usePreference = (await import("./worktreePreferences")).useLastWorktreeBaseBranch; + renderToString(); + expect(observe.mock.lastCall?.[0]).toBe("dev"); + renderToString( + , + ); + expect(observe.mock.lastCall?.[0]).toBeNull(); + renderToString( + , + ); + expect(observe.mock.lastCall?.[0]).toBeNull(); +}); diff --git a/apps/web/src/worktreePreferences.ts b/apps/web/src/worktreePreferences.ts new file mode 100644 index 0000000000000..7158ed51758c1 --- /dev/null +++ b/apps/web/src/worktreePreferences.ts @@ -0,0 +1,16 @@ +import { scopedProjectKey } from "@t3tools/client-runtime/environment"; +import type { ScopedProjectRef } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +import { useLocalStorage } from "./hooks/useLocalStorage"; + +const LastWorktreeBaseBranch = Schema.NullOr(Schema.String); + +/** Remembers explicit base selections independently of the draft that used them. */ +export function useLastWorktreeBaseBranch(projectRef: ScopedProjectRef | null) { + return useLocalStorage( + `t3code:last-worktree-base:${projectRef ? scopedProjectKey(projectRef) : "none"}`, + null, + LastWorktreeBaseBranch, + ); +} diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index e1e8aaaf2fbe4..6c4daae794eb7 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -130,6 +130,20 @@ such as another drive, set **Settings → Storage → Worktree location** to an `D:\worktrees` or `~/worktrees`. The setting is per machine. Existing worktrees stay where they are, and cleanup covers both the default folder and the custom one. +## Worktree base ref + +To start new worktrees from a branch other than the repository default, choose the project in +**Settings → General** and set **Worktree base ref** to a branch, tag, or commit such as `dev` +or `origin/dev`. Choose **Last used** to reuse the base you last explicitly selected for this +project and environment on the current device. Automatic defaults do not replace that memory. +If the remembered branch is gone, it falls back to the repository default, then the current +checkout. On mobile, the same setting is under **New threads**. You can still choose a +different base in an individual thread. Existing drafts keep their selection. Choose **Repository +default** (or clear the field on mobile) to use the repository default branch. Reset the override +to inherit the environment setting. +The **Start from origin** preference still applies. Turn it off to use the selected ref exactly +as it exists locally. + ## Storage cleanup Open **Settings → Storage** to enable automatic cleanup on one machine or all connected diff --git a/packages/client-runtime/src/state/vcsRef.ts b/packages/client-runtime/src/state/vcsRef.ts index 5e879356d2f97..94d5e28815677 100644 --- a/packages/client-runtime/src/state/vcsRef.ts +++ b/packages/client-runtime/src/state/vcsRef.ts @@ -4,6 +4,7 @@ export interface VcsRefTarget { readonly environmentId: EnvironmentId | null; readonly cwd: string | null; readonly query?: string | null; + readonly includeMatchingRemoteRefs?: boolean; } export type VcsRef = ContractVcsRef; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 20fc7b7ddd38d..9f29e24606ea7 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -49,6 +49,49 @@ describe("ServerSettings response streaming", () => { }); }); +describe("worktree base ref settings", () => { + it("preserves automatic selection for existing settings", () => { + expect(decodeServerSettings({}).defaultWorktreeBaseRef).toBeNull(); + }); + + it.each(["dev", "origin/dev", "refs/tags/v1.0", "a1b2c3d"])( + "round-trips %s as a project override", + (ref) => { + const patch = decodeServerSettingsPatch({ + projectSettingsOverrides: { project: { defaultWorktreeBaseRef: ` ${ref} ` } }, + }); + expect(encodeServerSettings(decodeServerSettings(patch)).projectSettingsOverrides).toEqual({ + project: { defaultWorktreeBaseRef: ref }, + }); + }, + ); + + it("round-trips last-used without reserving a valid branch name", () => { + const patch = decodeServerSettingsPatch({ + defaultWorktreeBaseRef: { mode: "last-used" }, + projectSettingsOverrides: { project: { defaultWorktreeBaseRef: { mode: "last-used" } } }, + }); + const encoded = encodeServerSettings(decodeServerSettings(patch)); + expect(encoded.defaultWorktreeBaseRef).toEqual({ mode: "last-used" }); + expect(encoded.projectSettingsOverrides).toEqual(patch.projectSettingsOverrides); + expect( + decodeServerSettingsPatch({ defaultWorktreeBaseRef: "last-used" }).defaultWorktreeBaseRef, + ).toBe("last-used"); + expect(() => + decodeServerSettingsPatch({ defaultWorktreeBaseRef: { mode: "unknown" } }), + ).toThrow(); + }); + + it("allows explicit repository defaults but rejects blank refs", () => { + expect( + decodeServerSettingsPatch({ + projectSettingsOverrides: { project: { defaultWorktreeBaseRef: null } }, + }).projectSettingsOverrides, + ).toEqual({ project: { defaultWorktreeBaseRef: null } }); + expect(() => decodeServerSettingsPatch({ defaultWorktreeBaseRef: " " })).toThrow(); + }); +}); + describe("storage cleanup settings", () => { it("keeps cleanup disabled for existing installations", () => { expect(decodeServerSettings({}).worktreeCleanup).toBeNull(); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7e73079a58d0c..1b610f977f82b 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1123,11 +1123,18 @@ export const WorktreeCleanup = Schema.NullOr( ); export type WorktreeCleanup = typeof WorktreeCleanup.Type; +/** A fixed ref, repository default (null), or the last explicit base on this device. */ +export const WorktreeBaseRef = Schema.NullOr( + Schema.Union([TrimmedNonEmptyString, Schema.Struct({ mode: Schema.Literal("last-used") })]), +); +export type WorktreeBaseRef = typeof WorktreeBaseRef.Type; + export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "worktreeCleanup", "defaultModelSelection", "defaultRuntimeMode", "defaultThreadEnvMode", + "defaultWorktreeBaseRef", "newWorktreesStartFromOrigin", "worktreeSubmodules", "defaultAutoPull", @@ -1159,6 +1166,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), defaultRuntimeMode: Schema.optionalKey(RuntimeMode), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), + defaultWorktreeBaseRef: Schema.optionalKey(WorktreeBaseRef), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), worktreeSubmodules: ForwardCompatibleOptional(WorktreeSubmodules), defaultAutoPull: Schema.optionalKey(Schema.Boolean), @@ -1195,6 +1203,7 @@ const NULLABLE_PROJECT_SETTINGS_OVERRIDES: ReadonlySet([ "defaultModelSelection", + "defaultWorktreeBaseRef", "sourceControlWriterModelSelection", "pullRequestMergeMethod", "sidebarAutoSettleAfterDays", @@ -1351,6 +1360,8 @@ export const ServerSettings = Schema.Struct({ * so older clients, which require a literal here, keep decoding. */ defaultThreadEnvMode: OmittedWhenNull(ThreadEnvMode), + /** Null uses the repository default branch, then the current checkout. */ + defaultWorktreeBaseRef: WorktreeBaseRef.pipe(Schema.withDecodingDefault(Effect.succeed(null))), newWorktreesStartFromOrigin: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), @@ -1682,6 +1693,7 @@ export const ServerSettingsPatch = Schema.Struct({ backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile), environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)), defaultThreadEnvMode: Schema.optionalKey(Schema.NullOr(ThreadEnvMode)), + defaultWorktreeBaseRef: Schema.optionalKey(WorktreeBaseRef), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 1a0cfe97b3bed..b3f29d2ee03c8 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { applyGitStatusStreamEvent, formatGeneratedBranchName, + resolveDefaultWorktreeBaseRef, buildTemporaryWorktreeBranchName, flattenTemporaryWorktreeBranchName, isTemporaryWorktreeBranch, @@ -341,3 +342,81 @@ describe("formatGeneratedBranchName", () => { ).toBe(branch); }); }); + +describe("resolveDefaultWorktreeBaseRef", () => { + const refs = [{ name: "origin/main", isDefault: true }]; + + it("waits for remembered refs and falls back only once their lookup settles", () => { + const input = { configuredRef: { mode: "last-used" as const }, refs, currentBranch: "main" }; + expect(resolveDefaultWorktreeBaseRef(input)).toBeNull(); + expect(resolveDefaultWorktreeBaseRef({ ...input, rememberedRef: "dev" })).toBe("dev"); + expect(resolveDefaultWorktreeBaseRef({ ...input, rememberedRef: "origin/dev" })).toBe( + "origin/dev", + ); + expect(resolveDefaultWorktreeBaseRef({ ...input, rememberedRef: null })).toBe("origin/main"); + expect(resolveDefaultWorktreeBaseRef({ ...input, rememberedRef: null, refs: [] })).toBe("main"); + expect( + resolveDefaultWorktreeBaseRef({ ...input, configuredRef: "release", rememberedRef: "dev" }), + ).toBe("release"); + expect( + resolveDefaultWorktreeBaseRef({ ...input, configuredRef: null, rememberedRef: "dev" }), + ).toBe("origin/main"); + }); + + it("waits for saved settings when refs load first", () => { + expect( + resolveDefaultWorktreeBaseRef({ + configuredRef: undefined, + refs, + currentBranch: "main", + }), + ).toBeNull(); + expect( + resolveDefaultWorktreeBaseRef({ configuredRef: "dev", refs, currentBranch: "main" }), + ).toBe("dev"); + }); + + it("keeps a fixed base when refs fail and waits for refs for dynamic defaults", () => { + expect( + resolveDefaultWorktreeBaseRef({ configuredRef: "dev", refs: null, currentBranch: null }), + ).toBe("dev"); + expect( + resolveDefaultWorktreeBaseRef({ configuredRef: null, refs: null, currentBranch: "main" }), + ).toBeNull(); + }); + + it("uses the live checkout fallback instead of a stale listRefs current flag", () => { + expect( + resolveDefaultWorktreeBaseRef({ + configuredRef: null, + refs: [{ name: "old", isDefault: false }], + currentBranch: "new", + }), + ).toBe("new"); + }); + + it.each(["dev", "upstream/dev", "refs/tags/v1", "a1b2c3d", "missing-branch"])( + "honors %s even when it is absent from the loaded page of refs", + (configuredRef) => { + expect( + resolveDefaultWorktreeBaseRef({ configuredRef, refs, currentBranch: "feature/current" }), + ).toBe(configuredRef); + }, + ); + + it("uses the repository default before the checkout and handles repositories with no refs", () => { + expect( + resolveDefaultWorktreeBaseRef({ + configuredRef: null, + refs, + currentBranch: "feature/current", + }), + ).toBe("origin/main"); + expect( + resolveDefaultWorktreeBaseRef({ configuredRef: null, refs: [], currentBranch: "dev" }), + ).toBe("dev"); + expect( + resolveDefaultWorktreeBaseRef({ configuredRef: null, refs: [], currentBranch: null }), + ).toBeNull(); + }); +}); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 6efef3f70ac3c..10f8caa8e1067 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -1,6 +1,7 @@ import type { BranchNamingOptions, VcsRef, + WorktreeBaseRef, SourceControlProviderInfo, VcsStatusLocalResult, VcsStatusRemoteResult, @@ -11,6 +12,28 @@ import * as Arr from "effect/Array"; import * as Result from "effect/Result"; import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts"; +/** + * Wait for settings (undefined); null opts into the repository default. + * Keep configured refs verbatim: tags, commits and paginated-out branches need not be listed. + */ +export function resolveDefaultWorktreeBaseRef(input: { + readonly configuredRef: WorktreeBaseRef | undefined; + /** Validated remembered ref; undefined means it is still loading. */ + readonly rememberedRef?: string | null | undefined; + /** Null means repository refs have not loaded. Fixed refs can still resolve. */ + readonly refs: readonly Pick[] | null; + readonly currentBranch: string | null; +}): string | null { + if (input.configuredRef === undefined) return null; + if (typeof input.configuredRef === "string") return input.configuredRef; + if (input.refs === null) return null; + if (input.configuredRef !== null) { + if (input.rememberedRef === undefined) return null; + if (input.rememberedRef !== null) return input.rememberedRef; + } + return input.refs.find((ref) => ref.isDefault)?.name ?? input.currentBranch; +} + export const WORKTREE_BRANCH_PREFIX = "t3"; // Canonical form is `t3/<8 hex>`. `t3-<8 hex>` is the fallback when a plain `t3` // branch blocks the namespace. The matcher also accepts every legacy shape, so diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts index dcde2af1f30ab..94e0b2cad0ad4 100644 --- a/packages/shared/src/projectSettings.test.ts +++ b/packages/shared/src/projectSettings.test.ts @@ -20,6 +20,40 @@ const projectId = ProjectId.make("project-a"); const otherProjectId = ProjectId.make("project-b"); describe("resolveProjectSettings", () => { + it("isolates worktree bases per project and restores inheritance when cleared", () => { + const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultWorktreeBaseRef: "main", + projectSettingsOverrides: { [projectId]: { defaultWorktreeBaseRef: "origin/dev" } }, + }); + expect(resolveProjectSettings(settings, projectId).settings.defaultWorktreeBaseRef).toBe( + "origin/dev", + ); + expect(resolveProjectSettings(settings, otherProjectId).settings.defaultWorktreeBaseRef).toBe( + "main", + ); + const remembered = applyServerSettingsPatch(settings, { + projectSettingsOverrides: { [projectId]: { defaultWorktreeBaseRef: { mode: "last-used" } } }, + }); + expect(resolveProjectSettings(remembered, projectId).settings.defaultWorktreeBaseRef).toEqual({ + mode: "last-used", + }); + expect(resolveProjectSettings(remembered, otherProjectId).settings.defaultWorktreeBaseRef).toBe( + "main", + ); + const automatic = applyServerSettingsPatch(settings, { + projectSettingsOverrides: { [projectId]: { defaultWorktreeBaseRef: null } }, + }); + expect(resolveProjectSettings(automatic, projectId).settings.defaultWorktreeBaseRef).toBeNull(); + const reset = applyServerSettingsPatch(automatic, { + projectSettingsOverrides: { + [projectId]: clearProjectSettingsOverrides(automatic, projectId, [ + "defaultWorktreeBaseRef", + ]), + }, + }); + expect(resolveProjectSettings(reset, projectId).settings.defaultWorktreeBaseRef).toBe("main"); + }); + it("inherits every scopable key when the project has no overrides", () => { const resolved = resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId); expect(resolved.settings).toBe(DEFAULT_SERVER_SETTINGS); From 55d2f1184d95550643cc602a5fcf94f91a3f917c Mon Sep 17 00:00:00 2001 From: Ayush Kaithwas Date: Tue, 6 Oct 2026 17:03:14 +0530 Subject: [PATCH 2/3] fix(mobile): preserve explicit worktree base choices --- .../settings/settings-scoped-server.test.ts | 1 + .../settings/settings-scoped-server.ts | 2 +- .../new-task-context-presentation.test.ts | 29 ++++++++++--------- .../threads/new-task-context-presentation.ts | 6 ++-- .../threads/new-task-flow-provider.tsx | 21 ++++++++++---- .../src/state/use-composer-drafts.test.ts | 19 ++++++++++++ apps/mobile/src/state/use-composer-drafts.ts | 2 ++ 7 files changed, 57 insertions(+), 23 deletions(-) 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 083036e4c9522..22b158cd90159 100644 --- a/apps/mobile/src/features/settings/settings-scoped-server.test.ts +++ b/apps/mobile/src/features/settings/settings-scoped-server.test.ts @@ -84,6 +84,7 @@ describe("mobile project settings scope", () => { null, ); expect(mobileSettingsAreMixed(targets, "defaultWorktreeBaseRef")).toBe(mixed); + expect(uniformMobileSetting(targets, "defaultWorktreeBaseRef")).toEqual(mixed ? null : first); }); it("has no uniform value when no environments are selected", () => { diff --git a/apps/mobile/src/features/settings/settings-scoped-server.ts b/apps/mobile/src/features/settings/settings-scoped-server.ts index 976167cf9befc..6161fbfa1528d 100644 --- a/apps/mobile/src/features/settings/settings-scoped-server.ts +++ b/apps/mobile/src/features/settings/settings-scoped-server.ts @@ -114,7 +114,7 @@ export function uniformMobileSetting( const reference = targets[0]; if (!reference) return null; const value = reference.settings[key]; - return targets.every((target) => target.settings[key] === value) ? value : null; + return targets.every((target) => Equal.equals(target.settings[key], value)) ? value : null; } export function planMobileScopedSettingsClear( 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 77b004f355f1f..d8f6e97ae0748 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 @@ -56,34 +56,37 @@ describe("resolveNewTaskLocalWorkspaceSelection", () => { }); describe("resolveNewTaskBranchAfterWorkspaceModeChange", () => { - const localSelection = { - branch: "feature/current", - worktreePath: null, - awaitsCurrentBranch: false, - }; - it("applies the configured base after leaving the current checkout", () => { expect( resolveNewTaskBranchAfterWorkspaceModeChange({ mode: "worktree", previousMode: "local", branch: "feature/current", - worktreePath: null, - localSelection, + branchSelection: "auto", }), ).toBeNull(); }); - it("keeps a distinct branch chosen for the draft", () => { + it("keeps an explicit branch choice even when it matches the checkout", () => { + expect( + resolveNewTaskBranchAfterWorkspaceModeChange({ + mode: "worktree", + previousMode: "local", + branch: "feature/current", + branchSelection: "manual", + }), + ).toBe("feature/current"); + }); + + it("keeps a legacy draft branch whose source is unknown", () => { expect( resolveNewTaskBranchAfterWorkspaceModeChange({ mode: "worktree", previousMode: "local", - branch: "feature/chosen", - worktreePath: null, - localSelection, + branch: "feature/current", + branchSelection: undefined, }), - ).toBe("feature/chosen"); + ).toBe("feature/current"); }); }); 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 adb13e369b031..8ace8a8d480c7 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.ts @@ -63,14 +63,12 @@ export function resolveNewTaskBranchAfterWorkspaceModeChange(input: { readonly mode: WorkspaceMode; readonly previousMode: WorkspaceMode; readonly branch: string | null; - readonly worktreePath: string | null; - readonly localSelection: ReturnType; + readonly branchSelection: "auto" | "manual" | undefined; }): string | null { if ( input.previousMode === "local" && input.mode === "worktree" && - input.branch === input.localSelection.branch && - input.worktreePath === input.localSelection.worktreePath + input.branchSelection === "auto" ) { return 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 1933cd007f6b4..de36e99c2739e 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -538,6 +538,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { : "local"; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; + const selectedBranchSelection = selectedProjectDraft.workspaceSelection?.branchSelection; const preferencesResult = useAtomValue(mobilePreferencesAtom); const preferencesLoaded = AsyncResult.isSuccess(preferencesResult); const savePreferences = useAtomSet(updateMobilePreferencesAtom); @@ -954,10 +955,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { mode, previousMode: workspaceMode, branch: selectedBranchName, - worktreePath: selectedWorktreePath, - localSelection, + branchSelection: selectedBranchSelection, }), - worktreePath: mode === "local" ? localSelection.worktreePath : selectedWorktreePath, + worktreePath: mode === "local" ? localSelection.worktreePath : null, + branchSelection: mode === "local" ? "auto" : (selectedBranchSelection ?? "manual"), ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), }, }); @@ -966,9 +967,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { availableBranches, draftStartFromOrigin, selectedBranchName, + selectedBranchSelection, selectedProject, selectedProjectDraftKey, - selectedWorktreePath, workspaceMode, ], ); @@ -996,6 +997,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { mode: "local", branch: localSelection.branch, worktreePath: localSelection.worktreePath, + branchSelection: "auto", ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), }, }); @@ -1027,6 +1029,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { workspaceSelection: { mode: workspaceMode, branch: branch.name, + branchSelection: "manual", worktreePath: resolveNewTaskBranchWorktreePath({ workspaceMode, projectCwd: selectedProject.workspaceRoot, @@ -1055,11 +1058,18 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { mode: workspaceMode, branch: selectedBranchName, worktreePath: selectedWorktreePath, + ...(selectedBranchSelection ? { branchSelection: selectedBranchSelection } : {}), startFromOrigin: value, }, }); }, - [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath, workspaceMode], + [ + selectedBranchName, + selectedBranchSelection, + selectedProjectDraftKey, + selectedWorktreePath, + workspaceMode, + ], ); const refreshBranches = branchState.refresh; @@ -1104,6 +1114,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { mode: "worktree", branch: preferredBranch, worktreePath: null, + branchSelection: "auto", ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), }, }); diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index ec79cf0431917..9115fee4383ee 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -1713,6 +1713,25 @@ describe("mobile composer drafts", () => { }, }); }); + + it("retains an explicit branch choice across draft hydration", () => { + const decoded = decodePersistedComposerState({ + schemaVersion: 1, + drafts: { + "new-task:environment-1:project-1": { + text: "", + attachments: [], + workspaceSelection: { + mode: "local", + branch: "main", + worktreePath: null, + branchSelection: "manual", + }, + }, + }, + }); + expect(Object.values(decoded.drafts)[0]?.workspaceSelection?.branchSelection).toBe("manual"); + }); it("keeps legacy content-only drafts and rejects invalid selector state", () => { expect( decodePersistedComposerState({ diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index e5efb14207851..cc1b008742c97 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -358,6 +358,7 @@ export interface ComposerDraftWorkspaceSelection { readonly mode: "local" | "worktree"; readonly branch: string | null; readonly worktreePath: string | null; + readonly branchSelection?: "auto" | "manual"; readonly startFromOrigin?: boolean; } @@ -370,6 +371,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ mode: Schema.Literals(["local", "worktree"]), branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), + branchSelection: Schema.optional(Schema.Literals(["auto", "manual"])), startFromOrigin: Schema.optional(Schema.Boolean), }); From ee3e3aba4fb20b058fbda43b5a58fc95d2bc29db Mon Sep 17 00:00:00 2001 From: Ayush Kaithwas Date: Tue, 6 Oct 2026 17:32:30 +0530 Subject: [PATCH 3/3] fix: keep worktree drafts unchanged until a base ref is configured Switching a fresh draft from Current checkout to New worktree only drops the synced checkout branch when a worktree base ref is configured, and the Last used hint only shows in Last used mode. Users who never set the option keep the behaviour they had before. Web also no longer marks a draft's environment as manually chosen on every mode switch, which turned off load balancing, and falls back to the current branch when the ref list fails to load. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../new-task-context-presentation.test.ts | 17 ++++++++++ .../threads/new-task-context-presentation.ts | 4 +++ .../threads/new-task-flow-provider.tsx | 32 ++++++++++++------- .../BranchToolbarBranchSelector.tsx | 4 +-- apps/web/src/components/ChatView.tsx | 14 +++++--- apps/web/src/lib/chatThreadActions.test.ts | 11 +++++++ apps/web/src/lib/chatThreadActions.ts | 11 +++++-- 7 files changed, 74 insertions(+), 19 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 d8f6e97ae0748..3f9bbcffd3906 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 @@ -63,10 +63,25 @@ describe("resolveNewTaskBranchAfterWorkspaceModeChange", () => { previousMode: "local", branch: "feature/current", branchSelection: "auto", + configuredBaseRef: "develop", }), ).toBeNull(); }); + it("keeps the checkout branch when no base ref is configured", () => { + for (const configuredBaseRef of [null, undefined]) { + expect( + resolveNewTaskBranchAfterWorkspaceModeChange({ + mode: "worktree", + previousMode: "local", + branch: "feature/current", + branchSelection: "auto", + configuredBaseRef, + }), + ).toBe("feature/current"); + } + }); + it("keeps an explicit branch choice even when it matches the checkout", () => { expect( resolveNewTaskBranchAfterWorkspaceModeChange({ @@ -74,6 +89,7 @@ describe("resolveNewTaskBranchAfterWorkspaceModeChange", () => { previousMode: "local", branch: "feature/current", branchSelection: "manual", + configuredBaseRef: { mode: "last-used" }, }), ).toBe("feature/current"); }); @@ -85,6 +101,7 @@ describe("resolveNewTaskBranchAfterWorkspaceModeChange", () => { previousMode: "local", branch: "feature/current", branchSelection: undefined, + configuredBaseRef: "develop", }), ).toBe("feature/current"); }); 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 8ace8a8d480c7..0aa2c9da6d4ee 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.ts @@ -1,3 +1,4 @@ +import type { WorktreeBaseRef } from "@t3tools/contracts"; import { sanitizeNewRefName } from "@t3tools/shared/git"; type WorkspaceMode = "local" | "worktree"; @@ -64,8 +65,11 @@ export function resolveNewTaskBranchAfterWorkspaceModeChange(input: { readonly previousMode: WorkspaceMode; readonly branch: string | null; readonly branchSelection: "auto" | "manual" | undefined; + readonly configuredBaseRef: WorktreeBaseRef | undefined; }): string | null { + // Without a configured base, keep the checkout branch exactly as before the setting existed. if ( + input.configuredBaseRef != null && input.previousMode === "local" && input.mode === "worktree" && input.branchSelection === "auto" 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 de36e99c2739e..f4f7b2375f67e 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -172,6 +172,7 @@ type NewTaskFlowContextValue = { /** False for threads without a project: their folder has no branch or worktree. */ readonly canChooseWorkspace: boolean; readonly selectedBranchName: string | null; + /** Set only when the base ref setting is Last used. */ readonly lastWorktreeBaseBranch: string | null; readonly selectedWorktreePath: string | null; readonly startFromOrigin: boolean; @@ -945,19 +946,26 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { } else { pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey); } + const branch = + mode === "local" + ? localSelection.branch + : resolveNewTaskBranchAfterWorkspaceModeChange({ + mode, + previousMode: workspaceMode, + branch: selectedBranchName, + branchSelection: selectedBranchSelection, + configuredBaseRef, + }); updateComposerDraftSettings(selectedProjectDraftKey, { workspaceSelection: { mode, - branch: + branch, + worktreePath: mode === "local" - ? localSelection.branch - : resolveNewTaskBranchAfterWorkspaceModeChange({ - mode, - previousMode: workspaceMode, - branch: selectedBranchName, - branchSelection: selectedBranchSelection, - }), - worktreePath: mode === "local" ? localSelection.worktreePath : null, + ? localSelection.worktreePath + : branch === selectedBranchName + ? selectedWorktreePath + : null, branchSelection: mode === "local" ? "auto" : (selectedBranchSelection ?? "manual"), ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), }, @@ -965,11 +973,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { }, [ availableBranches, + configuredBaseRef, draftStartFromOrigin, selectedBranchName, selectedBranchSelection, selectedProject, selectedProjectDraftKey, + selectedWorktreePath, workspaceMode, ], ); @@ -1394,7 +1404,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { workspaceMode, canChooseWorkspace, selectedBranchName, - lastWorktreeBaseBranch, + lastWorktreeBaseBranch: rememberedBranch, selectedWorktreePath, startFromOrigin, draftKey: selectedProjectDraftKey, @@ -1479,7 +1489,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { reset, runtimeMode, selectedBranchName, - lastWorktreeBaseBranch, + rememberedBranch, hasMoreBranches, selectedEnvironmentId, selectedModel, diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index cea1c94213de7..4243c7d099f2d 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -557,7 +557,7 @@ export function BranchToolbarBranchSelector({ const worktreeBaseBranchCandidate = resolveDefaultWorktreeBaseRef({ configuredRef: configuredBaseRef, rememberedRef: rememberedBranchPending ? undefined : (rememberedRef?.name ?? null), - refs: baseBranchesQuery.data?.refs ?? null, + refs: baseBranchesQuery.error ? [] : (baseBranchesQuery.data?.refs ?? null), currentBranch: currentGitBranch, }); @@ -704,7 +704,7 @@ export function BranchToolbarBranchSelector({ value={itemValue} onClick={() => selectPickerItem(itemValue)} onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} - hint={isSelectingWorktreeBase && itemValue === lastWorktreeBaseBranch ? "Last used" : null} + hint={isSelectingWorktreeBase && itemValue === rememberedBranch ? "Last used" : null} /> ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index dc0490c283f61..fc625585b7ff8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -10427,11 +10427,16 @@ export default function ChatView(props: ChatViewProps) { if (isLocalDraftThread) { setDraftThreadContext(composerDraftTarget, { envMode: mode, - branch: resolveDraftBranchAfterEnvModeChange({ + // Only send a cleared branch: a non-null branch marks the environment manual. + ...(draftThread?.branch && + resolveDraftBranchAfterEnvModeChange({ mode, - branch: draftThread?.branch ?? null, - environmentSelection: draftThread?.environmentSelection, - }), + branch: draftThread.branch, + environmentSelection: draftThread.environmentSelection, + defaultWorktreeBaseRef: activeProjectSettings.settings.defaultWorktreeBaseRef, + }) === null + ? { branch: null } + : {}), startFromOrigin: resolveNewDraftStartFromOrigin({ envMode: mode, newWorktreesStartFromOrigin: activeProjectSettings.settings.newWorktreesStartFromOrigin, @@ -10449,6 +10454,7 @@ export default function ChatView(props: ChatViewProps) { draftThread?.environmentSelection, isLocalDraftThread, multipleModelSelections, + activeProjectSettings.settings.defaultWorktreeBaseRef, activeProjectSettings.settings.newWorktreesStartFromOrigin, setPendingServerThreadEnvMode, scheduleComposerFocus, diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index 738e2299d2bc4..a8e1b724e0d32 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -109,13 +109,23 @@ describe("chatThreadActions", () => { mode: "worktree", branch: "feature/current-checkout", environmentSelection: "auto", + defaultWorktreeBaseRef: "develop", }), ).toBeNull(); + expect( + resolveDraftBranchAfterEnvModeChange({ + mode: "worktree", + branch: "feature/current-checkout", + environmentSelection: "auto", + defaultWorktreeBaseRef: null, + }), + ).toBe("feature/current-checkout"); expect( resolveDraftBranchAfterEnvModeChange({ mode: "worktree", branch: "feature/chosen-base", environmentSelection: "manual", + defaultWorktreeBaseRef: "develop", }), ).toBe("feature/chosen-base"); expect( @@ -123,6 +133,7 @@ describe("chatThreadActions", () => { mode: "local", branch: "feature/current-checkout", environmentSelection: "auto", + defaultWorktreeBaseRef: "develop", }), ).toBe("feature/current-checkout"); }); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 55f3b292b6df8..cb72b16048d69 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -4,6 +4,7 @@ import type { ModelSelection, ProjectId, ScopedProjectRef, + WorktreeBaseRef, } from "@t3tools/contracts"; import type { ComposerThreadDraftState, @@ -52,10 +53,16 @@ export function resolveDraftBranchAfterEnvModeChange(input: { mode: DraftThreadEnvMode; branch: string | null; environmentSelection: DraftThreadState["environmentSelection"]; + defaultWorktreeBaseRef: WorktreeBaseRef; }): string | null { // The local checkout branch is synced into a fresh draft automatically. - // Let the configured worktree base win when that draft switches modes. - return input.mode === "worktree" && input.environmentSelection === "auto" ? null : input.branch; + // Let a configured worktree base win when that draft switches modes; + // without one, keep the branch exactly as before the setting existed. + return input.defaultWorktreeBaseRef !== null && + input.mode === "worktree" && + input.environmentSelection === "auto" + ? null + : input.branch; } export function resolveNewThreadModelSelectionOverride(input: {