From 759df789fc84f89b752a1fa7663af4fc1a77e050 Mon Sep 17 00:00:00 2001 From: Guillermo Casanova Date: Mon, 31 Aug 2026 10:13:45 -0400 Subject: [PATCH] fix(web): new threads keep the viewed thread's model --- apps/web/src/hooks/useHandleNewThread.ts | 15 ++++++++------- apps/web/src/lib/chatThreadActions.test.ts | 21 ++++++++++++++++++++- apps/web/src/lib/chatThreadActions.ts | 11 +++++++---- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 78e17a76b374..5ecf419074b8 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -104,9 +104,10 @@ export function useNewThreadHandler() { } = useComposerDraftStore.getState(); const currentRouteTarget = getCurrentRouteTarget(); // A new thread carries the user's working mode from the thread being - // viewed. The target project's configured model still wins; runtime and - // interaction modes carry independently. Branch, worktree, and env mode - // come from configured defaults unless the caller passes them explicitly. + // viewed: model, runtime mode, and interaction mode. The target project's + // default model only fills in when there is nothing to carry. Branch, + // worktree, and env mode come from configured defaults unless the caller + // passes them explicitly. const carrySourceShell = currentRouteTarget?.kind === "server" ? readThreadShell(currentRouteTarget.threadRef) @@ -304,8 +305,8 @@ export function useNewThreadHandler() { } // Model intent: an explicit human pick always stands. Seeds and // legacy entries alike re-resolve here — sticky first, mirroring - // the mint-fresh path, then the project default or carried - // selection on top. This runs even when the draft is already open: + // the mint-fresh path, then the carried selection or project + // default on top. This runs even when the draft is already open: // without it, a changed pin could never reach the draft the user // is looking at, because explicit picks are the only thing the // flag protects. @@ -457,8 +458,8 @@ export function useNewThreadHandler() { applyStickyState(draftId); const modelSelectionOverride = resolveModelSelectionOverride(draftId); if (modelSelectionOverride) { - // Project defaults and carried selections both outrank global sticky - // state. The project default wins when both are present. + // Carried selections and project defaults both outrank global sticky + // state. The carried selection wins when both are present. setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true }); } carryComposerContentTo(draftId); diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index ee555231e43c..421a78beb0e8 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -59,7 +59,7 @@ describe("chatThreadActions", () => { ).toEqual(CARRIED_SELECTION); }); - it("keeps the project default above any carried selection", () => { + it("keeps the carried selection above the project default", () => { expect( resolveNewThreadModelSelectionOverride({ projectDefaultSelection: PROJECT_DEFAULT_SELECTION, @@ -67,6 +67,25 @@ describe("chatThreadActions", () => { carrySourceDraftId: "draft-a", destinationDraftId: "draft-b", }), + ).toEqual(CARRIED_SELECTION); + }); + + it("falls back to the project default when there is nothing to carry", () => { + expect( + resolveNewThreadModelSelectionOverride({ + projectDefaultSelection: PROJECT_DEFAULT_SELECTION, + carrySelection: null, + carrySourceDraftId: null, + destinationDraftId: "draft-b", + }), + ).toEqual(PROJECT_DEFAULT_SELECTION); + expect( + resolveNewThreadModelSelectionOverride({ + projectDefaultSelection: PROJECT_DEFAULT_SELECTION, + carrySelection: CARRIED_SELECTION, + carrySourceDraftId: "draft-a", + destinationDraftId: "draft-a", + }), ).toEqual(PROJECT_DEFAULT_SELECTION); }); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 4336fa5a825c..d6b55fc1dde8 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -39,16 +39,19 @@ export function resolveNewDraftStartFromOrigin(input: { return input.envMode === "worktree" && input.newWorktreesStartFromOrigin; } +// The model the user is looking at carries into the new thread. The project +// default only fills in when there is nothing to carry: every project gets a +// default seeded at creation, so letting it outrank the carried selection +// would reset the model on every new thread. export function resolveNewThreadModelSelectionOverride(input: { readonly projectDefaultSelection: ModelSelection | null; readonly carrySelection: ModelSelection | null; readonly carrySourceDraftId: string | null; readonly destinationDraftId: string; }): ModelSelection | null { - return ( - input.projectDefaultSelection ?? - (input.carrySourceDraftId === input.destinationDraftId ? null : input.carrySelection) - ); + const carrySelection = + input.carrySourceDraftId === input.destinationDraftId ? null : input.carrySelection; + return carrySelection ?? input.projectDefaultSelection; } export function resolveThreadActionProjectRef(