From 3e110b4082fce077387fe1281eff90541a4c8540 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 17 Mar 2026 16:08:47 -0300 Subject: [PATCH 1/8] feat(web): persist sticky codex composer settings --- apps/web/src/components/ChatView.browser.tsx | 44 +++++++++ apps/web/src/components/ChatView.tsx | 31 ++++++- apps/web/src/hooks/useHandleNewThread.ts | 18 +++- apps/web/src/stickyComposerSettings.ts | 97 ++++++++++++++++++++ 4 files changed, 184 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/stickyComposerSettings.ts diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 6cbef09bd6..0f313b7785 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1279,6 +1279,50 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("snapshots sticky codex traits into a new draft thread", async () => { + localStorage.setItem( + "t3code:sticky-composer-settings:v1", + JSON.stringify({ + model: null, + modelOptions: { + codex: { + reasoningEffort: "medium", + fastMode: true, + }, + }, + }), + ); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-sticky-codex-traits-test" as MessageId, + targetText: "sticky codex traits test", + }), + }); + + 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 have changed to a new draft thread UUID.", + ); + const newThreadId = newThreadPath.slice(1) as ThreadId; + + expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ + effort: "medium", + codexFastMode: true, + }); + } finally { + await mounted.cleanup(); + } + }); + it("creates a new thread from the global chat.new shortcut", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index eaead424fb..7bc902f781 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2,6 +2,7 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, type ClaudeCodeEffort, + type CodexReasoningEffort, type MessageId, type ProjectScript, type ModelSlug, @@ -143,6 +144,7 @@ import { type TerminalContextDraft, type TerminalContextSelection, } from "../lib/terminalContext"; +import { useStickyComposerSettings } from "../stickyComposerSettings"; import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; @@ -244,6 +246,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); + const { updateSettings: updateStickyComposerSettings } = useStickyComposerSettings(); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const rawSearch = useSearch({ @@ -3141,20 +3144,22 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus(); return; } + const resolvedModel = resolveAppModelSelection(provider, settings.customCodexModels, model); setComposerDraftProvider(activeThread.id, provider); - setComposerDraftModel( - activeThread.id, - resolveAppModelSelection(provider, customModelsByProvider[provider], model), - ); + setComposerDraftModel(activeThread.id, resolvedModel); + if (provider === "codex") { + updateStickyComposerSettings({ model: resolvedModel }); + } scheduleComposerFocus(); }, [ activeThread, - customModelsByProvider, lockedProvider, scheduleComposerFocus, setComposerDraftModel, setComposerDraftProvider, + settings.customCodexModels, + updateStickyComposerSettings, ], ); const setPromptFromTraits = useCallback( @@ -3173,6 +3178,22 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [scheduleComposerFocus, setPrompt], ); + const onEffortSelect = useCallback( + (effort: CodexReasoningEffort) => { + setComposerDraftEffort(threadId, effort); + updateStickyComposerSettings({ effort }); + scheduleComposerFocus(); + }, + [scheduleComposerFocus, setComposerDraftEffort, threadId, updateStickyComposerSettings], + ); + const onCodexFastModeChange = useCallback( + (enabled: boolean) => { + setComposerDraftCodexFastMode(threadId, enabled); + updateStickyComposerSettings({ codexFastMode: enabled }); + scheduleComposerFocus(); + }, + [scheduleComposerFocus, setComposerDraftCodexFastMode, threadId, updateStickyComposerSettings], + ); const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { if (isLocalDraftThread) { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 35f92d98e9..fdab85425a 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -6,12 +6,16 @@ import { type DraftThreadState, useComposerDraftStore, } from "../composerDraftStore"; +import { useStickyComposerSettings } from "../stickyComposerSettings"; import { newThreadId } from "../lib/utils"; import { useStore } from "../store"; export function useHandleNewThread() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); + const { + settings: { model: stickyModel, effort: stickyEffort, codexFastMode: stickyCodexFastMode }, + } = useStickyComposerSettings(); const navigate = useNavigate(); const routeThreadId = useParams({ strict: false, @@ -38,6 +42,9 @@ export function useHandleNewThread() { clearProjectDraftThreadId, getDraftThread, getDraftThreadByProjectId, + setCodexFastMode, + setEffort, + setModel, setDraftThreadContext, setProjectDraftThreadId, } = useComposerDraftStore.getState(); @@ -96,6 +103,15 @@ export function useHandleNewThread() { envMode: options?.envMode ?? "local", runtimeMode: DEFAULT_RUNTIME_MODE, }); + if (stickyModel) { + setModel(threadId, stickyModel); + } + if (stickyEffort) { + setEffort(threadId, stickyEffort); + } + if (stickyCodexFastMode) { + setCodexFastMode(threadId, true); + } await navigate({ to: "/$threadId", @@ -103,7 +119,7 @@ export function useHandleNewThread() { }); })(); }, - [navigate, routeThreadId], + [navigate, routeThreadId, stickyCodexFastMode, stickyEffort, stickyModel], ); return { diff --git a/apps/web/src/stickyComposerSettings.ts b/apps/web/src/stickyComposerSettings.ts new file mode 100644 index 0000000000..e261584b68 --- /dev/null +++ b/apps/web/src/stickyComposerSettings.ts @@ -0,0 +1,97 @@ +import { + type CodexReasoningEffort, + CODEX_REASONING_EFFORT_OPTIONS, + ProviderModelOptions, +} from "@t3tools/contracts"; +import { normalizeModelSlug } from "@t3tools/shared/model"; +import { Schema } from "effect"; +import { useCallback } from "react"; +import { useLocalStorage } from "./hooks/useLocalStorage"; + +const STICKY_COMPOSER_SETTINGS_STORAGE_KEY = "t3code:sticky-composer-settings:v1"; + +const StickyComposerSettingsStorageSchema = Schema.Struct({ + model: Schema.NullOr(Schema.String), + modelOptions: ProviderModelOptions, +}); + +type StickyComposerSettingsStorage = typeof StickyComposerSettingsStorageSchema.Type; + +export interface StickyComposerSettings { + model: string | null; + effort: CodexReasoningEffort | null; + codexFastMode: boolean; +} + +const DEFAULT_STICKY_COMPOSER_SETTINGS_STORAGE: StickyComposerSettingsStorage = { + model: null, + modelOptions: {}, +}; + +function normalizeStickyComposerSettings( + value: Partial | StickyComposerSettings, +): StickyComposerSettings { + const effort = value.effort; + return { + model: normalizeModelSlug(value.model, "codex") ?? null, + effort: + typeof effort === "string" && + (CODEX_REASONING_EFFORT_OPTIONS as readonly string[]).includes(effort) + ? (effort as CodexReasoningEffort) + : null, + codexFastMode: value.codexFastMode === true, + }; +} + +function stickyComposerSettingsFromStorage( + value: StickyComposerSettingsStorage, +): StickyComposerSettings { + return normalizeStickyComposerSettings({ + model: value.model, + effort: value.modelOptions.codex?.reasoningEffort ?? null, + codexFastMode: value.modelOptions.codex?.fastMode ?? false, + }); +} + +function stickyComposerSettingsToStorage( + value: StickyComposerSettings, +): StickyComposerSettingsStorage { + const normalized = normalizeStickyComposerSettings(value); + const codexModelOptions = { + ...(normalized.effort ? { reasoningEffort: normalized.effort } : {}), + ...(normalized.codexFastMode ? { fastMode: true } : {}), + }; + + return { + model: normalized.model, + modelOptions: Object.keys(codexModelOptions).length > 0 ? { codex: codexModelOptions } : {}, + }; +} + +export function useStickyComposerSettings() { + const [storedSettings, setStoredSettings] = useLocalStorage( + STICKY_COMPOSER_SETTINGS_STORAGE_KEY, + DEFAULT_STICKY_COMPOSER_SETTINGS_STORAGE, + StickyComposerSettingsStorageSchema, + ); + const settings = stickyComposerSettingsFromStorage(storedSettings); + + const updateSettings = useCallback( + (patch: Partial) => { + setStoredSettings((previous) => + stickyComposerSettingsToStorage( + normalizeStickyComposerSettings({ + ...stickyComposerSettingsFromStorage(previous), + ...patch, + }), + ), + ); + }, + [setStoredSettings], + ); + + return { + settings, + updateSettings, + } as const; +} From ed56cd838d18f9bc80511cfbd6ae458294c122fc Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 17 Mar 2026 16:13:13 -0300 Subject: [PATCH 2/8] fix(web): simplify sticky composer settings normalization --- apps/web/src/stickyComposerSettings.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/apps/web/src/stickyComposerSettings.ts b/apps/web/src/stickyComposerSettings.ts index e261584b68..4397dee463 100644 --- a/apps/web/src/stickyComposerSettings.ts +++ b/apps/web/src/stickyComposerSettings.ts @@ -1,8 +1,4 @@ -import { - type CodexReasoningEffort, - CODEX_REASONING_EFFORT_OPTIONS, - ProviderModelOptions, -} from "@t3tools/contracts"; +import { type CodexReasoningEffort, ProviderModelOptions } from "@t3tools/contracts"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { Schema } from "effect"; import { useCallback } from "react"; @@ -31,14 +27,9 @@ const DEFAULT_STICKY_COMPOSER_SETTINGS_STORAGE: StickyComposerSettingsStorage = function normalizeStickyComposerSettings( value: Partial | StickyComposerSettings, ): StickyComposerSettings { - const effort = value.effort; return { model: normalizeModelSlug(value.model, "codex") ?? null, - effort: - typeof effort === "string" && - (CODEX_REASONING_EFFORT_OPTIONS as readonly string[]).includes(effort) - ? (effort as CodexReasoningEffort) - : null, + effort: value.effort ?? null, codexFastMode: value.codexFastMode === true, }; } From 102aea6c68585d0723021821c09eee77dc7f6566 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 17 Mar 2026 16:29:19 -0300 Subject: [PATCH 3/8] test(web): cover sticky codex trait precedence --- apps/web/src/components/ChatView.browser.tsx | 58 ++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 0f313b7785..9197464f29 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1323,6 +1323,64 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("prefers draft codex traits over sticky settings", async () => { + localStorage.setItem( + "t3code:sticky-composer-settings:v1", + JSON.stringify({ + model: null, + modelOptions: { + codex: { + reasoningEffort: "medium", + fastMode: true, + }, + }, + }), + ); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-draft-codex-traits-precedence-test" as MessageId, + targetText: "draft codex traits precedence test", + }), + }); + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + const threadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a sticky draft thread UUID.", + ); + const threadId = threadPath.slice(1) as ThreadId; + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + effort: "medium", + codexFastMode: true, + }); + + useComposerDraftStore.getState().setEffort(threadId, "low"); + + await newThreadButton.click(); + + await waitForURL( + mounted.router, + (path) => path === threadPath, + "New-thread should reuse the existing project draft thread.", + ); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + effort: "low", + codexFastMode: true, + }); + } finally { + await mounted.cleanup(); + } + }); + it("creates a new thread from the global chat.new shortcut", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, From a9f7e881d6b820caa198d3899d318a552670b1e7 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 17 Mar 2026 20:48:18 -0300 Subject: [PATCH 4/8] refactor(web): simplify sticky composer settings --- apps/web/src/components/ChatView.browser.tsx | 76 +++++++++++----- apps/web/src/components/ChatView.tsx | 40 +++++++-- apps/web/src/composerDraftStore.test.ts | 92 +++++++++++-------- apps/web/src/composerDraftStore.ts | 93 ++++++++++++++++++++ apps/web/src/hooks/useHandleNewThread.ts | 14 ++- apps/web/src/stickyComposerSettings.ts | 88 ------------------ 6 files changed, 240 insertions(+), 163 deletions(-) delete mode 100644 apps/web/src/stickyComposerSettings.ts diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 9197464f29..b32c8693f7 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -752,6 +752,8 @@ describe("ChatView timeline estimator parity (full app)", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModel: null, + stickyModelOptions: {}, }); useStore.setState({ projects: [], @@ -1279,19 +1281,16 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("snapshots sticky codex traits into a new draft thread", async () => { - localStorage.setItem( - "t3code:sticky-composer-settings:v1", - JSON.stringify({ - model: null, - modelOptions: { - codex: { - reasoningEffort: "medium", - fastMode: true, - }, + it("snapshots sticky codex settings into a new draft thread", async () => { + useComposerDraftStore.setState({ + stickyModel: "gpt-5.3-codex", + stickyModelOptions: { + codex: { + reasoningEffort: "medium", + fastMode: true, }, - }), - ); + }, + }); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, @@ -1315,6 +1314,7 @@ describe("ChatView timeline estimator parity (full app)", () => { const newThreadId = newThreadPath.slice(1) as ThreadId; expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ + model: "gpt-5.3-codex", effort: "medium", codexFastMode: true, }); @@ -1323,19 +1323,44 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("prefers draft codex traits over sticky settings", async () => { - localStorage.setItem( - "t3code:sticky-composer-settings:v1", - JSON.stringify({ - model: null, - modelOptions: { - codex: { - reasoningEffort: "medium", - fastMode: true, - }, - }, + it("falls back to defaults when no sticky composer settings exist", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-default-codex-traits-test" as MessageId, + targetText: "default codex traits test", }), - ); + }); + + 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 have changed to a new draft thread UUID.", + ); + const newThreadId = newThreadPath.slice(1) as ThreadId; + + expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toBeUndefined(); + } finally { + await mounted.cleanup(); + } + }); + + it("prefers draft state over sticky composer settings and defaults", async () => { + useComposerDraftStore.setState({ + stickyModel: "gpt-5.3-codex", + stickyModelOptions: { + codex: { + reasoningEffort: "medium", + fastMode: true, + }, + }, + }); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, @@ -1359,10 +1384,12 @@ describe("ChatView timeline estimator parity (full app)", () => { const threadId = threadPath.slice(1) as ThreadId; expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + model: "gpt-5.3-codex", effort: "medium", codexFastMode: true, }); + useComposerDraftStore.getState().setModel(threadId, "gpt-5.4"); useComposerDraftStore.getState().setEffort(threadId, "low"); await newThreadButton.click(); @@ -1373,6 +1400,7 @@ describe("ChatView timeline estimator parity (full app)", () => { "New-thread should reuse the existing project draft thread.", ); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + model: "gpt-5.4", effort: "low", codexFastMode: true, }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7bc902f781..2eaaf89d99 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -144,7 +144,6 @@ import { type TerminalContextDraft, type TerminalContextSelection, } from "../lib/terminalContext"; -import { useStickyComposerSettings } from "../stickyComposerSettings"; import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; @@ -246,7 +245,10 @@ export default function ChatView({ threadId }: ChatViewProps) { const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); - const { updateSettings: updateStickyComposerSettings } = useStickyComposerSettings(); + const setStickyComposerModel = useComposerDraftStore((store) => store.setStickyModel); + const setStickyComposerModelOptions = useComposerDraftStore( + (store) => store.setStickyModelOptions, + ); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const rawSearch = useSearch({ @@ -3148,7 +3150,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerDraftProvider(activeThread.id, provider); setComposerDraftModel(activeThread.id, resolvedModel); if (provider === "codex") { - updateStickyComposerSettings({ model: resolvedModel }); + setStickyComposerModel(resolvedModel); } scheduleComposerFocus(); }, @@ -3158,8 +3160,8 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus, setComposerDraftModel, setComposerDraftProvider, + setStickyComposerModel, settings.customCodexModels, - updateStickyComposerSettings, ], ); const setPromptFromTraits = useCallback( @@ -3181,18 +3183,40 @@ export default function ChatView({ threadId }: ChatViewProps) { const onEffortSelect = useCallback( (effort: CodexReasoningEffort) => { setComposerDraftEffort(threadId, effort); - updateStickyComposerSettings({ effort }); + setStickyComposerModelOptions({ + codex: { + reasoningEffort: effort, + ...(selectedCodexFastModeEnabled ? { fastMode: true } : {}), + }, + }); scheduleComposerFocus(); }, - [scheduleComposerFocus, setComposerDraftEffort, threadId, updateStickyComposerSettings], + [ + scheduleComposerFocus, + selectedCodexFastModeEnabled, + setComposerDraftEffort, + setStickyComposerModelOptions, + threadId, + ], ); const onCodexFastModeChange = useCallback( (enabled: boolean) => { setComposerDraftCodexFastMode(threadId, enabled); - updateStickyComposerSettings({ codexFastMode: enabled }); + setStickyComposerModelOptions({ + codex: { + reasoningEffort: selectedEffort, + ...(enabled ? { fastMode: true } : {}), + }, + }); scheduleComposerFocus(); }, - [scheduleComposerFocus, setComposerDraftCodexFastMode, threadId, updateStickyComposerSettings], + [ + scheduleComposerFocus, + selectedEffort, + setComposerDraftCodexFastMode, + setStickyComposerModelOptions, + threadId, + ], ); const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 773f16ceab..93d7d7c58e 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -62,17 +62,23 @@ function makeTerminalContext(input: { }; } +function resetComposerDraftStore() { + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + stickyModel: null, + stickyModelOptions: {}, + }); +} + describe("composerDraftStore addImages", () => { const threadId = ThreadId.makeUnsafe("thread-dedupe"); let originalRevokeObjectUrl: typeof URL.revokeObjectURL; let revokeSpy: ReturnType void>>; beforeEach(() => { - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - }); + resetComposerDraftStore(); originalRevokeObjectUrl = URL.revokeObjectURL; revokeSpy = vi.fn(); URL.revokeObjectURL = revokeSpy; @@ -157,11 +163,7 @@ describe("composerDraftStore clearComposerContent", () => { let revokeSpy: ReturnType void>>; beforeEach(() => { - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - }); + resetComposerDraftStore(); originalRevokeObjectUrl = URL.revokeObjectURL; revokeSpy = vi.fn(); URL.revokeObjectURL = revokeSpy; @@ -423,11 +425,7 @@ describe("composerDraftStore project draft thread mapping", () => { const otherThreadId = ThreadId.makeUnsafe("thread-b"); beforeEach(() => { - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - }); + resetComposerDraftStore(); }); it("stores and reads project draft thread ids via actions", () => { @@ -599,11 +597,7 @@ describe("composerDraftStore modelOptions", () => { const threadId = ThreadId.makeUnsafe("thread-model-options"); beforeEach(() => { - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - }); + resetComposerDraftStore(); }); it("stores provider-scoped model options in the draft", () => { @@ -648,11 +642,7 @@ describe("composerDraftStore setModel", () => { const threadId = ThreadId.makeUnsafe("thread-model"); beforeEach(() => { - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - }); + resetComposerDraftStore(); }); it("keeps explicit DEFAULT_MODEL overrides instead of coercing to null", () => { @@ -666,15 +656,51 @@ describe("composerDraftStore setModel", () => { }); }); +describe("composerDraftStore sticky composer settings", () => { + beforeEach(() => { + resetComposerDraftStore(); + }); + + it("stores sticky model and codex model options", () => { + const store = useComposerDraftStore.getState(); + + store.setStickyModel("gpt-5.3-codex"); + store.setStickyModelOptions({ + codex: { + reasoningEffort: "medium", + fastMode: true, + }, + }); + + expect(useComposerDraftStore.getState()).toMatchObject({ + stickyModel: "gpt-5.3-codex", + stickyModelOptions: { + codex: { + reasoningEffort: "medium", + fastMode: true, + }, + }, + }); + }); + + it("normalizes empty sticky model options", () => { + const store = useComposerDraftStore.getState(); + + store.setStickyModelOptions({ + codex: { + fastMode: false, + }, + }); + + expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({}); + }); +}); + describe("composerDraftStore setProvider", () => { const threadId = ThreadId.makeUnsafe("thread-provider"); beforeEach(() => { - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - }); + resetComposerDraftStore(); }); it("persists provider-only selection even when prompt/model are empty", () => { @@ -699,11 +725,7 @@ describe("composerDraftStore runtime and interaction settings", () => { const threadId = ThreadId.makeUnsafe("thread-settings"); beforeEach(() => { - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - }); + resetComposerDraftStore(); }); it("stores runtime mode overrides in the composer draft", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 7733bfd62f..9f13990c45 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -104,6 +104,8 @@ const PersistedComposerDraftStoreState = Schema.Struct({ draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState), draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState), projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId), + stickyModel: Schema.NullOr(Schema.String), + stickyModelOptions: ProviderModelOptions, }); type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; @@ -143,6 +145,8 @@ interface ComposerDraftStoreState { draftsByThreadId: Record; draftThreadsByThreadId: Record; projectDraftThreadIdByProjectId: Record; + stickyModel: string | null; + stickyModelOptions: ProviderModelOptions; getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; getDraftThread: (threadId: ThreadId) => DraftThreadState | null; setProjectDraftThreadId: ( @@ -172,6 +176,8 @@ interface ComposerDraftStoreState { clearProjectDraftThreadId: (projectId: ProjectId) => void; clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; clearDraftThread: (threadId: ThreadId) => void; + setStickyModel: (model: string | null | undefined) => void; + setStickyModelOptions: (modelOptions: ProviderModelOptions | null | undefined) => void; setPrompt: (threadId: ThreadId, prompt: string) => void; setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void; @@ -207,10 +213,14 @@ interface ComposerDraftStoreState { clearThreadDraft: (threadId: ThreadId) => void; } +const EMPTY_PROVIDER_MODEL_OPTIONS = Object.freeze({}) as ProviderModelOptions; + const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModel: null, + stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS, }; const EMPTY_IMAGES: ComposerImageAttachment[] = []; @@ -233,6 +243,45 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ interactionMode: null, }); +function normalizeProviderModelOptions(value: unknown): ProviderModelOptions { + if (!value || typeof value !== "object") { + return EMPTY_PROVIDER_MODEL_OPTIONS; + } + const candidate = value as Record; + const rawCodex = candidate.codex; + if (!rawCodex || typeof rawCodex !== "object") { + return EMPTY_PROVIDER_MODEL_OPTIONS; + } + const codexCandidate = rawCodex as Record; + const reasoningEffortCandidate = + typeof codexCandidate.reasoningEffort === "string" ? codexCandidate.reasoningEffort : null; + const reasoningEffort = + reasoningEffortCandidate && + REASONING_EFFORT_VALUES.has(reasoningEffortCandidate as CodexReasoningEffort) + ? (reasoningEffortCandidate as CodexReasoningEffort) + : undefined; + const fastMode = codexCandidate.fastMode === true ? true : undefined; + if (!reasoningEffort && !fastMode) { + return EMPTY_PROVIDER_MODEL_OPTIONS; + } + return { + codex: { + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(fastMode ? { fastMode: true } : {}), + }, + }; +} + +function areProviderModelOptionsEqual( + left: ProviderModelOptions, + right: ProviderModelOptions, +): boolean { + return ( + left.codex?.reasoningEffort === right.codex?.reasoningEffort && + left.codex?.fastMode === right.codex?.fastMode + ); +} + function createEmptyThreadDraft(): ComposerThreadDraftState { return { prompt: "", @@ -674,6 +723,11 @@ function migratePersistedComposerDraftStoreState( const rawDraftMap = candidate.draftsByThreadId; const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId; const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId; + const stickyModel = + typeof candidate.stickyModel === "string" + ? (normalizeModelSlug(candidate.stickyModel, "codex") ?? null) + : null; + const stickyModelOptions = normalizeProviderModelOptions(candidate.stickyModelOptions); const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId); const draftsByThreadId = normalizePersistedDraftsByThreadId( @@ -691,6 +745,8 @@ function migratePersistedComposerDraftStoreState( draftsByThreadId, draftThreadsByThreadId, projectDraftThreadIdByProjectId, + stickyModel, + stickyModelOptions, }; } @@ -744,6 +800,8 @@ function partializeComposerDraftStoreState( draftsByThreadId: persistedDraftsByThreadId, draftThreadsByThreadId: state.draftThreadsByThreadId, projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, + stickyModel: state.stickyModel, + stickyModelOptions: state.stickyModelOptions, }; } @@ -759,6 +817,13 @@ function normalizeCurrentPersistedComposerDraftStoreState( normalizedPersistedState.draftThreadsByThreadId, normalizedPersistedState.projectDraftThreadIdByProjectId, ); + const stickyModel = + typeof normalizedPersistedState.stickyModel === "string" + ? (normalizeModelSlug(normalizedPersistedState.stickyModel, "codex") ?? null) + : null; + const stickyModelOptions = normalizeProviderModelOptions( + normalizedPersistedState.stickyModelOptions, + ); return { draftsByThreadId: normalizePersistedDraftsByThreadId( normalizedPersistedState.draftsByThreadId, @@ -767,6 +832,8 @@ function normalizeCurrentPersistedComposerDraftStoreState( ), draftThreadsByThreadId, projectDraftThreadIdByProjectId, + stickyModel, + stickyModelOptions, }; } @@ -870,6 +937,8 @@ export const useComposerDraftStore = create()( draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModel: null, + stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS, getDraftThreadByProjectId: (projectId) => { if (projectId.length === 0) { return null; @@ -1105,6 +1174,28 @@ export const useComposerDraftStore = create()( }; }); }, + setStickyModel: (model) => { + const normalizedModel = normalizeModelSlug(model, "codex") ?? null; + set((state) => { + if (state.stickyModel === normalizedModel) { + return state; + } + return { + stickyModel: normalizedModel, + }; + }); + }, + setStickyModelOptions: (modelOptions) => { + const normalizedModelOptions = normalizeProviderModelOptions(modelOptions); + set((state) => { + if (areProviderModelOptionsEqual(state.stickyModelOptions, normalizedModelOptions)) { + return state; + } + return { + stickyModelOptions: normalizedModelOptions, + }; + }); + }, setPrompt: (threadId, prompt) => { if (threadId.length === 0) { return; @@ -1644,6 +1735,8 @@ export const useComposerDraftStore = create()( draftsByThreadId, draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId, projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId, + stickyModel: normalizedPersisted.stickyModel, + stickyModelOptions: normalizedPersisted.stickyModelOptions, }; }, }, diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index fdab85425a..0fcbb2d807 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -6,16 +6,14 @@ import { type DraftThreadState, useComposerDraftStore, } from "../composerDraftStore"; -import { useStickyComposerSettings } from "../stickyComposerSettings"; import { newThreadId } from "../lib/utils"; import { useStore } from "../store"; export function useHandleNewThread() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); - const { - settings: { model: stickyModel, effort: stickyEffort, codexFastMode: stickyCodexFastMode }, - } = useStickyComposerSettings(); + const stickyModel = useComposerDraftStore((store) => store.stickyModel); + const stickyModelOptions = useComposerDraftStore((store) => store.stickyModelOptions); const navigate = useNavigate(); const routeThreadId = useParams({ strict: false, @@ -106,10 +104,10 @@ export function useHandleNewThread() { if (stickyModel) { setModel(threadId, stickyModel); } - if (stickyEffort) { - setEffort(threadId, stickyEffort); + if (stickyModelOptions.codex?.reasoningEffort) { + setEffort(threadId, stickyModelOptions.codex.reasoningEffort); } - if (stickyCodexFastMode) { + if (stickyModelOptions.codex?.fastMode) { setCodexFastMode(threadId, true); } @@ -119,7 +117,7 @@ export function useHandleNewThread() { }); })(); }, - [navigate, routeThreadId, stickyCodexFastMode, stickyEffort, stickyModel], + [navigate, routeThreadId, stickyModel, stickyModelOptions], ); return { diff --git a/apps/web/src/stickyComposerSettings.ts b/apps/web/src/stickyComposerSettings.ts deleted file mode 100644 index 4397dee463..0000000000 --- a/apps/web/src/stickyComposerSettings.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { type CodexReasoningEffort, ProviderModelOptions } from "@t3tools/contracts"; -import { normalizeModelSlug } from "@t3tools/shared/model"; -import { Schema } from "effect"; -import { useCallback } from "react"; -import { useLocalStorage } from "./hooks/useLocalStorage"; - -const STICKY_COMPOSER_SETTINGS_STORAGE_KEY = "t3code:sticky-composer-settings:v1"; - -const StickyComposerSettingsStorageSchema = Schema.Struct({ - model: Schema.NullOr(Schema.String), - modelOptions: ProviderModelOptions, -}); - -type StickyComposerSettingsStorage = typeof StickyComposerSettingsStorageSchema.Type; - -export interface StickyComposerSettings { - model: string | null; - effort: CodexReasoningEffort | null; - codexFastMode: boolean; -} - -const DEFAULT_STICKY_COMPOSER_SETTINGS_STORAGE: StickyComposerSettingsStorage = { - model: null, - modelOptions: {}, -}; - -function normalizeStickyComposerSettings( - value: Partial | StickyComposerSettings, -): StickyComposerSettings { - return { - model: normalizeModelSlug(value.model, "codex") ?? null, - effort: value.effort ?? null, - codexFastMode: value.codexFastMode === true, - }; -} - -function stickyComposerSettingsFromStorage( - value: StickyComposerSettingsStorage, -): StickyComposerSettings { - return normalizeStickyComposerSettings({ - model: value.model, - effort: value.modelOptions.codex?.reasoningEffort ?? null, - codexFastMode: value.modelOptions.codex?.fastMode ?? false, - }); -} - -function stickyComposerSettingsToStorage( - value: StickyComposerSettings, -): StickyComposerSettingsStorage { - const normalized = normalizeStickyComposerSettings(value); - const codexModelOptions = { - ...(normalized.effort ? { reasoningEffort: normalized.effort } : {}), - ...(normalized.codexFastMode ? { fastMode: true } : {}), - }; - - return { - model: normalized.model, - modelOptions: Object.keys(codexModelOptions).length > 0 ? { codex: codexModelOptions } : {}, - }; -} - -export function useStickyComposerSettings() { - const [storedSettings, setStoredSettings] = useLocalStorage( - STICKY_COMPOSER_SETTINGS_STORAGE_KEY, - DEFAULT_STICKY_COMPOSER_SETTINGS_STORAGE, - StickyComposerSettingsStorageSchema, - ); - const settings = stickyComposerSettingsFromStorage(storedSettings); - - const updateSettings = useCallback( - (patch: Partial) => { - setStoredSettings((previous) => - stickyComposerSettingsToStorage( - normalizeStickyComposerSettings({ - ...stickyComposerSettingsFromStorage(previous), - ...patch, - }), - ), - ); - }, - [setStoredSettings], - ); - - return { - settings, - updateSettings, - } as const; -} From 5d3f3293a2698f97f28497b6fef2d3d989274b4f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 20 Mar 2026 09:59:59 -0700 Subject: [PATCH 5/8] fix(web): align sticky composer settings with main Co-authored-by: codex --- apps/web/src/components/ChatView.browser.tsx | 22 +++++++--- apps/web/src/components/ChatView.tsx | 42 ------------------- .../src/components/chat/CodexTraitsPicker.tsx | 2 + apps/web/src/composerDraftStore.ts | 41 ++++-------------- apps/web/src/hooks/useHandleNewThread.ts | 10 ++--- 5 files changed, 29 insertions(+), 88 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index b32c8693f7..f8c07aaf3f 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1385,12 +1385,20 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ model: "gpt-5.3-codex", - effort: "medium", - codexFastMode: true, + modelOptions: { + codex: { + fastMode: true, + }, + }, }); useComposerDraftStore.getState().setModel(threadId, "gpt-5.4"); - useComposerDraftStore.getState().setEffort(threadId, "low"); + useComposerDraftStore.getState().setModelOptions(threadId, { + codex: { + reasoningEffort: "low", + fastMode: true, + }, + }); await newThreadButton.click(); @@ -1401,8 +1409,12 @@ describe("ChatView timeline estimator parity (full app)", () => { ); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ model: "gpt-5.4", - effort: "low", - codexFastMode: true, + modelOptions: { + codex: { + reasoningEffort: "low", + fastMode: true, + }, + }, }); } finally { await mounted.cleanup(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2eaaf89d99..b11ed5fa5b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2,7 +2,6 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, type ClaudeCodeEffort, - type CodexReasoningEffort, type MessageId, type ProjectScript, type ModelSlug, @@ -246,9 +245,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); const setStickyComposerModel = useComposerDraftStore((store) => store.setStickyModel); - const setStickyComposerModelOptions = useComposerDraftStore( - (store) => store.setStickyModelOptions, - ); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const rawSearch = useSearch({ @@ -3180,44 +3176,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [scheduleComposerFocus, setPrompt], ); - const onEffortSelect = useCallback( - (effort: CodexReasoningEffort) => { - setComposerDraftEffort(threadId, effort); - setStickyComposerModelOptions({ - codex: { - reasoningEffort: effort, - ...(selectedCodexFastModeEnabled ? { fastMode: true } : {}), - }, - }); - scheduleComposerFocus(); - }, - [ - scheduleComposerFocus, - selectedCodexFastModeEnabled, - setComposerDraftEffort, - setStickyComposerModelOptions, - threadId, - ], - ); - const onCodexFastModeChange = useCallback( - (enabled: boolean) => { - setComposerDraftCodexFastMode(threadId, enabled); - setStickyComposerModelOptions({ - codex: { - reasoningEffort: selectedEffort, - ...(enabled ? { fastMode: true } : {}), - }, - }); - scheduleComposerFocus(); - }, - [ - scheduleComposerFocus, - selectedEffort, - setComposerDraftCodexFastMode, - setStickyComposerModelOptions, - threadId, - ], - ); const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { if (isLocalDraftThread) { diff --git a/apps/web/src/components/chat/CodexTraitsPicker.tsx b/apps/web/src/components/chat/CodexTraitsPicker.tsx index 641d39277e..8cff69a616 100644 --- a/apps/web/src/components/chat/CodexTraitsPicker.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.tsx @@ -48,6 +48,7 @@ function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) { const draft = useComposerThreadDraft(props.threadId); const modelOptions = draft.modelOptions?.codex; const setModelOptions = useComposerDraftStore((store) => store.setModelOptions); + const setStickyModelOptions = useComposerDraftStore((store) => store.setStickyModelOptions); const options = getReasoningEffortOptions("codex"); const defaultReasoningEffort = getDefaultReasoningEffort("codex"); const { effort, fastModeEnabled } = getSelectedCodexTraits(modelOptions); @@ -60,6 +61,7 @@ function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) { ? otherProviderModelOptions : undefined; setModelOptions(props.threadId, nextProviderModelOptions); + setStickyModelOptions(nextProviderModelOptions); }; return ( diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 9f13990c45..d8f72f5a3c 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -243,35 +243,6 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ interactionMode: null, }); -function normalizeProviderModelOptions(value: unknown): ProviderModelOptions { - if (!value || typeof value !== "object") { - return EMPTY_PROVIDER_MODEL_OPTIONS; - } - const candidate = value as Record; - const rawCodex = candidate.codex; - if (!rawCodex || typeof rawCodex !== "object") { - return EMPTY_PROVIDER_MODEL_OPTIONS; - } - const codexCandidate = rawCodex as Record; - const reasoningEffortCandidate = - typeof codexCandidate.reasoningEffort === "string" ? codexCandidate.reasoningEffort : null; - const reasoningEffort = - reasoningEffortCandidate && - REASONING_EFFORT_VALUES.has(reasoningEffortCandidate as CodexReasoningEffort) - ? (reasoningEffortCandidate as CodexReasoningEffort) - : undefined; - const fastMode = codexCandidate.fastMode === true ? true : undefined; - if (!reasoningEffort && !fastMode) { - return EMPTY_PROVIDER_MODEL_OPTIONS; - } - return { - codex: { - ...(reasoningEffort ? { reasoningEffort } : {}), - ...(fastMode ? { fastMode: true } : {}), - }, - }; -} - function areProviderModelOptionsEqual( left: ProviderModelOptions, right: ProviderModelOptions, @@ -727,7 +698,8 @@ function migratePersistedComposerDraftStoreState( typeof candidate.stickyModel === "string" ? (normalizeModelSlug(candidate.stickyModel, "codex") ?? null) : null; - const stickyModelOptions = normalizeProviderModelOptions(candidate.stickyModelOptions); + const stickyModelOptions = + normalizeProviderModelOptions(candidate.stickyModelOptions) ?? EMPTY_PROVIDER_MODEL_OPTIONS; const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId); const draftsByThreadId = normalizePersistedDraftsByThreadId( @@ -821,9 +793,9 @@ function normalizeCurrentPersistedComposerDraftStoreState( typeof normalizedPersistedState.stickyModel === "string" ? (normalizeModelSlug(normalizedPersistedState.stickyModel, "codex") ?? null) : null; - const stickyModelOptions = normalizeProviderModelOptions( - normalizedPersistedState.stickyModelOptions, - ); + const stickyModelOptions = + normalizeProviderModelOptions(normalizedPersistedState.stickyModelOptions) ?? + EMPTY_PROVIDER_MODEL_OPTIONS; return { draftsByThreadId: normalizePersistedDraftsByThreadId( normalizedPersistedState.draftsByThreadId, @@ -1186,7 +1158,8 @@ export const useComposerDraftStore = create()( }); }, setStickyModelOptions: (modelOptions) => { - const normalizedModelOptions = normalizeProviderModelOptions(modelOptions); + const normalizedModelOptions = + normalizeProviderModelOptions(modelOptions) ?? EMPTY_PROVIDER_MODEL_OPTIONS; set((state) => { if (areProviderModelOptionsEqual(state.stickyModelOptions, normalizedModelOptions)) { return state; diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 0fcbb2d807..e88a1e6aa7 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -40,9 +40,8 @@ export function useHandleNewThread() { clearProjectDraftThreadId, getDraftThread, getDraftThreadByProjectId, - setCodexFastMode, - setEffort, setModel, + setModelOptions, setDraftThreadContext, setProjectDraftThreadId, } = useComposerDraftStore.getState(); @@ -104,11 +103,8 @@ export function useHandleNewThread() { if (stickyModel) { setModel(threadId, stickyModel); } - if (stickyModelOptions.codex?.reasoningEffort) { - setEffort(threadId, stickyModelOptions.codex.reasoningEffort); - } - if (stickyModelOptions.codex?.fastMode) { - setCodexFastMode(threadId, true); + if (stickyModelOptions.codex) { + setModelOptions(threadId, stickyModelOptions); } await navigate({ From 17312f79da3c3ade799842b755a81ad6bc42edca Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 20 Mar 2026 10:04:31 -0700 Subject: [PATCH 6/8] nit --- apps/web/src/composerDraftStore.ts | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index d8f72f5a3c..b8f8dfcf3b 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -11,6 +11,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import * as Equal from "effect/Equal"; import { DeepMutable } from "effect/Types"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; @@ -213,15 +214,15 @@ interface ComposerDraftStoreState { clearThreadDraft: (threadId: ThreadId) => void; } -const EMPTY_PROVIDER_MODEL_OPTIONS = Object.freeze({}) as ProviderModelOptions; +const EMPTY_PROVIDER_MODEL_OPTIONS = Object.freeze({}); -const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { +const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, stickyModel: null, stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS, -}; +}); const EMPTY_IMAGES: ComposerImageAttachment[] = []; const EMPTY_IDS: string[] = []; @@ -243,16 +244,6 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ interactionMode: null, }); -function areProviderModelOptionsEqual( - left: ProviderModelOptions, - right: ProviderModelOptions, -): boolean { - return ( - left.codex?.reasoningEffort === right.codex?.reasoningEffort && - left.codex?.fastMode === right.codex?.fastMode - ); -} - function createEmptyThreadDraft(): ComposerThreadDraftState { return { prompt: "", @@ -1161,7 +1152,7 @@ export const useComposerDraftStore = create()( const normalizedModelOptions = normalizeProviderModelOptions(modelOptions) ?? EMPTY_PROVIDER_MODEL_OPTIONS; set((state) => { - if (areProviderModelOptionsEqual(state.stickyModelOptions, normalizedModelOptions)) { + if (Equal.equals(state.stickyModelOptions, normalizedModelOptions)) { return state; } return { From 477a6f38beefb91c61eb3928b95e8a402050906d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 20 Mar 2026 10:49:36 -0700 Subject: [PATCH 7/8] Persist composer settings per provider - Store sticky model and trait options separately for Codex and Claude - Share model/trait resolution logic across composer UI - Add coverage for provider selection and sticky trait persistence --- .gitignore | 3 +- apps/web/src/appSettings.test.ts | 30 +++- apps/web/src/appSettings.ts | 47 +++--- apps/web/src/components/ChatView.browser.tsx | 56 ++++++- apps/web/src/components/ChatView.tsx | 140 ++++++---------- .../chat/ClaudeTraitsPicker.browser.tsx | 21 +++ .../components/chat/ClaudeTraitsPicker.tsx | 141 ++++++++-------- .../chat/CodexTraitsPicker.browser.tsx | 19 +++ .../src/components/chat/CodexTraitsPicker.tsx | 38 ++--- .../chat/ProviderModelPicker.browser.tsx | 20 +++ .../components/chat/ProviderModelPicker.tsx | 43 +---- .../chat/composerProviderRegistry.test.tsx | 149 +++++++++++++++++ .../chat/composerProviderRegistry.tsx | 137 ++++++++++++++++ apps/web/src/composerDraftStore.test.ts | 152 ++++++++++++++++++ apps/web/src/composerDraftStore.ts | 75 ++++++++- apps/web/src/hooks/useHandleNewThread.ts | 5 +- packages/shared/src/model.test.ts | 65 ++++++++ packages/shared/src/model.ts | 38 +++++ 18 files changed, 924 insertions(+), 255 deletions(-) create mode 100644 apps/web/src/components/chat/composerProviderRegistry.test.tsx create mode 100644 apps/web/src/components/chat/composerProviderRegistry.tsx diff --git a/.gitignore b/.gitignore index 3e8d287755..f5c9ebb69b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ release/ apps/web/.playwright apps/web/playwright-report apps/web/src/components/__screenshots__ -.vitest-* \ No newline at end of file +.vitest-* +__screenshots__/ \ No newline at end of file diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 605b281df3..2fa6fc9a72 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -66,13 +66,35 @@ describe("getAppModelOptions", () => { describe("resolveAppModelSelection", () => { it("preserves saved custom model slugs instead of falling back to the default", () => { - expect(resolveAppModelSelection("codex", ["galapagos-alpha"], "galapagos-alpha")).toBe( - "galapagos-alpha", - ); + expect( + resolveAppModelSelection( + "codex", + { codex: ["galapagos-alpha"], claudeAgent: [] }, + "galapagos-alpha", + ), + ).toBe("galapagos-alpha"); }); it("falls back to the provider default when no model is selected", () => { - expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4"); + expect(resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "")).toBe("gpt-5.4"); + }); + + it("resolves display names through the shared resolver", () => { + expect(resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "GPT-5.3 Codex")).toBe( + "gpt-5.3-codex", + ); + }); + + it("resolves aliases through the shared resolver", () => { + expect(resolveAppModelSelection("claudeAgent", { codex: [], claudeAgent: [] }, "sonnet")).toBe( + "claude-sonnet-4-6", + ); + }); + + it("resolves transient selected custom models included in app model options", () => { + expect( + resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "custom/selected-model"), + ).toBe("custom/selected-model"); }); }); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index e4f4d8b1ca..dec12299ce 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,7 +1,12 @@ import { useCallback } from "react"; import { Option, Schema } from "effect"; import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts"; -import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; +import { + getDefaultModel, + getModelOptions, + normalizeModelSlug, + resolveSelectableModel, +} from "@t3tools/shared/model"; import { useLocalStorage } from "./hooks/useLocalStorage"; import { EnvMode } from "./components/BranchToolbar.logic"; @@ -98,6 +103,7 @@ export function getAppModelOptions( isCustom: false, })); const seen = new Set(options.map((option) => option.slug)); + const trimmedSelectedModel = selectedModel?.trim().toLowerCase(); for (const slug of normalizeCustomModelSlugs(customModels, provider)) { if (seen.has(slug)) { @@ -113,7 +119,14 @@ export function getAppModelOptions( } const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider); - if (normalizedSelectedModel && !seen.has(normalizedSelectedModel)) { + const selectedModelMatchesExistingName = + typeof trimmedSelectedModel === "string" && + options.some((option) => option.name.toLowerCase() === trimmedSelectedModel); + if ( + normalizedSelectedModel && + !seen.has(normalizedSelectedModel) && + !selectedModelMatchesExistingName + ) { options.push({ slug: normalizedSelectedModel, name: normalizedSelectedModel, @@ -126,34 +139,12 @@ export function getAppModelOptions( export function resolveAppModelSelection( provider: ProviderKind, - customModels: readonly string[], + customModels: Record, selectedModel: string | null | undefined, ): string { - const options = getAppModelOptions(provider, customModels, selectedModel); - const trimmedSelectedModel = selectedModel?.trim(); - if (trimmedSelectedModel) { - const direct = options.find((option) => option.slug === trimmedSelectedModel); - if (direct) { - return direct.slug; - } - - const byName = options.find( - (option) => option.name.toLowerCase() === trimmedSelectedModel.toLowerCase(), - ); - if (byName) { - return byName.slug; - } - } - - const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider); - if (!normalizedSelectedModel) { - return getDefaultModel(provider); - } - - return ( - options.find((option) => option.slug === normalizedSelectedModel)?.slug ?? - getDefaultModel(provider) - ); + const customModelsForProvider = customModels[provider]; + const options = getAppModelOptions(provider, customModelsForProvider, selectedModel); + return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider); } export function useAppSettings() { diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index f8c07aaf3f..48c627747d 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1315,9 +1315,61 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ model: "gpt-5.3-codex", - effort: "medium", - codexFastMode: true, + provider: "codex", + modelOptions: { + codex: { + fastMode: true, + }, + }, + }); + } finally { + await mounted.cleanup(); + } + }); + + it("hydrates the provider alongside a sticky claude model", async () => { + useComposerDraftStore.setState({ + stickyModel: "claude-opus-4-6", + stickyModelOptions: { + claudeAgent: { + effort: "max", + fastMode: true, + }, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-sticky-claude-model-test" as MessageId, + targetText: "sticky claude model test", + }), + }); + + 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 have changed to a new sticky claude draft thread UUID.", + ); + const newThreadId = newThreadPath.slice(1) as ThreadId; + + expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ + provider: "claudeAgent", + model: "claude-opus-4-6", + modelOptions: { + claudeAgent: { + effort: "max", + fastMode: true, + }, + }, }); + await expect.element(page.getByText("Claude Opus 4.6")).toBeInTheDocument(); } finally { await mounted.cleanup(); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b11ed5fa5b..f4ee07d745 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -24,15 +24,8 @@ import { import { applyClaudePromptEffortPrefix, getDefaultModel, - getDefaultReasoningEffort, - getReasoningEffortOptions, - isClaudeUltrathinkPrompt, - normalizeClaudeModelOptions, - normalizeCodexModelOptions, normalizeModelSlug, - resolveReasoningEffortForProvider, resolveModelSlugForProvider, - supportsClaudeUltrathinkKeyword, } from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -153,12 +146,15 @@ import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/Expanded import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; -import { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./chat/ClaudeTraitsPicker"; -import { CodexTraitsMenuContent, CodexTraitsPicker } from "./chat/CodexTraitsPicker"; import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; +import { + getComposerProviderState, + renderProviderTraitsMenuContent, + renderProviderTraitsPicker, +} from "./chat/composerProviderRegistry"; import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { @@ -604,65 +600,26 @@ export default function ChatView({ threadId }: ChatViewProps) { }), [settings.customClaudeModels, settings.customCodexModels], ); - const customModelsForSelectedProvider = customModelsByProvider[selectedProvider]; const selectedModel = useMemo(() => { const draftModel = composerDraft.model; if (!draftModel) { return baseThreadModel; } - return resolveAppModelSelection( - selectedProvider, - customModelsForSelectedProvider, - draftModel, - ) as ModelSlug; - }, [baseThreadModel, composerDraft.model, customModelsForSelectedProvider, selectedProvider]); + return resolveAppModelSelection(selectedProvider, customModelsByProvider, draftModel); + }, [baseThreadModel, composerDraft.model, customModelsByProvider, selectedProvider]); const draftModelOptions = composerDraft.modelOptions; - const selectedCodexEffort = - selectedProvider === "codex" - ? (resolveReasoningEffortForProvider("codex", draftModelOptions?.codex?.reasoningEffort) ?? - getDefaultReasoningEffort("codex")) - : null; - const selectedClaudeReasoningOptions = - selectedProvider === "claudeAgent" - ? getReasoningEffortOptions("claudeAgent", selectedModel) - : ([] as const); - const selectedClaudeBaseEffort = - selectedProvider === "claudeAgent" && selectedClaudeReasoningOptions.length > 0 - ? (() => { - const draftEffort = resolveReasoningEffortForProvider( - "claudeAgent", - draftModelOptions?.claudeAgent?.effort, - ); - if ( - draftEffort && - draftEffort !== "ultrathink" && - selectedClaudeReasoningOptions.includes(draftEffort) - ) { - return draftEffort; - } - const defaultEffort = getDefaultReasoningEffort("claudeAgent"); - return selectedClaudeReasoningOptions.includes(defaultEffort) ? defaultEffort : null; - })() - : null; - const isClaudeUltrathink = - selectedProvider === "claudeAgent" && - supportsClaudeUltrathinkKeyword(selectedModel) && - isClaudeUltrathinkPrompt(prompt); - const selectedPromptEffort = selectedCodexEffort ?? selectedClaudeBaseEffort; - const selectedModelOptionsForDispatch = useMemo(() => { - if (selectedProvider === "codex") { - const codexOptions = normalizeCodexModelOptions(draftModelOptions?.codex); - return codexOptions ? { codex: codexOptions } : undefined; - } - if (selectedProvider === "claudeAgent") { - const claudeOptions = normalizeClaudeModelOptions( - selectedModel, - draftModelOptions?.claudeAgent, - ); - return claudeOptions ? { claudeAgent: claudeOptions } : undefined; - } - return undefined; - }, [draftModelOptions, selectedModel, selectedProvider]); + const composerProviderState = useMemo( + () => + getComposerProviderState({ + provider: selectedProvider, + model: selectedModel, + prompt, + modelOptions: draftModelOptions, + }), + [draftModelOptions, prompt, selectedModel, selectedProvider], + ); + const selectedPromptEffort = composerProviderState.promptEffort; + const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; const providerOptionsForDispatch = useMemo(() => { if (!settings.codexBinaryPath && !settings.codexHomePath) { return undefined; @@ -3142,12 +3099,10 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus(); return; } - const resolvedModel = resolveAppModelSelection(provider, settings.customCodexModels, model); + const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); setComposerDraftProvider(activeThread.id, provider); setComposerDraftModel(activeThread.id, resolvedModel); - if (provider === "codex") { - setStickyComposerModel(resolvedModel); - } + setStickyComposerModel(resolvedModel); scheduleComposerFocus(); }, [ @@ -3157,7 +3112,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerDraftModel, setComposerDraftProvider, setStickyComposerModel, - settings.customCodexModels, + customModelsByProvider, ], ); const setPromptFromTraits = useCallback( @@ -3176,6 +3131,18 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [scheduleComposerFocus, setPrompt], ); + const providerTraitsMenuContent = renderProviderTraitsMenuContent({ + provider: selectedProvider, + threadId, + model: selectedModel, + onPromptChange: setPromptFromTraits, + }); + const providerTraitsPicker = renderProviderTraitsPicker({ + provider: selectedProvider, + threadId, + model: selectedModel, + onPromptChange: setPromptFromTraits, + }); const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { if (isLocalDraftThread) { @@ -3609,7 +3576,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
{activePendingApproval ? ( @@ -3809,7 +3776,12 @@ export default function ChatView({ threadId }: ChatViewProps) { model={selectedModelForPickerWithCustomFallback} lockedProvider={lockedProvider} modelOptionsByProvider={modelOptionsByProvider} - ultrathinkActive={isClaudeUltrathink} + {...(composerProviderState.modelPickerIconClassName + ? { + activeProviderIconClassName: + composerProviderState.modelPickerIconClassName, + } + : {})} onProviderModelChange={onProviderModelSelect} /> @@ -3821,42 +3793,20 @@ export default function ChatView({ threadId }: ChatViewProps) { interactionMode={interactionMode} planSidebarOpen={planSidebarOpen} runtimeMode={runtimeMode} - traitsMenuContent={ - selectedProvider === "codex" ? ( - - ) : selectedProvider === "claudeAgent" ? ( - - ) : null - } + traitsMenuContent={providerTraitsMenuContent} onToggleInteractionMode={toggleInteractionMode} onTogglePlanSidebar={togglePlanSidebar} onToggleRuntimeMode={toggleRuntimeMode} /> ) : ( <> - {selectedProvider === "codex" ? ( + {providerTraitsPicker ? ( <> - - - ) : selectedProvider === "claudeAgent" ? ( - <> - - + {providerTraitsPicker} ) : null} diff --git a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx index fd80e10473..a675a82d89 100644 --- a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx @@ -173,4 +173,25 @@ describe("ClaudeTraitsPicker", () => { await mounted.cleanup(); } }); + + it("persists sticky claude model options when traits change", async () => { + const mounted = await mountPicker({ + model: "claude-opus-4-6", + effort: "medium", + fastModeEnabled: false, + }); + + try { + await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "Max" }).click(); + + expect(useComposerDraftStore.getState().stickyModelOptions).toMatchObject({ + claudeAgent: { + effort: "max", + }, + }); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/chat/ClaudeTraitsPicker.tsx b/apps/web/src/components/chat/ClaudeTraitsPicker.tsx index 2baf197ab0..d6585d43d8 100644 --- a/apps/web/src/components/chat/ClaudeTraitsPicker.tsx +++ b/apps/web/src/components/chat/ClaudeTraitsPicker.tsx @@ -1,7 +1,7 @@ import { + ProviderKind, type ClaudeCodeEffort, type ClaudeModelOptions, - type ProviderModelOptions, type ThreadId, } from "@t3tools/contracts"; import { @@ -15,7 +15,7 @@ import { supportsClaudeUltrathinkKeyword, isClaudeUltrathinkPrompt, } from "@t3tools/shared/model"; -import { memo, useState } from "react"; +import { memo, useCallback, useState } from "react"; import { ChevronDownIcon } from "lucide-react"; import { Button } from "../ui/button"; import { @@ -29,6 +29,8 @@ import { } from "../ui/menu"; import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore"; +const PROVIDER = "claudeAgent" as const satisfies ProviderKind; + const CLAUDE_EFFORT_LABELS: Record = { low: "Low", medium: "Medium", @@ -51,12 +53,12 @@ function getSelectedClaudeTraits( ultrathinkPromptControlled: boolean; supportsFastMode: boolean; } { - const options = getReasoningEffortOptions("claudeAgent", model); - const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent") as Exclude< + const options = getReasoningEffortOptions(PROVIDER, model); + const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER) as Exclude< ClaudeCodeEffort, "ultrathink" >; - const resolvedEffort = resolveReasoningEffortForProvider("claudeAgent", modelOptions?.effort); + const resolvedEffort = resolveReasoningEffortForProvider(PROVIDER, modelOptions?.effort); const effort = resolvedEffort && resolvedEffort !== "ultrathink" && options.includes(resolvedEffort) ? resolvedEffort @@ -78,15 +80,21 @@ function getSelectedClaudeTraits( }; } -function ClaudeTraitsMenuContentImpl(props: { +interface ClaudeTraitsMenuContentProps { threadId: ThreadId; model: string | null | undefined; onPromptChange: (prompt: string) => void; -}) { - const draft = useComposerThreadDraft(props.threadId); +} + +export const ClaudeTraitsMenuContent = memo(function ClaudeTraitsMenuContentImpl({ + threadId, + model, + onPromptChange, +}: ClaudeTraitsMenuContentProps) { + const draft = useComposerThreadDraft(threadId); const prompt = draft.prompt; - const modelOptions = draft.modelOptions?.claudeAgent; - const setModelOptions = useComposerDraftStore((store) => store.setModelOptions); + const modelOptions = draft.modelOptions?.[PROVIDER]; + const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); const { effort, thinkingEnabled, @@ -94,19 +102,44 @@ function ClaudeTraitsMenuContentImpl(props: { options, ultrathinkPromptControlled, supportsFastMode, - } = getSelectedClaudeTraits(props.model, prompt, modelOptions); - const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent"); + } = getSelectedClaudeTraits(model, prompt, modelOptions); + const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER); - const setClaudeModelOptions = (nextClaudeModelOptions: ClaudeModelOptions | undefined) => { - const { claudeAgent: _discardedClaude, ...otherProviderModelOptions } = - draft.modelOptions ?? {}; - const nextProviderModelOptions: ProviderModelOptions | undefined = nextClaudeModelOptions - ? { ...otherProviderModelOptions, claudeAgent: nextClaudeModelOptions } - : Object.keys(otherProviderModelOptions).length > 0 - ? otherProviderModelOptions - : undefined; - setModelOptions(props.threadId, nextProviderModelOptions); - }; + const handleEffortChange = useCallback( + (value: ClaudeCodeEffort) => { + if (ultrathinkPromptControlled) return; + if (!value) return; + const nextEffort = options.find((option) => option === value); + if (!nextEffort) return; + if (nextEffort === "ultrathink") { + const nextPrompt = + prompt.trim().length === 0 + ? ULTRATHINK_PROMPT_PREFIX + : applyClaudePromptEffortPrefix(prompt, "ultrathink"); + onPromptChange(nextPrompt); + return; + } + setProviderModelOptions( + threadId, + PROVIDER, + normalizeClaudeModelOptions(model, { + ...modelOptions, + effort: nextEffort, + }), + { persistSticky: true }, + ); + }, + [ + ultrathinkPromptControlled, + model, + modelOptions, + onPromptChange, + threadId, + setProviderModelOptions, + options, + prompt, + ], + ); if (effort === null && thinkingEnabled === null) { return null; @@ -123,29 +156,7 @@ function ClaudeTraitsMenuContentImpl(props: { Remove Ultrathink from the prompt to change effort.
) : null} - { - if (ultrathinkPromptControlled) return; - if (!value) return; - const nextEffort = options.find((option) => option === value); - if (!nextEffort) return; - if (nextEffort === "ultrathink") { - const nextPrompt = - prompt.trim().length === 0 - ? ULTRATHINK_PROMPT_PREFIX - : applyClaudePromptEffortPrefix(prompt, "ultrathink"); - props.onPromptChange(nextPrompt); - return; - } - setClaudeModelOptions( - normalizeClaudeModelOptions(props.model, { - ...modelOptions, - effort: nextEffort, - }), - ); - }} - > + {options.map((option) => ( {CLAUDE_EFFORT_LABELS[option]} @@ -161,11 +172,14 @@ function ClaudeTraitsMenuContentImpl(props: { { - setClaudeModelOptions( - normalizeClaudeModelOptions(props.model, { + setProviderModelOptions( + threadId, + PROVIDER, + normalizeClaudeModelOptions(model, { ...modelOptions, thinking: value === "on", }), + { persistSticky: true }, ); }} > @@ -182,11 +196,14 @@ function ClaudeTraitsMenuContentImpl(props: { { - setClaudeModelOptions( - normalizeClaudeModelOptions(props.model, { + setProviderModelOptions( + threadId, + PROVIDER, + normalizeClaudeModelOptions(model, { ...modelOptions, fastMode: value === "on", }), + { persistSticky: true }, ); }} > @@ -198,21 +215,19 @@ function ClaudeTraitsMenuContentImpl(props: { ) : null} ); -} - -export const ClaudeTraitsMenuContent = memo(ClaudeTraitsMenuContentImpl); +}); -export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker(props: { - threadId: ThreadId; - model: string | null | undefined; - onPromptChange: (prompt: string) => void; -}) { +export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker({ + threadId, + model, + onPromptChange, +}: ClaudeTraitsMenuContentProps) { const [isMenuOpen, setIsMenuOpen] = useState(false); - const draft = useComposerThreadDraft(props.threadId); + const draft = useComposerThreadDraft(threadId); const prompt = draft.prompt; - const modelOptions = draft.modelOptions?.claudeAgent; + const modelOptions = draft.modelOptions?.[PROVIDER]; const { effort, thinkingEnabled, fastModeEnabled, ultrathinkPromptControlled, supportsFastMode } = - getSelectedClaudeTraits(props.model, prompt, modelOptions); + getSelectedClaudeTraits(model, prompt, modelOptions); const triggerLabel = [ ultrathinkPromptControlled ? "Ultrathink" @@ -247,9 +262,9 @@ export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker(props: { diff --git a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx index d717f91923..9d2b73989d 100644 --- a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx @@ -116,6 +116,25 @@ describe("CodexTraitsPicker", () => { } }); + it("persists sticky codex model options when traits change", async () => { + const mounted = await mountPicker({ + fastModeEnabled: false, + }); + + try { + await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "on" }).click(); + + expect(useComposerDraftStore.getState().stickyModelOptions).toMatchObject({ + codex: { + fastMode: true, + }, + }); + } finally { + await mounted.cleanup(); + } + }); + it("hydrates legacy codex persisted state into modelOptions through the picker", async () => { const threadId = ThreadId.makeUnsafe("thread-codex-legacy"); localStorage.setItem( diff --git a/apps/web/src/components/chat/CodexTraitsPicker.tsx b/apps/web/src/components/chat/CodexTraitsPicker.tsx index 8cff69a616..7b37063bff 100644 --- a/apps/web/src/components/chat/CodexTraitsPicker.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.tsx @@ -1,7 +1,7 @@ import type { CodexModelOptions, CodexReasoningEffort, - ProviderModelOptions, + ProviderKind, ThreadId, } from "@t3tools/contracts"; import { @@ -24,6 +24,8 @@ import { MenuTrigger, } from "../ui/menu"; +const PROVIDER = "codex" as const satisfies ProviderKind; + const CODEX_REASONING_LABELS: Record = { low: "Low", medium: "Medium", @@ -35,10 +37,10 @@ function getSelectedCodexTraits(modelOptions: CodexModelOptions | null | undefin effort: CodexReasoningEffort; fastModeEnabled: boolean; } { - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); + const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER); return { effort: - resolveReasoningEffortForProvider("codex", modelOptions?.reasoningEffort) ?? + resolveReasoningEffortForProvider(PROVIDER, modelOptions?.reasoningEffort) ?? defaultReasoningEffort, fastModeEnabled: modelOptions?.fastMode === true, }; @@ -46,24 +48,12 @@ function getSelectedCodexTraits(modelOptions: CodexModelOptions | null | undefin function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) { const draft = useComposerThreadDraft(props.threadId); - const modelOptions = draft.modelOptions?.codex; - const setModelOptions = useComposerDraftStore((store) => store.setModelOptions); - const setStickyModelOptions = useComposerDraftStore((store) => store.setStickyModelOptions); - const options = getReasoningEffortOptions("codex"); - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); + const modelOptions = draft.modelOptions?.[PROVIDER]; + const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); + const options = getReasoningEffortOptions(PROVIDER); + const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER); const { effort, fastModeEnabled } = getSelectedCodexTraits(modelOptions); - const setCodexModelOptions = (nextCodexModelOptions: CodexModelOptions | undefined) => { - const { codex: _discardedCodex, ...otherProviderModelOptions } = draft.modelOptions ?? {}; - const nextProviderModelOptions: ProviderModelOptions | undefined = nextCodexModelOptions - ? { ...otherProviderModelOptions, codex: nextCodexModelOptions } - : Object.keys(otherProviderModelOptions).length > 0 - ? otherProviderModelOptions - : undefined; - setModelOptions(props.threadId, nextProviderModelOptions); - setStickyModelOptions(nextProviderModelOptions); - }; - return ( <> @@ -74,11 +64,14 @@ function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) { if (!value) return; const nextEffort = options.find((option) => option === value); if (!nextEffort) return; - setCodexModelOptions( + setProviderModelOptions( + props.threadId, + PROVIDER, normalizeCodexModelOptions({ ...modelOptions, reasoningEffort: nextEffort, }), + { persistSticky: true }, ); }} > @@ -96,11 +89,14 @@ function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) { { - setCodexModelOptions( + setProviderModelOptions( + props.threadId, + PROVIDER, normalizeCodexModelOptions({ ...modelOptions, fastMode: value === "on", }), + { persistSticky: true }, ); }} > diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 11a3289f45..1694b374c8 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -91,4 +91,24 @@ describe("ProviderModelPicker", () => { await mounted.cleanup(); } }); + + it("dispatches the canonical slug when a model is selected", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: "claudeAgent", + }); + + try { + await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "Claude Sonnet 4.6" }).click(); + + expect(mounted.onProviderModelChange).toHaveBeenCalledWith( + "claudeAgent", + "claude-sonnet-4-6", + ); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index e61ac6da5b..95f27f39cd 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,5 +1,5 @@ import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; -import { normalizeModelSlug } from "@t3tools/shared/model"; +import { resolveSelectableModel } from "@t3tools/shared/model"; import { memo, useState } from "react"; import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; import { ChevronDownIcon } from "lucide-react"; @@ -28,39 +28,6 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o return option.available; } -function resolveModelForProviderPicker( - provider: ProviderKind, - value: string, - options: ReadonlyArray<{ slug: string; name: string }>, -): ModelSlug | null { - const trimmedValue = value.trim(); - if (!trimmedValue) { - return null; - } - - const direct = options.find((option) => option.slug === trimmedValue); - if (direct) { - return direct.slug; - } - - const byName = options.find((option) => option.name.toLowerCase() === trimmedValue.toLowerCase()); - if (byName) { - return byName.slug; - } - - const normalized = normalizeModelSlug(trimmedValue, provider); - if (!normalized) { - return null; - } - - const resolved = options.find((option) => option.slug === normalized); - if (resolved) { - return resolved.slug; - } - - return null; -} - const PROVIDER_ICON_BY_PROVIDER: Record = { codex: OpenAI, claudeAgent: ClaudeAI, @@ -86,7 +53,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { model: ModelSlug; lockedProvider: ProviderKind | null; modelOptionsByProvider: Record>; - ultrathinkActive?: boolean; + activeProviderIconClassName?: string; compact?: boolean; disabled?: boolean; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; @@ -100,7 +67,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const handleModelChange = (provider: ProviderKind, value: string) => { if (props.disabled) return; if (!value) return; - const resolvedModel = resolveModelForProviderPicker( + const resolvedModel = resolveSelectableModel( provider, value, props.modelOptionsByProvider[provider], @@ -145,9 +112,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { className={cn( "size-4 shrink-0", providerIconClassName(activeProvider, "text-muted-foreground/70"), - activeProvider === "claudeAgent" && props.ultrathinkActive - ? "ultrathink-chroma" - : undefined, + props.activeProviderIconClassName, )} /> {selectedModelLabel} diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx new file mode 100644 index 0000000000..139876d6fa --- /dev/null +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import { getComposerProviderState } from "./composerProviderRegistry"; + +describe("getComposerProviderState", () => { + it("returns codex defaults when no codex draft options exist", () => { + const state = getComposerProviderState({ + provider: "codex", + model: "gpt-5.4", + prompt: "", + modelOptions: undefined, + }); + + expect(state).toEqual({ + provider: "codex", + promptEffort: "high", + modelOptionsForDispatch: undefined, + }); + }); + + it("normalizes codex dispatch options while preserving the selected effort", () => { + const state = getComposerProviderState({ + provider: "codex", + model: "gpt-5.4", + prompt: "", + modelOptions: { + codex: { + reasoningEffort: "low", + fastMode: true, + }, + }, + }); + + expect(state).toEqual({ + provider: "codex", + promptEffort: "low", + modelOptionsForDispatch: { + codex: { + reasoningEffort: "low", + fastMode: true, + }, + }, + }); + }); + + it("returns Claude defaults for effort-capable models", () => { + const state = getComposerProviderState({ + provider: "claudeAgent", + model: "claude-sonnet-4-6", + prompt: "", + modelOptions: undefined, + }); + + expect(state).toEqual({ + provider: "claudeAgent", + promptEffort: "high", + modelOptionsForDispatch: undefined, + }); + }); + + it("tracks Claude ultrathink from the prompt without changing dispatch effort", () => { + const state = getComposerProviderState({ + provider: "claudeAgent", + model: "claude-sonnet-4-6", + prompt: "Ultrathink:\nInvestigate this failure", + modelOptions: { + claudeAgent: { + effort: "medium", + }, + }, + }); + + expect(state).toEqual({ + provider: "claudeAgent", + promptEffort: "medium", + modelOptionsForDispatch: { + claudeAgent: { + effort: "medium", + }, + }, + composerFrameClassName: "ultrathink-frame", + composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]", + modelPickerIconClassName: "ultrathink-chroma", + }); + }); + + it("drops unsupported Claude effort options for models without effort controls", () => { + const state = getComposerProviderState({ + provider: "claudeAgent", + model: "claude-haiku-4-5", + prompt: "", + modelOptions: { + claudeAgent: { + effort: "max", + thinking: false, + }, + }, + }); + + expect(state).toEqual({ + provider: "claudeAgent", + promptEffort: null, + modelOptionsForDispatch: { + claudeAgent: { + thinking: false, + }, + }, + }); + }); + + it("ignores codex options while resolving Claude state", () => { + const state = getComposerProviderState({ + provider: "claudeAgent", + model: "claude-opus-4-6", + prompt: "", + modelOptions: { + codex: { + reasoningEffort: "low", + fastMode: true, + }, + }, + }); + + expect(state).toEqual({ + provider: "claudeAgent", + promptEffort: "high", + modelOptionsForDispatch: undefined, + }); + }); + + it("ignores Claude options while resolving codex state", () => { + const state = getComposerProviderState({ + provider: "codex", + model: "gpt-5.4", + prompt: "Ultrathink:\nThis should not matter", + modelOptions: { + claudeAgent: { + effort: "max", + fastMode: true, + }, + }, + }); + + expect(state).toEqual({ + provider: "codex", + promptEffort: "high", + modelOptionsForDispatch: undefined, + }); + }); +}); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx new file mode 100644 index 0000000000..c1ad0156ad --- /dev/null +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -0,0 +1,137 @@ +import { + type ModelSlug, + type ProviderKind, + type ProviderModelOptions, + type ThreadId, +} from "@t3tools/contracts"; +import { + getDefaultReasoningEffort, + getReasoningEffortOptions, + isClaudeUltrathinkPrompt, + normalizeClaudeModelOptions, + normalizeCodexModelOptions, + resolveReasoningEffortForProvider, + supportsClaudeUltrathinkKeyword, +} from "@t3tools/shared/model"; +import type { ReactNode } from "react"; +import { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./ClaudeTraitsPicker"; +import { CodexTraitsMenuContent, CodexTraitsPicker } from "./CodexTraitsPicker"; + +export type ComposerProviderStateInput = { + provider: ProviderKind; + model: ModelSlug; + prompt: string; + modelOptions: ProviderModelOptions | null | undefined; +}; + +export type ComposerProviderState = { + provider: ProviderKind; + promptEffort: string | null; + modelOptionsForDispatch: ProviderModelOptions | undefined; + composerFrameClassName?: string; + composerSurfaceClassName?: string; + modelPickerIconClassName?: string; +}; + +type ProviderRegistryEntry = { + getState: (input: ComposerProviderStateInput) => ComposerProviderState; + renderTraitsMenuContent: (input: { + threadId: ThreadId; + model: ModelSlug; + onPromptChange: (prompt: string) => void; + }) => ReactNode; + renderTraitsPicker: (input: { + threadId: ThreadId; + model: ModelSlug; + onPromptChange: (prompt: string) => void; + }) => ReactNode; +}; + +const composerProviderRegistry: Record = { + codex: { + getState: ({ modelOptions }) => { + const promptEffort = + resolveReasoningEffortForProvider("codex", modelOptions?.codex?.reasoningEffort) ?? + getDefaultReasoningEffort("codex"); + const normalizedCodexOptions = normalizeCodexModelOptions(modelOptions?.codex); + + return { + provider: "codex", + promptEffort, + modelOptionsForDispatch: normalizedCodexOptions + ? { codex: normalizedCodexOptions } + : undefined, + }; + }, + renderTraitsMenuContent: ({ threadId }) => , + renderTraitsPicker: ({ threadId }) => , + }, + claudeAgent: { + getState: ({ model, prompt, modelOptions }) => { + const reasoningOptions = getReasoningEffortOptions("claudeAgent", model); + const draftEffort = resolveReasoningEffortForProvider( + "claudeAgent", + modelOptions?.claudeAgent?.effort, + ); + const defaultEffort = getDefaultReasoningEffort("claudeAgent"); + const promptEffort = + draftEffort && draftEffort !== "ultrathink" && reasoningOptions.includes(draftEffort) + ? draftEffort + : reasoningOptions.includes(defaultEffort) + ? defaultEffort + : null; + const normalizedClaudeOptions = normalizeClaudeModelOptions(model, modelOptions?.claudeAgent); + const ultrathinkActive = + supportsClaudeUltrathinkKeyword(model) && isClaudeUltrathinkPrompt(prompt); + + return { + provider: "claudeAgent", + promptEffort, + modelOptionsForDispatch: normalizedClaudeOptions + ? { claudeAgent: normalizedClaudeOptions } + : undefined, + ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame" } : {}), + ...(ultrathinkActive + ? { composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" } + : {}), + ...(ultrathinkActive ? { modelPickerIconClassName: "ultrathink-chroma" } : {}), + }; + }, + renderTraitsMenuContent: ({ threadId, model, onPromptChange }) => ( + + ), + renderTraitsPicker: ({ threadId, model, onPromptChange }) => ( + + ), + }, +}; + +export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { + return composerProviderRegistry[input.provider].getState(input); +} + +export function renderProviderTraitsMenuContent(input: { + provider: ProviderKind; + threadId: ThreadId; + model: ModelSlug; + onPromptChange: (prompt: string) => void; +}): ReactNode { + return composerProviderRegistry[input.provider].renderTraitsMenuContent({ + threadId: input.threadId, + model: input.model, + onPromptChange: input.onPromptChange, + }); +} + +export function renderProviderTraitsPicker(input: { + provider: ProviderKind; + threadId: ThreadId; + model: ModelSlug; + onPromptChange: (prompt: string) => void; +}): ReactNode { + return composerProviderRegistry[input.provider].renderTraitsPicker({ + threadId: input.threadId, + model: input.model, + onPromptChange: input.onPromptChange, + }); +} diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 93d7d7c58e..98a6f17331 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -636,6 +636,158 @@ describe("composerDraftStore modelOptions", () => { expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); }); + + it("replaces only the targeted provider model options", () => { + const store = useComposerDraftStore.getState(); + + store.setModelOptions(threadId, { + codex: { + reasoningEffort: "xhigh", + }, + claudeAgent: { + effort: "max", + fastMode: true, + }, + }); + + store.setProviderModelOptions( + threadId, + "claudeAgent", + { + thinking: false, + }, + { persistSticky: true }, + ); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ + codex: { + reasoningEffort: "xhigh", + }, + claudeAgent: { + thinking: false, + }, + }); + expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({ + codex: { + reasoningEffort: "xhigh", + }, + claudeAgent: { + thinking: false, + }, + }); + }); + + it("removes only the targeted provider entry when next options normalize empty", () => { + const store = useComposerDraftStore.getState(); + + store.setModelOptions(threadId, { + codex: { + reasoningEffort: "xhigh", + }, + claudeAgent: { + effort: "max", + }, + }); + + store.setProviderModelOptions(threadId, "claudeAgent", { + thinking: true, + }); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ + codex: { + reasoningEffort: "xhigh", + }, + }); + expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({}); + }); + + it("removes model options entirely when the last provider entry normalizes empty", () => { + const store = useComposerDraftStore.getState(); + + store.setModelOptions(threadId, { + codex: { + fastMode: true, + }, + }); + + store.setProviderModelOptions(threadId, "codex", { + reasoningEffort: "high", + fastMode: false, + }); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + }); + + it("updates only the draft when sticky persistence is omitted", () => { + const store = useComposerDraftStore.getState(); + + store.setStickyModelOptions({ + codex: { + fastMode: true, + }, + }); + store.setModelOptions(threadId, { + codex: { + fastMode: true, + }, + claudeAgent: { + effort: "max", + }, + }); + + store.setProviderModelOptions(threadId, "claudeAgent", { + thinking: false, + }); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ + codex: { + fastMode: true, + }, + claudeAgent: { + thinking: false, + }, + }); + expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({ + codex: { + fastMode: true, + }, + }); + }); + + it("updates only the draft when sticky persistence is disabled", () => { + const store = useComposerDraftStore.getState(); + + store.setStickyModelOptions({ + claudeAgent: { + effort: "max", + }, + }); + store.setModelOptions(threadId, { + claudeAgent: { + effort: "max", + }, + }); + + store.setProviderModelOptions( + threadId, + "claudeAgent", + { + thinking: false, + }, + { persistSticky: false }, + ); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ + claudeAgent: { + thinking: false, + }, + }); + expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({ + claudeAgent: { + effort: "max", + }, + }); + }); }); describe("composerDraftStore setModel", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index b8f8dfcf3b..e1c3c0b5cd 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -187,6 +187,14 @@ interface ComposerDraftStoreState { threadId: ThreadId, modelOptions: ProviderModelOptions | null | undefined, ) => void; + setProviderModelOptions: ( + threadId: ThreadId, + provider: ProviderKind, + nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, + options?: { + persistSticky?: boolean; + }, + ) => void; setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void; setInteractionMode: ( threadId: ThreadId, @@ -408,6 +416,24 @@ function normalizeProviderModelOptions( }; } +function replaceProviderModelOptions( + currentModelOptions: ProviderModelOptions | null | undefined, + provider: ProviderKind, + nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, +): ProviderModelOptions | null { + const { [provider]: _discardedProviderModelOptions, ...otherProviderModelOptions } = + currentModelOptions ?? {}; + const normalizedNextProviderOptions = normalizeProviderModelOptions( + { [provider]: nextProviderOptions }, + provider, + ); + + return normalizeProviderModelOptions({ + ...otherProviderModelOptions, + ...(normalizedNextProviderOptions ? normalizedNextProviderOptions : {}), + }); +} + function revokeObjectPreviewUrl(previewUrl: string): void { if (typeof URL === "undefined") { return; @@ -1269,7 +1295,7 @@ export const useComposerDraftStore = create()( return state; } const base = existing ?? createEmptyThreadDraft(); - if (JSON.stringify(base.modelOptions) === JSON.stringify(nextModelOptions)) { + if (Equal.equals(base.modelOptions, nextModelOptions)) { return state; } const nextDraft: ComposerThreadDraftState = { @@ -1285,6 +1311,53 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId }; }); }, + setProviderModelOptions: (threadId, provider, nextProviderOptions, options) => { + if (threadId.length === 0) { + return; + } + const normalizedProvider = normalizeProviderKind(provider); + if (normalizedProvider === null) { + return; + } + set((state) => { + const existing = state.draftsByThreadId[threadId]; + const base = existing ?? createEmptyThreadDraft(); + const nextModelOptions = replaceProviderModelOptions( + base.modelOptions, + normalizedProvider, + nextProviderOptions, + ); + const nextStickyModelOptions = + options?.persistSticky === true + ? (nextModelOptions ?? EMPTY_PROVIDER_MODEL_OPTIONS) + : state.stickyModelOptions; + + if ( + Equal.equals(base.modelOptions, nextModelOptions) && + Equal.equals(state.stickyModelOptions, nextStickyModelOptions) + ) { + return state; + } + + const nextDraft: ComposerThreadDraftState = { + ...base, + modelOptions: nextModelOptions, + }; + const nextDraftsByThreadId = { ...state.draftsByThreadId }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadId[threadId]; + } else { + nextDraftsByThreadId[threadId] = nextDraft; + } + + return { + draftsByThreadId: nextDraftsByThreadId, + ...(options?.persistSticky === true + ? { stickyModelOptions: nextStickyModelOptions } + : {}), + }; + }); + }, setRuntimeMode: (threadId, runtimeMode) => { if (threadId.length === 0) { return; diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index e88a1e6aa7..e31809cdd2 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,6 +1,7 @@ import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; import { useNavigate, useParams } from "@tanstack/react-router"; import { useCallback } from "react"; +import { inferProviderForModel } from "@t3tools/shared/model"; import { type DraftThreadEnvMode, type DraftThreadState, @@ -42,6 +43,7 @@ export function useHandleNewThread() { getDraftThreadByProjectId, setModel, setModelOptions, + setProvider, setDraftThreadContext, setProjectDraftThreadId, } = useComposerDraftStore.getState(); @@ -101,9 +103,10 @@ export function useHandleNewThread() { runtimeMode: DEFAULT_RUNTIME_MODE, }); if (stickyModel) { + setProvider(threadId, inferProviderForModel(stickyModel)); setModel(threadId, stickyModel); } - if (stickyModelOptions.codex) { + if (Object.keys(stickyModelOptions).length > 0) { setModelOptions(threadId, stickyModelOptions); } diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index be1009c5e1..2c8aaf1986 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -21,6 +21,7 @@ import { normalizeCodexModelOptions, normalizeModelSlug, resolveReasoningEffortForProvider, + resolveSelectableModel, resolveModelSlug, resolveModelSlugForProvider, supportsClaudeAdaptiveReasoning, @@ -94,6 +95,70 @@ describe("resolveModelSlug", () => { }); }); +describe("resolveSelectableModel", () => { + it("resolves exact slug matches", () => { + expect( + resolveSelectableModel("codex", "gpt-5.3-codex", [ + { slug: "gpt-5.4", name: "GPT-5.4" }, + { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + ]), + ).toBe("gpt-5.3-codex"); + }); + + it("resolves case-insensitive display-name matches", () => { + expect( + resolveSelectableModel("codex", "gpt-5.3 codex", [ + { slug: "gpt-5.4", name: "GPT-5.4" }, + { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + ]), + ).toBe("gpt-5.3-codex"); + }); + + it("resolves provider-specific aliases after normalization", () => { + expect( + resolveSelectableModel("claudeAgent", "sonnet", [ + { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ]), + ).toBe("claude-sonnet-4-6"); + }); + + it("returns null for empty input", () => { + expect(resolveSelectableModel("codex", "", [{ slug: "gpt-5.4", name: "GPT-5.4" }])).toBeNull(); + expect( + resolveSelectableModel("codex", " ", [{ slug: "gpt-5.4", name: "GPT-5.4" }]), + ).toBeNull(); + expect( + resolveSelectableModel("codex", null, [{ slug: "gpt-5.4", name: "GPT-5.4" }]), + ).toBeNull(); + }); + + it("returns null for unknown values that are not present in options", () => { + expect( + resolveSelectableModel("codex", "gpt-4.1", [{ slug: "gpt-5.4", name: "GPT-5.4" }]), + ).toBeNull(); + }); + + it("does not accept normalized custom-looking slugs unless they exist in options", () => { + expect( + resolveSelectableModel("codex", "custom/internal-model", [ + { slug: "gpt-5.4", name: "GPT-5.4" }, + ]), + ).toBeNull(); + }); + + it("respects provider boundaries", () => { + expect( + resolveSelectableModel("codex", "sonnet", [{ slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }]), + ).toBeNull(); + expect( + resolveSelectableModel("claudeAgent", "5.3", [ + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ]), + ).toBeNull(); + }); +}); + describe("getReasoningEffortOptions", () => { it("returns codex reasoning options for codex", () => { expect(getReasoningEffortOptions("codex")).toEqual(REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 625dfd5adf..2d46320753 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -24,6 +24,11 @@ const CLAUDE_OPUS_4_6_MODEL = "claude-opus-4-6"; const CLAUDE_SONNET_4_6_MODEL = "claude-sonnet-4-6"; const CLAUDE_HAIKU_4_5_MODEL = "claude-haiku-4-5"; +export interface SelectableModelOption { + slug: string; + name: string; +} + export function getModelOptions(provider: ProviderKind = "codex") { return MODEL_OPTIONS_BY_PROVIDER[provider]; } @@ -77,6 +82,39 @@ export function normalizeModelSlug( return typeof aliased === "string" ? aliased : (trimmed as ModelSlug); } +export function resolveSelectableModel( + provider: ProviderKind, + value: string | null | undefined, + options: ReadonlyArray, +): ModelSlug | null { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + const direct = options.find((option) => option.slug === trimmed); + if (direct) { + return direct.slug; + } + + const byName = options.find((option) => option.name.toLowerCase() === trimmed.toLowerCase()); + if (byName) { + return byName.slug; + } + + const normalized = normalizeModelSlug(trimmed, provider); + if (!normalized) { + return null; + } + + const resolved = options.find((option) => option.slug === normalized); + return resolved ? resolved.slug : null; +} + export function resolveModelSlug( model: string | null | undefined, provider: ProviderKind = "codex", From ab1e7c7c1f0076b449dc293c99fea38160a61053 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 20 Mar 2026 10:57:52 -0700 Subject: [PATCH 8/8] unify settings selectin --- apps/web/src/appSettings.test.ts | 85 +++++++++++++++++++++++ apps/web/src/appSettings.ts | 74 ++++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 13 +--- apps/web/src/components/ChatView.tsx | 16 ++--- apps/web/src/routes/_chat.settings.tsx | 69 +++--------------- 5 files changed, 176 insertions(+), 81 deletions(-) diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 2fa6fc9a72..26d231537d 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -5,7 +5,13 @@ import { AppSettingsSchema, DEFAULT_TIMESTAMP_FORMAT, getAppModelOptions, + getCustomModelOptionsByProvider, + getCustomModelsByProvider, + getCustomModelsForProvider, + getDefaultCustomModelsForProvider, + MODEL_PROVIDER_SETTINGS, normalizeCustomModelSlugs, + patchCustomModels, resolveAppModelSelection, } from "./appSettings"; @@ -112,6 +118,85 @@ describe("provider-specific custom models", () => { }); }); +describe("provider-indexed custom model settings", () => { + const settings = { + customCodexModels: ["custom/codex-model"], + customClaudeModels: ["claude/custom-opus"], + } as const; + + it("exports one provider config per provider", () => { + expect(MODEL_PROVIDER_SETTINGS.map((config) => config.provider)).toEqual([ + "codex", + "claudeAgent", + ]); + }); + + it("reads custom models for each provider", () => { + expect(getCustomModelsForProvider(settings, "codex")).toEqual(["custom/codex-model"]); + expect(getCustomModelsForProvider(settings, "claudeAgent")).toEqual(["claude/custom-opus"]); + }); + + it("reads default custom models for each provider", () => { + const defaults = { + customCodexModels: ["default/codex-model"], + customClaudeModels: ["claude/default-opus"], + } as const; + + expect(getDefaultCustomModelsForProvider(defaults, "codex")).toEqual(["default/codex-model"]); + expect(getDefaultCustomModelsForProvider(defaults, "claudeAgent")).toEqual([ + "claude/default-opus", + ]); + }); + + it("patches custom models for codex", () => { + expect(patchCustomModels("codex", ["custom/codex-model"])).toEqual({ + customCodexModels: ["custom/codex-model"], + }); + }); + + it("patches custom models for claude", () => { + expect(patchCustomModels("claudeAgent", ["claude/custom-opus"])).toEqual({ + customClaudeModels: ["claude/custom-opus"], + }); + }); + + it("builds a complete provider-indexed custom model record", () => { + expect(getCustomModelsByProvider(settings)).toEqual({ + codex: ["custom/codex-model"], + claudeAgent: ["claude/custom-opus"], + }); + }); + + it("builds provider-indexed model options including custom models", () => { + const modelOptionsByProvider = getCustomModelOptionsByProvider(settings); + + expect( + modelOptionsByProvider.codex.some((option) => option.slug === "custom/codex-model"), + ).toBe(true); + expect( + modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude/custom-opus"), + ).toBe(true); + }); + + it("normalizes and deduplicates custom model options per provider", () => { + const modelOptionsByProvider = getCustomModelOptionsByProvider({ + customCodexModels: [" custom/codex-model ", "gpt-5.4", "custom/codex-model"], + customClaudeModels: [" sonnet ", "claude/custom-opus", "claude/custom-opus"], + }); + + expect( + modelOptionsByProvider.codex.filter((option) => option.slug === "custom/codex-model"), + ).toHaveLength(1); + expect(modelOptionsByProvider.codex.some((option) => option.slug === "gpt-5.4")).toBe(true); + expect( + modelOptionsByProvider.claudeAgent.filter((option) => option.slug === "claude/custom-opus"), + ).toHaveLength(1); + expect( + modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude-sonnet-4-6"), + ).toBe(true); + }); +}); + describe("AppSettingsSchema", () => { it("fills decoding defaults for persisted settings that predate newer keys", () => { const decode = Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema)); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index dec12299ce..14b6a6a92d 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -17,6 +17,16 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256; export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); export type TimestampFormat = typeof TimestampFormat.Type; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels"; +export type ProviderCustomModelConfig = { + provider: ProviderKind; + settingsKey: CustomModelSettingsKey; + defaultSettingsKey: CustomModelSettingsKey; + title: string; + description: string; + placeholder: string; + example: string; +}; const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), @@ -55,6 +65,27 @@ export interface AppModelOption { } const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); +const PROVIDER_CUSTOM_MODEL_CONFIG: Record = { + codex: { + provider: "codex", + settingsKey: "customCodexModels", + defaultSettingsKey: "customCodexModels", + title: "Codex", + description: "Save additional Codex model slugs for the picker and `/model` command.", + placeholder: "your-codex-model-slug", + example: "gpt-6.7-codex-ultra-preview", + }, + claudeAgent: { + provider: "claudeAgent", + settingsKey: "customClaudeModels", + defaultSettingsKey: "customClaudeModels", + title: "Claude", + description: "Save additional Claude model slugs for the picker and `/model` command.", + placeholder: "your-claude-model-slug", + example: "claude-sonnet-5-0", + }, +}; +export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG); export function normalizeCustomModelSlugs( models: Iterable, @@ -92,6 +123,39 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"), }; } + +export function getCustomModelsForProvider( + settings: Pick, + provider: ProviderKind, +): readonly string[] { + return settings[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]; +} + +export function getDefaultCustomModelsForProvider( + defaults: Pick, + provider: ProviderKind, +): readonly string[] { + return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey]; +} + +export function patchCustomModels( + provider: ProviderKind, + models: string[], +): Partial> { + return { + [PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]: models, + }; +} + +export function getCustomModelsByProvider( + settings: Pick, +): Record { + return { + codex: getCustomModelsForProvider(settings, "codex"), + claudeAgent: getCustomModelsForProvider(settings, "claudeAgent"), + }; +} + export function getAppModelOptions( provider: ProviderKind, customModels: readonly string[], @@ -147,6 +211,16 @@ export function resolveAppModelSelection( return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider); } +export function getCustomModelOptionsByProvider( + settings: Pick, +): Record> { + const customModelsByProvider = getCustomModelsByProvider(settings); + return { + codex: getAppModelOptions("codex", customModelsByProvider.codex), + claudeAgent: getAppModelOptions("claudeAgent", customModelsByProvider.claudeAgent), + }; +} + export function useAppSettings() { const [settings, setSettings] = useLocalStorage( APP_SETTINGS_STORAGE_KEY, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 80567927b3..ddc84718e6 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,7 +1,6 @@ -import { ProjectId, type ProviderKind, type ThreadId } from "@t3tools/contracts"; +import { ProjectId, type ThreadId } from "@t3tools/contracts"; import { type ChatMessage, type Thread } from "../types"; import { randomUUID } from "~/lib/utils"; -import { getAppModelOptions } from "../appSettings"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import { Schema } from "effect"; import { @@ -121,16 +120,6 @@ export function cloneComposerImageForRetry( } } -export function getCustomModelOptionsByProvider(settings: { - customCodexModels: readonly string[]; - customClaudeModels: readonly string[]; -}): Record> { - return { - codex: getAppModelOptions("codex", settings.customCodexModels), - claudeAgent: getAppModelOptions("claudeAgent", settings.customClaudeModels), - }; -} - export function deriveComposerSendState(options: { prompt: string; imageCount: number; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f4ee07d745..498f3203d1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -119,7 +119,12 @@ import { import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { resolveAppModelSelection, useAppSettings } from "../appSettings"; +import { + getCustomModelOptionsByProvider, + getCustomModelsByProvider, + resolveAppModelSelection, + useAppSettings, +} from "../appSettings"; import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, @@ -164,7 +169,6 @@ import { cloneComposerImageForRetry, collectUserMessageBlobPreviewUrls, deriveComposerSendState, - getCustomModelOptionsByProvider, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, PullRequestDialogState, @@ -593,13 +597,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider, activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), ); - const customModelsByProvider = useMemo( - () => ({ - codex: settings.customCodexModels, - claudeAgent: settings.customClaudeModels, - }), - [settings.customClaudeModels, settings.customCodexModels], - ); + const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]); const selectedModel = useMemo(() => { const draftModel = composerDraft.model; if (!draftModel) { diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index f3dee29096..acc8763fb4 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -3,7 +3,15 @@ import { useQuery } from "@tanstack/react-query"; import { useCallback, useState } from "react"; import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; -import { getAppModelOptions, MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; +import { + getAppModelOptions, + getCustomModelsForProvider, + getDefaultCustomModelsForProvider, + MAX_CUSTOM_MODEL_LENGTH, + MODEL_PROVIDER_SETTINGS, + patchCustomModels, + useAppSettings, +} from "../appSettings"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; @@ -40,71 +48,12 @@ const THEME_OPTIONS = [ }, ] as const; -const MODEL_PROVIDER_SETTINGS: Array<{ - provider: ProviderKind; - title: string; - description: string; - placeholder: string; - example: string; -}> = [ - { - provider: "codex", - title: "Codex", - description: "Save additional Codex model slugs for the picker and `/model` command.", - placeholder: "your-codex-model-slug", - example: "gpt-6.7-codex-ultra-preview", - }, - { - provider: "claudeAgent", - title: "Claude", - description: "Save additional Claude model slugs for the picker and `/model` command.", - placeholder: "your-claude-model-slug", - example: "claude-sonnet-5-0", - }, -] as const; - const TIMESTAMP_FORMAT_LABELS = { locale: "System default", "12-hour": "12-hour", "24-hour": "24-hour", } as const; -function getCustomModelsForProvider( - settings: ReturnType["settings"], - provider: ProviderKind, -) { - switch (provider) { - case "claudeAgent": - return settings.customClaudeModels; - case "codex": - default: - return settings.customCodexModels; - } -} - -function getDefaultCustomModelsForProvider( - defaults: ReturnType["defaults"], - provider: ProviderKind, -) { - switch (provider) { - case "claudeAgent": - return defaults.customClaudeModels; - case "codex": - default: - return defaults.customCodexModels; - } -} - -function patchCustomModels(provider: ProviderKind, models: string[]) { - switch (provider) { - case "claudeAgent": - return { customClaudeModels: models }; - case "codex": - default: - return { customCodexModels: models }; - } -} - function SettingsRouteView() { const { theme, setTheme, resolvedTheme } = useTheme(); const { settings, defaults, updateSettings } = useAppSettings();