From 690098ba25d79c5dc20358dd3b33312020bc2d6b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 17:20:42 -0700 Subject: [PATCH 1/5] fix(web): stop new threads inheriting checkout/branch from viewed thread "New thread" (cmd+n / cmd+shift+o, sidebar buttons, command palette) copied the viewed thread's branch, worktree, and env mode into the new draft, bypassing the configured defaults (new worktree from origin main). Remove the implicit carry-over everywhere: only the project is inherited from context; workspace context always comes from settings. Stored drafts resurrected without explicit options also reset their env context so stale "current checkout" drafts stop resurfacing. What still carries over is the working mode: model selection (including reasoning effort / context window options), permission mode, and interaction mode. Deliberate branch reuse gets explicit affordances instead: - "Previous worktree ()" entry in the composer workspace selector points a draft at the project's most recently active worktree. - "New thread on " in the sidebar thread context menu (v1+v2) seeds a draft with that thread's branch/worktree. Co-Authored-By: Claude Fable 5 --- .../components/BranchToolbar.logic.test.ts | 60 ++++++++++ .../web/src/components/BranchToolbar.logic.ts | 37 ++++++ apps/web/src/components/BranchToolbar.tsx | 66 ++++++++++- .../BranchToolbarEnvModeSelector.tsx | 30 ++++- apps/web/src/components/CommandPalette.tsx | 24 +--- apps/web/src/components/Sidebar.logic.test.ts | 108 ------------------ apps/web/src/components/Sidebar.logic.ts | 57 --------- apps/web/src/components/Sidebar.tsx | 76 ++++-------- apps/web/src/components/SidebarV2.tsx | 21 ++++ apps/web/src/hooks/useHandleNewThread.ts | 73 +++++++++++- apps/web/src/lib/chatThreadActions.test.ts | 81 ++----------- apps/web/src/lib/chatThreadActions.ts | 58 ++-------- apps/web/src/routes/_chat.tsx | 7 +- 13 files changed, 323 insertions(+), 375 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index cbb9ec82b88..298624a9e21 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,64 @@ 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(); + }); +}); + +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..60d1797f065 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -65,6 +65,43 @@ 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. +export function resolvePreviousWorktreeSeed(input: { + threads: ReadonlyArray<{ + branch: string | null; + worktreePath: string | null; + updatedAt: string; + }>; + currentWorktreePath: string | null; +}): PreviousWorktreeSeed | null { + let latest: { branch: string | null; worktreePath: string; updatedAt: string } | null = null; + for (const thread of input.threads) { + if (!thread.worktreePath || thread.worktreePath === input.currentWorktreePath) { + continue; + } + if (latest === null || thread.updatedAt > latest.updatedAt) { + latest = { + branch: thread.branch, + worktreePath: thread.worktreePath, + updatedAt: thread.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..803ab9d152f 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,18 @@ 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. + void handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId), { + branch: thread.branch, + worktreePath: thread.worktreePath, + envMode: thread.worktreePath ? "worktree" : "local", + startFromOrigin: false, + }); + return; + } + if (clicked === "rename") { startThreadRename(threadKey, thread.title); return; @@ -2229,6 +2194,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..fb951df0530 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,19 @@ 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. + void handleNewThreadRef.current( + scopeProjectRef(thread.environmentId, thread.projectId), + { + branch: thread.branch, + worktreePath: thread.worktreePath, + envMode: thread.worktreePath ? "worktree" : "local", + startFromOrigin: false, + }, + ); + return; case "settle": attemptSettle(threadRef); return; diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index cd8546893b0..4dc1ec2d4bb 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 && @@ -104,6 +142,32 @@ export function useNewThreadHandler() { ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), }); + } else if ( + currentRouteTarget?.kind !== "draft" || + currentRouteTarget.draftId !== reusableStoredDraftThread.draftId + ) { + // Resurrecting a stored draft must not resurrect its stale env + // context: drafts seeded before a defaults change (or by the old + // carry-over behavior) would otherwise keep landing on "current + // checkout" branches forever. Composer text is preserved; only + // the env context resets to the configured defaults. When the + // draft is already open, leave it alone — the user may have just + // picked a branch in the composer. + const defaultEnvMode = primaryServerSettings.defaultThreadEnvMode; + setDraftThreadContext(reusableStoredDraftThread.draftId, { + branch: null, + worktreePath: null, + envMode: defaultEnvMode, + startFromOrigin: resolveNewDraftStartFromOrigin({ + envMode: defaultEnvMode, + newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + }), + ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), + ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), + }); + if (carryModelSelection) { + setModelSelection(reusableStoredDraftThread.draftId, carryModelSelection); + } } setLogicalProjectDraftThreadId( logicalProjectKey, @@ -176,9 +240,16 @@ 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. + setModelSelection(draftId, carryModelSelection); + } 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, From 55a769555af0a457f5ad1d31e5eb9b49984a5bd6 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 17:23:55 -0700 Subject: [PATCH 2/5] fix(web): carry working mode when reusing a draft with explicit workspace options Review: the model/permissions/interaction-mode carry only ran in the no-options branch of the stored-draft reuse path, so "New thread on " (which passes explicit workspace options) reopened stale drafts with their old model and permission mode. Restructure the branch so explicit workspace options and the defaults-reset share one write that always includes the carried working mode. Co-Authored-By: Claude Fable 5 --- apps/web/src/hooks/useHandleNewThread.ts | 62 +++++++++++++----------- 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 4dc1ec2d4bb..fb02b75ac52 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -130,38 +130,44 @@ export function useNewThreadHandler() { : null; if (reusableStoredDraftThread) { return (async () => { - if ( + const isDraftAlreadyOpen = + currentRouteTarget?.kind === "draft" && + currentRouteTarget.draftId === reusableStoredDraftThread.draftId; + const hasExplicitWorkspaceOption = hasBranchOption || hasWorktreePathOption || hasEnvModeOption || - hasStartFromOriginOption - ) { - setDraftThreadContext(reusableStoredDraftThread.draftId, { - ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), - }); - } else if ( - currentRouteTarget?.kind !== "draft" || - currentRouteTarget.draftId !== reusableStoredDraftThread.draftId - ) { - // Resurrecting a stored draft must not resurrect its stale env - // context: drafts seeded before a defaults change (or by the old - // carry-over behavior) would otherwise keep landing on "current - // checkout" branches forever. Composer text is preserved; only - // the env context resets to the configured defaults. When the - // draft is already open, leave it alone — the user may have just - // picked a branch in the composer. - const defaultEnvMode = primaryServerSettings.defaultThreadEnvMode; + 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, { - branch: null, - worktreePath: null, - envMode: defaultEnvMode, - startFromOrigin: resolveNewDraftStartFromOrigin({ - envMode: defaultEnvMode, - newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, - }), + ...workspaceContext, ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); From fcae81764366f61ffdb2fac230b7a0451da235a9 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 17:27:49 -0700 Subject: [PATCH 3/5] fix(web): replace stale draft model options when carrying a complete selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review: setModelSelection merges — a selection without options keeps the draft's existing options, so carrying a thread whose model has no options (default effort/context window) left the resurrected draft's stale option state in place. Add a replaceOptions flag for callers passing a complete snapshot and use it for the working-mode carry. Co-Authored-By: Claude Fable 5 --- apps/web/src/composerDraftStore.ts | 17 ++++++++++++++--- apps/web/src/hooks/useHandleNewThread.ts | 13 ++++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) 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 fb02b75ac52..a7806a9e67f 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -172,7 +172,12 @@ export function useNewThreadHandler() { ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); if (carryModelSelection) { - setModelSelection(reusableStoredDraftThread.draftId, 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, + }); } } setLogicalProjectDraftThreadId( @@ -253,8 +258,10 @@ export function useNewThreadHandler() { 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. - setModelSelection(draftId, carryModelSelection); + // 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({ From fb9b1779ccfedde72c89864ceb7a0e08320c4b39 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 17:35:45 -0700 Subject: [PATCH 4/5] fix(web): keep workspace context when remapping a draft across project members Review (high): setLogicalProjectDraftThreadId was called with only threadId after the setDraftThreadContext write. When projectRef targets a different physical member of the same logical project, createDraftThreadState treats the remap as a project change and wipes branch/worktreePath and forces envMode to "local", undoing both the defaults reset and "New thread on " seeding. Pass the same workspace context and carried working mode through the remap call. Co-Authored-By: Claude Fable 5 --- .../components/BranchToolbar.logic.test.ts | 27 +++++++++++++++++++ .../web/src/components/BranchToolbar.logic.ts | 21 +++++++++++---- apps/web/src/hooks/useHandleNewThread.ts | 8 ++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 298624a9e21..c4ae7b96694 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -66,6 +66,33 @@ describe("resolvePreviousWorktreeSeed", () => { }), ).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", () => { diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 60d1797f065..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, @@ -73,25 +74,35 @@ export interface PreviousWorktreeSeed { // 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. +// 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: string } | null = 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) { + if ( + !thread.worktreePath || + thread.worktreePath === input.currentWorktreePath || + (thread.archivedAt ?? null) !== null + ) { continue; } - if (latest === null || thread.updatedAt > latest.updatedAt) { + const updatedAt = toSortableTimestamp(thread.updatedAt); + if (updatedAt === null) { + continue; + } + if (latest === null || updatedAt > latest.updatedAt) { latest = { branch: thread.branch, worktreePath: thread.worktreePath, - updatedAt: thread.updatedAt, + updatedAt, }; } } diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index a7806a9e67f..2479d6ba02f 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -180,12 +180,20 @@ export function useNewThreadHandler() { }); } } + // 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 ( From b06e656c4d58b38d1ba0916f535db3f18d45843b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 17:41:53 -0700 Subject: [PATCH 5/5] fix(web): surface failures from "New thread on " context-menu action Review: both sidebars discarded the handleNewThread promise for the new context-menu action, so a failed thread creation gave no user feedback. Wrap in settlePromise and show the same error toast the other new-thread flows use. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/Sidebar.tsx | 24 ++++++++++++++++++------ apps/web/src/components/SidebarV2.tsx | 20 +++++++++++++++----- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 803ab9d152f..a1d95eaa734 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2129,12 +2129,24 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec 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. - void handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId), { - branch: thread.branch, - worktreePath: thread.worktreePath, - envMode: thread.worktreePath ? "worktree" : "local", - startFromOrigin: false, - }); + 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; } diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index fb951df0530..1a78ef54eff 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1601,19 +1601,29 @@ export default function SidebarV2() { ); if (clicked._tag === "Failure") return; switch (clicked.value) { - case "new-thread-on-branch": + 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. - void handleNewThreadRef.current( - scopeProjectRef(thread.environmentId, thread.projectId), - { + 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;