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)", () => {
35813581 }
35823582 });
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
35843676 it("snapshots sticky codex settings into a new draft thread", async () => {
35853677 useComposerDraftStore.setState({
35863678 stickyModelSelectionByProvider: {
apps/web/src/components/Sidebar.logic.test.ts+22
......@@ -184,6 +184,28 @@ describe("resolveSidebarNewThreadEnvMode", () => {
184184});
185185
186186describe("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
187209 it("inherits the active server thread context when creating a new thread in the same project", () => {
188210 expect(
189211 resolveSidebarNewThreadSeedContext({
apps/web/src/components/Sidebar.logic.ts+6
......@@ -186,6 +186,12 @@ export function resolveSidebarNewThreadSeedContext(input: {
186186 worktreePath?: string | null;
187187 envMode: SidebarNewThreadEnvMode;
188188} {
189 if (input.defaultEnvMode === "worktree") {
190 return {
191 envMode: "worktree",
192 };
193 }
194
189195 if (input.activeDraftThread?.projectId === input.projectId) {
190196 return {
191197 branch: input.activeDraftThread.branch,
apps/web/src/composerDraftStore.ts+6-1
......@@ -1881,13 +1881,18 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
18811881 [draftId]: nextDraftThread,
18821882 };
18831883 let nextDraftsByThreadKey = state.draftsByThreadKey;
1884 const previousDraftThread =
1885 previousThreadKeyForLogicalProject === undefined
1886 ? undefined
1887 : nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject];
18841888 if (
18851889 previousThreadKeyForLogicalProject &&
18861890 previousThreadKeyForLogicalProject !== draftId &&
18871891 !isComposerThreadKeyInUse(
18881892 nextLogicalProjectDraftThreadKeyByLogicalProjectKey,
18891893 previousThreadKeyForLogicalProject,
1890 )
1894 ) &&
1895 !isDraftThreadPromoting(previousDraftThread)
18911896 ) {
18921897 delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject];
18931898 if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) {
apps/web/src/hooks/useHandleNewThread.ts+2-1
......@@ -87,7 +87,8 @@ function useNewThreadState() {
8787 if (
8888 latestActiveDraftThread &&
8989 currentRouteTarget?.kind === "draft" &&
90 latestActiveDraftThread.logicalProjectKey === logicalProjectKey
90 latestActiveDraftThread.logicalProjectKey === logicalProjectKey &&
91 latestActiveDraftThread.promotedTo == null
9192 ) {
9293 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
9394 setDraftThreadContext(currentRouteTarget.draftId, {