diff --git a/src/App.tsx b/src/App.tsx index 751b1700c..e4ea3d6a7 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,14 @@ 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 { + buildThreadCodexSeedPatch, + createPendingThreadSeed, + resolveThreadCodexState, + type PendingNewThreadSeed, +} from "./features/threads/utils/threadCodexParamsSeed"; import { isMobilePlatform } from "./utils/platformPaths"; import type { AccessMode, @@ -186,7 +193,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" @@ -245,6 +262,14 @@ 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); + // When sending the first message from the "no thread" composer, snapshot the + // collaboration mode so it can be applied to the created thread. + const pendingNewThreadSeedRef = useRef(null); const { sidebarWidth, rightPanelWidth, @@ -324,11 +349,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, @@ -441,8 +462,9 @@ function MainApp() { } = useModels({ activeWorkspace, onDebug: addDebugEntry, - preferredModelId: appSettings.lastComposerModelId, - preferredEffort: appSettings.lastComposerReasoningEffort, + preferredModelId, + preferredEffort, + selectionKey: threadCodexSelectionKey, }); const { @@ -453,9 +475,102 @@ 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); + const hasActiveThread = Boolean(activeThreadIdRef.current); + if (!appSettingsLoading) { + // Picking a model inside a thread should not overwrite the global defaults + // configured in Settings; it should remain thread-scoped. + if (!hasActiveThread) { + 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); + const hasActiveThread = Boolean(activeThreadIdRef.current); + if (!appSettingsLoading) { + // Keep per-thread overrides from mutating the global defaults. + if (!hasActiveThread) { + 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, @@ -466,14 +581,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, }; @@ -490,15 +605,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(), }); @@ -579,14 +694,6 @@ function MainApp() { } changed` : "Working tree clean"; - usePersistComposerSettings({ - appSettingsLoading, - selectedModelId, - selectedEffort, - setAppSettings, - queueSaveSettings, - }); - const { isExpanded: composerEditorExpanded, toggleExpanded: toggleComposerEditorExpanded } = useComposerEditorState(); @@ -716,6 +823,92 @@ function MainApp() { onDebug: addDebugEntry, }); + useLayoutEffect(() => { + const workspaceId = activeWorkspaceId ?? null; + const threadId = activeThreadId ?? null; + activeThreadIdRef.current = threadId; + + if (!workspaceId) { + return; + } + + const stored = threadId ? getThreadCodexParams(workspaceId, threadId) : null; + const resolved = resolveThreadCodexState({ + workspaceId, + threadId, + defaultAccessMode: appSettings.defaultAccessMode, + lastComposerModelId: appSettings.lastComposerModelId, + lastComposerReasoningEffort: appSettings.lastComposerReasoningEffort, + stored, + pendingSeed: pendingNewThreadSeedRef.current, + }); + + setThreadCodexSelectionKey(resolved.scopeKey); + setAccessMode(resolved.accessMode); + setPreferredModelId(resolved.preferredModelId); + setPreferredEffort(resolved.preferredEffort); + setPreferredCollabModeId(resolved.preferredCollabModeId); + }, [ + activeThreadId, + activeWorkspaceId, + appSettings.defaultAccessMode, + appSettings.lastComposerModelId, + appSettings.lastComposerReasoningEffort, + getThreadCodexParams, + setPreferredCollabModeId, + setPreferredEffort, + setPreferredModelId, + setThreadCodexSelectionKey, + threadCodexParamsVersion, + ]); + + const seededThreadParamsRef = useRef(new Set()); + useEffect(() => { + const workspaceId = activeWorkspaceId ?? null; + const threadId = activeThreadId ?? null; + if (!workspaceId || !threadId) { + return; + } + + const key = makeThreadCodexParamsKey(workspaceId, threadId); + if (seededThreadParamsRef.current.has(key)) { + return; + } + + const stored = getThreadCodexParams(workspaceId, threadId); + if (stored) { + seededThreadParamsRef.current.add(key); + return; + } + + seededThreadParamsRef.current.add(key); + const pendingSeed = pendingNewThreadSeedRef.current; + patchThreadCodexParams( + workspaceId, + threadId, + buildThreadCodexSeedPatch({ + workspaceId, + selectedModelId, + resolvedEffort, + accessMode, + selectedCollaborationModeId, + pendingSeed, + }), + ); + if (pendingSeed?.workspaceId === workspaceId) { + pendingNewThreadSeedRef.current = null; + } + }, [ + activeThreadId, + activeWorkspaceId, + accessMode, + getThreadCodexParams, + patchThreadCodexParams, + resolvedEffort, + selectedCollaborationModeId, + selectedModelId, + ]); + const { handleSetThreadListSortKey, handleRefreshAllWorkspaceThreads } = useThreadListActions({ threadListSortKey, @@ -758,11 +951,7 @@ function MainApp() { activeWorkspaceId, activeThreadId, }); - const activeThreadIdRef = useRef(activeThreadId ?? null); const { getThreadRows } = useThreadRows(threadParentById); - useEffect(() => { - activeThreadIdRef.current = activeThreadId ?? null; - }, [activeThreadId]); const { recordPendingThreadLink } = useSystemNotificationThreadLinks({ hasLoadedWorkspaces: hasLoaded, @@ -1565,14 +1754,26 @@ function MainApp() { handleSend, queueMessage, }); + + const rememberPendingNewThreadSeed = useCallback(() => { + pendingNewThreadSeedRef.current = createPendingThreadSeed({ + activeThreadId: activeThreadId ?? null, + activeWorkspaceId: activeWorkspaceId ?? null, + selectedCollaborationModeId, + accessMode, + }); + }, [accessMode, activeThreadId, activeWorkspaceId, selectedCollaborationModeId]); + const handleComposerSendWithDraftStart = useCallback( - (text: string, images: string[], appMentions?: AppMention[]) => - runWithDraftStart(() => ( + (text: string, images: string[], appMentions?: AppMention[]) => { + rememberPendingNewThreadSeed(); + return runWithDraftStart(() => ( appMentions && appMentions.length > 0 ? handleComposerSend(text, images, appMentions) : handleComposerSend(text, images) - )), - [handleComposerSend, runWithDraftStart], + )); + }, + [handleComposerSend, rememberPendingNewThreadSeed, runWithDraftStart], ); const handleComposerQueueWithDraftStart = useCallback( (text: string, images: string[], appMentions?: AppMention[]) => { @@ -1588,9 +1789,18 @@ function MainApp() { ? handleComposerSend(text, images, appMentions) : handleComposerSend(text, images) ); + if (!activeThreadId) { + rememberPendingNewThreadSeed(); + } return runWithDraftStart(runner); }, - [activeThreadId, handleComposerQueue, handleComposerSend, runWithDraftStart], + [ + activeThreadId, + handleComposerQueue, + handleComposerSend, + rememberPendingNewThreadSeed, + runWithDraftStart, + ], ); const handleSelectWorkspaceInstance = useCallback( @@ -2134,16 +2344,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/app/hooks/usePersistComposerSettings.ts b/src/features/app/hooks/usePersistComposerSettings.ts deleted file mode 100644 index be8321311..000000000 --- a/src/features/app/hooks/usePersistComposerSettings.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useEffect } from "react"; -import type { AppSettings } from "../../../types"; - -type Params = { - appSettingsLoading: boolean; - selectedModelId: string | null; - selectedEffort: string | null; - setAppSettings: (updater: (current: AppSettings) => AppSettings) => void; - queueSaveSettings: (next: AppSettings) => Promise; -}; - -export function usePersistComposerSettings({ - appSettingsLoading, - selectedModelId, - selectedEffort, - setAppSettings, - queueSaveSettings, -}: Params) { - useEffect(() => { - if (appSettingsLoading) { - return; - } - if (!selectedModelId && selectedEffort === null) { - return; - } - setAppSettings((current) => { - if ( - current.lastComposerModelId === selectedModelId && - current.lastComposerReasoningEffort === selectedEffort - ) { - return current; - } - const nextSettings = { - ...current, - lastComposerModelId: selectedModelId, - lastComposerReasoningEffort: selectedEffort, - }; - void queueSaveSettings(nextSettings); - return nextSettings; - }); - }, [ - appSettingsLoading, - queueSaveSettings, - selectedEffort, - selectedModelId, - setAppSettings, - ]); -} diff --git a/src/features/collaboration/hooks/useCollaborationModes.test.tsx b/src/features/collaboration/hooks/useCollaborationModes.test.tsx index 37ee58e36..f2d90168e 100644 --- a/src/features/collaboration/hooks/useCollaborationModes.test.tsx +++ b/src/features/collaboration/hooks/useCollaborationModes.test.tsx @@ -130,4 +130,139 @@ describe("useCollaborationModes", () => { ]), ); }); + + it("resets to the workspace default when selectionKey changes and preferredModeId is null", async () => { + vi.mocked(getCollaborationModes).mockResolvedValue(makeModesResponse()); + + const { result, rerender } = renderHook( + ({ + workspace, + enabled, + preferredModeId, + selectionKey, + }: { + workspace: WorkspaceInfo | null; + enabled: boolean; + preferredModeId: string | null; + selectionKey: string | null; + }) => + useCollaborationModes({ + activeWorkspace: workspace, + enabled, + preferredModeId, + selectionKey, + }), + { + initialProps: { + workspace: workspaceOne, + enabled: true, + preferredModeId: "default" as string | null, + selectionKey: "thread-a", + }, + }, + ); + + await waitFor(() => expect(result.current.selectedCollaborationModeId).toBe("default")); + + act(() => { + result.current.setSelectedCollaborationModeId("plan"); + }); + expect(result.current.selectedCollaborationModeId).toBe("plan"); + + // Thread switch with no stored override: preferredModeId is null. + rerender({ + workspace: workspaceOne, + enabled: true, + preferredModeId: null, + selectionKey: "thread-b", + }); + + expect(result.current.selectedCollaborationModeId).toBe("default"); + }); + + it("falls back to the workspace default when the preferredModeId is stale", async () => { + vi.mocked(getCollaborationModes).mockResolvedValue(makeModesResponse()); + + const { result, rerender } = renderHook( + (props: { + enabled: boolean; + preferredModeId: string | null; + selectionKey: string; + }) => + useCollaborationModes({ + activeWorkspace: workspaceOne, + enabled: props.enabled, + preferredModeId: props.preferredModeId, + selectionKey: props.selectionKey, + }), + { + initialProps: { + enabled: true, + preferredModeId: "plan", + selectionKey: "thread-a", + }, + }, + ); + + await waitFor(() => { + expect(result.current.collaborationModes.length).toBeGreaterThan(0); + }); + expect(result.current.selectedCollaborationModeId).toBe("plan"); + + rerender({ + enabled: true, + preferredModeId: "stale-mode-id", + selectionKey: "thread-b", + }); + + await waitFor(() => { + expect(result.current.selectedCollaborationModeId).toBe("default"); + }); + }); + + it("reapplies preferred mode when collaboration is re-enabled on the same thread", async () => { + vi.mocked(getCollaborationModes).mockResolvedValue(makeModesResponse()); + + const { result, rerender } = renderHook( + (props: { + enabled: boolean; + preferredModeId: string | null; + selectionKey: string; + }) => + useCollaborationModes({ + activeWorkspace: workspaceOne, + enabled: props.enabled, + preferredModeId: props.preferredModeId, + selectionKey: props.selectionKey, + }), + { + initialProps: { + enabled: true, + preferredModeId: "plan", + selectionKey: "thread-a", + }, + }, + ); + + await waitFor(() => { + expect(result.current.selectedCollaborationModeId).toBe("plan"); + }); + + rerender({ + enabled: false, + preferredModeId: "plan", + selectionKey: "thread-a", + }); + expect(result.current.selectedCollaborationModeId).toBeNull(); + + rerender({ + enabled: true, + preferredModeId: "plan", + selectionKey: "thread-a", + }); + + await waitFor(() => { + expect(result.current.selectedCollaborationModeId).toBe("plan"); + }); + }); }); diff --git a/src/features/collaboration/hooks/useCollaborationModes.ts b/src/features/collaboration/hooks/useCollaborationModes.ts index 71ab17e73..bb241e1a2 100644 --- a/src/features/collaboration/hooks/useCollaborationModes.ts +++ b/src/features/collaboration/hooks/useCollaborationModes.ts @@ -9,12 +9,33 @@ import { getCollaborationModes } from "../../../services/tauri"; type UseCollaborationModesOptions = { activeWorkspace: WorkspaceInfo | null; enabled: boolean; + preferredModeId?: string | null; + selectionKey?: string | null; onDebug?: (entry: DebugEntry) => void; }; +function pickWorkspaceDefaultModeId(modes: CollaborationModeOption[]): string | null { + return ( + modes.find( + (mode) => + mode.id.trim().toLowerCase() === "default" || + mode.mode.trim().toLowerCase() === "default", + )?.id ?? + modes.find( + (mode) => + mode.id.trim().toLowerCase() === "code" || + mode.mode.trim().toLowerCase() === "code", + )?.id ?? + modes[0]?.id ?? + null + ); +} + export function useCollaborationModes({ activeWorkspace, enabled, + preferredModeId = null, + selectionKey = null, onDebug, }: UseCollaborationModesOptions) { const [modes, setModes] = useState([]); @@ -23,6 +44,8 @@ export function useCollaborationModes({ const previousWorkspaceId = useRef(null); const inFlight = useRef(false); const selectedModeIdRef = useRef(null); + const lastSelectionKey = useRef(null); + const lastEnabled = useRef(enabled); const workspaceId = activeWorkspace?.id ?? null; const isConnected = Boolean(activeWorkspace?.connected); @@ -136,26 +159,14 @@ export function useCollaborationModes({ .filter((mode): mode is CollaborationModeOption => mode !== null); setModes(data); lastFetchedWorkspaceId.current = workspaceId; - const preferredModeId = - data.find( - (mode) => - mode.id.trim().toLowerCase() === "default" || - mode.mode.trim().toLowerCase() === "default", - )?.id ?? - data.find( - (mode) => - mode.id.trim().toLowerCase() === "code" || - mode.mode.trim().toLowerCase() === "code", - )?.id ?? - data[0]?.id ?? - null; + const workspaceDefaultModeId = pickWorkspaceDefaultModeId(data); setSelectedModeId((currentSelection) => { const selection = currentSelection ?? selectedModeIdRef.current; if (!selection) { - return preferredModeId; + return workspaceDefaultModeId; } if (!data.some((mode) => mode.id === selection)) { - return preferredModeId; + return workspaceDefaultModeId; } return selection; }); @@ -176,6 +187,33 @@ export function useCollaborationModes({ selectedModeIdRef.current = selectedModeId; }, [selectedModeId]); + useEffect(() => { + const wasEnabled = lastEnabled.current; + lastEnabled.current = enabled; + if (!enabled) { + return; + } + const enabledJustReenabled = !wasEnabled; + if (!enabledJustReenabled && selectionKey === lastSelectionKey.current) { + return; + } + lastSelectionKey.current = selectionKey; + // When switching threads, prefer the per-thread override. If there is no stored override, + // reset to the workspace default instead of carrying over the previous thread's selection. + // Also validate that a stored override still exists; otherwise fall back to the workspace default + // so collaboration payload generation remains enabled. + setSelectedModeId(() => { + if (!modes.length) { + // If modes aren't loaded yet, keep the preferred ID (if any) until refresh validates it. + return preferredModeId; + } + if (preferredModeId && modes.some((mode) => mode.id === preferredModeId)) { + return preferredModeId; + } + return pickWorkspaceDefaultModeId(modes); + }); + }, [enabled, modes, 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..4668c1d8b 100644 --- a/src/features/models/hooks/useModels.ts +++ b/src/features/models/hooks/useModels.ts @@ -1,24 +1,21 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { DebugEntry, ModelOption, WorkspaceInfo } from "../../../types"; import { getConfigModel, getModelList } from "../../../services/tauri"; +import { + normalizeEffortValue, + parseModelListResponse, +} from "../utils/modelListResponse"; type UseModelsOptions = { activeWorkspace: WorkspaceInfo | null; onDebug?: (entry: DebugEntry) => void; preferredModelId?: string | null; preferredEffort?: string | null; + selectionKey?: string | null; }; const CONFIG_MODEL_DESCRIPTION = "Configured in CODEX_HOME/config.toml"; -const normalizeEffort = (value: unknown): string | null => { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -}; - const findModelByIdOrModel = ( models: ModelOption[], idOrModel: string | null, @@ -44,6 +41,7 @@ export function useModels({ onDebug, preferredModelId = null, preferredEffort = null, + selectionKey = null, }: UseModelsOptions) { const [models, setModels] = useState([]); const [configModel, setConfigModel] = useState(null); @@ -54,10 +52,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; @@ -111,7 +119,7 @@ export function useModels({ if (supported && supported.length > 0) { return supported; } - const defaultEffort = normalizeEffort(selectedModel?.defaultReasoningEffort); + const defaultEffort = normalizeEffortValue(selectedModel?.defaultReasoningEffort); return defaultEffort ? [defaultEffort] : []; }, [selectedModel]); @@ -120,18 +128,18 @@ export function useModels({ const supportedEfforts = model.supportedReasoningEfforts.map( (effort) => effort.reasoningEffort, ); - const currentEffort = normalizeEffort(selectedEffort); + const currentEffort = normalizeEffortValue(selectedEffort); if (preferCurrent && currentEffort) { return currentEffort; } if (supportedEfforts.length === 0) { - return normalizeEffort(preferredEffort); + return normalizeEffortValue(preferredEffort); } - const preferred = normalizeEffort(preferredEffort); + const preferred = normalizeEffortValue(preferredEffort); if (preferred && supportedEfforts.includes(preferred)) { return preferred; } - return normalizeEffort(model.defaultReasoningEffort); + return normalizeEffortValue(model.defaultReasoningEffort); }, [preferredEffort, selectedEffort], ); @@ -194,27 +202,7 @@ export function useModels({ payload: response, }); setConfigModel(configModelFromConfig); - const rawData = response?.result?.data ?? response?.data ?? []; - const dataFromServer: ModelOption[] = rawData.map((item: any) => ({ - id: String(item.id ?? item.model ?? ""), - model: String(item.model ?? item.id ?? ""), - displayName: String(item.displayName ?? item.display_name ?? item.model ?? ""), - description: String(item.description ?? ""), - supportedReasoningEfforts: Array.isArray(item.supportedReasoningEfforts) - ? item.supportedReasoningEfforts - : Array.isArray(item.supported_reasoning_efforts) - ? item.supported_reasoning_efforts.map((effort: any) => ({ - reasoningEffort: String( - effort.reasoningEffort ?? effort.reasoning_effort ?? "", - ), - description: String(effort.description ?? ""), - })) - : [], - defaultReasoningEffort: normalizeEffort( - item.defaultReasoningEffort ?? item.default_reasoning_effort, - ), - isDefault: Boolean(item.isDefault ?? item.is_default ?? false), - })); + const dataFromServer: ModelOption[] = parseModelListResponse(response); const data = (() => { if (!configModelFromConfig) { return dataFromServer; @@ -290,11 +278,11 @@ export function useModels({ if (!selectedModel) { return; } - const currentEffort = normalizeEffort(selectedEffort); + const currentEffort = normalizeEffortValue(selectedEffort); if (currentEffort) { return; } - const nextEffort = normalizeEffort(selectedModel.defaultReasoningEffort); + const nextEffort = normalizeEffortValue(selectedModel.defaultReasoningEffort); if (nextEffort === null) { return; } diff --git a/src/features/models/utils/modelListResponse.ts b/src/features/models/utils/modelListResponse.ts new file mode 100644 index 000000000..a5065a73c --- /dev/null +++ b/src/features/models/utils/modelListResponse.ts @@ -0,0 +1,97 @@ +import type { ModelOption } from "../../../types"; + +export function normalizeEffortValue(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function extractModelItems(response: unknown): unknown[] { + if (!response || typeof response !== "object") { + return []; + } + + const record = response as Record; + const result = + record.result && typeof record.result === "object" + ? (record.result as Record) + : null; + + const resultData = result?.data; + if (Array.isArray(resultData)) { + return resultData; + } + + const topLevelData = record.data; + if (Array.isArray(topLevelData)) { + return topLevelData; + } + + return []; +} + +function parseReasoningEfforts(item: Record): ModelOption["supportedReasoningEfforts"] { + const camel = item.supportedReasoningEfforts; + if (Array.isArray(camel)) { + return camel + .map((effort) => { + if (!effort || typeof effort !== "object") { + return null; + } + const entry = effort as Record; + return { + reasoningEffort: String(entry.reasoningEffort ?? entry.reasoning_effort ?? ""), + description: String(entry.description ?? ""), + }; + }) + .filter((effort): effort is { reasoningEffort: string; description: string } => + effort !== null, + ); + } + + const snake = item.supported_reasoning_efforts; + if (Array.isArray(snake)) { + return snake + .map((effort) => { + if (!effort || typeof effort !== "object") { + return null; + } + const entry = effort as Record; + return { + reasoningEffort: String(entry.reasoningEffort ?? entry.reasoning_effort ?? ""), + description: String(entry.description ?? ""), + }; + }) + .filter((effort): effort is { reasoningEffort: string; description: string } => + effort !== null, + ); + } + + return []; +} + +export function parseModelListResponse(response: unknown): ModelOption[] { + const items = extractModelItems(response); + + return items + .map((item) => { + if (!item || typeof item !== "object") { + return null; + } + const record = item as Record; + return { + id: String(record.id ?? record.model ?? ""), + model: String(record.model ?? record.id ?? ""), + displayName: String(record.displayName ?? record.display_name ?? record.model ?? ""), + description: String(record.description ?? ""), + supportedReasoningEfforts: parseReasoningEfforts(record), + defaultReasoningEffort: normalizeEffortValue( + record.defaultReasoningEffort ?? record.default_reasoning_effort, + ), + isDefault: Boolean(record.isDefault ?? record.is_default ?? false), + } satisfies ModelOption; + }) + .filter((model): model is ModelOption => model !== null); +} diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index f3fce3b6a..4db0952c4 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 { DEFAULT_COMMIT_MESSAGE_PROMPT } from "../../../utils/commitMessagePrompt"; import { SettingsView } from "./SettingsView"; @@ -19,6 +20,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, @@ -1255,6 +1268,216 @@ 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: [ + { reasoningEffort: "low", description: "" }, + { reasoningEffort: "medium", description: "" }, + { reasoningEffort: "high", description: "" }, + ], + defaultReasoningEffort: "medium", + isDefault: false, + }, + { + id: "gpt-5.1", + model: "gpt-5.1", + displayName: "GPT-5.1", + description: "", + supportedReasoningEfforts: [ + { reasoningEffort: "low", description: "" }, + { reasoningEffort: "medium", description: "" }, + { reasoningEffort: "high", description: "" }, + ], + defaultReasoningEffort: "medium", + isDefault: false, + }, + ]), + ); + + render( + , + ); + + const modelSelect = screen.getByLabelText("Model") as HTMLSelectElement; + const effortSelect = screen.getByLabelText( + "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(within(effortSelect).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: [ + { reasoningEffort: "low", description: "" }, + { reasoningEffort: "medium", description: "" }, + { reasoningEffort: "high", description: "" }, + ], + defaultReasoningEffort: "medium", + isDefault: false, + }, + { + id: "gpt-5.1", + model: "gpt-5.1", + displayName: "GPT-5.1", + description: "", + supportedReasoningEfforts: [ + { reasoningEffort: "low", description: "" }, + { reasoningEffort: "medium", description: "" }, + { reasoningEffort: "high", description: "" }, + ], + defaultReasoningEffort: "medium", + isDefault: false, + }, + ]), + ); + + render( + , + ); + + const modelSelect = screen.getByLabelText("Model") as HTMLSelectElement; + const effortSelect = screen.getByLabelText( + "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 104376758..b4f67d3ac 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -39,6 +39,7 @@ import { import { DEFAULT_COMMIT_MESSAGE_PROMPT } from "../../../utils/commitMessagePrompt"; 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"; @@ -354,6 +355,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], @@ -1549,6 +1557,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..2f12cb63e 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,58 @@ const normalizeOverrideValue = (value: string): string | null => { return trimmed ? trimmed : null; }; +const DEFAULT_REASONING_EFFORT = "medium"; + +const normalizeEffortValue = (value: unknown): string | null => { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed.toLowerCase() : 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; +} + +const getReasoningSupport = (model: ModelOption | null): boolean => { + if (!model) { + return false; + } + return model.supportedReasoningEfforts.length > 0 || model.defaultReasoningEffort !== null; +}; + +const getReasoningOptions = (model: ModelOption | null): string[] => { + if (!model) { + return []; + } + const supported = model.supportedReasoningEfforts + .map((effort) => normalizeEffortValue(effort.reasoningEffort)) + .filter((effort): effort is string => Boolean(effort)); + if (supported.length > 0) { + return Array.from(new Set(supported)); + } + const fallback = normalizeEffortValue(model.defaultReasoningEffort); + return fallback ? [fallback] : []; +}; + export function SettingsCodexSection({ appSettings, onUpdateAppSettings, + defaultModels, + defaultModelsLoading, + defaultModelsError, + defaultModelsConnectedWorkspaceCount, + onRefreshDefaultModels, codexPathDraft, codexArgsDraft, codexDirty, @@ -113,6 +169,87 @@ 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 selectedModel = useMemo( + () => defaultModels.find((model) => model.model === selectedModelSlug) ?? null, + [defaultModels, selectedModelSlug], + ); + const reasoningSupported = useMemo( + () => getReasoningSupport(selectedModel), + [selectedModel], + ); + const reasoningOptions = useMemo( + () => getReasoningOptions(selectedModel), + [selectedModel], + ); + const savedEffort = useMemo( + () => normalizeEffortValue(appSettings.lastComposerReasoningEffort), + [appSettings.lastComposerReasoningEffort], + ); + const selectedEffort = useMemo(() => { + if (!reasoningSupported) { + return ""; + } + if (savedEffort && reasoningOptions.includes(savedEffort)) { + return savedEffort; + } + if (reasoningOptions.includes(DEFAULT_REASONING_EFFORT)) { + return DEFAULT_REASONING_EFFORT; + } + const fallback = normalizeEffortValue(selectedModel?.defaultReasoningEffort); + if (fallback && reasoningOptions.includes(fallback)) { + return fallback; + } + return reasoningOptions[0] ?? ""; + }, [reasoningOptions, reasoningSupported, savedEffort, selectedModel]); + + 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 = + reasoningSupported && + (savedRawEffort.length === 0 || + savedEffort === null || + !reasoningOptions.includes(savedEffort)); + if (!shouldNormalizeModel && !shouldNormalizeEffort) { + didNormalizeDefaultsRef.current = true; + return; + } + + const next: AppSettings = { + ...appSettings, + lastComposerModelId: shouldNormalizeModel ? selectedModelSlug : appSettings.lastComposerModelId, + lastComposerReasoningEffort: shouldNormalizeEffort + ? selectedEffort + : appSettings.lastComposerReasoningEffort, + }; + didNormalizeDefaultsRef.current = true; + void onUpdateAppSettings(next); + }, [ + appSettings, + defaultModels.length, + onUpdateAppSettings, + reasoningOptions, + reasoningSupported, + savedEffort, + savedModelSlug, + selectedModelSlug, + selectedEffort, + ]); + return (
Codex
@@ -266,10 +403,99 @@ export function SettingsCodexSection({ )} -
- +
+
+ 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 + ? "Available options depend on the selected model." + : "The selected model does not expose reasoning effort options."} +
+
+ +
+ +
+
+ +
+ Used when there is no thread-specific override. +
+