diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 92a456f12d30..d98ab2ac9bd6 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -73,6 +73,7 @@ import { type DraftThreadState, } from "../composerDraftStore"; import * as Schema from "effect/Schema"; +import { threadHasStarted } from "../lib/chatThreadActions"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadDetails } from "../state/threads"; import { @@ -825,10 +826,6 @@ export function recallCheckoutIsRepo( : sessionCheckoutIsRepo.get(checkoutIsRepoKey(environmentId, cwd)); } -function threadHasStarted(thread: Thread | null | undefined): boolean { - return Boolean(thread && (thread.latestRun !== null || thread.itemCount > 0 || thread.runtime)); -} - // Imported history lacks a session until its first prompt; resolve its instance before locking the driver. export function deriveLockedProvider(input: { thread: Thread | null | undefined; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index abc4cbdbf27a..7da579d0659a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -226,6 +226,7 @@ import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { isPreviewFocused } from "../lib/previewFocus"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; +import { draftModelSelectionApplies, threadHasStarted } from "../lib/chatThreadActions"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, @@ -608,15 +609,15 @@ export default function ChatView(props: ChatViewProps) { const composerActiveProvider = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, ); + const composerModelSelectionExplicit = useComposerDraftStore( + (store) => store.getComposerDraft(composerDraftTarget)?.modelSelectionExplicit === true, + ); const composerSelectedModelSelection = useComposerDraftStore((store) => { const draft = store.getComposerDraft(composerDraftTarget); return draft?.activeProvider ? (draft.modelSelectionByProvider[draft.activeProvider] ?? null) : null; }); - const composerModelSelectionExplicit = useComposerDraftStore( - (store) => store.getComposerDraft(composerDraftTarget)?.modelSelectionExplicit === true, - ); const composerHasAttachments = useComposerDraftStore((store) => { const draft = store.getComposerDraft(composerDraftTarget); return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0; @@ -1264,7 +1265,11 @@ export default function ChatView(props: ChatViewProps) { : providersByEnvironment.get(providerEnvironmentId)) ?? EMPTY_PROVIDERS, gitCwd, ); - const selectedProviderByThreadId = composerActiveProvider ?? null; + const composerModelSelectionApplies = draftModelSelectionApplies( + { modelSelectionExplicit: composerModelSelectionExplicit }, + threadHasStarted(activeThread), + ); + const selectedProviderByThreadId = composerModelSelectionApplies ? composerActiveProvider : null; const threadProvider = activeThread?.modelSelection.instanceId ?? activeProjectDefaultModelSelection?.instanceId ?? @@ -2480,7 +2485,9 @@ export default function ChatView(props: ChatViewProps) { item.type === "user_message" && (item.text.trim().toLowerCase() !== "/compact" || item.attachments.length > 0), ); - const pendingHandoffSelection = composerSelectedModelSelection ?? activeThread?.modelSelection; + const pendingHandoffSelection = + (composerModelSelectionApplies ? composerSelectedModelSelection : null) ?? + activeThread?.modelSelection; const pendingHandoffSelectionUsable = providerInstanceEntries.some( (entry) => entry.instanceId === pendingHandoffSelection?.instanceId && diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index fe21bd2e3a63..b0b8e510de5b 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -146,7 +146,11 @@ import { waitForProject, } from "../state/entities"; import { visibleLinkedThreads } from "@t3tools/client-runtime/state/threads"; -import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; +import { + resolveThreadActionProjectRef, + startNewThreadFromContext, + threadHasStarted, +} from "../lib/chatThreadActions"; import { appendBrowsePathSegment, ensureBrowseDirectoryPath, @@ -1397,6 +1401,7 @@ function OpenCommandPaletteDialog(props: { ? providersByEnvironment.get(promptLinkProjectRef.environmentId) : undefined) ?? [], threadModelSelection: activeThread?.modelSelection ?? null, + threadStarted: threadHasStarted(activeThread), }; const plainPromptLink = promptLinkInput ? composerPromptLink({ ...promptLinkInput, withContext: false }) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 00054443d7ca..9426a0b9b9b3 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -86,6 +86,7 @@ import { } from "../../composer-logic"; import { DEFAULT_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; import { deriveComposerSendState, getAntigravitySendBlockReason } from "../ChatView.logic"; +import { threadHasStarted } from "../../lib/chatThreadActions"; import { dataTransferHasComposerMention, makeComposerMentionDragHandlers, @@ -888,8 +889,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) threadModelSelection: activeThreadModelSelection, projectModelSelection: activeProjectDefaultModelSelection, promptInjectionState: getComposerPromptInjectionState(prompt), + threadStarted: threadHasStarted(activeThread), candidateInstanceIds: [ - composerDraft.activeProvider ?? null, activeThread?.runtime?.providerInstanceId, activeThreadModelSelection?.instanceId, activeProjectDefaultModelSelection?.instanceId, diff --git a/apps/web/src/components/chat/useComposerProviderModel.ts b/apps/web/src/components/chat/useComposerProviderModel.ts index 105df9fa4df5..a3db11d09a0f 100644 --- a/apps/web/src/components/chat/useComposerProviderModel.ts +++ b/apps/web/src/components/chat/useComposerProviderModel.ts @@ -3,6 +3,7 @@ import { deriveEffectiveComposerModelState, useComposerDraftModelState, } from "../../composerDraftStore"; +import { draftModelSelectionApplies } from "../../lib/chatThreadActions"; import { type ModelSelection, type ProjectId, @@ -35,6 +36,7 @@ export function useComposerProviderModel( readonly threadModelSelection: ModelSelection | null | undefined; readonly projectModelSelection: ModelSelection | null | undefined; readonly promptInjectionState: ReturnType; + readonly threadStarted: boolean; }, ) { const { @@ -48,6 +50,7 @@ export function useComposerProviderModel( candidateInstanceIds, lockedProvider, lockedInstanceId, + threadStarted, } = input; const draftModelState = useComposerDraftModelState(threadRef); const providerInstanceEntries: ReadonlyArray = providerInstanceEntriesFor( @@ -57,7 +60,12 @@ export function useComposerProviderModel( ); const selection = resolveComposerProviderSelection({ entries: providerInstanceEntries, - candidateInstanceIds, + candidateInstanceIds: [ + draftModelSelectionApplies(draftModelState, threadStarted) + ? draftModelState.activeProvider + : null, + ...candidateInstanceIds, + ], lockedProvider, lockedInstanceId, }); @@ -67,6 +75,7 @@ export function useComposerProviderModel( selection.selectedProviderEntry?.driverKind ?? selection.requestedDriverKind; const modelState = deriveEffectiveComposerModelState({ draft: draftModelState, + threadStarted, providers: providerStatuses, selectedProvider, selectedInstanceId, diff --git a/apps/web/src/components/chat/useComposerSelection.browser.test.tsx b/apps/web/src/components/chat/useComposerSelection.browser.test.tsx index d5fb9496b575..147a48aa8bd3 100644 --- a/apps/web/src/components/chat/useComposerSelection.browser.test.tsx +++ b/apps/web/src/components/chat/useComposerSelection.browser.test.tsx @@ -139,6 +139,7 @@ it("switches back to the thread's stored model while the session runs on another expect( deriveEffectiveComposerModelState({ draft, + threadStarted: true, providers, selectedProvider: ProviderDriverKind.make("cursor"), selectedInstanceId: cursor, @@ -175,3 +176,33 @@ it("saves a re-picked model again when its first save failed", async () => { }); await vi.waitFor(() => expect(setModelSelection.mock.calls).toEqual([[autoSave], [autoSave]])); }); + +it("saves a pick of a seeded model the started thread does not show", async () => { + const { setModelSelection } = setup(); + setModelSelection.mockResolvedValue(AsyncResult.success(undefined)); + const thread = cursorThread("thread-cursor-seeded"); + appAtomRegistry.set(shellAtom(thread.id), thread); + useComposerDraftStore + .getState() + .setModelSelection( + scopeThreadRef(environmentId, thread.id), + createModelSelection(cursor, "auto-smart"), + ); + await using _screen = await render( + + + , + ); + await page.getByRole("button", { name: "Pick the model" }).click(); + + await vi.waitFor(() => + expect(setModelSelection).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + threadId: thread.id, + modelSelection: expect.objectContaining({ instanceId: cursor, model: "auto-smart" }), + }, + }), + ), + ); +}); diff --git a/apps/web/src/components/chat/useComposerSelection.ts b/apps/web/src/components/chat/useComposerSelection.ts index c9bba2ae76bc..6641bf0903ff 100644 --- a/apps/web/src/components/chat/useComposerSelection.ts +++ b/apps/web/src/components/chat/useComposerSelection.ts @@ -18,7 +18,11 @@ import { type SetStateAction } from "react"; import { useNavigate } from "@tanstack/react-router"; import { toastManager } from "../ui/toast"; import { resolveAppModelSelectionForInstance } from "../../modelSelection"; -import { resolveNewDraftStartFromOrigin } from "../../lib/chatThreadActions"; +import { + draftModelSelectionApplies, + resolveNewDraftStartFromOrigin, + threadHasStarted, +} from "../../lib/chatThreadActions"; import { type DraftThreadEnvMode, useComposerDraftStore } from "../../composerDraftStore"; import { getStartedThreadModelChangeBlockReason, @@ -161,7 +165,10 @@ export function useComposerSelection(input: ComposerSelectionInput) { { preserveUnavailableSelection: true }, ); const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); - const draft = useComposerDraftStore.getState().getComposerDraft(threadRef); + const storedDraft = useComposerDraftStore.getState().getComposerDraft(threadRef); + const draft = draftModelSelectionApplies(storedDraft, threadHasStarted(activeThread)) + ? storedDraft + : null; const shownInstanceId = draft?.activeProvider ?? activeRuntime?.providerInstanceId ?? diff --git a/apps/web/src/components/promptLinks/copyPromptLink.ts b/apps/web/src/components/promptLinks/copyPromptLink.ts index 1ba1612d3c48..80bb453d6784 100644 --- a/apps/web/src/components/promptLinks/copyPromptLink.ts +++ b/apps/web/src/components/promptLinks/copyPromptLink.ts @@ -15,6 +15,7 @@ import { import { type ComposerThreadTarget, useComposerDraftStore } from "~/composerDraftStore"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { configuredHostedAppUrl } from "~/hostedPairing"; +import { draftModelSelectionApplies } from "~/lib/chatThreadActions"; import { stackedThreadToast, toastManager } from "../ui/toast"; @@ -48,14 +49,16 @@ export function composerPromptLink(input: { readonly project: EnvironmentProject | undefined; readonly providers: ReadonlyArray; readonly threadModelSelection: ModelSelection | null; + readonly threadStarted: boolean; readonly withContext: boolean; }): string | null { const draft = useComposerDraftStore.getState().getComposerDraft(input.target); if (!draft) return null; if (!input.withContext) return promptLinkFor(draft.prompt); const selection = - (draft.activeProvider ? draft.modelSelectionByProvider[draft.activeProvider] : undefined) ?? - input.threadModelSelection; + (draft.activeProvider && draftModelSelectionApplies(draft, input.threadStarted) + ? draft.modelSelectionByProvider[draft.activeProvider] + : undefined) ?? input.threadModelSelection; const provider = selection ? (input.providers.find((candidate) => candidate.instanceId === selection.instanceId)?.driver ?? null) diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index c698f96e92e3..a6d4e1ecde01 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -34,6 +34,7 @@ import { type PreviewAnnotationPayload, type ProviderOptionSelection, type ScopedThreadRef, + type ServerProvider, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; @@ -1704,6 +1705,7 @@ describe("composerDraftStore modelSelection", () => { const remoteModel = DEFAULT_MODEL_BY_PROVIDER[CLAUDE_AGENT_DRIVER]!; h.update({ modelSelection: modelSelection(CLAUDE_AGENT_DRIVER, remoteModel) }); const effective = deriveEffectiveComposerModelState({ + threadStarted: false, draft: draftFor(threadId, TEST_ENVIRONMENT_ID), providers: [ { @@ -1744,6 +1746,7 @@ describe("composerDraftStore modelSelection", () => { const h = modelReconciliationHarness(TEST_ENVIRONMENT_ID, threadId); h.update({ modelSelection: primary }); const fallback = deriveEffectiveComposerModelState({ + threadStarted: false, draft: before, providers: [], selectedProvider: CODEX_DRIVER, @@ -1850,6 +1853,91 @@ describe("composerDraftStore modelSelection", () => { ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.4", { reasoningEffort: "xhigh" })); }); + describe("seeded draft model on a started thread", () => { + const providers: ReadonlyArray = [ + { + instanceId: CODEX_INSTANCE, + driver: CODEX_DRIVER, + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: ["gpt-5.4", "gpt-5.3-codex"].map((slug) => ({ + slug, + name: slug, + isCustom: false, + capabilities: {}, + })), + slashCommands: [], + skills: [], + }, + ]; + const threadSelection = modelSelection(CODEX_DRIVER, "gpt-5.3-codex", { + reasoningEffort: "low", + }); + const effective = (threadStarted: boolean) => { + const state = deriveEffectiveComposerModelState({ + draft: useComposerDraftStore.getState().getComposerDraft(threadRef), + threadStarted, + providers, + selectedProvider: CODEX_DRIVER, + selectedInstanceId: CODEX_INSTANCE, + threadModelSelection: threadSelection, + projectModelSelection: null, + settings: DEFAULT_UNIFIED_SETTINGS, + }); + return { + model: state.selectedModel, + options: state.modelOptions?.[CODEX_INSTANCE] ?? [], + }; + }; + + it("applies the seed only until the thread starts, then only an explicit pick wins", () => { + const store = useComposerDraftStore.getState(); + store.setModelSelection( + threadRef, + modelSelection(CODEX_DRIVER, "gpt-5.4", { reasoningEffort: "high" }), + ); + + expect(effective(false)).toEqual({ + model: "gpt-5.4", + options: toSelections({ reasoningEffort: "high" }), + }); + expect(effective(true)).toEqual({ + model: "gpt-5.3-codex", + options: toSelections({ reasoningEffort: "low" }), + }); + + store.setModelSelection( + threadRef, + modelSelection(CODEX_DRIVER, "gpt-5.4", { reasoningEffort: "high" }), + { explicit: true }, + ); + expect(effective(true)).toEqual({ + model: "gpt-5.4", + options: toSelections({ reasoningEffort: "high" }), + }); + }); + + it("keeps the thread's model when traits change over a seed", () => { + const store = useComposerDraftStore.getState(); + store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4")); + store.setProviderModelOptions( + threadRef, + CODEX_DRIVER, + toSelections({ reasoningEffort: "high" }), + { instanceId: CODEX_INSTANCE, model: "gpt-5.3-codex" }, + ); + + expect(effective(true)).toEqual({ + model: "gpt-5.3-codex", + options: toSelections({ reasoningEffort: "high" }), + }); + }); + }); + it("stores provider option changes on a selected custom instance", () => { const store = useComposerDraftStore.getState(); @@ -1934,6 +2022,38 @@ describe("composerDraftStore per-model sticky options", () => { ); }); + it("keeps the sticky model's own traits when traits change on another shown model", () => { + const store = useComposerDraftStore.getState(); + store.setStickyModelSelection( + modelSelection(CODEX_DRIVER, "gpt-5.3-codex", { reasoningEffort: "xhigh" }), + ); + + store.setProviderModelOptions( + threadRef, + CODEX_DRIVER, + toSelections({ reasoningEffort: "low" }), + { instanceId: CODEX_INSTANCE, model: "gpt-5.4", persistSticky: true }, + ); + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_INSTANCE]).toEqual( + modelSelection(CODEX_DRIVER, "gpt-5.3-codex", { reasoningEffort: "xhigh" }), + ); + expect( + useComposerDraftStore.getState().stickyOptionsByModelByProvider[CODEX_INSTANCE]?.["gpt-5.4"], + ).toEqual(toSelections({ reasoningEffort: "low" })); + + store.setProviderModelOptions(threadRef, CODEX_DRIVER, null, { + instanceId: CODEX_INSTANCE, + model: "gpt-5.4", + persistSticky: true, + }); + + const state = useComposerDraftStore.getState(); + expect(state.stickyModelSelectionByProvider[CODEX_INSTANCE]).toEqual( + modelSelection(CODEX_DRIVER, "gpt-5.3-codex", { reasoningEffort: "xhigh" }), + ); + expect(state.stickyOptionsByModelByProvider[CODEX_INSTANCE]?.["gpt-5.4"]).toBeUndefined(); + }); + it("seeds per-model memory from a persisted sticky selection on upgrade", () => { const persistApi = useComposerDraftStore.persist as unknown as { getOptions: () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 68fdc2b2bca3..857ab8616449 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -95,6 +95,7 @@ import { import { create } from "zustand"; import { persist, type PersistStorage, type StorageValue } from "zustand/middleware"; import { useShallow } from "zustand/react/shallow"; +import { draftModelSelectionApplies } from "./lib/chatThreadActions"; import { createDeferredStorage, createMemoryStorage } from "./lib/storage"; import { getDefaultServerModel } from "./providerModels"; import { @@ -768,6 +769,7 @@ export interface EffectiveComposerModelState { interface ComposerDraftModelState { activeProvider: ProviderInstanceId | null; modelSelectionByProvider: Partial>; + modelSelectionExplicit: boolean; } function selectionsByInstance( @@ -870,6 +872,7 @@ const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial({ activeProvider: null, modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER, + modelSelectionExplicit: false, }); const EMPTY_THREAD_DRAFT = Object.freeze({ @@ -1222,9 +1225,13 @@ function legacyToModelSelectionByProvider( export function deriveEffectiveComposerModelState(input: { draft: - | Pick + | Pick< + ComposerThreadDraftState, + "modelSelectionByProvider" | "activeProvider" | "modelSelectionExplicit" + > | null | undefined; + threadStarted: boolean; providers: ReadonlyArray; selectedProvider: ProviderDriverKind; selectedInstanceId?: ProviderInstanceId | null | undefined; @@ -1234,6 +1241,7 @@ export function deriveEffectiveComposerModelState(input: { projectModelSelection: ModelSelection | null | undefined; settings: UnifiedSettings; }): EffectiveComposerModelState { + const draft = draftModelSelectionApplies(input.draft, input.threadStarted) ? input.draft : null; const baseModelCandidate = input.threadModelSelection?.model ?? input.projectModelSelection?.model ?? null; const preserveThreadModel = @@ -1261,14 +1269,14 @@ export function deriveEffectiveComposerModelState(input: { normalizeModelSlug(baseModelCandidate, input.selectedProvider) ?? getDefaultServerModel(input.providers, input.selectedProvider); const instanceSelection = input.selectedInstanceId - ? input.draft?.modelSelectionByProvider?.[input.selectedInstanceId] + ? draft?.modelSelectionByProvider?.[input.selectedInstanceId] : undefined; // Legacy driver-keyed drafts belong to the default instance. const legacySelection = input.selectedInstanceId && input.selectedInstanceId !== defaultInstanceIdForDriver(input.selectedProvider) ? undefined - : input.draft?.modelSelectionByProvider?.[ProviderInstanceId.make(input.selectedProvider)]; + : draft?.modelSelectionByProvider?.[ProviderInstanceId.make(input.selectedProvider)]; const activeSelection = instanceSelection ?? legacySelection; const activeSelectionInstanceId = instanceSelection ? (input.selectedInstanceId ?? ProviderInstanceId.make(input.selectedProvider)) @@ -1293,7 +1301,7 @@ export function deriveEffectiveComposerModelState(input: { // Selections stay with them, so a fallback instance can check which model the options belong to. const optionSelections = [ - input.draft?.modelSelectionByProvider, + draft?.modelSelectionByProvider, selectionsByInstance(input.threadModelSelection ?? input.projectModelSelection), ].find((selections) => modelSelectionByProviderToOptions(selections) !== null) ?? null; const modelOptions = modelSelectionByProviderToOptions(optionSelections); @@ -3307,10 +3315,13 @@ const composerDraftStore = create()( const base = existing ?? createEmptyThreadDraft(); const nextMap = { ...base.modelSelectionByProvider }; - const currentForProvider = nextMap[instanceKey]; + // Traits apply to the model the composer shows, which on a started + // thread is the thread's model rather than a stored seed. nextMap[instanceKey] = createModelSelection( instanceKey, - currentForProvider?.model ?? fallbackModel, + normalizeCustomModelSlug(options?.model) ?? + nextMap[instanceKey]?.model ?? + fallbackModel, providerOpts, ); @@ -3324,12 +3335,17 @@ const composerDraftStore = create()( base.modelSelectionByProvider[instanceKey] ?? createModelSelection(instanceKey, fallbackModel); const rememberedModel = normalizeCustomModelSlug(options?.model) ?? stickyBase.model; + // Traits for another shown model belong in per-model memory only; + // the sticky selection keeps its own model's traits. + const appliesToSticky = rememberedModel === stickyBase.model; if (providerOpts) { - nextStickyMap[instanceKey] = createModelSelection( - instanceKey, - stickyBase.model, - providerOpts, - ); + if (appliesToSticky) { + nextStickyMap[instanceKey] = createModelSelection( + instanceKey, + stickyBase.model, + providerOpts, + ); + } nextOptionsByModel = { ...state.stickyOptionsByModelByProvider, [instanceKey]: { @@ -3337,9 +3353,11 @@ const composerDraftStore = create()( [rememberedModel]: providerOpts, }, }; - } else if ((stickyBase.options?.length ?? 0) > 0) { - const { options: _, ...rest } = stickyBase; - nextStickyMap[instanceKey] = rest as ModelSelection; + } else { + if (appliesToSticky && (stickyBase.options?.length ?? 0) > 0) { + const { options: _, ...rest } = stickyBase; + nextStickyMap[instanceKey] = rest as ModelSelection; + } const rememberedByModel = { ...state.stickyOptionsByModelByProvider[instanceKey], }; @@ -4651,6 +4669,7 @@ export function useComposerDraftModelState( ? { activeProvider: draft.activeProvider, modelSelectionByProvider: draft.modelSelectionByProvider, + modelSelectionExplicit: draft.modelSelectionExplicit === true, } : EMPTY_COMPOSER_DRAFT_MODEL_STATE; }), diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 44215edfc5be..9fae0319539b 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -30,9 +30,11 @@ import { useQuietThreadShell, } from "../state/entities"; import { + draftModelSelectionApplies, hasExplicitComposerModelSelection, resolveNewDraftStartFromOrigin, resolveNewThreadModelSelectionOverride, + threadHasStarted, } from "../lib/chatThreadActions"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { readT3ProjectFile } from "../lib/t3ProjectFileDefaults"; @@ -139,7 +141,12 @@ export function createNewThreadHandler(router: AnyRouter) { : currentRouteTarget.draftId, ) : null; - const composerActiveProvider = carrySourceComposer?.activeProvider ?? null; + const composerActiveProvider = draftModelSelectionApplies( + carrySourceComposer, + threadHasStarted(carrySourceShell), + ) + ? (carrySourceComposer?.activeProvider ?? null) + : null; const composerModelSelection = composerActiveProvider ? (carrySourceComposer?.modelSelectionByProvider[composerActiveProvider] ?? null) : null; diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index ac5412c23975..2c52a3f56239 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -1,4 +1,5 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, ModelSelection, @@ -67,6 +68,24 @@ export function hasExplicitComposerModelSelection( ); } +export function threadHasStarted( + thread: Pick | null | undefined, +): boolean { + return Boolean(thread && (thread.latestRun !== null || thread.itemCount > 0 || thread.runtime)); +} + +/** + * A draft is seeded from sticky state, project defaults, prompt links, or a carried selection. + * That seed is what an unstarted thread sends; once the thread has started, its own selection + * wins unless the user picked a model or traits for it. + */ +export function draftModelSelectionApplies( + draft: Pick | null | undefined, + threadStarted: boolean, +): boolean { + return !threadStarted || draft?.modelSelectionExplicit === true; +} + export function resolveThreadActionProjectRef( context: ChatThreadActionContext, ): ScopedProjectRef | null { diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index ff5612896023..62a74204dd17 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -489,6 +489,7 @@ describe("instance-scoped model selection", () => { const driver = ProviderDriverKind.make("codex"); const providers = [provider({ provider: driver, instanceId, models: ["gpt-5.6-sol"] })]; const state = deriveEffectiveComposerModelState({ + threadStarted: false, draft: { activeProvider: instanceId, modelSelectionByProvider: { @@ -523,6 +524,7 @@ describe("instance-scoped model selection", () => { ]); const providers = [provider({ provider: driver, instanceId, models: ["auto-smart"] })]; const state = deriveEffectiveComposerModelState({ + threadStarted: false, draft: null, providers, selectedProvider: driver, @@ -552,6 +554,7 @@ describe("instance-scoped model selection", () => { ]); const providers = [provider({ provider: driver, instanceId, models: [] })]; const state = deriveEffectiveComposerModelState({ + threadStarted: false, draft: { activeProvider: instanceId, modelSelectionByProvider: { [instanceId]: draftSelection }, @@ -584,6 +587,7 @@ describe("instance-scoped model selection", () => { { activeProvider: instanceId, modelSelectionByProvider: { [instanceId]: saved } }, ]) { const state = deriveEffectiveComposerModelState({ + threadStarted: false, draft, providers, selectedProvider: driver, @@ -616,6 +620,7 @@ describe("instance-scoped model selection", () => { }, ]) { const state = deriveEffectiveComposerModelState({ + threadStarted: false, draft, providers, selectedProvider: driver, @@ -727,6 +732,7 @@ describe("instance-scoped model selection", () => { { id: "agent", value: "build" }, ]); const state = deriveEffectiveComposerModelState({ + threadStarted: false, draft: null, providers, selectedProvider: driver, @@ -780,6 +786,7 @@ describe("instance-scoped model selection", () => { threadModelSelection: ReturnType | null, ) => { const state = deriveEffectiveComposerModelState({ + threadStarted: false, draft: null, providers, selectedProvider: driver, @@ -832,6 +839,7 @@ describe("instance-scoped model selection", () => { { id: "effort", value: "max" }, ]); const state = deriveEffectiveComposerModelState({ + threadStarted: false, draft: { activeProvider: instanceId, modelSelectionByProvider: { [instanceId]: draftSelection }, @@ -869,6 +877,7 @@ describe("instance-scoped model selection", () => { provider({ provider: driver, instanceId: kimiInstanceId, models: [kimiModel] }), ]; const state = deriveEffectiveComposerModelState({ + threadStarted: false, draft: { activeProvider: kimiInstanceId, modelSelectionByProvider: { @@ -1089,6 +1098,7 @@ describe("default model settings and new-thread composer parity", () => { }; // ChatView builds the local draft with the project default as its thread selection. const composer = deriveEffectiveComposerModelState({ + threadStarted: false, draft: null, providers, selectedProvider: driver, @@ -1188,6 +1198,7 @@ describe("composer fallback to another instance of the same driver", () => { const selectedInstanceId = selection.selectedProviderEntry!.instanceId; const selectedProvider = selection.selectedProviderEntry!.driverKind; const state = deriveEffectiveComposerModelState({ + threadStarted: false, draft: input.draft ?? null, providers: input.providers, selectedProvider,