diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts
index faf21d636c..7beaec808d 100644
--- a/apps/web/src/components/BranchToolbar.logic.test.ts
+++ b/apps/web/src/components/BranchToolbar.logic.test.ts
@@ -10,6 +10,7 @@ import {
resolveEffectiveEnvMode,
resolveEnvModeLabel,
resolveBranchToolbarValue,
+ resolveLockedWorkspaceLabel,
shouldIncludeBranchPickerItem,
} from "./BranchToolbar.logic";
@@ -157,6 +158,16 @@ describe("resolveCurrentWorkspaceLabel", () => {
});
});
+describe("resolveLockedWorkspaceLabel", () => {
+ it("uses a shorter label for the main repo checkout", () => {
+ expect(resolveLockedWorkspaceLabel(null)).toBe("Local checkout");
+ });
+
+ it("uses a shorter label for an attached worktree", () => {
+ expect(resolveLockedWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe("Worktree");
+ });
+});
+
describe("deriveLocalBranchNameFromRemoteRef", () => {
it("strips the remote prefix from a remote ref", () => {
expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo");
diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts
index 54ec4370f4..7adab1a2e1 100644
--- a/apps/web/src/components/BranchToolbar.logic.ts
+++ b/apps/web/src/components/BranchToolbar.logic.ts
@@ -50,6 +50,10 @@ export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null):
return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local");
}
+export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): string {
+ return activeWorktreePath ? "Worktree" : "Local checkout";
+}
+
export function resolveEffectiveEnvMode(input: {
activeWorktreePath: string | null;
hasServerThread: boolean;
diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx
index e91266d65f..c6f37c42a9 100644
--- a/apps/web/src/components/BranchToolbar.tsx
+++ b/apps/web/src/components/BranchToolbar.tsx
@@ -20,6 +20,9 @@ interface BranchToolbarProps {
threadId: ThreadId;
draftId?: DraftId;
onEnvModeChange: (mode: EnvMode) => void;
+ effectiveEnvModeOverride?: EnvMode;
+ activeThreadBranchOverride?: string | null;
+ onActiveThreadBranchOverrideChange?: (branch: string | null) => void;
envLocked: boolean;
onCheckoutPullRequestRequest?: (reference: string) => void;
onComposerFocusRequest?: () => void;
@@ -32,6 +35,9 @@ export const BranchToolbar = memo(function BranchToolbar({
threadId,
draftId,
onEnvModeChange,
+ effectiveEnvModeOverride,
+ activeThreadBranchOverride,
+ onActiveThreadBranchOverrideChange,
envLocked,
onCheckoutPullRequestRequest,
onComposerFocusRequest,
@@ -59,11 +65,13 @@ export const BranchToolbar = memo(function BranchToolbar({
const activeProject = useStore(activeProjectSelector);
const hasActiveThread = serverThread !== undefined || draftThread !== null;
const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
- const effectiveEnvMode = resolveEffectiveEnvMode({
- activeWorktreePath,
- hasServerThread: serverThread !== undefined,
- draftThreadEnvMode: draftThread?.envMode,
- });
+ const effectiveEnvMode =
+ effectiveEnvModeOverride ??
+ resolveEffectiveEnvMode({
+ activeWorktreePath,
+ hasServerThread: serverThread !== undefined,
+ draftThreadEnvMode: draftThread?.envMode,
+ });
const envModeLocked = envLocked || (serverThread !== undefined && activeWorktreePath !== null);
const showEnvironmentPicker =
@@ -98,6 +106,9 @@ export const BranchToolbar = memo(function BranchToolbar({
threadId={threadId}
{...(draftId ? { draftId } : {})}
envLocked={envLocked}
+ {...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})}
+ {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})}
+ {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})}
{...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})}
{...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}
/>
diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx
index 76f64d93fe..123e19c57c 100644
--- a/apps/web/src/components/BranchToolbarBranchSelector.tsx
+++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx
@@ -49,6 +49,9 @@ interface BranchToolbarBranchSelectorProps {
threadId: ThreadId;
draftId?: DraftId;
envLocked: boolean;
+ effectiveEnvModeOverride?: "local" | "worktree";
+ activeThreadBranchOverride?: string | null;
+ onActiveThreadBranchOverrideChange?: (branch: string | null) => void;
onCheckoutPullRequestRequest?: (reference: string) => void;
onComposerFocusRequest?: () => void;
}
@@ -77,6 +80,9 @@ export function BranchToolbarBranchSelector({
threadId,
draftId,
envLocked,
+ effectiveEnvModeOverride,
+ activeThreadBranchOverride,
+ onActiveThreadBranchOverrideChange,
onCheckoutPullRequestRequest,
onComposerFocusRequest,
}: BranchToolbarBranchSelectorProps) {
@@ -108,16 +114,21 @@ export function BranchToolbarBranchSelector({
const activeProject = useStore(activeProjectSelector);
const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined);
- const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null;
+ const activeThreadBranch =
+ activeThreadBranchOverride !== undefined
+ ? activeThreadBranchOverride
+ : (serverThread?.branch ?? draftThread?.branch ?? null);
const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
const activeProjectCwd = activeProject?.cwd ?? null;
const branchCwd = activeWorktreePath ?? activeProjectCwd;
const hasServerThread = serverThread !== undefined;
- const effectiveEnvMode = resolveEffectiveEnvMode({
- activeWorktreePath,
- hasServerThread,
- draftThreadEnvMode: draftThread?.envMode,
- });
+ const effectiveEnvMode =
+ effectiveEnvModeOverride ??
+ resolveEffectiveEnvMode({
+ activeWorktreePath,
+ hasServerThread,
+ draftThreadEnvMode: draftThread?.envMode,
+ });
// ---------------------------------------------------------------------------
// Thread branch mutation (colocated — only this component calls it)
@@ -146,6 +157,7 @@ export function BranchToolbarBranchSelector({
});
}
if (hasServerThread) {
+ onActiveThreadBranchOverrideChange?.(branch);
setThreadBranchAction(threadRef, branch, worktreePath);
return;
}
@@ -167,6 +179,7 @@ export function BranchToolbarBranchSelector({
serverSession,
activeWorktreePath,
hasServerThread,
+ onActiveThreadBranchOverrideChange,
setThreadBranchAction,
setDraftThreadContext,
draftId,
diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx
index 39bf50359d..6e1c80f557 100644
--- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx
+++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx
@@ -4,6 +4,7 @@ import { memo, useMemo } from "react";
import {
resolveCurrentWorkspaceLabel,
resolveEnvModeLabel,
+ resolveLockedWorkspaceLabel,
type EnvMode,
} from "./BranchToolbar.logic";
import {
@@ -43,12 +44,12 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
{activeWorktreePath ? (
<>
- {resolveCurrentWorkspaceLabel(activeWorktreePath)}
+ {resolveLockedWorkspaceLabel(activeWorktreePath)}
>
) : (
<>
- {resolveCurrentWorkspaceLabel(activeWorktreePath)}
+ {resolveLockedWorkspaceLabel(activeWorktreePath)}
>
)}
diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx
index a65f5755f9..7455c2a8ca 100644
--- a/apps/web/src/components/ChatView.browser.tsx
+++ b/apps/web/src/components/ChatView.browser.tsx
@@ -2521,6 +2521,186 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});
+ it("keeps new-worktree mode on empty server threads and bootstraps the first send", async () => {
+ const snapshot = addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID);
+ const mounted = await mountChatView({
+ viewport: DEFAULT_VIEWPORT,
+ snapshot: {
+ ...snapshot,
+ threads: snapshot.threads.map((thread) =>
+ thread.id === THREAD_ID ? Object.assign({}, thread, { session: null }) : thread,
+ ),
+ },
+ resolveRpc: (body) => {
+ if (body._tag === WS_METHODS.gitListBranches) {
+ return {
+ isRepo: true,
+ hasOriginRemote: true,
+ nextCursor: null,
+ totalCount: 1,
+ branches: [
+ {
+ name: "main",
+ current: true,
+ isDefault: true,
+ worktreePath: null,
+ },
+ ],
+ };
+ }
+ if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
+ return {
+ sequence: fixture.snapshot.snapshotSequence + 1,
+ };
+ }
+ return undefined;
+ },
+ });
+
+ try {
+ await page.getByText("Current checkout", { exact: true }).click();
+ await page.getByText("New worktree", { exact: true }).click();
+
+ await vi.waitFor(
+ () => {
+ expect(findButtonByText("New worktree")).toBeTruthy();
+ },
+ { timeout: 8_000, interval: 16 },
+ );
+
+ useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it");
+ await waitForLayout();
+
+ const sendButton = await waitForSendButton();
+ expect(sendButton.disabled).toBe(false);
+ sendButton.click();
+
+ await vi.waitFor(
+ () => {
+ const turnStartRequest = wsRequests.find(
+ (request) =>
+ request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand &&
+ request.type === "thread.turn.start",
+ ) as
+ | {
+ _tag: string;
+ type?: string;
+ bootstrap?: {
+ createThread?: { projectId?: string };
+ prepareWorktree?: { projectCwd?: string; baseBranch?: string; branch?: string };
+ runSetupScript?: boolean;
+ };
+ }
+ | undefined;
+
+ expect(turnStartRequest).toMatchObject({
+ _tag: ORCHESTRATION_WS_METHODS.dispatchCommand,
+ type: "thread.turn.start",
+ bootstrap: {
+ prepareWorktree: {
+ projectCwd: "/repo/project",
+ baseBranch: "main",
+ branch: expect.stringMatching(/^t3code\/[0-9a-f]{8}$/),
+ },
+ runSetupScript: true,
+ },
+ });
+ expect(turnStartRequest?.bootstrap?.createThread).toBeUndefined();
+ },
+ { timeout: 8_000, interval: 16 },
+ );
+ } finally {
+ await mounted.cleanup();
+ }
+ });
+
+ it("updates the selected worktree base branch on empty server threads", async () => {
+ const snapshot = addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID);
+ const mounted = await mountChatView({
+ viewport: DEFAULT_VIEWPORT,
+ snapshot: {
+ ...snapshot,
+ threads: snapshot.threads.map((thread) =>
+ thread.id === THREAD_ID ? Object.assign({}, thread, { session: null }) : thread,
+ ),
+ },
+ resolveRpc: (body) => {
+ if (body._tag === WS_METHODS.gitListBranches) {
+ return {
+ isRepo: true,
+ hasOriginRemote: true,
+ nextCursor: null,
+ totalCount: 2,
+ branches: [
+ {
+ name: "main",
+ current: true,
+ isDefault: true,
+ worktreePath: null,
+ },
+ {
+ name: "release/next",
+ current: false,
+ isDefault: false,
+ worktreePath: null,
+ },
+ ],
+ };
+ }
+ if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
+ return {
+ sequence: fixture.snapshot.snapshotSequence + 1,
+ };
+ }
+ return undefined;
+ },
+ });
+
+ try {
+ await page.getByText("Current checkout", { exact: true }).click();
+ await page.getByText("New worktree", { exact: true }).click();
+ await page.getByText("From main", { exact: true }).click();
+ await page.getByText("release/next", { exact: true }).click();
+
+ await vi.waitFor(
+ () => {
+ expect(findButtonByText("From release/next")).toBeTruthy();
+ },
+ { timeout: 8_000, interval: 16 },
+ );
+
+ useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it");
+ await waitForLayout();
+
+ const sendButton = await waitForSendButton();
+ expect(sendButton.disabled).toBe(false);
+ sendButton.click();
+
+ await vi.waitFor(
+ () => {
+ const turnStartRequest = wsRequests.find(
+ (request) =>
+ request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand &&
+ request.type === "thread.turn.start",
+ ) as
+ | {
+ _tag: string;
+ type?: string;
+ bootstrap?: {
+ prepareWorktree?: { baseBranch?: string };
+ };
+ }
+ | undefined;
+
+ expect(turnStartRequest?.bootstrap?.prepareWorktree?.baseBranch).toBe("release/next");
+ },
+ { timeout: 8_000, interval: 16 },
+ );
+ } finally {
+ await mounted.cleanup();
+ }
+ });
+
it("shows the send state once bootstrap dispatch is in flight", async () => {
useTerminalStateStore.setState({
terminalStateByThreadKey: {},
diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts
index 1b96265a40..18ec7f506f 100644
--- a/apps/web/src/components/ChatView.logic.test.ts
+++ b/apps/web/src/components/ChatView.logic.test.ts
@@ -11,6 +11,7 @@ import {
deriveComposerSendState,
hasServerAcknowledgedLocalDispatch,
reconcileMountedTerminalThreadIds,
+ resolveSendEnvMode,
shouldWriteThreadErrorToCurrentServerThread,
waitForStartedServerThread,
} from "./ChatView.logic";
@@ -82,6 +83,17 @@ describe("buildExpiredTerminalContextToastCopy", () => {
});
});
+describe("resolveSendEnvMode", () => {
+ it("keeps worktree mode for git repositories", () => {
+ expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree");
+ });
+
+ it("forces local mode for non-git repositories", () => {
+ expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: false })).toBe("local");
+ expect(resolveSendEnvMode({ requestedEnvMode: "local", isGitRepo: false })).toBe("local");
+ });
+});
+
describe("reconcileMountedTerminalThreadIds", () => {
it("keeps previously mounted open threads and adds the active open thread", () => {
expect(
diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts
index a753a71b39..daa526051e 100644
--- a/apps/web/src/components/ChatView.logic.ts
+++ b/apps/web/src/components/ChatView.logic.ts
@@ -17,6 +17,7 @@ import {
stripInlineTerminalContextPlaceholders,
type TerminalContextDraft,
} from "../lib/terminalContext";
+import type { DraftThreadEnvMode } from "../composerDraftStore";
export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
@@ -163,6 +164,13 @@ export function buildTemporaryWorktreeBranchName(): string {
return `${WORKTREE_BRANCH_PREFIX}/${token}`;
}
+export function resolveSendEnvMode(input: {
+ requestedEnvMode: DraftThreadEnvMode;
+ isGitRepo: boolean;
+}): DraftThreadEnvMode {
+ return input.isGitRepo ? input.requestedEnvMode : "local";
+}
+
export function cloneComposerImageForRetry(
image: ComposerImageAttachment,
): ComposerImageAttachment {
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 7a5a2875cc..1de2a588fa 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -155,6 +155,7 @@ import {
deriveLockedProvider,
readFileAsDataUrl,
reconcileMountedTerminalThreadIds,
+ resolveSendEnvMode,
revokeBlobPreviewUrl,
revokeUserMessagePreviewUrls,
shouldWriteThreadErrorToCurrentServerThread,
@@ -690,6 +691,9 @@ export default function ChatView(props: ChatViewProps) {
const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<
Record
>({});
+ const [pendingServerThreadEnvMode, setPendingServerThreadEnvMode] =
+ useState(null);
+ const [pendingServerThreadBranch, setPendingServerThreadBranch] = useState();
const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage(
LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
{},
@@ -2200,11 +2204,37 @@ export default function ChatView(props: ChatViewProps) {
}, []);
const activeWorktreePath = activeThread?.worktreePath ?? null;
- const envMode: DraftThreadEnvMode = resolveEffectiveEnvMode({
+ const derivedEnvMode: DraftThreadEnvMode = resolveEffectiveEnvMode({
activeWorktreePath,
hasServerThread: isServerThread,
draftThreadEnvMode: isLocalDraftThread ? draftThread?.envMode : undefined,
});
+ const canOverrideServerThreadEnvMode = Boolean(
+ isServerThread &&
+ activeThread &&
+ activeThread.messages.length === 0 &&
+ activeThread.worktreePath === null &&
+ !envLocked,
+ );
+ const envMode: DraftThreadEnvMode = canOverrideServerThreadEnvMode
+ ? (pendingServerThreadEnvMode ?? draftThread?.envMode ?? derivedEnvMode)
+ : derivedEnvMode;
+ const activeThreadBranch =
+ canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined
+ ? pendingServerThreadBranch
+ : (activeThread?.branch ?? null);
+ const sendEnvMode = resolveSendEnvMode({
+ requestedEnvMode: envMode,
+ isGitRepo,
+ });
+
+ useEffect(() => {
+ if (canOverrideServerThreadEnvMode) {
+ return;
+ }
+ setPendingServerThreadEnvMode(null);
+ setPendingServerThreadBranch(undefined);
+ }, [canOverrideServerThreadEnvMode, activeThread?.id]);
useEffect(() => {
if (!activeThreadId) {
@@ -2527,15 +2557,15 @@ export default function ChatView(props: ChatViewProps) {
const threadIdForSend = activeThread.id;
const isFirstMessage = !isServerThread || activeThread.messages.length === 0;
const baseBranchForWorktree =
- isFirstMessage && envMode === "worktree" && !activeThread.worktreePath
- ? activeThread.branch
+ isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath
+ ? activeThreadBranch
: null;
// In worktree mode, require an explicit base branch so we don't silently
// fall back to local execution when branch selection is missing.
const shouldCreateWorktree =
- isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
- if (shouldCreateWorktree && !activeThread.branch) {
+ isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath;
+ if (shouldCreateWorktree && !activeThreadBranch) {
setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");
return;
}
@@ -2669,7 +2699,7 @@ export default function ChatView(props: ChatViewProps) {
modelSelection: threadCreateModelSelection,
runtimeMode,
interactionMode,
- branch: activeThread.branch,
+ branch: activeThreadBranch,
worktreePath: activeThread.worktreePath,
createdAt: activeThread.createdAt,
},
@@ -3117,7 +3147,7 @@ export default function ChatView(props: ChatViewProps) {
modelSelection: nextThreadModelSelection,
runtimeMode,
interactionMode: "default",
- branch: activeThread.branch,
+ branch: activeThreadBranch,
worktreePath: activeThread.worktreePath,
createdAt,
})
@@ -3176,6 +3206,7 @@ export default function ChatView(props: ChatViewProps) {
}, [
activeProject,
activeProposedPlan,
+ activeThreadBranch,
activeThread,
beginLocalDispatch,
isConnecting,
@@ -3224,6 +3255,11 @@ export default function ChatView(props: ChatViewProps) {
);
const onEnvModeChange = useCallback(
(mode: DraftThreadEnvMode) => {
+ if (canOverrideServerThreadEnvMode) {
+ setPendingServerThreadEnvMode(mode);
+ scheduleComposerFocus();
+ return;
+ }
if (isLocalDraftThread) {
setDraftThreadContext(composerDraftTarget, {
envMode: mode,
@@ -3233,9 +3269,11 @@ export default function ChatView(props: ChatViewProps) {
scheduleComposerFocus();
},
[
+ canOverrideServerThreadEnvMode,
composerDraftTarget,
draftThread?.worktreePath,
isLocalDraftThread,
+ setPendingServerThreadEnvMode,
scheduleComposerFocus,
setDraftThreadContext,
],
@@ -3473,6 +3511,13 @@ export default function ChatView(props: ChatViewProps) {
threadId={activeThread.id}
{...(routeKind === "draft" && draftId ? { draftId } : {})}
onEnvModeChange={onEnvModeChange}
+ {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})}
+ {...(canOverrideServerThreadEnvMode
+ ? {
+ activeThreadBranchOverride: activeThreadBranch,
+ onActiveThreadBranchOverrideChange: setPendingServerThreadBranch,
+ }
+ : {})}
envLocked={envLocked}
onComposerFocusRequest={scheduleComposerFocus}
{...(canCheckoutPullRequestIntoThread