diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 6b595106b..9dd8a9ff2 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -485,6 +485,8 @@ pub(crate) struct AppSettings { pub(crate) last_composer_model_id: Option, #[serde(default, rename = "lastComposerReasoningEffort")] pub(crate) last_composer_reasoning_effort: Option, + #[serde(default, rename = "lastComposerServiceTier")] + pub(crate) last_composer_service_tier: Option, #[serde(default = "default_ui_scale", rename = "uiScale")] pub(crate) ui_scale: f64, #[serde(default = "default_theme", rename = "theme")] @@ -1153,6 +1155,7 @@ impl Default for AppSettings { cycle_workspace_prev_shortcut: default_cycle_workspace_prev_shortcut(), last_composer_model_id: None, last_composer_reasoning_effort: None, + last_composer_service_tier: None, ui_scale: 1.0, theme: default_theme(), usage_show_remaining: default_usage_show_remaining(), @@ -1319,6 +1322,7 @@ mod tests { ); assert!(settings.last_composer_model_id.is_none()); assert!(settings.last_composer_reasoning_effort.is_none()); + assert!(settings.last_composer_service_tier.is_none()); assert!((settings.ui_scale - 1.0).abs() < f64::EPSILON); assert_eq!(settings.theme, "system"); assert!(!settings.usage_show_remaining); @@ -1385,6 +1389,7 @@ mod tests { sort_order: Some(2), copies_folder: Some("/tmp/group-copies".to_string()), }]; + settings.last_composer_service_tier = Some("fast".to_string()); let json = serde_json::to_string(&settings).expect("serialize settings"); let decoded: AppSettings = serde_json::from_str(&json).expect("deserialize settings"); @@ -1393,6 +1398,10 @@ mod tests { decoded.workspace_groups[0].copies_folder.as_deref(), Some("/tmp/group-copies") ); + assert_eq!( + decoded.last_composer_service_tier.as_deref(), + Some("fast") + ); } #[test] diff --git a/src/features/app/components/MainApp.tsx b/src/features/app/components/MainApp.tsx index 1ce7cc587..32699c36c 100644 --- a/src/features/app/components/MainApp.tsx +++ b/src/features/app/components/MainApp.tsx @@ -670,6 +670,7 @@ export default function MainApp() { defaultAccessMode: appSettings.defaultAccessMode, lastComposerModelId: appSettings.lastComposerModelId, lastComposerReasoningEffort: appSettings.lastComposerReasoningEffort, + lastComposerServiceTier: appSettings.lastComposerServiceTier, }, threadCodexParamsVersion, getThreadCodexParams, diff --git a/src/features/app/orchestration/useThreadOrchestration.test.ts b/src/features/app/orchestration/useThreadOrchestration.test.ts index 29caafd0d..462d68242 100644 --- a/src/features/app/orchestration/useThreadOrchestration.test.ts +++ b/src/features/app/orchestration/useThreadOrchestration.test.ts @@ -59,6 +59,7 @@ function makeSyncParams( defaultAccessMode: "current", lastComposerModelId: "gpt-5", lastComposerReasoningEffort: "medium", + lastComposerServiceTier: null, }, threadCodexParamsVersion: 0, getThreadCodexParams, @@ -148,6 +149,24 @@ describe("useThreadSelectionHandlersOrchestration codex args selection", () => { }); }); + it("persists service tier selections as the global default when no thread is active", () => { + const params = makeSelectionParams(); + const { result } = renderHook(() => useThreadSelectionHandlersOrchestration(params)); + + act(() => { + result.current.handleSelectServiceTier("fast"); + }); + + expect(params.setAppSettings).toHaveBeenCalledTimes(1); + const update = vi.mocked(params.setAppSettings).mock.calls[0]?.[0] as ( + current: AppSettings, + ) => AppSettings; + const nextSettings = update({ lastComposerServiceTier: null } as AppSettings); + + expect(nextSettings.lastComposerServiceTier).toBe("fast"); + expect(params.queueSaveSettings).toHaveBeenCalledWith(nextSettings); + }); + it("normalizes smart quotes/dashes before persisting selected override", () => { const params = makeSelectionParams(); const { result } = renderHook(() => useThreadSelectionHandlersOrchestration(params)); diff --git a/src/features/app/orchestration/useThreadOrchestration.ts b/src/features/app/orchestration/useThreadOrchestration.ts index b3261db48..3ac37851c 100644 --- a/src/features/app/orchestration/useThreadOrchestration.ts +++ b/src/features/app/orchestration/useThreadOrchestration.ts @@ -57,7 +57,10 @@ type UseThreadCodexSyncOrchestrationParams = { activeThreadId: string | null; appSettings: Pick< AppSettings, - "defaultAccessMode" | "lastComposerModelId" | "lastComposerReasoningEffort" + | "defaultAccessMode" + | "lastComposerModelId" + | "lastComposerReasoningEffort" + | "lastComposerServiceTier" >; threadCodexParamsVersion: number; getThreadCodexParams: ReturnType["getThreadCodexParams"]; @@ -165,6 +168,7 @@ export function useThreadCodexSyncOrchestration({ defaultAccessMode: appSettings.defaultAccessMode, lastComposerModelId: appSettings.lastComposerModelId, lastComposerReasoningEffort: appSettings.lastComposerReasoningEffort, + lastComposerServiceTier: appSettings.lastComposerServiceTier, stored, noThreadStored, pendingSeed: pendingNewThreadSeedRef.current, @@ -183,6 +187,7 @@ export function useThreadCodexSyncOrchestration({ appSettings.defaultAccessMode, appSettings.lastComposerModelId, appSettings.lastComposerReasoningEffort, + appSettings.lastComposerServiceTier, getThreadCodexParams, setPreferredCollabModeId, setPreferredCodexArgsOverride, @@ -342,9 +347,28 @@ export function useThreadSelectionHandlersOrchestration({ const handleSelectServiceTier = useCallback( (tier: ServiceTier | null | undefined) => { setSelectedServiceTier(tier); + const hasActiveThread = Boolean(activeThreadIdRef.current); + if (!appSettingsLoading && !hasActiveThread) { + setAppSettings((current) => { + const nextTier = tier ?? null; + if (current.lastComposerServiceTier === nextTier) { + return current; + } + const nextSettings = { ...current, lastComposerServiceTier: nextTier }; + void queueSaveSettings(nextSettings); + return nextSettings; + }); + } persistThreadCodexParams({ serviceTier: tier }); }, - [persistThreadCodexParams, setSelectedServiceTier], + [ + activeThreadIdRef, + appSettingsLoading, + persistThreadCodexParams, + queueSaveSettings, + setAppSettings, + setSelectedServiceTier, + ], ); const handleSelectCollaborationMode = useCallback( diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index 9633cbca0..f8eb3a0c9 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -107,6 +107,7 @@ const baseSettings: AppSettings = { cycleWorkspacePrevShortcut: null, lastComposerModelId: null, lastComposerReasoningEffort: null, + lastComposerServiceTier: null, uiScale: 1, theme: "system", usageShowRemaining: false, @@ -1661,6 +1662,7 @@ describe("SettingsView Codex defaults", () => { const effortSelect = screen.getByLabelText( "Reasoning effort", ) as HTMLSelectElement; + const serviceTierSelect = screen.getByLabelText("Service tier") as HTMLSelectElement; await waitFor(() => { expect(modelSelect.disabled).toBe(false); @@ -1687,6 +1689,15 @@ describe("SettingsView Codex defaults", () => { expect.objectContaining({ lastComposerReasoningEffort: "high" }), ); }); + + onUpdateAppSettings.mockClear(); + fireEvent.change(serviceTierSelect, { target: { value: "fast" } }); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledWith( + expect.objectContaining({ lastComposerServiceTier: "fast" }), + ); + }); }); }); diff --git a/src/features/settings/components/sections/SettingsCodexSection.tsx b/src/features/settings/components/sections/SettingsCodexSection.tsx index a522b3ac1..1c58a1e81 100644 --- a/src/features/settings/components/sections/SettingsCodexSection.tsx +++ b/src/features/settings/components/sections/SettingsCodexSection.tsx @@ -6,6 +6,7 @@ import type { CodexDoctorResult, CodexUpdateResult, ModelOption, + ServiceTier, } from "@/types"; import { SettingsSection, @@ -71,6 +72,13 @@ const normalizeEffortValue = (value: unknown): string | null => { return trimmed.length > 0 ? trimmed.toLowerCase() : null; }; +const normalizeServiceTierValue = (value: unknown): ServiceTier | null => { + if (value === "fast" || value === "flex") { + return value; + } + return null; +}; + function coerceSavedModelSlug(value: string | null, models: ModelOption[]): string | null { const trimmed = (value ?? "").trim(); if (!trimmed) { @@ -184,6 +192,10 @@ export function SettingsCodexSection({ } return reasoningOptions[0] ?? ""; }, [reasoningOptions, reasoningSupported, savedEffort, selectedModel]); + const selectedServiceTier = useMemo( + () => normalizeServiceTierValue(appSettings.lastComposerServiceTier), + [appSettings.lastComposerServiceTier], + ); const didNormalizeDefaultsRef = useRef(false); useEffect(() => { @@ -475,6 +487,33 @@ export function SettingsCodexSection({ + + Service tier + + } + subtitle="Used when there is no thread-specific override. Choose Fast to default new projects to Fast mode." + > + + + diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index 331bca4c0..22b517cbc 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -23,6 +23,7 @@ import { DEFAULT_COMMIT_MESSAGE_PROMPT } from "@utils/commitMessagePrompt"; const allowedThemes = new Set(["system", "light", "dark", "dim"]); const allowedPersonality = new Set(["friendly", "pragmatic"]); const allowedFollowUpMessageBehavior = new Set(["queue", "steer"]); +const allowedServiceTiers = new Set(["fast", "flex"]); const DEFAULT_REMOTE_BACKEND_HOST = "127.0.0.1:4732"; const DEFAULT_REMOTE_BACKEND_ID = "remote-default"; const DEFAULT_REMOTE_BACKEND_NAME = "Primary remote"; @@ -165,6 +166,7 @@ function buildDefaultSettings(): AppSettings { cycleWorkspacePrevShortcut: isMac ? "cmd+shift+up" : "ctrl+alt+shift+up", lastComposerModelId: null, lastComposerReasoningEffort: null, + lastComposerServiceTier: null, uiScale: UI_SCALE_DEFAULT, theme: "system", usageShowRemaining: false, @@ -259,6 +261,11 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { personality: allowedPersonality.has(settings.personality) ? settings.personality : "friendly", + lastComposerServiceTier: + settings.lastComposerServiceTier && + allowedServiceTiers.has(settings.lastComposerServiceTier) + ? settings.lastComposerServiceTier + : null, followUpMessageBehavior: allowedFollowUpMessageBehavior.has( settings.followUpMessageBehavior, ) diff --git a/src/features/threads/utils/threadCodexParamsSeed.test.ts b/src/features/threads/utils/threadCodexParamsSeed.test.ts index 64d286b50..4bf2a4dc1 100644 --- a/src/features/threads/utils/threadCodexParamsSeed.test.ts +++ b/src/features/threads/utils/threadCodexParamsSeed.test.ts @@ -57,6 +57,7 @@ describe("threadCodexParamsSeed", () => { defaultAccessMode: "current", lastComposerModelId: "gpt-5", lastComposerReasoningEffort: "medium", + lastComposerServiceTier: null, stored: { modelId: "gpt-4.1", effort: "low", @@ -92,6 +93,7 @@ describe("threadCodexParamsSeed", () => { defaultAccessMode: "current", lastComposerModelId: "gpt-5", lastComposerReasoningEffort: "medium", + lastComposerServiceTier: null, stored: null, noThreadStored: null, pendingSeed: { @@ -119,6 +121,7 @@ describe("threadCodexParamsSeed", () => { defaultAccessMode: "current", lastComposerModelId: "gpt-5", lastComposerReasoningEffort: "medium", + lastComposerServiceTier: null, stored: { modelId: null, effort: null, @@ -146,6 +149,7 @@ describe("threadCodexParamsSeed", () => { defaultAccessMode: "current", lastComposerModelId: "gpt-5", lastComposerReasoningEffort: "medium", + lastComposerServiceTier: null, stored: { modelId: null, effort: null, @@ -173,6 +177,7 @@ describe("threadCodexParamsSeed", () => { defaultAccessMode: "current", lastComposerModelId: "gpt-5", lastComposerReasoningEffort: "medium", + lastComposerServiceTier: null, stored: { modelId: null, effort: null, @@ -206,6 +211,7 @@ describe("threadCodexParamsSeed", () => { defaultAccessMode: "current", lastComposerModelId: "gpt-5", lastComposerReasoningEffort: "medium", + lastComposerServiceTier: "fast", stored: { modelId: "gpt-4.1", effort: "low", @@ -230,6 +236,36 @@ describe("threadCodexParamsSeed", () => { }); }); + it("falls back to the saved service tier default when workspace state is unset", () => { + const noThreadResolved = resolveThreadCodexState({ + workspaceId: "ws-1", + threadId: null, + defaultAccessMode: "current", + lastComposerModelId: "gpt-5", + lastComposerReasoningEffort: "medium", + lastComposerServiceTier: "fast", + stored: null, + noThreadStored: null, + pendingSeed: null, + }); + + expect(noThreadResolved.preferredServiceTier).toBe("fast"); + + const threadResolved = resolveThreadCodexState({ + workspaceId: "ws-1", + threadId: "thread-1", + defaultAccessMode: "current", + lastComposerModelId: "gpt-5", + lastComposerReasoningEffort: "medium", + lastComposerServiceTier: "fast", + stored: null, + noThreadStored: null, + pendingSeed: null, + }); + + expect(threadResolved.preferredServiceTier).toBe("fast"); + }); + it("keeps explicit thread-scoped Fast off when no-thread scope is fast", () => { const resolved = resolveThreadCodexState({ workspaceId: "ws-1", @@ -237,6 +273,7 @@ describe("threadCodexParamsSeed", () => { defaultAccessMode: "current", lastComposerModelId: "gpt-5", lastComposerReasoningEffort: "medium", + lastComposerServiceTier: null, stored: { modelId: null, effort: null, diff --git a/src/features/threads/utils/threadCodexParamsSeed.ts b/src/features/threads/utils/threadCodexParamsSeed.ts index 98b0b70c4..23cd06693 100644 --- a/src/features/threads/utils/threadCodexParamsSeed.ts +++ b/src/features/threads/utils/threadCodexParamsSeed.ts @@ -22,6 +22,7 @@ type ResolveThreadCodexStateInput = { defaultAccessMode: AccessMode; lastComposerModelId: string | null; lastComposerReasoningEffort: string | null; + lastComposerServiceTier: ServiceTier | null; stored: ThreadCodexParams | null; noThreadStored: ThreadCodexParams | null; pendingSeed: PendingNewThreadSeed | null; @@ -120,6 +121,7 @@ export function resolveThreadCodexState( defaultAccessMode, lastComposerModelId, lastComposerReasoningEffort, + lastComposerServiceTier, stored, noThreadStored, pendingSeed, @@ -131,7 +133,10 @@ export function resolveThreadCodexState( accessMode: stored?.accessMode ?? defaultAccessMode, preferredModelId: stored?.modelId ?? lastComposerModelId ?? null, preferredEffort: stored?.effort ?? lastComposerReasoningEffort ?? null, - preferredServiceTier: stored?.serviceTier, + preferredServiceTier: + stored?.serviceTier !== undefined + ? stored.serviceTier + : lastComposerServiceTier ?? undefined, preferredCollabModeId: stored?.collaborationModeId ?? null, preferredCodexArgsOverride: stored?.codexArgsOverride ?? null, }; @@ -148,7 +153,7 @@ export function resolveThreadCodexState( preferredServiceTier: stored?.serviceTier !== undefined ? stored.serviceTier - : noThreadStored?.serviceTier, + : noThreadStored?.serviceTier ?? lastComposerServiceTier ?? undefined, preferredCollabModeId: stored?.collaborationModeId ?? (pendingForWorkspace diff --git a/src/types.ts b/src/types.ts index 51b1515c9..e235e60a8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -267,6 +267,7 @@ export type AppSettings = { cycleWorkspacePrevShortcut: string | null; lastComposerModelId: string | null; lastComposerReasoningEffort: string | null; + lastComposerServiceTier: ServiceTier | null; uiScale: number; theme: ThemePreference; usageShowRemaining: boolean;