diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts
index cbb9ec82b88..c4ae7b96694 100644
--- a/apps/web/src/components/BranchToolbar.logic.test.ts
+++ b/apps/web/src/components/BranchToolbar.logic.test.ts
@@ -12,6 +12,8 @@ import {
resolveBranchToolbarValue,
resolveLockedWorkspaceLabel,
resolveLocalCheckoutBranchMismatch,
+ resolvePreviousWorktreeLabel,
+ resolvePreviousWorktreeSeed,
shouldIncludeBranchPickerItem,
shouldShowEnvironmentIndicator,
} from "./BranchToolbar.logic";
@@ -19,6 +21,91 @@ import {
const localEnvironmentId = EnvironmentId.make("environment-local");
const remoteEnvironmentId = EnvironmentId.make("environment-remote");
+describe("resolvePreviousWorktreeSeed", () => {
+ it("picks the most recently updated worktree thread", () => {
+ expect(
+ resolvePreviousWorktreeSeed({
+ threads: [
+ {
+ branch: "t3/older",
+ worktreePath: "/repo/.t3/worktrees/older",
+ updatedAt: "2026-07-20T00:00:00.000Z",
+ },
+ {
+ branch: "t3/newer",
+ worktreePath: "/repo/.t3/worktrees/newer",
+ updatedAt: "2026-07-22T00:00:00.000Z",
+ },
+ { branch: "main", worktreePath: null, updatedAt: "2026-07-23T00:00:00.000Z" },
+ ],
+ currentWorktreePath: null,
+ }),
+ ).toEqual({ branch: "t3/newer", worktreePath: "/repo/.t3/worktrees/newer" });
+ });
+
+ it("skips the worktree the composer already points at", () => {
+ expect(
+ resolvePreviousWorktreeSeed({
+ threads: [
+ {
+ branch: "t3/current",
+ worktreePath: "/repo/.t3/worktrees/current",
+ updatedAt: "2026-07-22T00:00:00.000Z",
+ },
+ ],
+ currentWorktreePath: "/repo/.t3/worktrees/current",
+ }),
+ ).toBeNull();
+ });
+
+ it("returns null when no thread has a worktree", () => {
+ expect(
+ resolvePreviousWorktreeSeed({
+ threads: [{ branch: "main", worktreePath: null, updatedAt: "2026-07-22T00:00:00.000Z" }],
+ currentWorktreePath: null,
+ }),
+ ).toBeNull();
+ });
+
+ it("ignores archived threads and threads with unparseable timestamps", () => {
+ expect(
+ resolvePreviousWorktreeSeed({
+ threads: [
+ {
+ branch: "t3/archived",
+ worktreePath: "/repo/.t3/worktrees/archived",
+ updatedAt: "2026-07-23T00:00:00.000Z",
+ archivedAt: "2026-07-23T01:00:00.000Z",
+ },
+ {
+ branch: "t3/garbage-timestamp",
+ worktreePath: "/repo/.t3/worktrees/garbage",
+ updatedAt: "not-a-date",
+ },
+ {
+ branch: "t3/live",
+ worktreePath: "/repo/.t3/worktrees/live",
+ updatedAt: "2026-07-21T00:00:00.000Z",
+ archivedAt: null,
+ },
+ ],
+ currentWorktreePath: null,
+ }),
+ ).toEqual({ branch: "t3/live", worktreePath: "/repo/.t3/worktrees/live" });
+ });
+});
+
+describe("resolvePreviousWorktreeLabel", () => {
+ it("includes the branch when known", () => {
+ expect(resolvePreviousWorktreeLabel({ branch: "t3/fix-thing", worktreePath: "/wt" })).toBe(
+ "Previous worktree (t3/fix-thing)",
+ );
+ expect(resolvePreviousWorktreeLabel({ branch: null, worktreePath: "/wt" })).toBe(
+ "Previous worktree",
+ );
+ });
+});
+
describe("resolveDraftEnvModeAfterBranchChange", () => {
it("switches to local mode when returning from an existing worktree to the main worktree", () => {
expect(
diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts
index c083c335292..8fe35fa464a 100644
--- a/apps/web/src/components/BranchToolbar.logic.ts
+++ b/apps/web/src/components/BranchToolbar.logic.ts
@@ -1,5 +1,6 @@
import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts";
import * as Schema from "effect/Schema";
+import { toSortableTimestamp } from "../lib/threadSort";
export {
dedupeRemoteBranchesWithLocalMatches,
deriveLocalBranchNameFromRemoteRef,
@@ -65,6 +66,53 @@ export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null):
return activeWorktreePath ? "Worktree" : "Local checkout";
}
+export interface PreviousWorktreeSeed {
+ branch: string | null;
+ worktreePath: string;
+}
+
+// The most recently touched worktree in the project that the composer isn't
+// already pointing at. Backs the "Previous worktree" entry in the workspace
+// selector so a follow-up thread can hop back into the worktree you just
+// worked in without hunting for its branch. Archived threads don't compete —
+// the rest of the UI hides them, so their worktrees shouldn't resurface here.
+export function resolvePreviousWorktreeSeed(input: {
+ threads: ReadonlyArray<{
+ branch: string | null;
+ worktreePath: string | null;
+ updatedAt: string;
+ archivedAt?: string | null;
+ }>;
+ currentWorktreePath: string | null;
+}): PreviousWorktreeSeed | null {
+ let latest: { branch: string | null; worktreePath: string; updatedAt: number } | null = null;
+ for (const thread of input.threads) {
+ if (
+ !thread.worktreePath ||
+ thread.worktreePath === input.currentWorktreePath ||
+ (thread.archivedAt ?? null) !== null
+ ) {
+ continue;
+ }
+ const updatedAt = toSortableTimestamp(thread.updatedAt);
+ if (updatedAt === null) {
+ continue;
+ }
+ if (latest === null || updatedAt > latest.updatedAt) {
+ latest = {
+ branch: thread.branch,
+ worktreePath: thread.worktreePath,
+ updatedAt,
+ };
+ }
+ }
+ return latest === null ? null : { branch: latest.branch, worktreePath: latest.worktreePath };
+}
+
+export function resolvePreviousWorktreeLabel(seed: PreviousWorktreeSeed): string {
+ return seed.branch ? `Previous worktree (${seed.branch})` : "Previous worktree";
+}
+
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 bee8478a4b7..e703427d4b6 100644
--- a/apps/web/src/components/BranchToolbar.tsx
+++ b/apps/web/src/components/BranchToolbar.tsx
@@ -6,12 +6,13 @@ import {
FolderGit2Icon,
FolderGitIcon,
FolderIcon,
+ HistoryIcon,
MonitorIcon,
} from "lucide-react";
-import { memo, useMemo } from "react";
+import { memo, useCallback, useMemo } from "react";
import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
-import { useProject, useThread } from "../state/entities";
+import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities";
import { useIsMobile } from "../hooks/useMediaQuery";
import {
type EnvMode,
@@ -20,6 +21,8 @@ import {
resolveEnvModeLabel,
resolveEffectiveEnvMode,
resolveLockedWorkspaceLabel,
+ resolvePreviousWorktreeLabel,
+ resolvePreviousWorktreeSeed,
shouldShowEnvironmentIndicator,
} from "./BranchToolbar.logic";
import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector";
@@ -66,6 +69,8 @@ interface MobileRunContextSelectorProps {
effectiveEnvMode: EnvMode;
activeWorktreePath: string | null;
onEnvModeChange: (mode: EnvMode) => void;
+ previousWorktreeLabel: string | null;
+ onUsePreviousWorktree: () => void;
}
const MobileRunContextSelector = memo(function MobileRunContextSelector({
@@ -79,6 +84,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
effectiveEnvMode,
activeWorktreePath,
onEnvModeChange,
+ previousWorktreeLabel,
+ onUsePreviousWorktree,
}: MobileRunContextSelectorProps) {
const activeEnvironment = useMemo(
() => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null,
@@ -166,7 +173,13 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
Workspace
onEnvModeChange(value as EnvMode)}
+ onValueChange={(value) => {
+ if (value === "previous-worktree") {
+ onUsePreviousWorktree();
+ return;
+ }
+ onEnvModeChange(value as EnvMode);
+ }}
>
@@ -186,6 +199,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
{resolveEnvModeLabel("worktree")}
+ {previousWorktreeLabel ? (
+
+
+
+ {previousWorktreeLabel}
+
+
+ ) : null}
@@ -217,6 +238,7 @@ export const BranchToolbar = memo(function BranchToolbar({
const draftThread = useComposerDraftStore((store) =>
draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef),
);
+ const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
const activeProjectRef = serverThread
? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
: draftThread
@@ -234,6 +256,40 @@ export const BranchToolbar = memo(function BranchToolbar({
});
const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null);
+ // "Previous worktree" hops a draft into the most recently active worktree
+ // of this project — the "keep going where I just was" follow-up flow. Only
+ // drafts can hop; started server threads have their workspace pinned.
+ const canUsePreviousWorktree = draftThread !== null && serverThread === null && !envModeLocked;
+ const projectRefsForWorktreeLookup = useMemo(
+ () => (canUsePreviousWorktree && activeProjectRef ? [activeProjectRef] : []),
+ [canUsePreviousWorktree, activeProjectRef],
+ );
+ const projectThreads = useThreadShellsForProjectRefs(projectRefsForWorktreeLookup);
+ const previousWorktreeSeed = useMemo(
+ () =>
+ canUsePreviousWorktree
+ ? resolvePreviousWorktreeSeed({
+ threads: projectThreads,
+ currentWorktreePath: activeWorktreePath,
+ })
+ : null,
+ [activeWorktreePath, canUsePreviousWorktree, projectThreads],
+ );
+ const previousWorktreeLabel = previousWorktreeSeed
+ ? resolvePreviousWorktreeLabel(previousWorktreeSeed)
+ : null;
+ const onUsePreviousWorktree = useCallback(() => {
+ if (!previousWorktreeSeed || !activeProjectRef) return;
+ // Same shape the branch selector writes when picking a branch that
+ // already lives in a worktree: point the draft at the existing tree.
+ setDraftThreadContext(draftId ?? threadRef, {
+ branch: previousWorktreeSeed.branch,
+ worktreePath: previousWorktreeSeed.worktreePath,
+ envMode: "worktree",
+ projectRef: activeProjectRef,
+ });
+ }, [activeProjectRef, draftId, previousWorktreeSeed, setDraftThreadContext, threadRef]);
+
const showEnvironmentPicker = Boolean(
availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange,
);
@@ -261,6 +317,8 @@ export const BranchToolbar = memo(function BranchToolbar({
effectiveEnvMode={effectiveEnvMode}
activeWorktreePath={activeWorktreePath}
onEnvModeChange={onEnvModeChange}
+ previousWorktreeLabel={previousWorktreeLabel}
+ onUsePreviousWorktree={onUsePreviousWorktree}
/>
) : (
@@ -280,6 +338,8 @@ export const BranchToolbar = memo(function BranchToolbar({
effectiveEnvMode={effectiveEnvMode}
activeWorktreePath={activeWorktreePath}
onEnvModeChange={onEnvModeChange}
+ previousWorktreeLabel={previousWorktreeLabel}
+ onUsePreviousWorktree={onUsePreviousWorktree}
/>
)}
diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx
index 6d06882662f..e915c27312c 100644
--- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx
+++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx
@@ -1,4 +1,4 @@
-import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react";
+import { FolderGit2Icon, FolderGitIcon, FolderIcon, HistoryIcon } from "lucide-react";
import { memo, useMemo } from "react";
import {
@@ -17,11 +17,15 @@ import {
SelectValue,
} from "./ui/select";
+export const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree";
+
interface BranchToolbarEnvModeSelectorProps {
envLocked: boolean;
effectiveEnvMode: EnvMode;
activeWorktreePath: string | null;
onEnvModeChange: (mode: EnvMode) => void;
+ previousWorktreeLabel?: string | null;
+ onUsePreviousWorktree?: () => void;
}
export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({
@@ -29,13 +33,19 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
effectiveEnvMode,
activeWorktreePath,
onEnvModeChange,
+ previousWorktreeLabel,
+ onUsePreviousWorktree,
}: BranchToolbarEnvModeSelectorProps) {
+ const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree);
const envModeItems = useMemo(
() => [
{ value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) },
{ value: "worktree", label: resolveEnvModeLabel("worktree") },
+ ...(showPreviousWorktree && previousWorktreeLabel
+ ? [{ value: PREVIOUS_WORKTREE_SELECT_VALUE, label: previousWorktreeLabel }]
+ : []),
],
- [activeWorktreePath],
+ [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree],
);
if (envLocked) {
@@ -60,7 +70,13 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx
index ffe3264fbf8..aa7547c8ba6 100644
--- a/apps/web/src/components/CommandPalette.tsx
+++ b/apps/web/src/components/CommandPalette.tsx
@@ -58,11 +58,7 @@ import { useAtomCommand } from "../state/use-atom-command";
import { useAtomQueryRunner } from "../state/use-atom-query-runner";
import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
import { useProjects, useThreadShells } from "../state/entities";
-import {
- resolveThreadActionProjectRef,
- startNewThreadInProjectFromContext,
- startNewThreadFromContext,
-} from "../lib/chatThreadActions";
+import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";
import {
appendBrowsePathSegment,
canNavigateUp,
@@ -837,13 +833,7 @@ function OpenCommandPaletteDialog(props: {
projectRef.environmentId === contextualProjectRef.environmentId &&
projectRef.projectId === contextualProjectRef.projectId,
);
- await startNewThreadInProjectFromContext(
- {
- activeDraftThread,
- activeThread: activeThread ?? undefined,
- defaultProjectRef,
- handleNewThread,
- },
+ await handleNewThread(
contextualRefBelongsToGroup
? contextualProjectRef
: scopeProjectRef(project.environmentId, project.id),
@@ -851,15 +841,7 @@ function OpenCommandPaletteDialog(props: {
},
}),
),
- [
- activeDraftThread,
- activeThread,
- contextualProjectRef,
- defaultProjectRef,
- handleNewThread,
- pickerProjects,
- projectGroupByTargetKey,
- ],
+ [contextualProjectRef, handleNewThread, pickerProjects, projectGroupByTargetKey],
);
const allThreadItems = useMemo(
diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts
index 65424b830a2..59784bf8fac 100644
--- a/apps/web/src/components/Sidebar.logic.test.ts
+++ b/apps/web/src/components/Sidebar.logic.test.ts
@@ -14,8 +14,6 @@ import {
isTrailingDoubleClick,
orderItemsByPreferredIds,
resolveProjectStatusIndicator,
- resolveSidebarNewThreadSeedContext,
- resolveSidebarNewThreadEnvMode,
resolveSidebarStageBadgeLabel,
resolveThreadRowClassName,
resolveSidebarV2Status,
@@ -371,112 +369,6 @@ describe("isTrailingDoubleClick", () => {
});
});
-describe("resolveSidebarNewThreadEnvMode", () => {
- it("uses the app default when the caller does not request a specific mode", () => {
- expect(
- resolveSidebarNewThreadEnvMode({
- defaultEnvMode: "worktree",
- }),
- ).toBe("worktree");
- });
-
- it("preserves an explicit requested mode over the app default", () => {
- expect(
- resolveSidebarNewThreadEnvMode({
- requestedEnvMode: "local",
- defaultEnvMode: "worktree",
- }),
- ).toBe("local");
- });
-});
-
-describe("resolveSidebarNewThreadSeedContext", () => {
- it("prefers the default worktree mode over active thread context", () => {
- expect(
- resolveSidebarNewThreadSeedContext({
- projectId: "project-1",
- defaultEnvMode: "worktree",
- activeThread: {
- projectId: "project-1",
- branch: "feature/existing",
- worktreePath: "/repo/.t3/worktrees/existing",
- },
- activeDraftThread: {
- projectId: "project-1",
- branch: "feature/draft",
- worktreePath: "/repo/.t3/worktrees/draft",
- envMode: "worktree",
- startFromOrigin: true,
- },
- }),
- ).toEqual({
- envMode: "worktree",
- });
- });
-
- it("inherits the active server thread context when creating a new thread in the same project", () => {
- expect(
- resolveSidebarNewThreadSeedContext({
- projectId: "project-1",
- defaultEnvMode: "local",
- activeThread: {
- projectId: "project-1",
- branch: "effect-atom",
- worktreePath: null,
- },
- activeDraftThread: null,
- }),
- ).toEqual({
- branch: "effect-atom",
- worktreePath: null,
- envMode: "local",
- });
- });
-
- it("prefers the active draft thread context when it matches the target project", () => {
- expect(
- resolveSidebarNewThreadSeedContext({
- projectId: "project-1",
- defaultEnvMode: "local",
- activeThread: {
- projectId: "project-1",
- branch: "effect-atom",
- worktreePath: null,
- },
- activeDraftThread: {
- projectId: "project-1",
- branch: "feature/new-draft",
- worktreePath: "/repo/worktree",
- envMode: "worktree",
- startFromOrigin: true,
- },
- }),
- ).toEqual({
- branch: "feature/new-draft",
- worktreePath: "/repo/worktree",
- envMode: "worktree",
- startFromOrigin: true,
- });
- });
-
- it("falls back to the default env mode when there is no matching active thread context", () => {
- expect(
- resolveSidebarNewThreadSeedContext({
- projectId: "project-2",
- defaultEnvMode: "worktree",
- activeThread: {
- projectId: "project-1",
- branch: "effect-atom",
- worktreePath: null,
- },
- activeDraftThread: null,
- }),
- ).toEqual({
- envMode: "worktree",
- });
- });
-});
-
describe("orderItemsByPreferredIds", () => {
it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => {
const ordered = orderItemsByPreferredIds({
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts
index e7082ca8ada..7aee3100d0e 100644
--- a/apps/web/src/components/Sidebar.logic.ts
+++ b/apps/web/src/components/Sidebar.logic.ts
@@ -18,7 +18,6 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100;
// Visible sidebar rows are prewarmed into the thread-detail cache so opening a
// nearby thread usually reuses an already-hot subscription.
export const SIDEBAR_THREAD_PREWARM_LIMIT = 10;
-export type SidebarNewThreadEnvMode = "local" | "worktree";
type SidebarProject = {
id: string;
title: string;
@@ -246,62 +245,6 @@ export function isTrailingDoubleClick(detail: number): boolean {
return detail > 1;
}
-export function resolveSidebarNewThreadEnvMode(input: {
- requestedEnvMode?: SidebarNewThreadEnvMode;
- defaultEnvMode: SidebarNewThreadEnvMode;
-}): SidebarNewThreadEnvMode {
- return input.requestedEnvMode ?? input.defaultEnvMode;
-}
-
-export function resolveSidebarNewThreadSeedContext(input: {
- projectId: string;
- defaultEnvMode: SidebarNewThreadEnvMode;
- activeThread?: {
- projectId: string;
- branch: string | null;
- worktreePath: string | null;
- } | null;
- activeDraftThread?: {
- projectId: string;
- branch: string | null;
- worktreePath: string | null;
- envMode: SidebarNewThreadEnvMode;
- startFromOrigin: boolean;
- } | null;
-}): {
- branch?: string | null;
- worktreePath?: string | null;
- envMode: SidebarNewThreadEnvMode;
- startFromOrigin?: boolean;
-} {
- if (input.defaultEnvMode === "worktree") {
- return {
- envMode: "worktree",
- };
- }
-
- if (input.activeDraftThread?.projectId === input.projectId) {
- return {
- branch: input.activeDraftThread.branch,
- worktreePath: input.activeDraftThread.worktreePath,
- envMode: input.activeDraftThread.envMode,
- startFromOrigin: input.activeDraftThread.startFromOrigin,
- };
- }
-
- if (input.activeThread?.projectId === input.projectId) {
- return {
- branch: input.activeThread.branch,
- worktreePath: input.activeThread.worktreePath,
- envMode: input.activeThread.worktreePath ? "worktree" : "local",
- };
- }
-
- return {
- envMode: input.defaultEnvMode,
- };
-}
-
export function orderItemsByPreferredIds(input: {
items: readonly TItem[];
preferredIds: readonly TId[];
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 014e629fda7..a1d95eaa734 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -177,8 +177,6 @@ import {
isContextMenuPointerDown,
isTrailingDoubleClick,
resolveProjectStatusIndicator,
- resolveSidebarNewThreadSeedContext,
- resolveSidebarNewThreadEnvMode,
resolveThreadRowClassName,
resolveThreadStatusPill,
orderItemsByPreferredIds,
@@ -193,7 +191,7 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { useIsMobile } from "~/hooks/useMediaQuery";
import { CommandDialogTrigger } from "./ui/command";
import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
-import { primaryServerKeybindingsAtom, primaryServerSettingsAtom } from "../state/server";
+import { primaryServerKeybindingsAtom } from "../state/server";
import {
derivePhysicalProjectKey,
deriveProjectGroupingOverrideKey,
@@ -1103,7 +1101,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
(settings) => settings.confirmThreadArchive,
);
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
- const primaryServerSettings = useAtomValue(primaryServerSettingsAtom);
const deleteProject = useAtomCommand(projectEnvironment.delete, {
reportFailure: false,
});
@@ -1875,61 +1872,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
const createThreadForProjectMember = useCallback(
(member: SidebarProjectGroupMember) => {
- const currentRouteParams =
- router.state.matches[router.state.matches.length - 1]?.params ?? {};
- const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams);
- const currentActiveThread =
- currentRouteTarget?.kind === "server"
- ? readThreadShell(currentRouteTarget.threadRef)
- : null;
- const draftStore = useComposerDraftStore.getState();
- const currentActiveDraftThread =
- currentRouteTarget?.kind === "server"
- ? (draftStore.getDraftThread(currentRouteTarget.threadRef) ?? null)
- : currentRouteTarget?.kind === "draft"
- ? (draftStore.getDraftSession(currentRouteTarget.draftId) ?? null)
- : null;
- const seedContext = resolveSidebarNewThreadSeedContext({
- projectId: member.id,
- // The default env mode is a user preference stored on the primary
- // environment's settings.json; remote environments never carry it.
- defaultEnvMode: resolveSidebarNewThreadEnvMode({
- defaultEnvMode: primaryServerSettings.defaultThreadEnvMode,
- }),
- activeThread:
- currentActiveThread && currentActiveThread.projectId === member.id
- ? {
- projectId: currentActiveThread.projectId,
- branch: currentActiveThread.branch,
- worktreePath: currentActiveThread.worktreePath,
- }
- : null,
- activeDraftThread:
- currentActiveDraftThread && currentActiveDraftThread.projectId === member.id
- ? {
- projectId: currentActiveDraftThread.projectId,
- branch: currentActiveDraftThread.branch,
- worktreePath: currentActiveDraftThread.worktreePath,
- envMode: currentActiveDraftThread.envMode,
- startFromOrigin: currentActiveDraftThread.startFromOrigin,
- }
- : null,
- });
if (isMobile) {
setOpenMobile(false);
}
void (async () => {
+ // No options: branch, worktree, and env mode come from the user's
+ // configured defaults, never from the currently viewed thread.
const result = await settlePromise(() =>
- handleNewThread(scopeProjectRef(member.environmentId, member.id), {
- ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}),
- ...(seedContext.worktreePath !== undefined
- ? { worktreePath: seedContext.worktreePath }
- : {}),
- envMode: seedContext.envMode,
- ...(seedContext.startFromOrigin !== undefined
- ? { startFromOrigin: seedContext.startFromOrigin }
- : {}),
- }),
+ handleNewThread(scopeProjectRef(member.environmentId, member.id)),
);
if (result._tag === "Failure") {
const error = squashAtomCommandFailure(result);
@@ -1943,7 +1893,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
}
})();
},
- [handleNewThread, isMobile, primaryServerSettings.defaultThreadEnvMode, router, setOpenMobile],
+ [handleNewThread, isMobile, setOpenMobile],
);
const handleCreateThreadClick = useCallback(
@@ -2164,6 +2114,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null;
const clicked = await api.contextMenu.show(
[
+ ...(thread.branch
+ ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }]
+ : []),
{ id: "rename", label: "Rename thread" },
{ id: "mark-unread", label: "Mark unread" },
{ id: "copy-path", label: "Copy Path" },
@@ -2173,6 +2126,30 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
position,
);
+ if (clicked === "new-thread-on-branch") {
+ // Explicit branch carry-over: reuse the thread's worktree when it
+ // has one, otherwise its branch on the local checkout.
+ const result = await settlePromise(() =>
+ handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId), {
+ branch: thread.branch,
+ worktreePath: thread.worktreePath,
+ envMode: thread.worktreePath ? "worktree" : "local",
+ startFromOrigin: false,
+ }),
+ );
+ if (result._tag === "Failure") {
+ const error = squashAtomCommandFailure(result);
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Could not create thread",
+ description: error instanceof Error ? error.message : "An error occurred.",
+ }),
+ );
+ }
+ return;
+ }
+
if (clicked === "rename") {
startThreadRename(threadKey, thread.title);
return;
@@ -2229,6 +2206,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
copyPathToClipboard,
copyThreadIdToClipboard,
deleteThread,
+ handleNewThread,
markThreadUnread,
memberProjectByScopedKey,
project.workspaceRoot,
diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx
index 361c3460bfd..1a78ef54eff 100644
--- a/apps/web/src/components/SidebarV2.tsx
+++ b/apps/web/src/components/SidebarV2.tsx
@@ -1577,6 +1577,14 @@ export default function SidebarV2() {
const clicked = await settlePromise(() =>
api.contextMenu.show(
[
+ ...(thread.branch
+ ? [
+ {
+ id: "new-thread-on-branch",
+ label: `New thread on ${thread.branch}`,
+ },
+ ]
+ : []),
...(supportsSettlement
? [
isSettled
@@ -1593,6 +1601,29 @@ export default function SidebarV2() {
);
if (clicked._tag === "Failure") return;
switch (clicked.value) {
+ case "new-thread-on-branch": {
+ // Explicit branch carry-over: reuse the thread's worktree when it
+ // has one, otherwise its branch on the local checkout.
+ const result = await settlePromise(() =>
+ handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId), {
+ branch: thread.branch,
+ worktreePath: thread.worktreePath,
+ envMode: thread.worktreePath ? "worktree" : "local",
+ startFromOrigin: false,
+ }),
+ );
+ if (result._tag === "Failure") {
+ const error = squashAtomCommandFailure(result);
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Could not create thread",
+ description: error instanceof Error ? error.message : "An error occurred.",
+ }),
+ );
+ }
+ return;
+ }
case "settle":
attemptSettle(threadRef);
return;
diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts
index fdb8bfe7b18..d4f38adcacd 100644
--- a/apps/web/src/composerDraftStore.ts
+++ b/apps/web/src/composerDraftStore.ts
@@ -406,6 +406,15 @@ interface ComposerDraftStoreState {
setModelSelection: (
threadRef: ComposerThreadTarget,
modelSelection: ModelSelection | null | undefined,
+ opts?: {
+ /**
+ * Replace the stored entry outright instead of preserving its
+ * existing options when the incoming selection has none. Used when
+ * the selection is a complete snapshot (e.g. carried from another
+ * thread) rather than a model-only change.
+ */
+ replaceOptions?: boolean;
+ },
) => void;
/** Replace the model options for one or more providers in the draft. */
setModelOptions: (
@@ -2594,7 +2603,7 @@ const composerDraftStore = create()(
return { draftsByThreadKey: nextDraftsByThreadKey };
});
},
- setModelSelection: (threadRef, modelSelection) => {
+ setModelSelection: (threadRef, modelSelection, opts) => {
const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
if (threadKey.length === 0) {
return;
@@ -2609,8 +2618,10 @@ const composerDraftStore = create()(
const nextMap = { ...base.modelSelectionByProvider };
if (normalized) {
const current = nextMap[normalized.instanceId];
- if (normalized.options !== undefined) {
- // Explicit options provided → use them
+ if (normalized.options !== undefined || opts?.replaceOptions) {
+ // Explicit options provided (or the caller passed a complete
+ // snapshot whose absent options mean "no options") → use the
+ // selection as-is.
nextMap[normalized.instanceId] = normalized as ModelSelection;
} else {
// No options in selection → preserve existing options, update provider+model
diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts
index cd8546893b0..2479d6ba02f 100644
--- a/apps/web/src/hooks/useHandleNewThread.ts
+++ b/apps/web/src/hooks/useHandleNewThread.ts
@@ -54,14 +54,52 @@ export function useNewThreadHandler() {
},
): Promise => {
const {
+ getComposerDraft,
getDraftSessionByLogicalProjectKey,
getDraftSession,
getDraftThread,
applyStickyState,
setDraftThreadContext,
setLogicalProjectDraftThreadId,
+ setModelSelection,
} = useComposerDraftStore.getState();
const currentRouteTarget = getCurrentRouteTarget();
+ // A new thread carries the user's *working mode* from the thread being
+ // viewed: model (including options like reasoning effort and context
+ // window), permission mode, and interaction mode. Branch, worktree, and
+ // env mode never carry implicitly — those come from the configured
+ // defaults unless the caller passes them explicitly.
+ const carrySourceShell =
+ currentRouteTarget?.kind === "server"
+ ? readThreadShell(currentRouteTarget.threadRef)
+ : null;
+ const carrySourceDraft =
+ currentRouteTarget?.kind === "draft" ? getDraftSession(currentRouteTarget.draftId) : null;
+ // Composer overrides win over the persisted thread state — they are
+ // what the user currently sees in the composer controls.
+ const carrySourceComposer = currentRouteTarget
+ ? getComposerDraft(
+ currentRouteTarget.kind === "server"
+ ? currentRouteTarget.threadRef
+ : currentRouteTarget.draftId,
+ )
+ : null;
+ const composerActiveProvider = carrySourceComposer?.activeProvider ?? null;
+ const composerModelSelection = composerActiveProvider
+ ? (carrySourceComposer?.modelSelectionByProvider[composerActiveProvider] ?? null)
+ : null;
+ const carryModelSelection =
+ composerModelSelection ?? carrySourceShell?.modelSelection ?? null;
+ const carryRuntimeMode =
+ carrySourceComposer?.runtimeMode ??
+ carrySourceShell?.runtimeMode ??
+ carrySourceDraft?.runtimeMode ??
+ null;
+ const carryInteractionMode =
+ carrySourceComposer?.interactionMode ??
+ carrySourceShell?.interactionMode ??
+ carrySourceDraft?.interactionMode ??
+ null;
const project = projects.find(
(candidate) =>
candidate.id === projectRef.projectId &&
@@ -92,25 +130,70 @@ export function useNewThreadHandler() {
: null;
if (reusableStoredDraftThread) {
return (async () => {
- if (
+ const isDraftAlreadyOpen =
+ currentRouteTarget?.kind === "draft" &&
+ currentRouteTarget.draftId === reusableStoredDraftThread.draftId;
+ const hasExplicitWorkspaceOption =
hasBranchOption ||
hasWorktreePathOption ||
hasEnvModeOption ||
- hasStartFromOriginOption
- ) {
+ hasStartFromOriginOption;
+ // Resurrecting a stored draft must not resurrect its stale context:
+ // explicit workspace options win outright; otherwise the env context
+ // resets to the configured defaults so drafts seeded before a
+ // defaults change (or by the old carry-over behavior) stop landing
+ // on "current checkout" branches forever. Composer text is
+ // preserved. When the draft is already open and no options were
+ // passed, leave it alone entirely — the user may have just picked a
+ // branch in the composer.
+ const defaultEnvMode = primaryServerSettings.defaultThreadEnvMode;
+ const workspaceContext = hasExplicitWorkspaceOption
+ ? {
+ ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
+ ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
+ ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
+ ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}),
+ }
+ : isDraftAlreadyOpen
+ ? null
+ : {
+ branch: null,
+ worktreePath: null,
+ envMode: defaultEnvMode,
+ startFromOrigin: resolveNewDraftStartFromOrigin({
+ envMode: defaultEnvMode,
+ newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin,
+ }),
+ };
+ if (workspaceContext) {
setDraftThreadContext(reusableStoredDraftThread.draftId, {
- ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
- ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
- ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
- ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}),
+ ...workspaceContext,
+ ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),
+ ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
});
+ if (carryModelSelection) {
+ // The carried selection is a complete snapshot of the viewed
+ // thread's model state: absent options mean "no options", not
+ // "keep the stale draft's options".
+ setModelSelection(reusableStoredDraftThread.draftId, carryModelSelection, {
+ replaceOptions: true,
+ });
+ }
}
+ // The workspace context must also ride along here: when projectRef
+ // targets a different physical member of the logical project,
+ // createDraftThreadState treats the remap as a project change and
+ // would otherwise wipe branch/worktree and force "local" mode,
+ // undoing the write above.
setLogicalProjectDraftThreadId(
logicalProjectKey,
projectRef,
reusableStoredDraftThread.draftId,
{
threadId: reusableStoredDraftThread.threadId,
+ ...(workspaceContext ?? {}),
+ ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),
+ ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
},
);
if (
@@ -176,9 +259,18 @@ export function useNewThreadHandler() {
envMode: initialEnvMode,
newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin,
}),
- runtimeMode: DEFAULT_RUNTIME_MODE,
+ runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE,
+ ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
});
applyStickyState(draftId);
+ if (carryModelSelection) {
+ // After sticky state so the viewed thread's exact selection
+ // (model + options like effort and context window) wins over the
+ // globally sticky one. replaceOptions: the carried selection is a
+ // complete snapshot — absent options mean "no options", not "keep
+ // whatever sticky state just wrote".
+ setModelSelection(draftId, carryModelSelection, { replaceOptions: true });
+ }
await router.navigate({
to: "/draft/$draftId",
diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts
index 2189f1e3ca2..0902d8de795 100644
--- a/apps/web/src/lib/chatThreadActions.test.ts
+++ b/apps/web/src/lib/chatThreadActions.test.ts
@@ -4,9 +4,7 @@ import { describe, expect, it, vi } from "vite-plus/test";
import {
resolveThreadActionProjectRef,
resolveNewDraftStartFromOrigin,
- startNewLocalThreadFromContext,
startNewThreadFromContext,
- startNewThreadInProjectFromContext,
type ChatThreadActionContext,
} from "./chatThreadActions";
@@ -40,16 +38,12 @@ describe("chatThreadActions", () => {
).toBe(false);
});
- it("prefers the active draft thread project when resolving thread actions", () => {
+ it("prefers the active thread project when resolving thread actions", () => {
const projectRef = resolveThreadActionProjectRef(
createContext({
- activeDraftThread: {
+ activeThread: {
environmentId: ENVIRONMENT_ID,
projectId: PROJECT_ID,
- branch: "feature/refactor",
- worktreePath: "/tmp/worktree",
- envMode: "worktree",
- startFromOrigin: true,
},
}),
);
@@ -57,95 +51,40 @@ describe("chatThreadActions", () => {
expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID));
});
- it("falls back to the default project ref when there is no active thread context", () => {
+ it("falls back to the active draft thread project when there is no active thread", () => {
const projectRef = resolveThreadActionProjectRef(
- createContext({
- defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID),
- }),
- );
-
- expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID));
- });
-
- it("starts a contextual new thread from the active draft thread", async () => {
- const handleNewThread = vi.fn(async () => {});
-
- const didStart = await startNewThreadFromContext(
createContext({
activeDraftThread: {
environmentId: ENVIRONMENT_ID,
projectId: PROJECT_ID,
- branch: "feature/refactor",
- worktreePath: "/tmp/worktree",
- envMode: "worktree",
- startFromOrigin: true,
},
- handleNewThread,
}),
);
- expect(didStart).toBe(true);
- expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), {
- branch: "feature/refactor",
- worktreePath: "/tmp/worktree",
- envMode: "worktree",
- startFromOrigin: true,
- });
+ expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID));
});
- it("preserves an explicitly disabled origin base in contextual thread options", async () => {
- const handleNewThread = vi.fn(async () => {});
-
- await startNewThreadFromContext(
+ it("falls back to the default project ref when there is no active thread context", () => {
+ const projectRef = resolveThreadActionProjectRef(
createContext({
- activeDraftThread: {
- environmentId: ENVIRONMENT_ID,
- projectId: PROJECT_ID,
- branch: "feature/refactor",
- worktreePath: "/tmp/worktree",
- envMode: "worktree",
- startFromOrigin: false,
- },
- handleNewThread,
+ defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID),
}),
);
- expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), {
- branch: "feature/refactor",
- worktreePath: "/tmp/worktree",
- envMode: "worktree",
- startFromOrigin: false,
- });
+ expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID));
});
- it("does not carry branch or worktree context into a different project", async () => {
+ it("inherits only the project from context, never branch or worktree state", async () => {
const handleNewThread = vi.fn(async () => {});
- const targetProjectRef = scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID);
- await startNewThreadInProjectFromContext(
+ const didStart = await startNewThreadFromContext(
createContext({
activeThread: {
environmentId: ENVIRONMENT_ID,
projectId: PROJECT_ID,
- branch: "feature/refactor",
- worktreePath: "/tmp/worktree",
},
handleNewThread,
}),
- targetProjectRef,
- );
-
- expect(handleNewThread).toHaveBeenCalledWith(targetProjectRef);
- });
-
- it("delegates the target environment defaults to the new-thread handler", async () => {
- const handleNewThread = vi.fn(async () => {});
-
- const didStart = await startNewLocalThreadFromContext(
- createContext({
- defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID),
- handleNewThread,
- }),
);
expect(didStart).toBe(true);
diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts
index d8b831ac3ad..4a150d617ea 100644
--- a/apps/web/src/lib/chatThreadActions.ts
+++ b/apps/web/src/lib/chatThreadActions.ts
@@ -5,13 +5,6 @@ import type { DraftThreadEnvMode } from "../composerDraftStore";
interface ThreadContextLike {
environmentId: EnvironmentId;
projectId: ProjectId;
- branch: string | null;
- worktreePath: string | null;
-}
-
-interface DraftThreadContextLike extends ThreadContextLike {
- envMode: DraftThreadEnvMode;
- startFromOrigin: boolean;
}
interface NewThreadHandler {
@@ -26,10 +19,8 @@ interface NewThreadHandler {
): Promise;
}
-type NewThreadOptions = NonNullable[1]>;
-
export interface ChatThreadActionContext {
- readonly activeDraftThread: DraftThreadContextLike | null;
+ readonly activeDraftThread: ThreadContextLike | null;
readonly activeThread: ThreadContextLike | undefined;
readonly defaultProjectRef: ScopedProjectRef | null;
readonly handleNewThread: NewThreadHandler;
@@ -57,35 +48,12 @@ export function resolveThreadActionProjectRef(
return context.defaultProjectRef;
}
-function buildContextualThreadOptions(context: ChatThreadActionContext): NewThreadOptions {
- return {
- branch: context.activeThread?.branch ?? context.activeDraftThread?.branch ?? null,
- worktreePath:
- context.activeThread?.worktreePath ?? context.activeDraftThread?.worktreePath ?? null,
- envMode:
- context.activeDraftThread?.envMode ??
- (context.activeThread?.worktreePath ? "worktree" : "local"),
- ...(context.activeDraftThread
- ? { startFromOrigin: context.activeDraftThread.startFromOrigin }
- : {}),
- };
-}
-
-export async function startNewThreadInProjectFromContext(
- context: ChatThreadActionContext,
- projectRef: ScopedProjectRef,
-): Promise {
- const contextualProjectRef = resolveThreadActionProjectRef(context);
- const matchesContext =
- contextualProjectRef?.environmentId === projectRef.environmentId &&
- contextualProjectRef.projectId === projectRef.projectId;
- if (!matchesContext) {
- await context.handleNewThread(projectRef);
- return;
- }
- await context.handleNewThread(projectRef, buildContextualThreadOptions(context));
-}
-
+// New threads inherit only the *project* from the current context. Branch,
+// worktree, and env mode always come from the user's configured defaults —
+// carrying them over from the viewed thread meant "new thread" silently
+// reused checkouts and branches. Explicit affordances (branch toolbar's
+// "new thread in this worktree") pass those options to handleNewThread
+// directly instead.
export async function startNewThreadFromContext(
context: ChatThreadActionContext,
): Promise {
@@ -94,18 +62,6 @@ export async function startNewThreadFromContext(
return false;
}
- await startNewThreadInProjectFromContext(context, projectRef);
- return true;
-}
-
-export async function startNewLocalThreadFromContext(
- context: ChatThreadActionContext,
-): Promise {
- const projectRef = resolveThreadActionProjectRef(context);
- if (!projectRef) {
- return false;
- }
-
await context.handleNewThread(projectRef);
return true;
}
diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx
index 2e29d222b5b..d3cf003d99c 100644
--- a/apps/web/src/routes/_chat.tsx
+++ b/apps/web/src/routes/_chat.tsx
@@ -11,10 +11,7 @@ import { selectProjectGroupingSettings } from "../logicalProject";
import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";
import { dispatchPreviewAction } from "../components/preview/previewActionBus";
import { useHandleNewThread } from "../hooks/useHandleNewThread";
-import {
- startNewLocalThreadFromContext,
- startNewThreadFromContext,
-} from "../lib/chatThreadActions";
+import { startNewThreadFromContext } from "../lib/chatThreadActions";
import { isPreviewFocused } from "../lib/previewFocus";
import { isTerminalFocused } from "../lib/terminalFocus";
import { resolveShortcutCommand } from "../keybindings";
@@ -83,7 +80,7 @@ function ChatRouteGlobalShortcuts() {
if (command === "chat.newLocal") {
event.preventDefault();
event.stopPropagation();
- void startNewLocalThreadFromContext({
+ void startNewThreadFromContext({
activeDraftThread,
activeThread: activeThread ?? undefined,
defaultProjectRef,