diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 3bad1c2e59..f127047b71 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -3581,6 +3581,98 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("creates a fresh worktree draft from an existing worktree thread when the default mode is worktree", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...createSnapshotForTargetUser({ + targetMessageId: "msg-user-new-thread-worktree-default-test" as MessageId, + targetText: "new thread worktree default test", + }), + threads: createSnapshotForTargetUser({ + targetMessageId: "msg-user-new-thread-worktree-default-test" as MessageId, + targetText: "new thread worktree default test", + }).threads.map((thread) => + thread.id === THREAD_ID + ? Object.assign({}, thread, { + branch: "feature/existing", + worktreePath: "/repo/.t3/worktrees/existing", + }) + : thread, + ), + }, + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + settings: { + ...nextFixture.serverConfig.settings, + defaultThreadEnvMode: "worktree", + }, + }; + }, + }); + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + const newThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should change to a new draft thread.", + ); + const newDraftId = draftIdFromPath(newThreadPath); + + expect(useComposerDraftStore.getState().getDraftSession(newDraftId)).toMatchObject({ + envMode: "worktree", + worktreePath: null, + }); + } finally { + await mounted.cleanup(); + } + }); + + it("creates a new draft instead of reusing a promoting draft thread", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-promoting-draft-new-thread-test" as MessageId, + targetText: "promoting draft new thread test", + }), + }); + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + const firstDraftPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should change to the first draft thread.", + ); + const firstDraftId = draftIdFromPath(firstDraftPath); + const firstThreadId = draftThreadIdFor(firstDraftId); + + await materializePromotedDraftThreadViaDomainEvent(firstThreadId); + expect(mounted.router.state.location.pathname).toBe(firstDraftPath); + + await newThreadButton.click(); + + const secondDraftPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path) && path !== firstDraftPath, + "Route should change to a second draft thread instead of reusing the promoting draft.", + ); + expect(draftIdFromPath(secondDraftPath)).not.toBe(firstDraftId); + } finally { + await mounted.cleanup(); + } + }); + it("snapshots sticky codex settings into a new draft thread", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 35534af9f3..fb75013928 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -169,6 +169,28 @@ describe("resolveSidebarNewThreadEnvMode", () => { }); 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", + }, + }), + ).toEqual({ + envMode: "worktree", + }); + }); + it("inherits the active server thread context when creating a new thread in the same project", () => { expect( resolveSidebarNewThreadSeedContext({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 8c742cbe9b..67f6fa49ed 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -183,6 +183,12 @@ export function resolveSidebarNewThreadSeedContext(input: { worktreePath?: string | null; envMode: SidebarNewThreadEnvMode; } { + if (input.defaultEnvMode === "worktree") { + return { + envMode: "worktree", + }; + } + if (input.activeDraftThread?.projectId === input.projectId) { return { branch: input.activeDraftThread.branch, diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 231cf06566..20f7cbe032 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1881,13 +1881,18 @@ const composerDraftStore = create()( [draftId]: nextDraftThread, }; let nextDraftsByThreadKey = state.draftsByThreadKey; + const previousDraftThread = + previousThreadKeyForLogicalProject === undefined + ? undefined + : nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; if ( previousThreadKeyForLogicalProject && previousThreadKeyForLogicalProject !== draftId && !isComposerThreadKeyInUse( nextLogicalProjectDraftThreadKeyByLogicalProjectKey, previousThreadKeyForLogicalProject, - ) + ) && + !isDraftThreadPromoting(previousDraftThread) ) { delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 6856140daa..f567735691 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -87,7 +87,8 @@ function useNewThreadState() { if ( latestActiveDraftThread && currentRouteTarget?.kind === "draft" && - latestActiveDraftThread.logicalProjectKey === logicalProjectKey + latestActiveDraftThread.logicalProjectKey === logicalProjectKey && + latestActiveDraftThread.promotedTo == null ) { if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { setDraftThreadContext(currentRouteTarget.draftId, {