diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 7b31dbdf38..34c54e52a1 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -750,6 +750,8 @@ describe("ChatView timeline estimator parity (full app)", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModel: null, + stickyModelOptions: {}, }); useStore.setState({ projects: [], @@ -1277,6 +1279,134 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + 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, + 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({ + model: "gpt-5.3-codex", + effort: "medium", + codexFastMode: true, + }); + } finally { + await mounted.cleanup(); + } + }); + + 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, + 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({ + model: "gpt-5.3-codex", + effort: "medium", + codexFastMode: true, + }); + + useComposerDraftStore.getState().setModel(threadId, "gpt-5.4"); + 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({ + model: "gpt-5.4", + 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, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 77cdb0ea19..3ca323efd1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -224,6 +224,10 @@ export default function ChatView({ threadId }: ChatViewProps) { const setStoreThreadError = useStore((store) => store.setError); 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({ @@ -3048,11 +3052,12 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus(); return; } + const resolvedModel = resolveAppModelSelection(provider, settings.customCodexModels, model); setComposerDraftProvider(activeThread.id, provider); - setComposerDraftModel( - activeThread.id, - resolveAppModelSelection(provider, settings.customCodexModels, model), - ); + setComposerDraftModel(activeThread.id, resolvedModel); + if (provider === "codex") { + setStickyComposerModel(resolvedModel); + } scheduleComposerFocus(); }, [ @@ -3061,22 +3066,47 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus, setComposerDraftModel, setComposerDraftProvider, + setStickyComposerModel, settings.customCodexModels, ], ); const onEffortSelect = useCallback( (effort: CodexReasoningEffort) => { setComposerDraftEffort(threadId, effort); + setStickyComposerModelOptions({ + codex: { + reasoningEffort: effort, + ...(selectedCodexFastModeEnabled ? { fastMode: true } : {}), + }, + }); scheduleComposerFocus(); }, - [scheduleComposerFocus, setComposerDraftEffort, threadId], + [ + scheduleComposerFocus, + selectedCodexFastModeEnabled, + setComposerDraftEffort, + setStickyComposerModelOptions, + threadId, + ], ); const onCodexFastModeChange = useCallback( (enabled: boolean) => { setComposerDraftCodexFastMode(threadId, enabled); + setStickyComposerModelOptions({ + codex: { + reasoningEffort: selectedEffort, + ...(enabled ? { fastMode: true } : {}), + }, + }); scheduleComposerFocus(); }, - [scheduleComposerFocus, setComposerDraftCodexFastMode, threadId], + [ + 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 7362eda089..e8857d5d25 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -59,17 +59,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; @@ -154,11 +160,7 @@ describe("composerDraftStore clearComposerContent", () => { let revokeSpy: ReturnType void>>; beforeEach(() => { - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - }); + resetComposerDraftStore(); originalRevokeObjectUrl = URL.revokeObjectURL; revokeSpy = vi.fn(); URL.revokeObjectURL = revokeSpy; @@ -332,11 +334,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", () => { @@ -508,11 +506,7 @@ describe("composerDraftStore codex fast mode", () => { const threadId = ThreadId.makeUnsafe("thread-service-tier"); beforeEach(() => { - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - }); + resetComposerDraftStore(); }); it("stores codex fast mode in the draft", () => { @@ -535,11 +529,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", () => { @@ -553,15 +543,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", () => { @@ -586,11 +612,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 364dc67a41..ef96235a24 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -6,6 +6,7 @@ import { type CodexReasoningEffort, type ProviderKind, type ProviderInteractionMode, + type ProviderModelOptions, type RuntimeMode, } from "@t3tools/contracts"; import { normalizeModelSlug } from "@t3tools/shared/model"; @@ -113,6 +114,8 @@ interface PersistedComposerDraftStoreState { draftsByThreadId: Record; draftThreadsByThreadId: Record; projectDraftThreadIdByProjectId: Record; + stickyModel: string | null; + stickyModelOptions: ProviderModelOptions; } interface ComposerThreadDraftState { @@ -147,6 +150,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: ( @@ -176,6 +181,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; @@ -209,10 +216,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[] = []; @@ -241,6 +252,45 @@ const REASONING_EFFORT_VALUES = new Set( REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex, ); +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: "", @@ -437,6 +487,11 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer 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: PersistedComposerDraftStoreState["draftThreadsByThreadId"] = {}; if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") { for (const [threadId, rawDraftThread] of Object.entries( @@ -515,7 +570,13 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer } } if (!rawDraftMap || typeof rawDraftMap !== "object") { - return { draftsByThreadId: {}, draftThreadsByThreadId, projectDraftThreadIdByProjectId }; + return { + draftsByThreadId: {}, + draftThreadsByThreadId, + projectDraftThreadIdByProjectId, + stickyModel, + stickyModelOptions, + }; } const nextDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {}; for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record)) { @@ -595,6 +656,8 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer draftsByThreadId: nextDraftsByThreadId, draftThreadsByThreadId, projectDraftThreadIdByProjectId, + stickyModel, + stickyModelOptions, }; } @@ -709,6 +772,8 @@ export const useComposerDraftStore = create()( draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModel: null, + stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS, getDraftThreadByProjectId: (projectId) => { if (projectId.length === 0) { return null; @@ -944,6 +1009,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; @@ -1556,6 +1643,8 @@ export const useComposerDraftStore = create()( draftsByThreadId: persistedDraftsByThreadId, draftThreadsByThreadId: state.draftThreadsByThreadId, projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, + stickyModel: state.stickyModel, + stickyModelOptions: state.stickyModelOptions, }; }, merge: (persistedState, currentState) => { @@ -1571,6 +1660,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 35f92d98e9..0fcbb2d807 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -12,6 +12,8 @@ import { useStore } from "../store"; export function useHandleNewThread() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); + const stickyModel = useComposerDraftStore((store) => store.stickyModel); + const stickyModelOptions = useComposerDraftStore((store) => store.stickyModelOptions); const navigate = useNavigate(); const routeThreadId = useParams({ strict: false, @@ -38,6 +40,9 @@ export function useHandleNewThread() { clearProjectDraftThreadId, getDraftThread, getDraftThreadByProjectId, + setCodexFastMode, + setEffort, + setModel, setDraftThreadContext, setProjectDraftThreadId, } = useComposerDraftStore.getState(); @@ -96,6 +101,15 @@ export function useHandleNewThread() { envMode: options?.envMode ?? "local", runtimeMode: DEFAULT_RUNTIME_MODE, }); + if (stickyModel) { + setModel(threadId, stickyModel); + } + if (stickyModelOptions.codex?.reasoningEffort) { + setEffort(threadId, stickyModelOptions.codex.reasoningEffort); + } + if (stickyModelOptions.codex?.fastMode) { + setCodexFastMode(threadId, true); + } await navigate({ to: "/$threadId", @@ -103,7 +117,7 @@ export function useHandleNewThread() { }); })(); }, - [navigate, routeThreadId], + [navigate, routeThreadId, stickyModel, stickyModelOptions], ); return {