Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,8 @@ pub(crate) struct AppSettings {
pub(crate) last_composer_model_id: Option<String>,
#[serde(default, rename = "lastComposerReasoningEffort")]
pub(crate) last_composer_reasoning_effort: Option<String>,
#[serde(default, rename = "lastComposerServiceTier")]
pub(crate) last_composer_service_tier: Option<String>,
#[serde(default = "default_ui_scale", rename = "uiScale")]
pub(crate) ui_scale: f64,
#[serde(default = "default_theme", rename = "theme")]
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand All @@ -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]
Expand Down
1 change: 1 addition & 0 deletions src/features/app/components/MainApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,7 @@ export default function MainApp() {
defaultAccessMode: appSettings.defaultAccessMode,
lastComposerModelId: appSettings.lastComposerModelId,
lastComposerReasoningEffort: appSettings.lastComposerReasoningEffort,
lastComposerServiceTier: appSettings.lastComposerServiceTier,
},
threadCodexParamsVersion,
getThreadCodexParams,
Expand Down
19 changes: 19 additions & 0 deletions src/features/app/orchestration/useThreadOrchestration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ function makeSyncParams(
defaultAccessMode: "current",
lastComposerModelId: "gpt-5",
lastComposerReasoningEffort: "medium",
lastComposerServiceTier: null,
},
threadCodexParamsVersion: 0,
getThreadCodexParams,
Expand Down Expand Up @@ -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));
Expand Down
28 changes: 26 additions & 2 deletions src/features/app/orchestration/useThreadOrchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ type UseThreadCodexSyncOrchestrationParams = {
activeThreadId: string | null;
appSettings: Pick<
AppSettings,
"defaultAccessMode" | "lastComposerModelId" | "lastComposerReasoningEffort"
| "defaultAccessMode"
| "lastComposerModelId"
| "lastComposerReasoningEffort"
| "lastComposerServiceTier"
>;
threadCodexParamsVersion: number;
getThreadCodexParams: ReturnType<typeof useThreadCodexParams>["getThreadCodexParams"];
Expand Down Expand Up @@ -165,6 +168,7 @@ export function useThreadCodexSyncOrchestration({
defaultAccessMode: appSettings.defaultAccessMode,
lastComposerModelId: appSettings.lastComposerModelId,
lastComposerReasoningEffort: appSettings.lastComposerReasoningEffort,
lastComposerServiceTier: appSettings.lastComposerServiceTier,
stored,
noThreadStored,
pendingSeed: pendingNewThreadSeedRef.current,
Expand All @@ -183,6 +187,7 @@ export function useThreadCodexSyncOrchestration({
appSettings.defaultAccessMode,
appSettings.lastComposerModelId,
appSettings.lastComposerReasoningEffort,
appSettings.lastComposerServiceTier,
getThreadCodexParams,
setPreferredCollabModeId,
setPreferredCodexArgsOverride,
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions src/features/settings/components/SettingsView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ const baseSettings: AppSettings = {
cycleWorkspacePrevShortcut: null,
lastComposerModelId: null,
lastComposerReasoningEffort: null,
lastComposerServiceTier: null,
uiScale: 1,
theme: "system",
usageShowRemaining: false,
Expand Down Expand Up @@ -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);
Expand All @@ -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" }),
);
});
});
});

Expand Down
39 changes: 39 additions & 0 deletions src/features/settings/components/sections/SettingsCodexSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
CodexDoctorResult,
CodexUpdateResult,
ModelOption,
ServiceTier,
} from "@/types";
import {
SettingsSection,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -475,6 +487,33 @@ export function SettingsCodexSection({
</select>
</SettingsToggleRow>

<SettingsToggleRow
title={
<label htmlFor="default-service-tier">
Service tier
</label>
}
subtitle="Used when there is no thread-specific override. Choose Fast to default new projects to Fast mode."
>
<select
id="default-service-tier"
className="settings-select"
value={selectedServiceTier ?? ""}
onChange={(event) => {
const nextTier = normalizeServiceTierValue(event.target.value);
void onUpdateAppSettings({
...appSettings,
lastComposerServiceTier: nextTier,
});
}}
aria-label="Service tier"
>
<option value="">default / off</option>
<option value="fast">fast</option>
<option value="flex">flex</option>
</select>
</SettingsToggleRow>

<SettingsToggleRow
title={
<label htmlFor="default-access">
Expand Down
7 changes: 7 additions & 0 deletions src/features/settings/hooks/useAppSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
Expand Down
37 changes: 37 additions & 0 deletions src/features/threads/utils/threadCodexParamsSeed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ describe("threadCodexParamsSeed", () => {
defaultAccessMode: "current",
lastComposerModelId: "gpt-5",
lastComposerReasoningEffort: "medium",
lastComposerServiceTier: null,
stored: {
modelId: "gpt-4.1",
effort: "low",
Expand Down Expand Up @@ -92,6 +93,7 @@ describe("threadCodexParamsSeed", () => {
defaultAccessMode: "current",
lastComposerModelId: "gpt-5",
lastComposerReasoningEffort: "medium",
lastComposerServiceTier: null,
stored: null,
noThreadStored: null,
pendingSeed: {
Expand Down Expand Up @@ -119,6 +121,7 @@ describe("threadCodexParamsSeed", () => {
defaultAccessMode: "current",
lastComposerModelId: "gpt-5",
lastComposerReasoningEffort: "medium",
lastComposerServiceTier: null,
stored: {
modelId: null,
effort: null,
Expand Down Expand Up @@ -146,6 +149,7 @@ describe("threadCodexParamsSeed", () => {
defaultAccessMode: "current",
lastComposerModelId: "gpt-5",
lastComposerReasoningEffort: "medium",
lastComposerServiceTier: null,
stored: {
modelId: null,
effort: null,
Expand Down Expand Up @@ -173,6 +177,7 @@ describe("threadCodexParamsSeed", () => {
defaultAccessMode: "current",
lastComposerModelId: "gpt-5",
lastComposerReasoningEffort: "medium",
lastComposerServiceTier: null,
stored: {
modelId: null,
effort: null,
Expand Down Expand Up @@ -206,6 +211,7 @@ describe("threadCodexParamsSeed", () => {
defaultAccessMode: "current",
lastComposerModelId: "gpt-5",
lastComposerReasoningEffort: "medium",
lastComposerServiceTier: "fast",
stored: {
modelId: "gpt-4.1",
effort: "low",
Expand All @@ -230,13 +236,44 @@ 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",
threadId: "thread-1",
defaultAccessMode: "current",
lastComposerModelId: "gpt-5",
lastComposerReasoningEffort: "medium",
lastComposerServiceTier: null,
stored: {
modelId: null,
effort: null,
Expand Down
9 changes: 7 additions & 2 deletions src/features/threads/utils/threadCodexParamsSeed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -120,6 +121,7 @@ export function resolveThreadCodexState(
defaultAccessMode,
lastComposerModelId,
lastComposerReasoningEffort,
lastComposerServiceTier,
stored,
noThreadStored,
pendingSeed,
Expand All @@ -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,
};
Expand All @@ -148,7 +153,7 @@ export function resolveThreadCodexState(
preferredServiceTier:
stored?.serviceTier !== undefined
? stored.serviceTier
: noThreadStored?.serviceTier,
: noThreadStored?.serviceTier ?? lastComposerServiceTier ?? undefined,
preferredCollabModeId:
stored?.collaborationModeId ??
(pendingForWorkspace
Expand Down
Loading