From 85b7f1d1120778e5c6a20e3e9a3b1995d5612d55 Mon Sep 17 00:00:00 2001 From: Samigos Date: Mon, 9 Feb 2026 20:39:04 +0200 Subject: [PATCH 01/10] feat(composer): persist Codex params per thread Persist model, effort, access mode, and collaboration mode selections per thread in localStorage. --- src/App.tsx | 193 +++++++++++++++--- .../hooks/useCollaborationModes.ts | 16 ++ src/features/models/hooks/useModels.ts | 12 ++ .../threads/hooks/useThreadCodexParams.ts | 125 ++++++++++++ src/features/threads/utils/threadStorage.ts | 52 +++++ 5 files changed, 368 insertions(+), 30 deletions(-) create mode 100644 src/features/threads/hooks/useThreadCodexParams.ts diff --git a/src/App.tsx b/src/App.tsx index 98eaf16d5..0c37f596e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import "./styles/base.css"; import "./styles/ds-tokens.css"; import "./styles/ds-modal.css"; @@ -89,7 +89,6 @@ import { useWorkspaceSelection } from "./features/workspaces/hooks/useWorkspaceS import { useLocalUsage } from "./features/home/hooks/useLocalUsage"; import { useGitHubPanelController } from "./features/app/hooks/useGitHubPanelController"; import { useSettingsModalState } from "./features/app/hooks/useSettingsModalState"; -import { usePersistComposerSettings } from "./features/app/hooks/usePersistComposerSettings"; import { useSyncSelectedDiffPath } from "./features/app/hooks/useSyncSelectedDiffPath"; import { useMenuAcceleratorController } from "./features/app/hooks/useMenuAcceleratorController"; import { useAppMenuEvents } from "./features/app/hooks/useAppMenuEvents"; @@ -111,6 +110,8 @@ import { MobileServerSetupWizard } from "./features/mobile/components/MobileServ import { useMobileServerSetup } from "./features/mobile/hooks/useMobileServerSetup"; import { useWorkspaceHome } from "./features/workspaces/hooks/useWorkspaceHome"; import { useWorkspaceAgentMd } from "./features/workspaces/hooks/useWorkspaceAgentMd"; +import { useThreadCodexParams } from "./features/threads/hooks/useThreadCodexParams"; +import { makeThreadCodexParamsKey } from "./features/threads/utils/threadStorage"; import { isMobilePlatform } from "./utils/platformPaths"; import type { AccessMode, @@ -185,7 +186,17 @@ function MainApp() { } = useDebugLog(); const shouldReduceTransparency = reduceTransparency || isMobilePlatform(); useLiquidGlassEffect({ reduceTransparency: shouldReduceTransparency, onDebug: addDebugEntry }); + const { version: threadCodexParamsVersion, getThreadCodexParams, patchThreadCodexParams } = + useThreadCodexParams(); const [accessMode, setAccessMode] = useState("current"); + const [preferredModelId, setPreferredModelId] = useState(null); + const [preferredEffort, setPreferredEffort] = useState(null); + const [preferredCollabModeId, setPreferredCollabModeId] = useState( + null, + ); + const [threadCodexSelectionKey, setThreadCodexSelectionKey] = useState( + null, + ); const { threadListSortKey, setThreadListSortKey } = useThreadListSortKey(); const [activeTab, setActiveTab] = useState< "home" | "projects" | "codex" | "git" | "log" @@ -243,6 +254,11 @@ function MainApp() { () => new Map(workspaces.map((workspace) => [workspace.id, workspace])), [workspaces], ); + const activeWorkspaceIdForParamsRef = useRef(activeWorkspaceId ?? null); + useEffect(() => { + activeWorkspaceIdForParamsRef.current = activeWorkspaceId ?? null; + }, [activeWorkspaceId]); + const activeThreadIdRef = useRef(null); const { sidebarWidth, rightPanelWidth, @@ -322,11 +338,7 @@ function MainApp() { const { errorToasts, dismissErrorToast } = useErrorToasts(); - useEffect(() => { - setAccessMode((prev) => - prev === "current" ? appSettings.defaultAccessMode : prev - ); - }, [appSettings.defaultAccessMode]); + // Access mode is thread-scoped (best-effort persisted) and falls back to the app default. const { gitIssues, @@ -438,8 +450,9 @@ function MainApp() { } = useModels({ activeWorkspace, onDebug: addDebugEntry, - preferredModelId: appSettings.lastComposerModelId, - preferredEffort: appSettings.lastComposerReasoningEffort, + preferredModelId, + preferredEffort, + selectionKey: threadCodexSelectionKey, }); const { @@ -450,9 +463,93 @@ function MainApp() { } = useCollaborationModes({ activeWorkspace, enabled: appSettings.collaborationModesEnabled, + preferredModeId: preferredCollabModeId, + selectionKey: threadCodexSelectionKey, onDebug: addDebugEntry, }); + const persistThreadCodexParams = useCallback( + (patch: { + modelId?: string | null; + effort?: string | null; + accessMode?: AccessMode | null; + collaborationModeId?: string | null; + }) => { + const workspaceId = activeWorkspaceIdForParamsRef.current; + const threadId = activeThreadIdRef.current; + if (!workspaceId || !threadId) { + return; + } + patchThreadCodexParams(workspaceId, threadId, patch); + }, + [patchThreadCodexParams], + ); + + const handleSelectModel = useCallback( + (id: string | null) => { + setSelectedModelId(id); + if (!appSettingsLoading) { + setAppSettings((current) => { + if (current.lastComposerModelId === id) { + return current; + } + const nextSettings = { ...current, lastComposerModelId: id }; + void queueSaveSettings(nextSettings); + return nextSettings; + }); + } + persistThreadCodexParams({ modelId: id }); + }, + [ + appSettingsLoading, + persistThreadCodexParams, + queueSaveSettings, + setAppSettings, + setSelectedModelId, + ], + ); + + const handleSelectEffort = useCallback( + (raw: string | null) => { + const next = typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : null; + setSelectedEffort(next); + if (!appSettingsLoading) { + setAppSettings((current) => { + if (current.lastComposerReasoningEffort === next) { + return current; + } + const nextSettings = { ...current, lastComposerReasoningEffort: next }; + void queueSaveSettings(nextSettings); + return nextSettings; + }); + } + persistThreadCodexParams({ effort: next }); + }, + [ + appSettingsLoading, + persistThreadCodexParams, + queueSaveSettings, + setAppSettings, + setSelectedEffort, + ], + ); + + const handleSelectCollaborationMode = useCallback( + (id: string | null) => { + setSelectedCollaborationModeId(id); + persistThreadCodexParams({ collaborationModeId: id }); + }, + [persistThreadCodexParams, setSelectedCollaborationModeId], + ); + + const handleSelectAccessMode = useCallback( + (mode: AccessMode) => { + setAccessMode(mode); + persistThreadCodexParams({ accessMode: mode }); + }, + [persistThreadCodexParams], + ); + const composerShortcuts = { modelShortcut: appSettings.composerModelShortcut, accessShortcut: appSettings.composerAccessShortcut, @@ -463,14 +560,14 @@ function MainApp() { models, collaborationModes, selectedModelId, - onSelectModel: setSelectedModelId, + onSelectModel: handleSelectModel, selectedCollaborationModeId, - onSelectCollaborationMode: setSelectedCollaborationModeId, + onSelectCollaborationMode: handleSelectCollaborationMode, accessMode, - onSelectAccessMode: setAccessMode, + onSelectAccessMode: handleSelectAccessMode, reasoningOptions, selectedEffort, - onSelectEffort: setSelectedEffort, + onSelectEffort: handleSelectEffort, reasoningSupported, }; @@ -487,15 +584,15 @@ function MainApp() { useComposerMenuActions({ models, selectedModelId, - onSelectModel: setSelectedModelId, + onSelectModel: handleSelectModel, collaborationModes, selectedCollaborationModeId, - onSelectCollaborationMode: setSelectedCollaborationModeId, + onSelectCollaborationMode: handleSelectCollaborationMode, accessMode, - onSelectAccessMode: setAccessMode, + onSelectAccessMode: handleSelectAccessMode, reasoningOptions, selectedEffort, - onSelectEffort: setSelectedEffort, + onSelectEffort: handleSelectEffort, reasoningSupported, onFocusComposer: () => composerInputRef.current?.focus(), }); @@ -581,14 +678,6 @@ function MainApp() { } changed` : "Working tree clean"; - usePersistComposerSettings({ - appSettingsLoading, - selectedModelId, - selectedEffort, - setAppSettings, - queueSaveSettings, - }); - const { isExpanded: composerEditorExpanded, toggleExpanded: toggleComposerEditorExpanded } = useComposerEditorState(); @@ -711,6 +800,51 @@ function MainApp() { threadSortKey: threadListSortKey, }); + useLayoutEffect(() => { + const workspaceId = activeWorkspaceId ?? null; + const threadId = activeThreadId ?? null; + activeThreadIdRef.current = threadId; + + if (!workspaceId) { + return; + } + + const scopeKey = threadId + ? makeThreadCodexParamsKey(workspaceId, threadId) + : `${workspaceId}:__no_thread__`; + + const stored = threadId ? getThreadCodexParams(workspaceId, threadId) : null; + + setThreadCodexSelectionKey(scopeKey); + setAccessMode(stored?.accessMode ?? appSettings.defaultAccessMode); + + if (threadId) { + // Thread-scoped defaults: no global fallback. If the thread has no stored override, + // use the model hook's defaults (config/isDefault) by passing null preferences. + setPreferredModelId(stored?.modelId ?? null); + setPreferredEffort(stored?.effort ?? null); + setPreferredCollabModeId(stored?.collaborationModeId ?? null); + return; + } + + // No active thread: use global "last composer" preferences. + setPreferredModelId(appSettings.lastComposerModelId); + setPreferredEffort(appSettings.lastComposerReasoningEffort); + setPreferredCollabModeId(null); + }, [ + activeThreadId, + activeWorkspaceId, + appSettings.defaultAccessMode, + appSettings.lastComposerModelId, + appSettings.lastComposerReasoningEffort, + getThreadCodexParams, + setPreferredCollabModeId, + setPreferredEffort, + setPreferredModelId, + setThreadCodexSelectionKey, + threadCodexParamsVersion, + ]); + const { handleSetThreadListSortKey, handleRefreshAllWorkspaceThreads } = useThreadListActions({ threadListSortKey, @@ -753,7 +887,6 @@ function MainApp() { activeWorkspaceId, activeThreadId, }); - const activeThreadIdRef = useRef(activeThreadId ?? null); const { getThreadRows } = useThreadRows(threadParentById); useEffect(() => { activeThreadIdRef.current = activeThreadId ?? null; @@ -2118,16 +2251,16 @@ function MainApp() { onDeleteQueued: handleDeleteQueued, collaborationModes, selectedCollaborationModeId, - onSelectCollaborationMode: setSelectedCollaborationModeId, + onSelectCollaborationMode: handleSelectCollaborationMode, models, selectedModelId, - onSelectModel: setSelectedModelId, + onSelectModel: handleSelectModel, reasoningOptions, selectedEffort, - onSelectEffort: setSelectedEffort, + onSelectEffort: handleSelectEffort, reasoningSupported, accessMode, - onSelectAccessMode: setAccessMode, + onSelectAccessMode: handleSelectAccessMode, skills, appsEnabled: appSettings.experimentalAppsEnabled, apps, diff --git a/src/features/collaboration/hooks/useCollaborationModes.ts b/src/features/collaboration/hooks/useCollaborationModes.ts index 71ab17e73..e68743d6e 100644 --- a/src/features/collaboration/hooks/useCollaborationModes.ts +++ b/src/features/collaboration/hooks/useCollaborationModes.ts @@ -9,12 +9,16 @@ import { getCollaborationModes } from "../../../services/tauri"; type UseCollaborationModesOptions = { activeWorkspace: WorkspaceInfo | null; enabled: boolean; + preferredModeId?: string | null; + selectionKey?: string | null; onDebug?: (entry: DebugEntry) => void; }; export function useCollaborationModes({ activeWorkspace, enabled, + preferredModeId = null, + selectionKey = null, onDebug, }: UseCollaborationModesOptions) { const [modes, setModes] = useState([]); @@ -23,6 +27,7 @@ export function useCollaborationModes({ const previousWorkspaceId = useRef(null); const inFlight = useRef(false); const selectedModeIdRef = useRef(null); + const lastSelectionKey = useRef(null); const workspaceId = activeWorkspace?.id ?? null; const isConnected = Boolean(activeWorkspace?.connected); @@ -176,6 +181,17 @@ export function useCollaborationModes({ selectedModeIdRef.current = selectedModeId; }, [selectedModeId]); + useEffect(() => { + if (!enabled) { + return; + } + if (selectionKey === lastSelectionKey.current) { + return; + } + lastSelectionKey.current = selectionKey; + setSelectedModeId(preferredModeId); + }, [enabled, preferredModeId, selectionKey]); + useEffect(() => { if (previousWorkspaceId.current !== workspaceId) { previousWorkspaceId.current = workspaceId; diff --git a/src/features/models/hooks/useModels.ts b/src/features/models/hooks/useModels.ts index 8e9a62019..452b67741 100644 --- a/src/features/models/hooks/useModels.ts +++ b/src/features/models/hooks/useModels.ts @@ -7,6 +7,7 @@ type UseModelsOptions = { onDebug?: (entry: DebugEntry) => void; preferredModelId?: string | null; preferredEffort?: string | null; + selectionKey?: string | null; }; const CONFIG_MODEL_DESCRIPTION = "Configured in CODEX_HOME/config.toml"; @@ -44,6 +45,7 @@ export function useModels({ onDebug, preferredModelId = null, preferredEffort = null, + selectionKey = null, }: UseModelsOptions) { const [models, setModels] = useState([]); const [configModel, setConfigModel] = useState(null); @@ -54,10 +56,20 @@ export function useModels({ const hasUserSelectedModel = useRef(false); const hasUserSelectedEffort = useRef(false); const lastWorkspaceId = useRef(null); + const lastSelectionKey = useRef(null); const workspaceId = activeWorkspace?.id ?? null; const isConnected = Boolean(activeWorkspace?.connected); + useEffect(() => { + if (selectionKey === lastSelectionKey.current) { + return; + } + lastSelectionKey.current = selectionKey; + hasUserSelectedModel.current = false; + hasUserSelectedEffort.current = false; + }, [selectionKey]); + useEffect(() => { if (workspaceId === lastWorkspaceId.current) { return; diff --git a/src/features/threads/hooks/useThreadCodexParams.ts b/src/features/threads/hooks/useThreadCodexParams.ts new file mode 100644 index 000000000..2ca063b1f --- /dev/null +++ b/src/features/threads/hooks/useThreadCodexParams.ts @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { AccessMode } from "../../../types"; +import { + STORAGE_KEY_THREAD_CODEX_PARAMS, + type ThreadCodexParams, + type ThreadCodexParamsMap, + loadThreadCodexParams, + makeThreadCodexParamsKey, + saveThreadCodexParams, +} from "../utils/threadStorage"; + +export type ThreadCodexParamsPatch = Partial< + Pick +>; + +type UseThreadCodexParamsResult = { + version: number; + getThreadCodexParams: (workspaceId: string, threadId: string) => ThreadCodexParams | null; + patchThreadCodexParams: ( + workspaceId: string, + threadId: string, + patch: ThreadCodexParamsPatch, + ) => void; + deleteThreadCodexParams: (workspaceId: string, threadId: string) => void; +}; + +const DEFAULT_ENTRY: ThreadCodexParams = { + modelId: null, + effort: null, + accessMode: null, + collaborationModeId: null, + updatedAt: 0, +}; + +function coerceAccessMode(value: unknown): AccessMode | null { + if (value === "read-only" || value === "current" || value === "full-access") { + return value; + } + return null; +} + +function sanitizeEntry(value: unknown): ThreadCodexParams | null { + if (!value || typeof value !== "object") { + return null; + } + const entry = value as Record; + return { + modelId: typeof entry.modelId === "string" ? entry.modelId : null, + effort: typeof entry.effort === "string" ? entry.effort : null, + accessMode: coerceAccessMode(entry.accessMode), + collaborationModeId: + typeof entry.collaborationModeId === "string" + ? entry.collaborationModeId + : null, + updatedAt: typeof entry.updatedAt === "number" ? entry.updatedAt : 0, + }; +} + +export function useThreadCodexParams(): UseThreadCodexParamsResult { + const paramsRef = useRef(loadThreadCodexParams()); + const [version, setVersion] = useState(0); + + useEffect(() => { + if (typeof window === "undefined") { + return undefined; + } + const handleStorage = (event: StorageEvent) => { + if (event.key !== STORAGE_KEY_THREAD_CODEX_PARAMS) { + return; + } + paramsRef.current = loadThreadCodexParams(); + setVersion((v) => v + 1); + }; + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + }, []); + + const getThreadCodexParams = useCallback( + (workspaceId: string, threadId: string): ThreadCodexParams | null => { + const key = makeThreadCodexParamsKey(workspaceId, threadId); + const entry = paramsRef.current[key]; + return sanitizeEntry(entry) ?? null; + }, + [], + ); + + const patchThreadCodexParams = useCallback( + (workspaceId: string, threadId: string, patch: ThreadCodexParamsPatch) => { + const key = makeThreadCodexParamsKey(workspaceId, threadId); + const current = sanitizeEntry(paramsRef.current[key]) ?? DEFAULT_ENTRY; + const nextEntry: ThreadCodexParams = { + ...current, + ...patch, + updatedAt: Date.now(), + }; + const next: ThreadCodexParamsMap = { ...paramsRef.current, [key]: nextEntry }; + paramsRef.current = next; + saveThreadCodexParams(next); + setVersion((v) => v + 1); + }, + [], + ); + + const deleteThreadCodexParams = useCallback((workspaceId: string, threadId: string) => { + const key = makeThreadCodexParamsKey(workspaceId, threadId); + if (!(key in paramsRef.current)) { + return; + } + const { [key]: _removed, ...rest } = paramsRef.current; + paramsRef.current = rest; + saveThreadCodexParams(rest); + setVersion((v) => v + 1); + }, []); + + return useMemo( + () => ({ + version, + getThreadCodexParams, + patchThreadCodexParams, + deleteThreadCodexParams, + }), + [deleteThreadCodexParams, getThreadCodexParams, patchThreadCodexParams, version], + ); +} + diff --git a/src/features/threads/utils/threadStorage.ts b/src/features/threads/utils/threadStorage.ts index 4b9d24d44..998a998a2 100644 --- a/src/features/threads/utils/threadStorage.ts +++ b/src/features/threads/utils/threadStorage.ts @@ -1,12 +1,64 @@ +import type { AccessMode } from "../../../types"; + export const STORAGE_KEY_THREAD_ACTIVITY = "codexmonitor.threadLastUserActivity"; export const STORAGE_KEY_PINNED_THREADS = "codexmonitor.pinnedThreads"; export const STORAGE_KEY_CUSTOM_NAMES = "codexmonitor.threadCustomNames"; +export const STORAGE_KEY_THREAD_CODEX_PARAMS = "codexmonitor.threadCodexParams"; export const MAX_PINS_SOFT_LIMIT = 5; export type ThreadActivityMap = Record>; export type PinnedThreadsMap = Record; export type CustomNamesMap = Record; +// Per-thread Codex parameter overrides. Keyed by `${workspaceId}:${threadId}`. +// These are UI-level preferences (not server state) and are best-effort persisted. +export type ThreadCodexParams = { + modelId: string | null; + effort: string | null; + accessMode: AccessMode | null; + collaborationModeId: string | null; + updatedAt: number; +}; + +export type ThreadCodexParamsMap = Record; + +export function makeThreadCodexParamsKey(workspaceId: string, threadId: string): string { + return `${workspaceId}:${threadId}`; +} + +export function loadThreadCodexParams(): ThreadCodexParamsMap { + if (typeof window === "undefined") { + return {}; + } + try { + const raw = window.localStorage.getItem(STORAGE_KEY_THREAD_CODEX_PARAMS); + if (!raw) { + return {}; + } + const parsed = JSON.parse(raw) as ThreadCodexParamsMap; + if (!parsed || typeof parsed !== "object") { + return {}; + } + return parsed; + } catch { + return {}; + } +} + +export function saveThreadCodexParams(next: ThreadCodexParamsMap): void { + if (typeof window === "undefined") { + return; + } + try { + window.localStorage.setItem( + STORAGE_KEY_THREAD_CODEX_PARAMS, + JSON.stringify(next), + ); + } catch { + // Best-effort persistence. + } +} + export function loadThreadActivity(): ThreadActivityMap { if (typeof window === "undefined") { return {}; From 91aa5783be24661feae124ce122acae98de00b7f Mon Sep 17 00:00:00 2001 From: Samigos Date: Tue, 10 Feb 2026 01:13:46 +0200 Subject: [PATCH 02/10] feat(settings): set Codex default model and effort --- .../settings/components/SettingsView.test.tsx | 206 ++++++++++++++++++ .../settings/components/SettingsView.tsx | 15 ++ .../sections/SettingsCodexSection.tsx | 150 +++++++++++++ .../hooks/useSettingsDefaultModels.ts | 206 ++++++++++++++++++ 4 files changed, 577 insertions(+) create mode 100644 src/features/settings/hooks/useSettingsDefaultModels.ts diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index 653733fe6..52de55de0 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -11,6 +11,7 @@ import { import type { ComponentProps } from "react"; import { describe, expect, it, vi } from "vitest"; import type { AppSettings, WorkspaceInfo } from "../../../types"; +import { getModelList } from "../../../services/tauri"; import { SettingsView } from "./SettingsView"; vi.mock("@tauri-apps/plugin-dialog", () => ({ @@ -18,6 +19,18 @@ vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn(), })); +vi.mock("../../../services/tauri", async () => { + const actual = await vi.importActual( + "../../../services/tauri", + ); + return { + ...actual, + getModelList: vi.fn(), + }; +}); + +const getModelListMock = vi.mocked(getModelList); + const baseSettings: AppSettings = { codexBin: null, codexArgs: null, @@ -1227,6 +1240,199 @@ describe("SettingsView Codex overrides", () => { }); }); +describe("SettingsView Codex defaults", () => { + const createModelListResponse = (models: Array>) => ({ + result: { data: models }, + }); + + it("uses the latest model and medium effort by default (no Default option)", async () => { + cleanup(); + const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); + getModelListMock.mockResolvedValue( + createModelListResponse([ + { + id: "gpt-4.1", + model: "gpt-4.1", + displayName: "GPT-4.1", + description: "", + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: false, + }, + { + id: "gpt-5.1", + model: "gpt-5.1", + displayName: "GPT-5.1", + description: "", + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: false, + }, + ]), + ); + + render( + , + ); + + const modelSelect = screen.getByLabelText("Default model") as HTMLSelectElement; + const effortSelect = screen.getByLabelText( + "Default reasoning effort", + ) as HTMLSelectElement; + + await waitFor(() => { + expect(getModelListMock).toHaveBeenCalledWith("w1"); + expect(modelSelect.value).toBe("gpt-5.1"); + }); + + expect(within(modelSelect).queryByRole("option", { name: /default/i })).toBeNull(); + expect(effortSelect.value).toBe("medium"); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledWith( + expect.objectContaining({ + lastComposerModelId: "gpt-5.1", + lastComposerReasoningEffort: "medium", + }), + ); + }); + }); + + it("updates model and effort when the user changes the selects", async () => { + cleanup(); + const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); + getModelListMock.mockResolvedValue( + createModelListResponse([ + { + id: "gpt-4.1", + model: "gpt-4.1", + displayName: "GPT-4.1", + description: "", + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: false, + }, + { + id: "gpt-5.1", + model: "gpt-5.1", + displayName: "GPT-5.1", + description: "", + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: false, + }, + ]), + ); + + render( + , + ); + + const modelSelect = screen.getByLabelText("Default model") as HTMLSelectElement; + const effortSelect = screen.getByLabelText( + "Default reasoning effort", + ) as HTMLSelectElement; + + await waitFor(() => { + expect(modelSelect.disabled).toBe(false); + expect(modelSelect.value).toBe("gpt-5.1"); + expect(onUpdateAppSettings).toHaveBeenCalledWith( + expect.objectContaining({ lastComposerModelId: "gpt-5.1" }), + ); + }); + + onUpdateAppSettings.mockClear(); + fireEvent.change(modelSelect, { target: { value: "gpt-4.1" } }); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledWith( + expect.objectContaining({ lastComposerModelId: "gpt-4.1" }), + ); + }); + + onUpdateAppSettings.mockClear(); + fireEvent.change(effortSelect, { target: { value: "high" } }); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledWith( + expect.objectContaining({ lastComposerReasoningEffort: "high" }), + ); + }); + }); +}); + describe("SettingsView Features", () => { it("updates personality selection", async () => { const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index 57ad12c97..e2677e5c8 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -38,6 +38,7 @@ import { } from "../../../utils/fonts"; import { useGlobalAgentsMd } from "../hooks/useGlobalAgentsMd"; import { useGlobalCodexConfigToml } from "../hooks/useGlobalCodexConfigToml"; +import { useSettingsDefaultModels } from "../hooks/useSettingsDefaultModels"; import { useSettingsOpenAppDrafts } from "../hooks/useSettingsOpenAppDrafts"; import { useSettingsShortcutDrafts } from "../hooks/useSettingsShortcutDrafts"; import { useSettingsViewCloseShortcuts } from "../hooks/useSettingsViewCloseShortcuts"; @@ -333,6 +334,13 @@ export function SettingsView({ () => groupedWorkspaces.flatMap((group) => group.workspaces), [groupedWorkspaces], ); + const { + models: defaultModels, + isLoading: defaultModelsLoading, + error: defaultModelsError, + connectedWorkspaceCount: defaultModelsConnectedWorkspaceCount, + refresh: refreshDefaultModels, + } = useSettingsDefaultModels(projects); const mainWorkspaces = useMemo( () => projects.filter((workspace) => (workspace.kind ?? "main") !== "worktree"), [projects], @@ -1480,6 +1488,13 @@ export function SettingsView({ { + void refreshDefaultModels(); + }} codexPathDraft={codexPathDraft} codexArgsDraft={codexArgsDraft} codexDirty={codexDirty} diff --git a/src/features/settings/components/sections/SettingsCodexSection.tsx b/src/features/settings/components/sections/SettingsCodexSection.tsx index 0d2c5f06b..6a6d06ac8 100644 --- a/src/features/settings/components/sections/SettingsCodexSection.tsx +++ b/src/features/settings/components/sections/SettingsCodexSection.tsx @@ -1,9 +1,11 @@ +import { useEffect, useMemo, useRef } from "react"; import Stethoscope from "lucide-react/dist/esm/icons/stethoscope"; import type { Dispatch, SetStateAction } from "react"; import type { AppSettings, CodexDoctorResult, CodexUpdateResult, + ModelOption, WorkspaceInfo, } from "../../../../types"; import { FileEditorCard } from "../../../shared/components/FileEditorCard"; @@ -11,6 +13,11 @@ import { FileEditorCard } from "../../../shared/components/FileEditorCard"; type SettingsCodexSectionProps = { appSettings: AppSettings; onUpdateAppSettings: (next: AppSettings) => Promise; + defaultModels: ModelOption[]; + defaultModelsLoading: boolean; + defaultModelsError: string | null; + defaultModelsConnectedWorkspaceCount: number; + onRefreshDefaultModels: () => void; codexPathDraft: string; codexArgsDraft: string; codexDirty: boolean; @@ -68,9 +75,40 @@ const normalizeOverrideValue = (value: string): string | null => { return trimmed ? trimmed : null; }; +const DEFAULT_REASONING_EFFORT = "medium"; +const REASONING_EFFORT_OPTIONS = ["low", "medium", "high"] as const; + +function coerceReasoningEffort(value: unknown): (typeof REASONING_EFFORT_OPTIONS)[number] | null { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().toLowerCase(); + return (REASONING_EFFORT_OPTIONS as readonly string[]).includes(normalized) + ? (normalized as (typeof REASONING_EFFORT_OPTIONS)[number]) + : null; +} + +function coerceSavedModelSlug(value: string | null, models: ModelOption[]): string | null { + const trimmed = (value ?? "").trim(); + if (!trimmed) { + return null; + } + const bySlug = models.find((model) => model.model === trimmed); + if (bySlug) { + return bySlug.model; + } + const byId = models.find((model) => model.id === trimmed); + return byId ? byId.model : null; +} + export function SettingsCodexSection({ appSettings, onUpdateAppSettings, + defaultModels, + defaultModelsLoading, + defaultModelsError, + defaultModelsConnectedWorkspaceCount, + onRefreshDefaultModels, codexPathDraft, codexArgsDraft, codexDirty, @@ -113,6 +151,50 @@ export function SettingsCodexSection({ onUpdateWorkspaceCodexBin, onUpdateWorkspaceSettings, }: SettingsCodexSectionProps) { + const latestModelSlug = defaultModels[0]?.model ?? null; + const savedModelSlug = useMemo( + () => coerceSavedModelSlug(appSettings.lastComposerModelId, defaultModels), + [appSettings.lastComposerModelId, defaultModels], + ); + const selectedModelSlug = savedModelSlug ?? latestModelSlug ?? ""; + const selectedEffort = + coerceReasoningEffort(appSettings.lastComposerReasoningEffort) ?? DEFAULT_REASONING_EFFORT; + + const didNormalizeDefaultsRef = useRef(false); + useEffect(() => { + if (didNormalizeDefaultsRef.current) { + return; + } + if (!defaultModels.length) { + return; + } + const savedRawModel = (appSettings.lastComposerModelId ?? "").trim(); + const savedRawEffort = (appSettings.lastComposerReasoningEffort ?? "").trim(); + const shouldNormalizeModel = savedRawModel.length === 0 || savedModelSlug === null; + const shouldNormalizeEffort = + savedRawEffort.length === 0 || coerceReasoningEffort(savedRawEffort) === null; + if (!shouldNormalizeModel && !shouldNormalizeEffort) { + didNormalizeDefaultsRef.current = true; + return; + } + + const next: AppSettings = { + ...appSettings, + lastComposerModelId: shouldNormalizeModel ? selectedModelSlug : appSettings.lastComposerModelId, + lastComposerReasoningEffort: shouldNormalizeEffort + ? DEFAULT_REASONING_EFFORT + : appSettings.lastComposerReasoningEffort, + }; + didNormalizeDefaultsRef.current = true; + void onUpdateAppSettings(next); + }, [ + appSettings, + defaultModels.length, + onUpdateAppSettings, + savedModelSlug, + selectedModelSlug, + ]); + return (
Codex
@@ -266,6 +348,74 @@ export function SettingsCodexSection({ )} +
+ +
+ + +
+ {defaultModelsConnectedWorkspaceCount === 0 && ( +
+ Connect a project to load available models. +
+ )} + {defaultModelsLoading && ( +
Loading models…
+ )} + {defaultModelsError && ( +
Couldn’t load models: {defaultModelsError}
+ )} +
+ +
+ + +
+
-
- +
+
Default parameters
+ +
+
+ +
+ {defaultModelsConnectedWorkspaceCount === 0 + ? "Connect a project to load available models." + : defaultModelsLoading + ? "Loading models…" + : defaultModelsError + ? `Couldn’t load models: ${defaultModelsError}` + : "Used when there is no thread-specific override."} +
+
- - - + {!reasoningSupported && } + {reasoningOptions.map((effort) => ( + + ))}
-
- +
+
+ +
+ Used when there is no thread-specific override. +
+