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