Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions apps/web/src/lib/chatThreadActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
hasExplicitComposerModelSelection,
resolveNewDraftStartFromOrigin,
resolveNewThreadModelSelectionOverride,
startNewLocalThreadFromContext,
startNewThreadFromContext,
type ChatThreadActionContext,
} from "./chatThreadActions";
Expand Down Expand Up @@ -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<ChatThreadActionContext["handleNewThread"]>(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<ChatThreadActionContext["handleNewThread"]>(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<ChatThreadActionContext["handleNewThread"]>(async () => {});

Expand Down
46 changes: 45 additions & 1 deletion apps/web/src/lib/chatThreadActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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 <branch>": 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<boolean> {
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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High lib/chatThreadActions.ts:143

startNewLocalThreadFromContext always passes startFromOrigin: false, so a draft with envMode: "worktree", no worktreePath, and startFromOrigin: true creates its worktree from the stale local branch instead of origin. Preserve the draft's setting when no existing checkout is being reused, while keeping false for real threads or drafts with an existing worktreePath.

Suggested change
startFromOrigin: false,
startFromOrigin: thread || worktreePath ? false : draft?.startFromOrigin,
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/lib/chatThreadActions.ts around line 143:

`startNewLocalThreadFromContext` always passes `startFromOrigin: false`, so a draft with `envMode: "worktree"`, no `worktreePath`, and `startFromOrigin: true` creates its worktree from the stale local branch instead of `origin`. Preserve the draft's setting when no existing checkout is being reused, while keeping `false` for real threads or drafts with an existing `worktreePath`.

});
return true;
}
7 changes: 5 additions & 2 deletions apps/web/src/routes/_chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -80,7 +83,7 @@ function ChatRouteGlobalShortcuts() {
if (command === "chat.newLocal") {
event.preventDefault();
event.stopPropagation();
void startNewThreadFromContext({
void startNewLocalThreadFromContext({
activeDraftThread,
activeThread: activeThread ?? undefined,
defaultProjectRef,
Expand Down
Loading