diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index c145404a74e2..3a4a6220a219 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -11,6 +11,7 @@ import { hasExplicitComposerModelSelection, resolveNewDraftStartFromOrigin, resolveNewThreadModelSelectionOverride, + startNewLocalThreadFromContext, startNewThreadFromContext, type ChatThreadActionContext, } from "./chatThreadActions"; @@ -155,6 +156,54 @@ describe("chatThreadActions", () => { expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); }); + it("keeps the active thread's worktree for the new-local shortcut", async () => { + const handleNewThread = vi.fn(async () => {}); + + const didStart = await startNewLocalThreadFromContext( + createContext({ + activeThread: { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + branch: "feature/one", + worktreePath: "/tmp/worktrees/feature-one", + }, + handleNewThread, + }), + ); + + expect(didStart).toBe(true); + expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), { + branch: "feature/one", + worktreePath: "/tmp/worktrees/feature-one", + envMode: "worktree", + startFromOrigin: false, + }); + }); + + it("keeps a branch-only thread on the local checkout for the new-local shortcut", async () => { + const handleNewThread = vi.fn(async () => {}); + + await startNewLocalThreadFromContext( + createContext({ + activeDraftThread: { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + branch: "feature/two", + worktreePath: null, + envMode: "local", + }, + handleNewThread, + }), + ); + + expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), { + branch: "feature/two", + worktreePath: null, + envMode: "local", + startFromOrigin: false, + }); + }); + it("does not start a thread when there is no project context", async () => { const handleNewThread = vi.fn(async () => {}); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index c14a26d03d1c..60def0a54eee 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -15,6 +15,15 @@ type ComposerModelSelectionState = Pick< interface ThreadContextLike { environmentId: EnvironmentId; projectId: ProjectId; + // Only the "new thread in this workspace" shortcut reads the checkout the + // context is sitting on; the generic new-thread paths ignore these. + branch?: string | null; + worktreePath?: string | null; +} + +interface DraftThreadContextLike extends ThreadContextLike { + envMode?: DraftThreadEnvMode; + startFromOrigin?: boolean; } interface NewThreadHandler { @@ -31,7 +40,7 @@ interface NewThreadHandler { } export interface ChatThreadActionContext { - readonly activeDraftThread: ThreadContextLike | null; + readonly activeDraftThread: DraftThreadContextLike | null; readonly activeThread: ThreadContextLike | undefined; readonly defaultProjectRef: ScopedProjectRef | null; readonly handleNewThread: NewThreadHandler; @@ -100,3 +109,36 @@ export async function startNewThreadFromContext( await context.handleNewThread(projectRef); return true; } + +// The new-local shortcut keeps the current checkout; without an open context, +// it uses the default project and its new-thread defaults. +export async function startNewLocalThreadFromContext( + context: ChatThreadActionContext, +): Promise { + const projectRef = resolveThreadActionProjectRef(context); + if (!projectRef) { + return false; + } + + const thread = context.activeThread; + const draft = context.activeDraftThread; + const source = thread ?? draft; + if (!source) { + await context.handleNewThread(projectRef); + return true; + } + + const worktreePath = source.worktreePath ?? null; + // Preserve origin bootstrapping only for a draft awaiting its new worktree. + const startFromOrigin = + !thread && !worktreePath && draft?.envMode === "worktree" ? draft.startFromOrigin : false; + await context.handleNewThread(projectRef, { + branch: source.branch ?? null, + worktreePath, + // A draft still owns its env mode outright; a real thread only ever ran + // in its own worktree or the local checkout. + envMode: (thread ? undefined : draft?.envMode) ?? (worktreePath ? "worktree" : "local"), + ...(startFromOrigin !== undefined ? { startFromOrigin } : {}), + }); + return true; +} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 5be294ae0061..b4e95615f925 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -15,7 +15,10 @@ import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useScratchProject } from "../hooks/useScratchProject"; -import { startNewThreadFromContext } from "../lib/chatThreadActions"; +import { + startNewLocalThreadFromContext, + startNewThreadFromContext, +} from "../lib/chatThreadActions"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isEditableFocused } from "../lib/editableFocus"; @@ -98,7 +101,7 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.newLocal") { event.preventDefault(); event.stopPropagation(); - void startNewThreadFromContext({ + void startNewLocalThreadFromContext({ activeDraftThread, activeThread: activeThread ?? undefined, defaultProjectRef,