authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-13 14:57:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-13 14:57:46-07:00
log5f7ec73a22b28e2775987b03a744f75e5e151152
tree5b1ea0b52b882e2e979d857de56e9e88afc24b9b
parent569fea87a90f1d9a5d4d9b6c7c1c59801324a561
signature Signed by PGP key B5690EEEBB952194

Fix new-thread draft reuse for worktree defaults (#2003)


5 files changed, 128 insertions(+), 2 deletions(-)

apps/web/src/components/ChatView.browser.tsx+92
...@@ -3581,6 +3581,98 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -3581,6 +3581,98 @@ describe("ChatView timeline estimator parity (full app)", () => {
3581 }3581 }
3582 });3582 });
35833583
3584 it("creates a fresh worktree draft from an existing worktree thread when the default mode is worktree", async () => {
3585 const mounted = await mountChatView({
3586 viewport: DEFAULT_VIEWPORT,
3587 snapshot: {
3588 ...createSnapshotForTargetUser({
3589 targetMessageId: "msg-user-new-thread-worktree-default-test" as MessageId,
3590 targetText: "new thread worktree default test",
3591 }),
3592 threads: createSnapshotForTargetUser({
3593 targetMessageId: "msg-user-new-thread-worktree-default-test" as MessageId,
3594 targetText: "new thread worktree default test",
3595 }).threads.map((thread) =>
3596 thread.id === THREAD_ID
3597 ? Object.assign({}, thread, {
3598 branch: "feature/existing",
3599 worktreePath: "/repo/.t3/worktrees/existing",
3600 })
3601 : thread,
3602 ),
3603 },
3604 configureFixture: (nextFixture) => {
3605 nextFixture.serverConfig = {
3606 ...nextFixture.serverConfig,
3607 settings: {
3608 ...nextFixture.serverConfig.settings,
3609 defaultThreadEnvMode: "worktree",
3610 },
3611 };
3612 },
3613 });
3614
3615 try {
3616 const newThreadButton = page.getByTestId("new-thread-button");
3617 await expect.element(newThreadButton).toBeInTheDocument();
3618
3619 await newThreadButton.click();
3620
3621 const newThreadPath = await waitForURL(
3622 mounted.router,
3623 (path) => UUID_ROUTE_RE.test(path),
3624 "Route should change to a new draft thread.",
3625 );
3626 const newDraftId = draftIdFromPath(newThreadPath);
3627
3628 expect(useComposerDraftStore.getState().getDraftSession(newDraftId)).toMatchObject({
3629 envMode: "worktree",
3630 worktreePath: null,
3631 });
3632 } finally {
3633 await mounted.cleanup();
3634 }
3635 });
3636
3637 it("creates a new draft instead of reusing a promoting draft thread", async () => {
3638 const mounted = await mountChatView({
3639 viewport: DEFAULT_VIEWPORT,
3640 snapshot: createSnapshotForTargetUser({
3641 targetMessageId: "msg-user-promoting-draft-new-thread-test" as MessageId,
3642 targetText: "promoting draft new thread test",
3643 }),
3644 });
3645
3646 try {
3647 const newThreadButton = page.getByTestId("new-thread-button");
3648 await expect.element(newThreadButton).toBeInTheDocument();
3649
3650 await newThreadButton.click();
3651
3652 const firstDraftPath = await waitForURL(
3653 mounted.router,
3654 (path) => UUID_ROUTE_RE.test(path),
3655 "Route should change to the first draft thread.",
3656 );
3657 const firstDraftId = draftIdFromPath(firstDraftPath);
3658 const firstThreadId = draftThreadIdFor(firstDraftId);
3659
3660 await materializePromotedDraftThreadViaDomainEvent(firstThreadId);
3661 expect(mounted.router.state.location.pathname).toBe(firstDraftPath);
3662
3663 await newThreadButton.click();
3664
3665 const secondDraftPath = await waitForURL(
3666 mounted.router,
3667 (path) => UUID_ROUTE_RE.test(path) && path !== firstDraftPath,
3668 "Route should change to a second draft thread instead of reusing the promoting draft.",
3669 );
3670 expect(draftIdFromPath(secondDraftPath)).not.toBe(firstDraftId);
3671 } finally {
3672 await mounted.cleanup();
3673 }
3674 });
3675
3584 it("snapshots sticky codex settings into a new draft thread", async () => {3676 it("snapshots sticky codex settings into a new draft thread", async () => {
3585 useComposerDraftStore.setState({3677 useComposerDraftStore.setState({
3586 stickyModelSelectionByProvider: {3678 stickyModelSelectionByProvider: {
apps/web/src/components/Sidebar.logic.test.ts+22
...@@ -184,6 +184,28 @@ describe("resolveSidebarNewThreadEnvMode", () => {...@@ -184,6 +184,28 @@ describe("resolveSidebarNewThreadEnvMode", () => {
184});184});
185185
186describe("resolveSidebarNewThreadSeedContext", () => {186describe("resolveSidebarNewThreadSeedContext", () => {
187 it("prefers the default worktree mode over active thread context", () => {
188 expect(
189 resolveSidebarNewThreadSeedContext({
190 projectId: "project-1",
191 defaultEnvMode: "worktree",
192 activeThread: {
193 projectId: "project-1",
194 branch: "feature/existing",
195 worktreePath: "/repo/.t3/worktrees/existing",
196 },
197 activeDraftThread: {
198 projectId: "project-1",
199 branch: "feature/draft",
200 worktreePath: "/repo/.t3/worktrees/draft",
201 envMode: "worktree",
202 },
203 }),
204 ).toEqual({
205 envMode: "worktree",
206 });
207 });
208
187 it("inherits the active server thread context when creating a new thread in the same project", () => {209 it("inherits the active server thread context when creating a new thread in the same project", () => {
188 expect(210 expect(
189 resolveSidebarNewThreadSeedContext({211 resolveSidebarNewThreadSeedContext({
apps/web/src/components/Sidebar.logic.ts+6
...@@ -186,6 +186,12 @@ export function resolveSidebarNewThreadSeedContext(input: {...@@ -186,6 +186,12 @@ export function resolveSidebarNewThreadSeedContext(input: {
186 worktreePath?: string | null;186 worktreePath?: string | null;
187 envMode: SidebarNewThreadEnvMode;187 envMode: SidebarNewThreadEnvMode;
188} {188} {
189 if (input.defaultEnvMode === "worktree") {
190 return {
191 envMode: "worktree",
192 };
193 }
194
189 if (input.activeDraftThread?.projectId === input.projectId) {195 if (input.activeDraftThread?.projectId === input.projectId) {
190 return {196 return {
191 branch: input.activeDraftThread.branch,197 branch: input.activeDraftThread.branch,
apps/web/src/composerDraftStore.ts+6-1
...@@ -1881,13 +1881,18 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -1881,13 +1881,18 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
1881 [draftId]: nextDraftThread,1881 [draftId]: nextDraftThread,
1882 };1882 };
1883 let nextDraftsByThreadKey = state.draftsByThreadKey;1883 let nextDraftsByThreadKey = state.draftsByThreadKey;
1884 const previousDraftThread =
1885 previousThreadKeyForLogicalProject === undefined
1886 ? undefined
1887 : nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject];
1884 if (1888 if (
1885 previousThreadKeyForLogicalProject &&1889 previousThreadKeyForLogicalProject &&
1886 previousThreadKeyForLogicalProject !== draftId &&1890 previousThreadKeyForLogicalProject !== draftId &&
1887 !isComposerThreadKeyInUse(1891 !isComposerThreadKeyInUse(
1888 nextLogicalProjectDraftThreadKeyByLogicalProjectKey,1892 nextLogicalProjectDraftThreadKeyByLogicalProjectKey,
1889 previousThreadKeyForLogicalProject,1893 previousThreadKeyForLogicalProject,
1890 )1894 ) &&
1895 !isDraftThreadPromoting(previousDraftThread)
1891 ) {1896 ) {
1892 delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject];1897 delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject];
1893 if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) {1898 if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) {
apps/web/src/hooks/useHandleNewThread.ts+2-1
...@@ -87,7 +87,8 @@ function useNewThreadState() {...@@ -87,7 +87,8 @@ function useNewThreadState() {
87 if (87 if (
88 latestActiveDraftThread &&88 latestActiveDraftThread &&
89 currentRouteTarget?.kind === "draft" &&89 currentRouteTarget?.kind === "draft" &&
90 latestActiveDraftThread.logicalProjectKey === logicalProjectKey90 latestActiveDraftThread.logicalProjectKey === logicalProjectKey &&
91 latestActiveDraftThread.promotedTo == null
91 ) {92 ) {
92 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {93 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
93 setDraftThreadContext(currentRouteTarget.draftId, {94 setDraftThreadContext(currentRouteTarget.draftId, {