diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index df19d97b724..2d876c39833 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6941,6 +6941,226 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("checks out the base branch directly when bootstrap reuses it", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const fetchRemote = vi.fn( + (_: Parameters[0]) => Effect.void, + ); + const listRefs = vi.fn((_: Parameters[0]) => + Effect.succeed({ + refs: [ + { + name: "feature/base", + current: false, + isDefault: false, + worktreePath: null, + }, + ], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 1, + }), + ); + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "feature/base", + path: "/tmp/reuse-worktree", + }, + }), + ); + + yield* buildAppUnderTest({ + layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, + gitVcsDriver: { + fetchRemote, + listRefs, + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-reuse-branch"), + threadId: ThreadId.make("thread-bootstrap-reuse-branch"), + message: { + messageId: MessageId.make("msg-bootstrap-reuse-branch"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/base", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "feature/base", + branch: "t3code/bootstrap-refName", + startFromOrigin: true, + reuseBaseBranch: true, + }, + }, + createdAt, + }), + ), + ); + + assert.equal(response.sequence, 3); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.meta.update", "thread.turn.start"], + ); + // Reuse wins over the requested new branch and origin refresh. + assert.equal(fetchRemote.mock.calls.length, 0); + assert.equal(listRefs.mock.calls.length, 1); + assert.deepEqual(createWorktree.mock.calls[0]?.[0], { + cwd: "/tmp/project", + refName: "feature/base", + path: null, + }); + const metaUpdate = dispatchedCommands[1]; + assertTrue(metaUpdate?.type === "thread.meta.update"); + if (metaUpdate?.type === "thread.meta.update") { + assert.equal(metaUpdate.branch, "feature/base"); + assert.equal(metaUpdate.worktreePath, "/tmp/reuse-worktree"); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("materializes a reused remote base branch as its derived local branch", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const listRefs = vi.fn((_: Parameters[0]) => + Effect.succeed({ + refs: [ + { + name: "origin/feature/base", + isRemote: true, + remoteName: "origin", + current: false, + isDefault: false, + worktreePath: null, + }, + ], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 1, + }), + ); + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "feature/base", + path: "/tmp/reuse-worktree", + }, + }), + ); + + yield* buildAppUnderTest({ + layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, + gitVcsDriver: { + listRefs, + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-reuse-remote-branch"), + threadId: ThreadId.make("thread-bootstrap-reuse-remote-branch"), + message: { + messageId: MessageId.make("msg-bootstrap-reuse-remote-branch"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "origin/feature/base", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "origin/feature/base", + reuseBaseBranch: true, + }, + }, + createdAt, + }), + ), + ); + + assert.deepEqual(createWorktree.mock.calls[0]?.[0], { + cwd: "/tmp/project", + refName: "origin/feature/base", + newRefName: "feature/base", + path: null, + }); + const metaUpdate = dispatchedCommands[1]; + assertTrue(metaUpdate?.type === "thread.meta.update"); + if (metaUpdate?.type === "thread.meta.update") { + assert.equal(metaUpdate.branch, "feature/base"); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("records setup-script failures without aborting bootstrap turn start", () => Effect.gen(function* () { const dispatchedCommands: Array = []; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1cc0dccb1cb..5878198e16e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -117,6 +117,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; +import { deriveLocalBranchNameFromRemoteRef } from "@t3tools/shared/git"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -1000,24 +1001,45 @@ const makeWsRpcLayer = ( } if (bootstrap?.prepareWorktree) { - let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch; - if (bootstrap.prepareWorktree.startFromOrigin) { + const prepareWorktree = bootstrap.prepareWorktree; + let worktreeBaseRef = prepareWorktree.baseBranch; + let worktreeNewRefName = prepareWorktree.branch; + let worktreeBaseRefName: string | undefined = prepareWorktree.baseBranch; + if (prepareWorktree.reuseBaseBranch) { + // Reuse the selected branch: check it out in the worktree + // instead of branching off it. A remote ref cannot be checked + // out directly (it would detach), so materialize it as its + // derived local branch; the branch keeps its own history, so + // skip the gh-merge-base config that new branches record. + const refsResult = yield* gitWorkflow.listRefs({ + cwd: prepareWorktree.projectCwd, + query: prepareWorktree.baseBranch, + includeMatchingRemoteRefs: true, + }); + const selectedRef = refsResult.refs.find( + (ref) => ref.name === prepareWorktree.baseBranch, + ); + worktreeNewRefName = selectedRef?.isRemote + ? deriveLocalBranchNameFromRemoteRef(prepareWorktree.baseBranch) + : undefined; + worktreeBaseRefName = undefined; + } else if (prepareWorktree.startFromOrigin) { yield* gitWorkflow.fetchRemote({ - cwd: bootstrap.prepareWorktree.projectCwd, + cwd: prepareWorktree.projectCwd, remoteName: "origin", }); const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ - cwd: bootstrap.prepareWorktree.projectCwd, - refName: bootstrap.prepareWorktree.baseBranch, + cwd: prepareWorktree.projectCwd, + refName: prepareWorktree.baseBranch, fallbackRemoteName: "origin", }); worktreeBaseRef = resolvedRemoteBase.commitSha; } const worktree = yield* gitWorkflow.createWorktree({ - cwd: bootstrap.prepareWorktree.projectCwd, + cwd: prepareWorktree.projectCwd, refName: worktreeBaseRef, - newRefName: bootstrap.prepareWorktree.branch, - baseRefName: bootstrap.prepareWorktree.baseBranch, + ...(worktreeNewRefName !== undefined ? { newRefName: worktreeNewRefName } : {}), + ...(worktreeBaseRefName !== undefined ? { baseRefName: worktreeBaseRefName } : {}), path: null, }); targetWorktreePath = worktree.worktree.path; diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index d0d0f308633..6ac68d8cf0a 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -51,6 +51,8 @@ interface BranchToolbarProps { onActiveThreadBranchOverrideChange?: (branch: string | null) => void; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; + reuseBaseBranch: boolean; + onReuseBaseBranchChange: (reuseBaseBranch: boolean) => void; envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; @@ -224,6 +226,8 @@ export const BranchToolbar = memo(function BranchToolbar({ onActiveThreadBranchOverrideChange, startFromOrigin, onStartFromOriginChange, + reuseBaseBranch, + onReuseBaseBranchChange, envLocked, onCheckoutPullRequestRequest, onComposerFocusRequest, @@ -355,6 +359,8 @@ export const BranchToolbar = memo(function BranchToolbar({ {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} startFromOrigin={startFromOrigin} onStartFromOriginChange={onStartFromOriginChange} + reuseBaseBranch={reuseBaseBranch} + onReuseBaseBranchChange={onReuseBaseBranchChange} {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} /> diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index cccc444b4f2..46431edf5ec 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -72,6 +72,8 @@ interface BranchToolbarBranchSelectorProps { onActiveThreadBranchOverrideChange?: (refName: string | null) => void; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; + reuseBaseBranch: boolean; + onReuseBaseBranchChange: (reuseBaseBranch: boolean) => void; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } @@ -84,12 +86,15 @@ function getBranchTriggerLabel(input: { activeWorktreePath: string | null; effectiveEnvMode: "local" | "worktree"; resolvedActiveBranch: string | null; + reuseBaseBranch: boolean; }): string { - const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input; + const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch, reuseBaseBranch } = input; if (!resolvedActiveBranch) { return "Select ref"; } - if (effectiveEnvMode === "worktree" && !activeWorktreePath) { + // "From X" signals a new branch will be created off X; a reused branch is + // checked out as-is, so show its plain name. + if (effectiveEnvMode === "worktree" && !activeWorktreePath && !reuseBaseBranch) { return `From ${resolvedActiveBranch}`; } return resolvedActiveBranch; @@ -106,10 +111,13 @@ export function BranchToolbarBranchSelector({ onActiveThreadBranchOverrideChange, startFromOrigin, onStartFromOriginChange, + reuseBaseBranch, + onReuseBaseBranchChange, onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { const startFromOriginSwitchId = useId(); + const reuseBaseBranchSwitchId = useId(); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); const updateThreadMetadata = useAtomCommand( threadEnvironment.updateMetadata, @@ -587,6 +595,7 @@ export function BranchToolbarBranchSelector({ activeWorktreePath, effectiveEnvMode, resolvedActiveBranch, + reuseBaseBranch, }); // PR pill shown next to the branch selector when the active branch has one. @@ -800,32 +809,65 @@ export function BranchToolbarBranchSelector({ {isSelectingWorktreeBase ? ( - - - - - onStartFromOriginChange(Boolean(checked))} - /> - - } - /> - - Creates the worktree from the latest matching branch on origin instead of your local - branch. - - +
+ + + + + onReuseBaseBranchChange(Boolean(checked))} + /> + + } + /> + + Checks out the selected branch in the worktree instead of creating a new branch + from it. + + + + + + + onStartFromOriginChange(Boolean(checked))} + /> + + } + /> + + {reuseBaseBranch + ? "Not available when reusing the selected branch." + : "Creates the worktree from the latest matching branch on origin instead of your local branch."} + + +
) : null} {branchStatusText ? {branchStatusText} : null} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0f8aa1e9f3e..2ab2f2ffdbb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1287,6 +1287,10 @@ function ChatViewContent(props: ChatViewProps) { pendingServerThreadStartFromOriginByThreadId, setPendingServerThreadStartFromOriginByThreadId, ] = useState>({}); + const [ + pendingServerThreadReuseBaseBranchByThreadId, + setPendingServerThreadReuseBaseBranchByThreadId, + ] = useState>({}); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, {}, @@ -3821,12 +3825,18 @@ function ChatViewContent(props: ChatViewProps) { ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? primaryServerSettings.newWorktreesStartFromOrigin) : false; + const reuseBaseBranch = isLocalDraftThread + ? (draftThread?.reuseBaseBranch ?? false) + : canOverrideServerThreadEnvMode + ? (pendingServerThreadReuseBaseBranchByThreadId[activeThread?.id ?? ""] ?? false) + : false; const handleStartNewThread = useCallback(() => { if (!activeProjectRef) return; void handleNewThread(activeProjectRef, { branch: activeThreadBranch, worktreePath: activeWorktreePath, envMode, + reuseBaseBranch, startFromOrigin, }); }, [ @@ -3835,6 +3845,7 @@ function ChatViewContent(props: ChatViewProps) { activeWorktreePath, envMode, handleNewThread, + reuseBaseBranch, startFromOrigin, ]); const sendEnvMode = resolveSendEnvMode({ @@ -4763,8 +4774,12 @@ function ChatViewContent(props: ChatViewProps) { prepareWorktree: { projectCwd: activeProject.workspaceRoot, baseBranch: baseBranchForWorktree, - branch: buildTemporaryWorktreeBranchName(randomHex), - ...(startFromOrigin ? { startFromOrigin: true } : {}), + ...(reuseBaseBranch + ? { reuseBaseBranch: true } + : { + branch: buildTemporaryWorktreeBranchName(randomHex), + ...(startFromOrigin ? { startFromOrigin: true } : {}), + }), }, runSetupScript: true, } @@ -5466,6 +5481,7 @@ function ChatViewContent(props: ChatViewProps) { envMode: mode, newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, }), + reuseBaseBranch: false, ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), }); } @@ -5499,6 +5515,22 @@ function ChatViewContent(props: ChatViewProps) { } }; + const onReuseBaseBranchChange = (nextReuseBaseBranch: boolean) => { + if (canOverrideServerThreadEnvMode && activeThread) { + setPendingServerThreadReuseBaseBranchByThreadId((current) => + current[activeThread.id] === nextReuseBaseBranch + ? current + : { ...current, [activeThread.id]: nextReuseBaseBranch }, + ); + return; + } + if (isLocalDraftThread) { + setDraftThreadContext(composerDraftTarget, { + reuseBaseBranch: nextReuseBaseBranch, + }); + } + }; + const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); @@ -5899,6 +5931,8 @@ function ChatViewContent(props: ChatViewProps) { onEnvModeChange={onEnvModeChange} startFromOrigin={startFromOrigin} onStartFromOriginChange={onStartFromOriginChange} + reuseBaseBranch={reuseBaseBranch} + onReuseBaseBranchChange={onReuseBaseBranchChange} {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})} diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index d4f38adcacd..da5d6aabff0 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -216,6 +216,7 @@ const PersistedDraftThreadState = Schema.Struct({ worktreePath: Schema.NullOr(Schema.String), envMode: DraftThreadEnvModeSchema, startFromOrigin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + reuseBaseBranch: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), promotedTo: Schema.optionalKey( Schema.NullOr( Schema.Struct({ @@ -295,6 +296,7 @@ export interface DraftSessionState { worktreePath: string | null; envMode: DraftThreadEnvMode; startFromOrigin: boolean; + reuseBaseBranch: boolean; promotedTo?: ScopedThreadRef | null; } @@ -357,6 +359,7 @@ interface ComposerDraftStoreState { createdAt?: string; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; + reuseBaseBranch?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -372,6 +375,7 @@ interface ComposerDraftStoreState { createdAt?: string; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; + reuseBaseBranch?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -386,6 +390,7 @@ interface ComposerDraftStoreState { createdAt?: string; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; + reuseBaseBranch?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -1329,6 +1334,7 @@ function createDraftThreadState( createdAt?: string; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; + reuseBaseBranch?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -1355,6 +1361,12 @@ function createDraftThreadState( ? false : (existingThread?.startFromOrigin ?? false) : options.startFromOrigin; + const nextReuseBaseBranch = + options?.reuseBaseBranch === undefined + ? projectChanged + ? false + : (existingThread?.reuseBaseBranch ?? false) + : options.reuseBaseBranch; return { threadId, environmentId: projectRef.environmentId, @@ -1374,6 +1386,7 @@ function createDraftThreadState( ? "local" : (existingThread?.envMode ?? "local")), startFromOrigin: nextStartFromOrigin, + reuseBaseBranch: nextReuseBaseBranch, promotedTo: null, }; } @@ -1406,6 +1419,7 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea left.worktreePath === right.worktreePath && left.envMode === right.envMode && left.startFromOrigin === right.startFromOrigin && + left.reuseBaseBranch === right.reuseBaseBranch && scopedThreadRefsEqual(left.promotedTo, right.promotedTo) ); } @@ -1501,6 +1515,7 @@ function normalizePersistedDraftThreads( const branch = candidateDraftThread.branch; const worktreePath = candidateDraftThread.worktreePath; const startFromOrigin = candidateDraftThread.startFromOrigin === true; + const reuseBaseBranch = candidateDraftThread.reuseBaseBranch === true; const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null; const promotedToCandidate = candidateDraftThread.promotedTo; const promotedToRecord = @@ -1549,6 +1564,7 @@ function normalizePersistedDraftThreads( worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), startFromOrigin, + reuseBaseBranch, promotedTo, }; } @@ -1595,6 +1611,7 @@ function normalizePersistedDraftThreads( worktreePath: null, envMode: "local", startFromOrigin: false, + reuseBaseBranch: false, promotedTo: null, }; } else if ( @@ -2166,6 +2183,7 @@ function toHydratedDraftThreadState( worktreePath: persistedDraftThread.worktreePath, envMode: persistedDraftThread.envMode, startFromOrigin: persistedDraftThread.startFromOrigin, + reuseBaseBranch: persistedDraftThread.reuseBaseBranch, promotedTo: persistedDraftThread.promotedTo ? scopeThreadRef( persistedDraftThread.promotedTo.environmentId as EnvironmentId, @@ -2357,6 +2375,12 @@ const composerDraftStore = create()( ? false : existing.startFromOrigin : options.startFromOrigin; + const nextReuseBaseBranch = + options.reuseBaseBranch === undefined + ? projectChanged + ? false + : existing.reuseBaseBranch + : options.reuseBaseBranch; const nextDraftThread: DraftThreadState = { threadId: existing.threadId, environmentId: nextProjectRef.environmentId, @@ -2378,6 +2402,7 @@ const composerDraftStore = create()( ? "local" : (existing.envMode ?? "local")), startFromOrigin: nextStartFromOrigin, + reuseBaseBranch: nextReuseBaseBranch, promotedTo: existing.promotedTo ?? null, }; const isUnchanged = @@ -2391,6 +2416,7 @@ const composerDraftStore = create()( nextDraftThread.worktreePath === existing.worktreePath && nextDraftThread.envMode === existing.envMode && nextDraftThread.startFromOrigin === existing.startFromOrigin && + nextDraftThread.reuseBaseBranch === existing.reuseBaseBranch && scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo); if (isUnchanged) { return state; diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 2479d6ba02f..3bfa6287ec1 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -50,6 +50,7 @@ export function useNewThreadHandler() { worktreePath?: string | null; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; + reuseBaseBranch?: boolean; replace?: boolean; }, ): Promise => { @@ -112,6 +113,7 @@ export function useNewThreadHandler() { const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; const hasStartFromOriginOption = options?.startFromOrigin !== undefined; + const hasReuseBaseBranchOption = options?.reuseBaseBranch !== undefined; const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey); const storedDraftThreadRef = storedDraftThread ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId) @@ -137,7 +139,8 @@ export function useNewThreadHandler() { hasBranchOption || hasWorktreePathOption || hasEnvModeOption || - hasStartFromOriginOption; + hasStartFromOriginOption || + hasReuseBaseBranchOption; // Resurrecting a stored draft must not resurrect its stale context: // explicit workspace options win outright; otherwise the env context // resets to the configured defaults so drafts seeded before a @@ -153,6 +156,7 @@ export function useNewThreadHandler() { ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), + ...(hasReuseBaseBranchOption ? { reuseBaseBranch: options?.reuseBaseBranch } : {}), } : isDraftAlreadyOpen ? null @@ -164,6 +168,7 @@ export function useNewThreadHandler() { envMode: defaultEnvMode, newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, }), + reuseBaseBranch: false, }; if (workspaceContext) { setDraftThreadContext(reusableStoredDraftThread.draftId, { @@ -220,13 +225,15 @@ export function useNewThreadHandler() { hasBranchOption || hasWorktreePathOption || hasEnvModeOption || - hasStartFromOriginOption + hasStartFromOriginOption || + hasReuseBaseBranchOption ) { setDraftThreadContext(currentRouteTarget.draftId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), + ...(hasReuseBaseBranchOption ? { reuseBaseBranch: options?.reuseBaseBranch } : {}), }); } setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, { @@ -238,6 +245,7 @@ export function useNewThreadHandler() { ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), + ...(hasReuseBaseBranchOption ? { reuseBaseBranch: options?.reuseBaseBranch } : {}), }); return Promise.resolve(); } @@ -259,6 +267,7 @@ export function useNewThreadHandler() { envMode: initialEnvMode, newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, }), + reuseBaseBranch: options?.reuseBaseBranch ?? false, runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 4a150d617ea..f40da442b13 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -15,6 +15,7 @@ interface NewThreadHandler { worktreePath?: string | null; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; + reuseBaseBranch?: boolean; }, ): Promise; } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 84b7a8fa07f..3fc2fb3e151 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -655,6 +655,9 @@ const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({ baseBranch: TrimmedNonEmptyString, branch: Schema.optional(TrimmedNonEmptyString), startFromOrigin: Schema.optional(Schema.Boolean), + // Check out `baseBranch` in the worktree instead of creating a new branch + // from it. Wins over `branch`/`startFromOrigin` when set. + reuseBaseBranch: Schema.optional(Schema.Boolean), }); const ThreadTurnStartBootstrap = Schema.Struct({