diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index faecc7f51..6281fee5e 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1087,6 +1087,48 @@ 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, + effort: "medium", + codexFastMode: 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, + hasEffortOverride: true, + hasCodexFastModeOverride: 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 52637695e..02d802e3d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -127,6 +127,7 @@ import { useComposerDraftStore, useComposerThreadDraft, } from "../composerDraftStore"; +import { useStickyComposerSettings } from "../stickyComposerSettings"; import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; @@ -197,6 +198,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({ @@ -2879,11 +2881,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(); }, [ @@ -2893,21 +2896,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/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 927a16060..d23d52a43 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -355,12 +355,15 @@ describe("composerDraftStore codex fast mode", () => { expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.codexFastMode).toBe(true); }); - it("clears codex fast mode when reset to the default", () => { + it("keeps an explicit codex fast mode override when reset to false", () => { const store = useComposerDraftStore.getState(); store.setCodexFastMode(threadId, true); store.setCodexFastMode(threadId, false); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + codexFastMode: false, + hasCodexFastModeOverride: true, + }); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2af920527..8da83e30e 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -80,6 +80,8 @@ interface PersistedComposerThreadDraftState { interactionMode?: ProviderInteractionMode | null; effort?: CodexReasoningEffort | null; codexFastMode?: boolean | null; + hasEffortOverride?: boolean | null; + hasCodexFastModeOverride?: boolean | null; serviceTier?: string | null; } @@ -110,6 +112,8 @@ interface ComposerThreadDraftState { interactionMode: ProviderInteractionMode | null; effort: CodexReasoningEffort | null; codexFastMode: boolean; + hasEffortOverride: boolean; + hasCodexFastModeOverride: boolean; } export interface DraftThreadState { @@ -204,6 +208,8 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ interactionMode: null, effort: null, codexFastMode: false, + hasEffortOverride: false, + hasCodexFastModeOverride: false, }) as ComposerThreadDraftState; const REASONING_EFFORT_VALUES = new Set( @@ -222,6 +228,8 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { interactionMode: null, effort: null, codexFastMode: false, + hasEffortOverride: false, + hasCodexFastModeOverride: false, }; } @@ -241,7 +249,9 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.runtimeMode === null && draft.interactionMode === null && draft.effort === null && - draft.codexFastMode === false + draft.codexFastMode === false && + draft.hasEffortOverride === false && + draft.hasCodexFastModeOverride === false ); } @@ -427,6 +437,9 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer const codexFastMode = draftCandidate.codexFastMode === true || (typeof draftCandidate.serviceTier === "string" && draftCandidate.serviceTier === "fast"); + const hasEffortOverride = draftCandidate.hasEffortOverride === true || effort !== null; + const hasCodexFastModeOverride = + draftCandidate.hasCodexFastModeOverride === true || codexFastMode; if ( prompt.length === 0 && attachments.length === 0 && @@ -435,7 +448,9 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer !runtimeMode && !interactionMode && !effort && - !codexFastMode + !codexFastMode && + !hasEffortOverride && + !hasCodexFastModeOverride ) { continue; } @@ -448,6 +463,8 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer ...(interactionMode ? { interactionMode } : {}), ...(effort ? { effort } : {}), ...(codexFastMode ? { codexFastMode } : {}), + ...(hasEffortOverride ? { hasEffortOverride } : {}), + ...(hasCodexFastModeOverride ? { hasCodexFastModeOverride } : {}), }; } return { @@ -554,6 +571,8 @@ function toHydratedThreadDraft( interactionMode: persistedDraft.interactionMode ?? null, effort: persistedDraft.effort ?? null, codexFastMode: persistedDraft.codexFastMode === true, + hasEffortOverride: persistedDraft.hasEffortOverride === true, + hasCodexFastModeOverride: persistedDraft.hasCodexFastModeOverride === true, }; } @@ -939,16 +958,14 @@ export const useComposerDraftStore = create()( : null; set((state) => { const existing = state.draftsByThreadId[threadId]; - if (!existing && nextEffort === null) { - return state; - } const base = existing ?? createEmptyThreadDraft(); - if (base.effort === nextEffort) { + if (base.effort === nextEffort && base.hasEffortOverride) { return state; } const nextDraft: ComposerThreadDraftState = { ...base, effort: nextEffort, + hasEffortOverride: true, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; if (shouldRemoveDraft(nextDraft)) { @@ -966,16 +983,14 @@ export const useComposerDraftStore = create()( const nextCodexFastMode = enabled === true; set((state) => { const existing = state.draftsByThreadId[threadId]; - if (!existing && nextCodexFastMode === false) { - return state; - } const base = existing ?? createEmptyThreadDraft(); - if (base.codexFastMode === nextCodexFastMode) { + if (base.codexFastMode === nextCodexFastMode && base.hasCodexFastModeOverride) { return state; } const nextDraft: ComposerThreadDraftState = { ...base, codexFastMode: nextCodexFastMode, + hasCodexFastModeOverride: true, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; if (shouldRemoveDraft(nextDraft)) { @@ -1223,7 +1238,9 @@ export const useComposerDraftStore = create()( draft.runtimeMode === null && draft.interactionMode === null && draft.effort === null && - draft.codexFastMode === false + draft.codexFastMode === false && + draft.hasEffortOverride === false && + draft.hasCodexFastModeOverride === false ) { continue; } @@ -1249,6 +1266,12 @@ export const useComposerDraftStore = create()( if (draft.codexFastMode) { persistedDraft.codexFastMode = true; } + if (draft.hasEffortOverride) { + persistedDraft.hasEffortOverride = true; + } + if (draft.hasCodexFastModeOverride) { + persistedDraft.hasCodexFastModeOverride = true; + } persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft; } return { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 35f92d98e..fe0e5b201 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,4 +1,5 @@ import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; +import { getDefaultReasoningEffort } from "@t3tools/shared/model"; import { useNavigate, useParams } from "@tanstack/react-router"; import { useCallback } from "react"; import { @@ -6,12 +7,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 +43,9 @@ export function useHandleNewThread() { clearProjectDraftThreadId, getDraftThread, getDraftThreadByProjectId, + setCodexFastMode, + setEffort, + setModel, setDraftThreadContext, setProjectDraftThreadId, } = useComposerDraftStore.getState(); @@ -96,6 +104,11 @@ export function useHandleNewThread() { envMode: options?.envMode ?? "local", runtimeMode: DEFAULT_RUNTIME_MODE, }); + if (stickyModel) { + setModel(threadId, stickyModel); + } + setEffort(threadId, stickyEffort ?? getDefaultReasoningEffort("codex")); + setCodexFastMode(threadId, stickyCodexFastMode); await navigate({ to: "/$threadId", @@ -103,7 +116,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 000000000..263370660 --- /dev/null +++ b/apps/web/src/stickyComposerSettings.ts @@ -0,0 +1,57 @@ +import { type CodexReasoningEffort, CODEX_REASONING_EFFORT_OPTIONS } 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 StickyComposerSettingsSchema = Schema.Struct({ + model: Schema.NullOr(Schema.String), + effort: Schema.NullOr(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), + codexFastMode: Schema.Boolean, +}); + +export type StickyComposerSettings = typeof StickyComposerSettingsSchema.Type; + +const DEFAULT_STICKY_COMPOSER_SETTINGS: StickyComposerSettings = { + model: null, + effort: null, + codexFastMode: false, +}; + +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, + }; +} + +export function useStickyComposerSettings() { + const [settings, setSettings] = useLocalStorage( + STICKY_COMPOSER_SETTINGS_STORAGE_KEY, + DEFAULT_STICKY_COMPOSER_SETTINGS, + StickyComposerSettingsSchema, + ); + + const updateSettings = useCallback( + (patch: Partial) => { + setSettings((previous) => normalizeStickyComposerSettings({ ...previous, ...patch })); + }, + [setSettings], + ); + + return { + settings, + updateSettings, + defaults: DEFAULT_STICKY_COMPOSER_SETTINGS, + } as const; +}