From 908c85cc2a6860d92242f25a308b775f917c9e32 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 14:36:36 -0700 Subject: [PATCH 1/3] Fix worktree base branch updates for active draft - Route branch selection to the active draft session - Prevent draft branch sync from overwriting an in-progress worktree setup - Add coverage for the draft-specific branch selector flow --- apps/web/src/components/BranchToolbar.tsx | 4 +- .../BranchToolbarBranchSelector.tsx | 4 +- apps/web/src/components/ChatView.browser.tsx | 116 +++++++++++++++++- apps/web/src/components/ChatView.tsx | 1 + .../components/GitActionsControl.browser.tsx | 33 +++++ apps/web/src/components/GitActionsControl.tsx | 28 ++++- apps/web/src/components/chat/ChatHeader.tsx | 4 + 7 files changed, 181 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 8a8f9bd6ab..e91266d65f 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -44,7 +44,9 @@ export const BranchToolbar = memo(function BranchToolbar({ ); const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); const serverThread = useStore(serverThreadSelector); - const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const draftThread = useComposerDraftStore((store) => + draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), + ); const activeProjectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 32c80f6542..76f64d93fe 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -91,7 +91,9 @@ export function BranchToolbarBranchSelector({ const serverThread = useStore(serverThreadSelector); const serverSession = serverThread?.session ?? null; const setThreadBranchAction = useStore((store) => store.setThreadBranch); - const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const draftThread = useComposerDraftStore((store) => + draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), + ); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const activeProjectRef = serverThread diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index de51449704..a086737a64 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1327,6 +1327,7 @@ async function mountChatView(options: { snapshot: OrchestrationReadModel; configureFixture?: (fixture: TestFixture) => void; resolveRpc?: (body: NormalizedWsRpcRequestBody) => unknown | undefined; + initialPath?: string; }): Promise { fixture = buildFixture(options.snapshot); options.configureFixture?.(fixture); @@ -1346,7 +1347,7 @@ async function mountChatView(options: { const router = getRouter( createMemoryHistory({ - initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`], + initialEntries: [options.initialPath ?? `/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`], }), ); @@ -2512,6 +2513,119 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("uses the active draft route session when changing the base branch", async () => { + const staleDraftId = DraftId.makeUnsafe("draft-stale-branch-session"); + const activeDraftId = DraftId.makeUnsafe("draft-active-branch-session"); + + useComposerDraftStore.setState({ + draftThreadsByThreadKey: { + [staleDraftId]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, + projectId: PROJECT_ID, + logicalProjectKey: `${PROJECT_DRAFT_KEY}:stale`, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + envMode: "worktree", + }, + [activeDraftId]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, + projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + envMode: "worktree", + }, + }, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [`${PROJECT_DRAFT_KEY}:stale`]: staleDraftId, + [PROJECT_DRAFT_KEY]: activeDraftId, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + initialPath: `/draft/${activeDraftId}`, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.gitListBranches) { + return { + isRepo: true, + hasOriginRemote: true, + nextCursor: null, + totalCount: 2, + branches: [ + { + name: "main", + current: true, + isDefault: true, + worktreePath: null, + }, + { + name: "release/next", + current: false, + isDefault: false, + worktreePath: null, + }, + ], + }; + } + return undefined; + }, + }); + + try { + const branchButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "From main", + ) as HTMLButtonElement | null, + 'Unable to find branch selector button with "From main".', + ); + branchButton.click(); + + const branchOption = await waitForElement( + () => + Array.from(document.querySelectorAll("span")).find( + (element) => element.textContent?.trim() === "release/next", + ) as HTMLSpanElement | null, + 'Unable to find the "release/next" branch option.', + ); + branchOption.click(); + + await vi.waitFor( + () => { + expect(useComposerDraftStore.getState().getDraftSession(activeDraftId)?.branch).toBe( + "release/next", + ); + expect(useComposerDraftStore.getState().getDraftSession(staleDraftId)?.branch).toBe( + "main", + ); + }, + { timeout: 8_000, interval: 16 }, + ); + + await vi.waitFor( + () => { + const updatedButton = Array.from(document.querySelectorAll("button")).find((button) => + button.textContent?.trim().includes("From release/next"), + ); + expect(updatedButton).toBeTruthy(); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("surrounds selected plain text and preserves the inner selection for repeated wrapping", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 95ec82a26c..0aad648199 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3270,6 +3270,7 @@ export default function ChatView(props: ChatViewProps) { { host.remove(); } }); + + it("does not overwrite a selected base branch while a new worktree draft is being configured", async () => { + hasServerThreadRef.current = false; + activeDraftThreadRef.current = { + threadId: SHARED_THREAD_ID, + environmentId: ENVIRONMENT_A, + branch: "feature/base-branch", + worktreePath: null, + envMode: "worktree", + }; + + const host = document.createElement("div"); + document.body.append(host); + const screen = await render( + , + { + container: host, + }, + ); + + try { + await Promise.resolve(); + + expect(setDraftThreadContextSpy).not.toHaveBeenCalled(); + expect(setThreadBranchSpy).not.toHaveBeenCalled(); + } finally { + await screen.unmount(); + host.remove(); + } + }); }); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index d8ff73da78..6d2312e4aa 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -49,7 +49,7 @@ import { import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; -import { useComposerDraftStore } from "~/composerDraftStore"; +import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; import { useStore } from "~/store"; @@ -58,6 +58,7 @@ import { createThreadSelectorByRef } from "~/storeSelectors"; interface GitActionsControlProps { gitCwd: string | null; activeThreadRef: ScopedThreadRef | null; + draftId?: DraftId; } interface PendingDefaultBranchAction { @@ -209,7 +210,11 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { return ; } -export default function GitActionsControl({ gitCwd, activeThreadRef }: GitActionsControlProps) { +export default function GitActionsControl({ + gitCwd, + activeThreadRef, + draftId, +}: GitActionsControlProps) { const activeEnvironmentId = activeThreadRef?.environmentId ?? null; const threadToastData = useMemo( () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), @@ -221,7 +226,11 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction ); const activeServerThread = useStore(activeServerThreadSelector); const activeDraftThread = useComposerDraftStore((store) => - activeThreadRef ? store.getDraftThreadByRef(activeThreadRef) : null, + draftId + ? store.getDraftSession(draftId) + : activeThreadRef + ? store.getDraftThreadByRef(activeThreadRef) + : null, ); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const setThreadBranch = useStore((store) => store.setThreadBranch); @@ -282,7 +291,7 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction return; } - setDraftThreadContext(activeThreadRef, { + setDraftThreadContext(draftId ?? activeThreadRef, { branch, worktreePath: activeDraftThread.worktreePath, }); @@ -291,6 +300,7 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction activeDraftThread, activeServerThread, activeThreadRef, + draftId, setDraftThreadContext, setThreadBranch, ], @@ -344,14 +354,18 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0; const isGitActionRunning = isRunStackedActionRunning || isPullRunning; + const isSelectingWorktreeBase = + !activeServerThread && + activeDraftThread?.envMode === "worktree" && + activeDraftThread.worktreePath === null; useEffect(() => { - if (isGitActionRunning) { + if (isGitActionRunning || isSelectingWorktreeBase) { return; } const branchUpdate = resolveLiveThreadBranchUpdate({ - threadBranch: activeServerThread?.branch ?? null, + threadBranch: activeServerThread?.branch ?? activeDraftThread?.branch ?? null, gitStatus: gitStatusForActions, }); if (!branchUpdate) { @@ -361,8 +375,10 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction persistThreadBranchSync(branchUpdate.branch); }, [ activeServerThread?.branch, + activeDraftThread?.branch, gitStatusForActions, isGitActionRunning, + isSelectingWorktreeBase, persistThreadBranchSync, ]); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 9857ef22eb..cda0bb1367 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -8,6 +8,7 @@ import { import { scopeThreadRef } from "@t3tools/client-runtime"; import { memo } from "react"; import GitActionsControl from "../GitActionsControl"; +import { type DraftId } from "~/composerDraftStore"; import { DiffIcon, TerminalSquareIcon } from "lucide-react"; import { Badge } from "../ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -19,6 +20,7 @@ import { OpenInPicker } from "./OpenInPicker"; interface ChatHeaderProps { activeThreadEnvironmentId: EnvironmentId; activeThreadId: ThreadId; + draftId?: DraftId; activeThreadTitle: string; activeProjectName: string | undefined; isGitRepo: boolean; @@ -44,6 +46,7 @@ interface ChatHeaderProps { export const ChatHeader = memo(function ChatHeader({ activeThreadEnvironmentId, activeThreadId, + draftId, activeThreadTitle, activeProjectName, isGitRepo, @@ -109,6 +112,7 @@ export const ChatHeader = memo(function ChatHeader({ )} From ace12af546349befd88cb3b7d6c317b24b60e94f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 14:45:37 -0700 Subject: [PATCH 2/3] Use draft ID type in base-branch session test - Replace unsafe DraftId construction with the draft ID helper type - Keep the base-branch session parity test aligned with active draft routing --- apps/web/src/components/ChatView.browser.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index a086737a64..571a880cef 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2514,8 +2514,8 @@ describe("ChatView timeline estimator parity (full app)", () => { }); it("uses the active draft route session when changing the base branch", async () => { - const staleDraftId = DraftId.makeUnsafe("draft-stale-branch-session"); - const activeDraftId = DraftId.makeUnsafe("draft-active-branch-session"); + const staleDraftId = "draft-stale-branch-session" as ReturnType; + const activeDraftId = "draft-active-branch-session" as ReturnType; useComposerDraftStore.setState({ draftThreadsByThreadKey: { From efce830b30f28a85da13cd574d22166ffcde8a9d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 14:55:08 -0700 Subject: [PATCH 3/3] Use draft route IDs in base branch test - Build draft session IDs with `draftIdFromPath` - Keep the active draft route selection test aligned with runtime behavior --- apps/web/src/components/ChatView.browser.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 571a880cef..09a1eda240 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2514,8 +2514,8 @@ describe("ChatView timeline estimator parity (full app)", () => { }); it("uses the active draft route session when changing the base branch", async () => { - const staleDraftId = "draft-stale-branch-session" as ReturnType; - const activeDraftId = "draft-active-branch-session" as ReturnType; + const staleDraftId = draftIdFromPath("/draft/draft-stale-branch-session"); + const activeDraftId = draftIdFromPath("/draft/draft-active-branch-session"); useComposerDraftStore.setState({ draftThreadsByThreadKey: {