From 4e9e46fb3e43ca2c6ef8a30f7ffbf780560109bb Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 17 Mar 2026 16:08:47 -0300 Subject: [PATCH 1/4] feat(web): persist sticky codex composer settings --- apps/web/src/components/ChatView.browser.tsx | 44 +++++++++ apps/web/src/components/ChatView.tsx | 18 ++-- apps/web/src/hooks/useHandleNewThread.ts | 18 +++- apps/web/src/stickyComposerSettings.ts | 97 ++++++++++++++++++++ 4 files changed, 170 insertions(+), 7 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 7b31dbdf38..3757f37274 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1277,6 +1277,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 77cdb0ea19..d3e7d047a8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -135,6 +135,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"; @@ -224,6 +225,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({ @@ -3048,11 +3050,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") { + updateStickyComposerSettings({ model: resolvedModel }); + } scheduleComposerFocus(); }, [ @@ -3062,21 +3065,24 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerDraftModel, setComposerDraftProvider, settings.customCodexModels, + updateStickyComposerSettings, ], ); const onEffortSelect = useCallback( (effort: CodexReasoningEffort) => { setComposerDraftEffort(threadId, effort); + updateStickyComposerSettings({ effort }); scheduleComposerFocus(); }, - [scheduleComposerFocus, setComposerDraftEffort, threadId], + [scheduleComposerFocus, setComposerDraftEffort, threadId, updateStickyComposerSettings], ); const onCodexFastModeChange = useCallback( (enabled: boolean) => { setComposerDraftCodexFastMode(threadId, enabled); + updateStickyComposerSettings({ codexFastMode: enabled }); scheduleComposerFocus(); }, - [scheduleComposerFocus, setComposerDraftCodexFastMode, threadId], + [scheduleComposerFocus, setComposerDraftCodexFastMode, threadId, updateStickyComposerSettings], ); const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { 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 5e85c63c22472584305af2ced5adedb3773d36cc Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 17 Mar 2026 16:13:13 -0300 Subject: [PATCH 2/4] 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 a16c9541bc5fd32da2f0327ff9fb017095a81b4e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 17 Mar 2026 16:29:19 -0300 Subject: [PATCH 3/4] 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 3757f37274..623ef3b036 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1321,6 +1321,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 92c5dee0df4a65c75568ab5039b35b4bad95f69c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 17 Mar 2026 20:48:18 -0300 Subject: [PATCH 4/4] 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, 239 insertions(+), 164 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 623ef3b036..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,19 +1279,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, @@ -1313,6 +1312,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, }); @@ -1321,19 +1321,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, @@ -1357,10 +1382,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(); @@ -1371,6 +1398,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 d3e7d047a8..3ca323efd1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -135,7 +135,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"; @@ -225,7 +224,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({ @@ -3054,7 +3056,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerDraftProvider(activeThread.id, provider); setComposerDraftModel(activeThread.id, resolvedModel); if (provider === "codex") { - updateStickyComposerSettings({ model: resolvedModel }); + setStickyComposerModel(resolvedModel); } scheduleComposerFocus(); }, @@ -3064,25 +3066,47 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus, setComposerDraftModel, setComposerDraftProvider, + setStickyComposerModel, settings.customCodexModels, - updateStickyComposerSettings, ], ); 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 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 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; -}