From d40a3b4459077bcc33263192994b019f8ab4b4cb Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 10 Sep 2026 00:36:13 +0000 Subject: [PATCH 1/2] fix(web): keep the current worktree on the new-local-thread shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cmd/Ctrl+Shift+N routed through the generic startNewThreadFromContext, which deliberately inherits only the project, so a quick parallel chat landed on the project's configured defaults instead of the branch and worktree the user was looking at. The chat.newLocal shortcut now goes through its own startNewLocalThreadFromContext, which passes the active thread or draft's branch, worktreePath, and env mode to handleNewThread — the same options the thread menu's "New thread on " already uses. Other new-thread entry points are unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/lib/chatThreadActions.test.ts | 49 ++++++++++++++++++++++ apps/web/src/lib/chatThreadActions.ts | 46 +++++++++++++++++++- apps/web/src/routes/_chat.tsx | 7 +++- 3 files changed, 99 insertions(+), 3 deletions(-) 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..e8b659984d04 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,38 @@ export async function startNewThreadFromContext( await context.handleNewThread(projectRef); return true; } + +// The `chat.newLocal` shortcut (mod+shift+n) is the keyboard twin of the +// thread menu's "New thread on ": it starts a thread in the checkout +// the user is already looking at, so a quick parallel task lands in the same +// worktree instead of the project's configured defaults. Falls back to plain +// defaults when nothing is open and only the default project applies. +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 branch = source.branch ?? null; + const worktreePath = source.worktreePath ?? null; + await context.handleNewThread(projectRef, { + branch, + 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"), + // Reusing an existing checkout must never re-bootstrap it from origin. + startFromOrigin: false, + }); + return true; +} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index e084e22c2cbb..0a6553ff5cd4 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -11,7 +11,10 @@ import { selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; -import { startNewThreadFromContext } from "../lib/chatThreadActions"; +import { + startNewLocalThreadFromContext, + startNewThreadFromContext, +} from "../lib/chatThreadActions"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { resolveShortcutCommand } from "../keybindings"; @@ -80,7 +83,7 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.newLocal") { event.preventDefault(); event.stopPropagation(); - void startNewThreadFromContext({ + void startNewLocalThreadFromContext({ activeDraftThread, activeThread: activeThread ?? undefined, defaultProjectRef, From 9e78e929fb667b6f54e6c158164cca63adb80baf Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 6 Oct 2026 17:01:01 +0000 Subject: [PATCH 2/2] fix(web): omit unspecified draft origin preference --- apps/web/src/lib/chatThreadActions.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 43458f399772..60def0a54eee 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -129,15 +129,16 @@ export async function startNewLocalThreadFromContext( } 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"), - // Preserve origin bootstrapping only for a draft awaiting its new worktree. - startFromOrigin: - !thread && !worktreePath && draft?.envMode === "worktree" ? draft.startFromOrigin : false, + ...(startFromOrigin !== undefined ? { startFromOrigin } : {}), }); return true; }