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,