From 5fe8a93be1322f387873ee3c67aa1e31aa22a02e Mon Sep 17 00:00:00 2001 From: Joshua Hughes Date: Tue, 17 Feb 2026 17:43:09 -0500 Subject: [PATCH 1/2] feat(git): add model selection for commit message generation Allow users to choose which model generates commit messages via a dropdown in the git diff panel. The selection persists in app settings and falls back to the workspace default when the chosen model is no longer available. --- .gitignore | 4 + src-tauri/src/bin/codex_monitor_daemon.rs | 15 ++- .../src/bin/codex_monitor_daemon/rpc/git.rs | 6 +- src-tauri/src/codex/mod.rs | 21 +++- src-tauri/src/shared/codex_aux_core.rs | 9 +- src-tauri/src/types.rs | 3 + src/App.tsx | 13 +++ .../hooks/useCommitMessageModelSelection.ts | 54 ++++++++++ .../app/hooks/useGitCommitController.ts | 6 +- src/features/git/components/GitDiffPanel.tsx | 11 ++- .../components/GitDiffPanelModeContent.tsx | 85 +++++++++++++++- .../utils/commitMessageModelSelection.test.ts | 67 +++++++++++++ .../git/utils/commitMessageModelSelection.ts | 40 ++++++++ .../hooks/layoutNodes/buildGitNodes.tsx | 3 + .../layout/hooks/layoutNodes/types.ts | 2 + .../models/utils/modelListResponse.test.ts | 98 +++++++++++++++++++ .../models/utils/modelListResponse.ts | 32 +++++- .../settings/components/SettingsView.test.tsx | 1 + src/features/settings/hooks/useAppSettings.ts | 1 + src/services/tauri.ts | 3 +- src/styles/diff.css | 30 ++++++ src/types.ts | 1 + 22 files changed, 489 insertions(+), 16 deletions(-) create mode 100644 src/features/app/hooks/useCommitMessageModelSelection.ts create mode 100644 src/features/git/utils/commitMessageModelSelection.test.ts create mode 100644 src/features/git/utils/commitMessageModelSelection.ts create mode 100644 src/features/models/utils/modelListResponse.test.ts diff --git a/.gitignore b/.gitignore index 246c31cac..6405f3c92 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,10 @@ dist-ssr CodexMonitor.zip .codex-worktrees/ .codexmonitor/ +.agent/ +.agents/ +.claude/ +.cursor/ public/assets/material-icons/ # Nix diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index b53f47cd9..47b061df4 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -1205,22 +1205,31 @@ impl DaemonState { codex_aux_core::codex_doctor_core(&self.app_settings, codex_bin, codex_args).await } - async fn generate_commit_message(&self, workspace_id: String) -> Result { + async fn generate_commit_message( + &self, + workspace_id: String, + commit_message_model_id: Option, + ) -> Result { let repo_root = git_ui_core::resolve_repo_root_for_workspace_core( &self.workspaces, workspace_id.clone(), ) .await?; let diff = git_ui_core::collect_workspace_diff_core(&repo_root)?; - let commit_message_prompt = { + let (commit_message_prompt, default_commit_message_model_id) = { let settings = self.app_settings.lock().await; - settings.commit_message_prompt.clone() + ( + settings.commit_message_prompt.clone(), + settings.commit_message_model_id.clone(), + ) }; + let commit_message_model_id = commit_message_model_id.or(default_commit_message_model_id); codex_aux_core::generate_commit_message_core( &self.sessions, workspace_id, &diff, &commit_message_prompt, + commit_message_model_id.as_deref(), |workspace_id, thread_id| { emit_background_thread_hide(&self.event_sink, workspace_id, thread_id); }, diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs index 0a719ac67..388819b37 100644 --- a/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs @@ -366,7 +366,11 @@ pub(super) async fn try_handle( Ok(value) => value, Err(err) => return Some(Err(err)), }; - let message = match state.generate_commit_message(workspace_id).await { + let commit_message_model_id = parse_optional_string(params, "commitMessageModelId"); + let message = match state + .generate_commit_message(workspace_id, commit_message_model_id) + .await + { Ok(value) => value, Err(err) => return Some(Err(err)), }; diff --git a/src-tauri/src/codex/mod.rs b/src-tauri/src/codex/mod.rs index 1db804cb1..b4af746c0 100644 --- a/src-tauri/src/codex/mod.rs +++ b/src-tauri/src/codex/mod.rs @@ -875,15 +875,25 @@ pub(crate) async fn get_config_model( #[tauri::command] pub(crate) async fn generate_commit_message( workspace_id: String, + commit_message_model_id: Option, state: State<'_, AppState>, app: AppHandle, ) -> Result { if remote_backend::is_remote_mode(&*state).await { + let commit_message_model_id = if commit_message_model_id.is_some() { + commit_message_model_id + } else { + let settings = state.app_settings.lock().await; + settings.commit_message_model_id.clone() + }; let value = remote_backend::call_remote( &*state, app, "generate_commit_message", - json!({ "workspaceId": workspace_id }), + json!({ + "workspaceId": workspace_id, + "commitMessageModelId": commit_message_model_id, + }), ) .await?; return serde_json::from_value(value).map_err(|err| err.to_string()); @@ -891,15 +901,20 @@ pub(crate) async fn generate_commit_message( let diff = crate::git::get_workspace_diff(&workspace_id, &state).await?; - let commit_message_prompt = { + let (commit_message_prompt, default_commit_message_model_id) = { let settings = state.app_settings.lock().await; - settings.commit_message_prompt.clone() + ( + settings.commit_message_prompt.clone(), + settings.commit_message_model_id.clone(), + ) }; + let commit_message_model_id = commit_message_model_id.or(default_commit_message_model_id); crate::shared::codex_aux_core::generate_commit_message_core( &state.sessions, workspace_id, &diff, &commit_message_prompt, + commit_message_model_id.as_deref(), |workspace_id, thread_id| { let _ = app.emit( "app-server-event", diff --git a/src-tauri/src/shared/codex_aux_core.rs b/src-tauri/src/shared/codex_aux_core.rs index 53e504bb0..1b1a2c0de 100644 --- a/src-tauri/src/shared/codex_aux_core.rs +++ b/src-tauri/src/shared/codex_aux_core.rs @@ -395,6 +395,7 @@ pub(crate) async fn run_background_prompt_core( sessions: &Mutex>>, workspace_id: String, prompt: String, + model: Option<&str>, on_hide_thread: F, timeout_error: &str, turn_error_fallback: &str, @@ -452,13 +453,16 @@ where callbacks.insert(thread_id.clone(), tx); } - let turn_params = json!({ + let mut turn_params = json!({ "threadId": thread_id, "input": [{ "type": "text", "text": prompt }], "cwd": session.entry.path, "approvalPolicy": "never", "sandboxPolicy": { "type": "readOnly" }, }); + if let Some(model_id) = model { + turn_params["model"] = json!(model_id); + } let turn_result = session.send_request("turn/start", turn_params).await; let turn_result = match turn_result { Ok(result) => result, @@ -545,6 +549,7 @@ pub(crate) async fn generate_commit_message_core( workspace_id: String, diff: &str, template: &str, + model: Option<&str>, on_hide_thread: F, ) -> Result where @@ -555,6 +560,7 @@ where sessions, workspace_id, prompt, + model, on_hide_thread, "Timeout waiting for commit message generation", "Unknown error during commit message generation", @@ -581,6 +587,7 @@ where sessions, workspace_id, metadata_prompt, + None, on_hide_thread, "Timeout waiting for metadata generation", "Unknown error during metadata generation", diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 12d4e3576..eec548865 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -533,6 +533,8 @@ pub(crate) struct AppSettings { rename = "commitMessagePrompt" )] pub(crate) commit_message_prompt: String, + #[serde(default, rename = "commitMessageModelId")] + pub(crate) commit_message_model_id: Option, #[serde( default = "default_system_notifications_enabled", rename = "systemNotificationsEnabled" @@ -1140,6 +1142,7 @@ impl Default for AppSettings { preload_git_diffs: default_preload_git_diffs(), git_diff_ignore_whitespace_changes: default_git_diff_ignore_whitespace_changes(), commit_message_prompt: default_commit_message_prompt(), + commit_message_model_id: None, collaboration_modes_enabled: true, steer_enabled: true, pause_queued_messages_when_response_required: diff --git a/src/App.tsx b/src/App.tsx index a0b7056ed..567e56ec7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -107,6 +107,7 @@ import { useWorkspaceLaunchScript } from "@app/hooks/useWorkspaceLaunchScript"; import { useWorkspaceLaunchScripts } from "@app/hooks/useWorkspaceLaunchScripts"; import { useWorktreeSetupScript } from "@app/hooks/useWorktreeSetupScript"; import { useGitCommitController } from "@app/hooks/useGitCommitController"; +import { useCommitMessageModelSelection } from "@app/hooks/useCommitMessageModelSelection"; import { WorkspaceHome } from "@/features/workspaces/components/WorkspaceHome"; import { MobileServerSetupWizard } from "@/features/mobile/components/MobileServerSetupWizard"; import { useMobileServerSetup } from "@/features/mobile/hooks/useMobileServerSetup"; @@ -428,6 +429,15 @@ function MainApp() { setAccessMode, persistThreadCodexParams, }); + const { + resolvedCommitMessageModelId, + onCommitMessageModelChange, + } = useCommitMessageModelSelection({ + models, + commitMessageModelId: appSettings.commitMessageModelId, + setAppSettings, + queueSaveSettings, + }); const composerShortcuts = { modelShortcut: appSettings.composerModelShortcut, @@ -1448,6 +1458,7 @@ function MainApp() { activeWorkspace, activeWorkspaceId, activeWorkspaceIdRef, + commitMessageModelId: resolvedCommitMessageModelId, gitStatus, refreshGitStatus, refreshGitLog, @@ -2186,6 +2197,8 @@ function MainApp() { commitMessageError, onCommitMessageChange: handleCommitMessageChange, onGenerateCommitMessage: handleGenerateCommitMessage, + commitMessageModelId: resolvedCommitMessageModelId, + onCommitMessageModelChange, onCommit: handleCommit, onCommitAndPush: handleCommitAndPush, onCommitAndSync: handleCommitAndSync, diff --git a/src/features/app/hooks/useCommitMessageModelSelection.ts b/src/features/app/hooks/useCommitMessageModelSelection.ts new file mode 100644 index 000000000..1d23694a6 --- /dev/null +++ b/src/features/app/hooks/useCommitMessageModelSelection.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useMemo } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import type { AppSettings, ModelOption } from "@/types"; +import { resolveCommitMessageModelSelection } from "@/features/git/utils/commitMessageModelSelection"; + +type UseCommitMessageModelSelectionOptions = { + models: ModelOption[]; + commitMessageModelId: string | null; + setAppSettings: Dispatch>; + queueSaveSettings: (next: AppSettings) => Promise; +}; + +type UseCommitMessageModelSelectionResult = { + resolvedCommitMessageModelId: string | null; + onCommitMessageModelChange: (id: string | null) => void; +}; + +export function useCommitMessageModelSelection({ + models, + commitMessageModelId, + setAppSettings, + queueSaveSettings, +}: UseCommitMessageModelSelectionOptions): UseCommitMessageModelSelectionResult { + const selection = useMemo( + () => resolveCommitMessageModelSelection(models, commitMessageModelId), + [models, commitMessageModelId], + ); + + const persistCommitMessageModelId = useCallback( + (id: string | null) => { + setAppSettings((current) => { + if (current.commitMessageModelId === id) { + return current; + } + const next = { ...current, commitMessageModelId: id }; + void queueSaveSettings(next); + return next; + }); + }, + [queueSaveSettings, setAppSettings], + ); + + useEffect(() => { + if (!selection.shouldNormalize) { + return; + } + persistCommitMessageModelId(selection.normalizedModelId); + }, [persistCommitMessageModelId, selection.normalizedModelId, selection.shouldNormalize]); + + return { + resolvedCommitMessageModelId: selection.resolvedModelId, + onCommitMessageModelChange: persistCommitMessageModelId, + }; +} diff --git a/src/features/app/hooks/useGitCommitController.ts b/src/features/app/hooks/useGitCommitController.ts index 9461f9aff..1c70b40a1 100644 --- a/src/features/app/hooks/useGitCommitController.ts +++ b/src/features/app/hooks/useGitCommitController.ts @@ -18,6 +18,7 @@ type GitCommitControllerOptions = { activeWorkspace: WorkspaceInfo | null; activeWorkspaceId: string | null; activeWorkspaceIdRef: RefObject; + commitMessageModelId: string | null; gitStatus: GitStatusState; refreshGitStatus: () => void; refreshGitLog?: () => void; @@ -53,6 +54,7 @@ export function useGitCommitController({ activeWorkspace, activeWorkspaceId, activeWorkspaceIdRef, + commitMessageModelId, gitStatus, refreshGitStatus, refreshGitLog, @@ -100,7 +102,7 @@ export function useGitCommitController({ setCommitMessageLoading(true); setCommitMessageError(null); try { - const message = await generateCommitMessage(workspaceId); + const message = await generateCommitMessage(workspaceId, commitMessageModelId); if (!shouldApplyCommitMessage(activeWorkspaceIdRef.current, workspaceId)) { return; } @@ -117,7 +119,7 @@ export function useGitCommitController({ setCommitMessageLoading(false); } } - }, [activeWorkspace, commitMessageLoading, activeWorkspaceIdRef]); + }, [activeWorkspace, commitMessageLoading, activeWorkspaceIdRef, commitMessageModelId]); useEffect(() => { setCommitMessage(""); diff --git a/src/features/git/components/GitDiffPanel.tsx b/src/features/git/components/GitDiffPanel.tsx index 179376e2b..9fa6f4330 100644 --- a/src/features/git/components/GitDiffPanel.tsx +++ b/src/features/git/components/GitDiffPanel.tsx @@ -1,4 +1,4 @@ -import type { GitHubIssue, GitHubPullRequest, GitLogEntry } from "../../../types"; +import type { GitHubIssue, GitHubPullRequest, GitLogEntry, ModelOption } from "../../../types"; import type { MouseEvent as ReactMouseEvent } from "react"; import { Menu, MenuItem } from "@tauri-apps/api/menu"; import { LogicalPosition } from "@tauri-apps/api/dpi"; @@ -127,6 +127,9 @@ type GitDiffPanelProps = { commitMessageError?: string | null; onCommitMessageChange?: (value: string) => void; onGenerateCommitMessage?: () => void | Promise; + models?: ModelOption[]; + commitMessageModelId?: string | null; + onCommitMessageModelChange?: (id: string | null) => void; // Git operations onCommit?: () => void | Promise; onCommitAndPush?: () => void | Promise; @@ -216,6 +219,9 @@ export function GitDiffPanel({ commitMessageError = null, onCommitMessageChange, onGenerateCommitMessage, + models = [], + commitMessageModelId = null, + onCommitMessageModelChange, onCommit, onCommitAndPush: _onCommitAndPush, onCommitAndSync: _onCommitAndSync, @@ -730,6 +736,9 @@ export function GitDiffPanel({ commitMessageLoading={commitMessageLoading} canGenerateCommitMessage={canGenerateCommitMessage} onGenerateCommitMessage={onGenerateCommitMessage} + models={models} + commitMessageModelId={commitMessageModelId} + onCommitMessageModelChange={onCommitMessageModelChange} stagedFiles={stagedFiles} unstagedFiles={unstagedFiles} commitLoading={commitLoading} diff --git a/src/features/git/components/GitDiffPanelModeContent.tsx b/src/features/git/components/GitDiffPanelModeContent.tsx index bb26d6805..443faad3e 100644 --- a/src/features/git/components/GitDiffPanelModeContent.tsx +++ b/src/features/git/components/GitDiffPanelModeContent.tsx @@ -1,10 +1,11 @@ -import type { GitHubIssue, GitHubPullRequest, GitLogEntry } from "../../../types"; +import type { GitHubIssue, GitHubPullRequest, GitLogEntry, ModelOption } from "../../../types"; import type { MouseEvent as ReactMouseEvent } from "react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; import ArrowLeftRight from "lucide-react/dist/esm/icons/arrow-left-right"; import ChevronDown from "lucide-react/dist/esm/icons/chevron-down"; import ChevronRight from "lucide-react/dist/esm/icons/chevron-right"; +import Cpu from "lucide-react/dist/esm/icons/cpu"; import Download from "lucide-react/dist/esm/icons/download"; import RotateCcw from "lucide-react/dist/esm/icons/rotate-ccw"; import RotateCw from "lucide-react/dist/esm/icons/rotate-cw"; @@ -14,6 +15,11 @@ import { MagicSparkleIcon, MagicSparkleLoaderIcon, } from "@/features/shared/components/MagicSparkleIcon"; +import { + PopoverMenuItem, + PopoverSurface, +} from "../../design-system/components/popover/PopoverPrimitives"; +import { useDismissibleMenu } from "../../app/hooks/useDismissibleMenu"; import type { GitPanelMode } from "../types"; import type { PerFileDiffGroup } from "../utils/perFileThreadDiffs"; import { @@ -324,6 +330,9 @@ type GitDiffModeContentProps = { commitMessageLoading: boolean; canGenerateCommitMessage: boolean; onGenerateCommitMessage?: () => void | Promise; + models: ModelOption[]; + commitMessageModelId: string | null; + onCommitMessageModelChange?: (id: string | null) => void; stagedFiles: DiffFile[]; unstagedFiles: DiffFile[]; commitLoading: boolean; @@ -380,6 +389,9 @@ export function GitDiffModeContent({ commitMessageLoading, canGenerateCommitMessage, onGenerateCommitMessage, + models, + commitMessageModelId, + onCommitMessageModelChange, stagedFiles, unstagedFiles, commitLoading, @@ -404,6 +416,20 @@ export function GitDiffModeContent({ onShowFileMenu, onDiffListClick, }: GitDiffModeContentProps) { + const [commitModelOpen, setCommitModelOpen] = useState(false); + const commitModelRef = useRef(null); + useDismissibleMenu({ + isOpen: commitModelOpen, + containerRef: commitModelRef, + onClose: () => setCommitModelOpen(false), + }); + const selectedCommitModel = commitMessageModelId + ? models.find((m) => m.model === commitMessageModelId) ?? null + : models.find((m) => m.isDefault) ?? models[0] ?? null; + const commitModelLabel = selectedCommitModel?.displayName?.trim() + || selectedCommitModel?.model?.trim() + || "Model"; + const normalizedGitRoot = normalizeRootPath(gitRoot); const missingRepo = isMissingRepo(error); const gitRootNotFound = isGitRootNotFound(error); @@ -546,6 +572,61 @@ export function GitDiffModeContent({ )} + {models.length > 0 && ( +
+
+ + +
+ {commitModelOpen && ( + + {models.map((m) => { + const isActive = commitMessageModelId + ? m.model === commitMessageModelId + : m === selectedCommitModel; + return ( + { + onCommitMessageModelChange?.(m.model); + setCommitModelOpen(false); + }} + icon={} + active={isActive} + > + {m.displayName?.trim() || m.model} + + ); + })} + + )} +
+ )} 0} diff --git a/src/features/git/utils/commitMessageModelSelection.test.ts b/src/features/git/utils/commitMessageModelSelection.test.ts new file mode 100644 index 000000000..fa26f1b47 --- /dev/null +++ b/src/features/git/utils/commitMessageModelSelection.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { ModelOption } from "@/types"; +import { resolveCommitMessageModelSelection } from "./commitMessageModelSelection"; + +const MODELS: ModelOption[] = [ + { + id: "m-1", + model: "gpt-5.1", + displayName: "GPT-5.1", + description: "", + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: false, + }, + { + id: "m-2", + model: "gpt-5.2", + displayName: "GPT-5.2", + description: "", + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: true, + }, +]; + +describe("resolveCommitMessageModelSelection", () => { + it("keeps null selection unchanged", () => { + expect(resolveCommitMessageModelSelection(MODELS, null)).toEqual({ + resolvedModelId: null, + normalizedModelId: null, + shouldNormalize: false, + }); + }); + + it("keeps explicit selection when it still exists", () => { + expect(resolveCommitMessageModelSelection(MODELS, "gpt-5.1")).toEqual({ + resolvedModelId: "gpt-5.1", + normalizedModelId: "gpt-5.1", + shouldNormalize: false, + }); + }); + + it("falls back to the default model when selected model disappears", () => { + expect(resolveCommitMessageModelSelection(MODELS, "gpt-4.1")).toEqual({ + resolvedModelId: "gpt-5.2", + normalizedModelId: "gpt-5.2", + shouldNormalize: true, + }); + }); + + it("falls back to first model when no default exists", () => { + const noDefault = MODELS.map((model) => ({ ...model, isDefault: false })); + expect(resolveCommitMessageModelSelection(noDefault, "gpt-4.1")).toEqual({ + resolvedModelId: "gpt-5.1", + normalizedModelId: "gpt-5.1", + shouldNormalize: true, + }); + }); + + it("normalizes to null when no models are available", () => { + expect(resolveCommitMessageModelSelection([], "gpt-4.1")).toEqual({ + resolvedModelId: null, + normalizedModelId: null, + shouldNormalize: true, + }); + }); +}); diff --git a/src/features/git/utils/commitMessageModelSelection.ts b/src/features/git/utils/commitMessageModelSelection.ts new file mode 100644 index 000000000..5a6e158d0 --- /dev/null +++ b/src/features/git/utils/commitMessageModelSelection.ts @@ -0,0 +1,40 @@ +import type { ModelOption } from "@/types"; + +export type CommitMessageModelSelection = { + resolvedModelId: string | null; + normalizedModelId: string | null; + shouldNormalize: boolean; +}; + +function findFallbackModelId(models: ModelOption[]): string | null { + return (models.find((model) => model.isDefault) ?? models[0] ?? null)?.model ?? null; +} + +export function resolveCommitMessageModelSelection( + models: ModelOption[], + commitMessageModelId: string | null, +): CommitMessageModelSelection { + if (commitMessageModelId === null) { + return { + resolvedModelId: null, + normalizedModelId: null, + shouldNormalize: false, + }; + } + + const hasSelectedModel = models.some((model) => model.model === commitMessageModelId); + if (hasSelectedModel) { + return { + resolvedModelId: commitMessageModelId, + normalizedModelId: commitMessageModelId, + shouldNormalize: false, + }; + } + + const fallbackModelId = findFallbackModelId(models); + return { + resolvedModelId: fallbackModelId, + normalizedModelId: fallbackModelId, + shouldNormalize: true, + }; +} diff --git a/src/features/layout/hooks/layoutNodes/buildGitNodes.tsx b/src/features/layout/hooks/layoutNodes/buildGitNodes.tsx index 71e83d349..5cd8d9116 100644 --- a/src/features/layout/hooks/layoutNodes/buildGitNodes.tsx +++ b/src/features/layout/hooks/layoutNodes/buildGitNodes.tsx @@ -142,6 +142,9 @@ export function buildGitNodes(options: LayoutNodesOptions): GitLayoutNodes { commitMessageError={options.commitMessageError} onCommitMessageChange={options.onCommitMessageChange} onGenerateCommitMessage={options.onGenerateCommitMessage} + models={options.models} + commitMessageModelId={options.commitMessageModelId} + onCommitMessageModelChange={options.onCommitMessageModelChange} onCommit={options.onCommit} onCommitAndPush={options.onCommitAndPush} onCommitAndSync={options.onCommitAndSync} diff --git a/src/features/layout/hooks/layoutNodes/types.ts b/src/features/layout/hooks/layoutNodes/types.ts index 9bb6647ca..e97c7d5ce 100644 --- a/src/features/layout/hooks/layoutNodes/types.ts +++ b/src/features/layout/hooks/layoutNodes/types.ts @@ -336,6 +336,8 @@ export type LayoutNodesOptions = { commitMessageError: string | null; onCommitMessageChange: (value: string) => void; onGenerateCommitMessage: () => void | Promise; + commitMessageModelId: string | null; + onCommitMessageModelChange: (id: string | null) => void; onCommit?: () => void | Promise; onCommitAndPush?: () => void | Promise; onCommitAndSync?: () => void | Promise; diff --git a/src/features/models/utils/modelListResponse.test.ts b/src/features/models/utils/modelListResponse.test.ts new file mode 100644 index 000000000..6af73c3fd --- /dev/null +++ b/src/features/models/utils/modelListResponse.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { formatModelSlug, parseModelListResponse } from "./modelListResponse"; + +describe("formatModelSlug", () => { + it("capitalizes plain segments", () => { + expect(formatModelSlug("codex-mini")).toBe("Codex-Mini"); + }); + + it("uppercases known acronyms", () => { + expect(formatModelSlug("gpt-5.3-codex")).toBe("GPT-5.3-Codex"); + }); + + it("leaves version-like segments unchanged", () => { + expect(formatModelSlug("gpt-5.1-codex-max")).toBe("GPT-5.1-Codex-Max"); + }); + + it("handles a version-only slug", () => { + expect(formatModelSlug("gpt-5.2")).toBe("GPT-5.2"); + }); + it("is case-insensitive for acronym detection", () => { + expect(formatModelSlug("GPT-5.3-codex")).toBe("GPT-5.3-Codex"); + expect(formatModelSlug("Gpt-5.3-codex")).toBe("GPT-5.3-Codex"); + }); + + it("returns empty string for non-string input", () => { + expect(formatModelSlug(null)).toBe(""); + expect(formatModelSlug(undefined)).toBe(""); + expect(formatModelSlug(42)).toBe(""); + }); + + it("returns empty string for blank strings", () => { + expect(formatModelSlug("")).toBe(""); + expect(formatModelSlug(" ")).toBe(""); + }); + + it("handles a single segment", () => { + expect(formatModelSlug("codex")).toBe("Codex"); + expect(formatModelSlug("gpt")).toBe("GPT"); + }); +}); + +describe("parseModelListResponse", () => { + it("uses displayName when present", () => { + const response = { + result: { + data: [ + { id: "m1", model: "gpt-5.3-codex-spark", displayName: "GPT-5.3-Codex-Spark" }, + ], + }, + }; + const [model] = parseModelListResponse(response); + expect(model.displayName).toBe("GPT-5.3-Codex-Spark"); + }); + + it("formats the slug when displayName is missing", () => { + const response = { + result: { + data: [{ id: "m1", model: "gpt-5.3-codex" }], + }, + }; + const [model] = parseModelListResponse(response); + expect(model.displayName).toBe("GPT-5.3-Codex"); + }); + + it("formats the slug when displayName is an empty string", () => { + const response = { + result: { + data: [{ id: "m1", model: "gpt-5.1-codex-mini", displayName: "" }], + }, + }; + const [model] = parseModelListResponse(response); + expect(model.displayName).toBe("GPT-5.1-Codex-Mini"); + }); + + it("formats the slug when displayName equals the model slug", () => { + const response = { + result: { + data: [{ id: "m1", model: "gpt-5.3-codex", displayName: "gpt-5.3-codex" }], + }, + }; + const [model] = parseModelListResponse(response); + expect(model.displayName).toBe("GPT-5.3-Codex"); + }); + + it("preserves displayName when it differs from the slug", () => { + const response = { + result: { + data: [ + { id: "m1", model: "gpt-5.3-codex-spark", displayName: "GPT-5.3-Codex-Spark" }, + { id: "m2", model: "gpt-5.2-codex", displayName: "gpt-5.2-codex" }, + ], + }, + }; + const models = parseModelListResponse(response); + expect(models[0].displayName).toBe("GPT-5.3-Codex-Spark"); + expect(models[1].displayName).toBe("GPT-5.2-Codex"); + }); +}); diff --git a/src/features/models/utils/modelListResponse.ts b/src/features/models/utils/modelListResponse.ts index a5065a73c..12161da91 100644 --- a/src/features/models/utils/modelListResponse.ts +++ b/src/features/models/utils/modelListResponse.ts @@ -1,5 +1,30 @@ import type { ModelOption } from "../../../types"; +const UPPERCASE_SEGMENTS = new Set(["gpt"]); + +/** + * Formats a model slug like "gpt-5.3-codex" into "GPT-5.3-Codex". + * Known acronyms are uppercased, version-like segments are left as-is, + * and everything else is capitalized. + */ +export function formatModelSlug(slug: unknown): string { + if (typeof slug !== "string" || !slug.trim()) { + return ""; + } + return slug + .split("-") + .map((segment) => { + if (UPPERCASE_SEGMENTS.has(segment.toLowerCase())) { + return segment.toUpperCase(); + } + if (/^\d/.test(segment)) { + return segment; + } + return segment.charAt(0).toUpperCase() + segment.slice(1); + }) + .join("-"); +} + export function normalizeEffortValue(value: unknown): string | null { if (typeof value !== "string") { return null; @@ -81,10 +106,13 @@ export function parseModelListResponse(response: unknown): ModelOption[] { return null; } const record = item as Record; + const modelSlug = String(record.model ?? record.id ?? ""); + const rawDisplayName = String(record.displayName || record.display_name || ""); + const hasCustomDisplayName = rawDisplayName !== "" && rawDisplayName !== modelSlug; return { id: String(record.id ?? record.model ?? ""), - model: String(record.model ?? record.id ?? ""), - displayName: String(record.displayName ?? record.display_name ?? record.model ?? ""), + model: modelSlug, + displayName: hasCustomDisplayName ? rawDisplayName : (formatModelSlug(modelSlug) || modelSlug), description: String(record.description ?? ""), supportedReasoningEfforts: parseReasoningEfforts(record), defaultReasoningEffort: normalizeEffortValue( diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index 53d7985eb..c411eb72d 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -116,6 +116,7 @@ const baseSettings: AppSettings = { preloadGitDiffs: true, gitDiffIgnoreWhitespaceChanges: false, commitMessagePrompt: DEFAULT_COMMIT_MESSAGE_PROMPT, + commitMessageModelId: null, collaborationModesEnabled: true, steerEnabled: true, pauseQueuedMessagesWhenResponseRequired: true, diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index f13d949e6..a4caa4b16 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -180,6 +180,7 @@ function buildDefaultSettings(): AppSettings { preloadGitDiffs: true, gitDiffIgnoreWhitespaceChanges: false, commitMessagePrompt: DEFAULT_COMMIT_MESSAGE_PROMPT, + commitMessageModelId: null, collaborationModesEnabled: true, steerEnabled: true, pauseQueuedMessagesWhenResponseRequired: true, diff --git a/src/services/tauri.ts b/src/services/tauri.ts index 4ee0ec368..5c86f78c0 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -1014,8 +1014,9 @@ export async function setThreadName( export async function generateCommitMessage( workspaceId: string, + commitMessageModelId: string | null, ): Promise { - return invoke("generate_commit_message", { workspaceId }); + return invoke("generate_commit_message", { workspaceId, commitMessageModelId }); } export type GeneratedAgentConfiguration = { diff --git a/src/styles/diff.css b/src/styles/diff.css index 0f79a2f85..2215baf19 100644 --- a/src/styles/diff.css +++ b/src/styles/diff.css @@ -1179,6 +1179,36 @@ padding: 2px 0; } +/* Commit message model picker */ +.commit-model-menu { + align-self: flex-start; +} + +.commit-model-menu .open-app-button { + font-size: 11px; +} + +.commit-model-menu .open-app-action { + padding: 4px 8px; +} + +.commit-model-menu .open-app-toggle { + padding: 4px 6px; +} + +.commit-model-menu .open-app-action:disabled, +.commit-model-menu .open-app-toggle:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.commit-model-dropdown { + left: 0; + right: auto; + min-width: 190px; + padding: 6px; +} + /* Commit button */ .commit-button-container { margin-top: 4px; diff --git a/src/types.ts b/src/types.ts index 13e08964e..521176e8b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -226,6 +226,7 @@ export type AppSettings = { preloadGitDiffs: boolean; gitDiffIgnoreWhitespaceChanges: boolean; commitMessagePrompt: string; + commitMessageModelId: string | null; collaborationModesEnabled: boolean; steerEnabled: boolean; pauseQueuedMessagesWhenResponseRequired: boolean; From 8bd315faebecf096d33fe2f6e40409833baf9101 Mon Sep 17 00:00:00 2001 From: Joshua Hughes Date: Thu, 19 Feb 2026 14:32:06 -0500 Subject: [PATCH 2/2] refactor: move model picker to Settings and fix runtime validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the commit-message model dropdown from the git diff panel to Settings > Git. Source the model list from the existing codex-section fetch to avoid duplicate getModelList fan-out. Replace the old normalize-and-persist hook with a pure runtime guard (effectiveCommitMessageModelId) that validates the saved preference against the active workspace's models without overwriting the persisted setting — so switching workspaces no longer silently clears the choice. --- src-tauri/src/bin/codex_monitor_daemon.rs | 8 +- src-tauri/src/codex/mod.rs | 14 +-- src-tauri/src/shared/codex_aux_core.rs | 1 + src/App.tsx | 19 ++--- .../hooks/useCommitMessageModelSelection.ts | 54 ------------ src/features/git/components/GitDiffPanel.tsx | 11 +-- .../components/GitDiffPanelModeContent.tsx | 85 +------------------ .../utils/commitMessageModelSelection.test.ts | 45 +++------- .../git/utils/commitMessageModelSelection.ts | 47 +++------- .../hooks/layoutNodes/buildGitNodes.tsx | 3 - .../layout/hooks/layoutNodes/types.ts | 2 - .../sections/SettingsGitSection.tsx | 34 +++++++- .../settings/hooks/useSettingsGitSection.ts | 6 +- .../hooks/useSettingsViewOrchestration.ts | 11 +-- src/styles/diff.css | 30 ------- 15 files changed, 79 insertions(+), 291 deletions(-) delete mode 100644 src/features/app/hooks/useCommitMessageModelSelection.ts diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 47b061df4..c9d8764a9 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -1216,14 +1216,10 @@ impl DaemonState { ) .await?; let diff = git_ui_core::collect_workspace_diff_core(&repo_root)?; - let (commit_message_prompt, default_commit_message_model_id) = { + let commit_message_prompt = { let settings = self.app_settings.lock().await; - ( - settings.commit_message_prompt.clone(), - settings.commit_message_model_id.clone(), - ) + settings.commit_message_prompt.clone() }; - let commit_message_model_id = commit_message_model_id.or(default_commit_message_model_id); codex_aux_core::generate_commit_message_core( &self.sessions, workspace_id, diff --git a/src-tauri/src/codex/mod.rs b/src-tauri/src/codex/mod.rs index b4af746c0..7996bab50 100644 --- a/src-tauri/src/codex/mod.rs +++ b/src-tauri/src/codex/mod.rs @@ -880,12 +880,6 @@ pub(crate) async fn generate_commit_message( app: AppHandle, ) -> Result { if remote_backend::is_remote_mode(&*state).await { - let commit_message_model_id = if commit_message_model_id.is_some() { - commit_message_model_id - } else { - let settings = state.app_settings.lock().await; - settings.commit_message_model_id.clone() - }; let value = remote_backend::call_remote( &*state, app, @@ -901,14 +895,10 @@ pub(crate) async fn generate_commit_message( let diff = crate::git::get_workspace_diff(&workspace_id, &state).await?; - let (commit_message_prompt, default_commit_message_model_id) = { + let commit_message_prompt = { let settings = state.app_settings.lock().await; - ( - settings.commit_message_prompt.clone(), - settings.commit_message_model_id.clone(), - ) + settings.commit_message_prompt.clone() }; - let commit_message_model_id = commit_message_model_id.or(default_commit_message_model_id); crate::shared::codex_aux_core::generate_commit_message_core( &state.sessions, workspace_id, diff --git a/src-tauri/src/shared/codex_aux_core.rs b/src-tauri/src/shared/codex_aux_core.rs index 1b1a2c0de..5e3186057 100644 --- a/src-tauri/src/shared/codex_aux_core.rs +++ b/src-tauri/src/shared/codex_aux_core.rs @@ -616,6 +616,7 @@ where sessions, workspace_id, prompt, + None, on_hide_thread, "Timeout waiting for agent configuration generation", "Unknown error during agent configuration generation", diff --git a/src/App.tsx b/src/App.tsx index 567e56ec7..a5a2e3c46 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -107,7 +107,7 @@ import { useWorkspaceLaunchScript } from "@app/hooks/useWorkspaceLaunchScript"; import { useWorkspaceLaunchScripts } from "@app/hooks/useWorkspaceLaunchScripts"; import { useWorktreeSetupScript } from "@app/hooks/useWorktreeSetupScript"; import { useGitCommitController } from "@app/hooks/useGitCommitController"; -import { useCommitMessageModelSelection } from "@app/hooks/useCommitMessageModelSelection"; +import { effectiveCommitMessageModelId } from "@/features/git/utils/commitMessageModelSelection"; import { WorkspaceHome } from "@/features/workspaces/components/WorkspaceHome"; import { MobileServerSetupWizard } from "@/features/mobile/components/MobileServerSetupWizard"; import { useMobileServerSetup } from "@/features/mobile/hooks/useMobileServerSetup"; @@ -429,15 +429,10 @@ function MainApp() { setAccessMode, persistThreadCodexParams, }); - const { - resolvedCommitMessageModelId, - onCommitMessageModelChange, - } = useCommitMessageModelSelection({ - models, - commitMessageModelId: appSettings.commitMessageModelId, - setAppSettings, - queueSaveSettings, - }); + const commitMessageModelId = useMemo( + () => effectiveCommitMessageModelId(models, appSettings.commitMessageModelId), + [models, appSettings.commitMessageModelId], + ); const composerShortcuts = { modelShortcut: appSettings.composerModelShortcut, @@ -1458,7 +1453,7 @@ function MainApp() { activeWorkspace, activeWorkspaceId, activeWorkspaceIdRef, - commitMessageModelId: resolvedCommitMessageModelId, + commitMessageModelId, gitStatus, refreshGitStatus, refreshGitLog, @@ -2197,8 +2192,6 @@ function MainApp() { commitMessageError, onCommitMessageChange: handleCommitMessageChange, onGenerateCommitMessage: handleGenerateCommitMessage, - commitMessageModelId: resolvedCommitMessageModelId, - onCommitMessageModelChange, onCommit: handleCommit, onCommitAndPush: handleCommitAndPush, onCommitAndSync: handleCommitAndSync, diff --git a/src/features/app/hooks/useCommitMessageModelSelection.ts b/src/features/app/hooks/useCommitMessageModelSelection.ts deleted file mode 100644 index 1d23694a6..000000000 --- a/src/features/app/hooks/useCommitMessageModelSelection.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { useCallback, useEffect, useMemo } from "react"; -import type { Dispatch, SetStateAction } from "react"; -import type { AppSettings, ModelOption } from "@/types"; -import { resolveCommitMessageModelSelection } from "@/features/git/utils/commitMessageModelSelection"; - -type UseCommitMessageModelSelectionOptions = { - models: ModelOption[]; - commitMessageModelId: string | null; - setAppSettings: Dispatch>; - queueSaveSettings: (next: AppSettings) => Promise; -}; - -type UseCommitMessageModelSelectionResult = { - resolvedCommitMessageModelId: string | null; - onCommitMessageModelChange: (id: string | null) => void; -}; - -export function useCommitMessageModelSelection({ - models, - commitMessageModelId, - setAppSettings, - queueSaveSettings, -}: UseCommitMessageModelSelectionOptions): UseCommitMessageModelSelectionResult { - const selection = useMemo( - () => resolveCommitMessageModelSelection(models, commitMessageModelId), - [models, commitMessageModelId], - ); - - const persistCommitMessageModelId = useCallback( - (id: string | null) => { - setAppSettings((current) => { - if (current.commitMessageModelId === id) { - return current; - } - const next = { ...current, commitMessageModelId: id }; - void queueSaveSettings(next); - return next; - }); - }, - [queueSaveSettings, setAppSettings], - ); - - useEffect(() => { - if (!selection.shouldNormalize) { - return; - } - persistCommitMessageModelId(selection.normalizedModelId); - }, [persistCommitMessageModelId, selection.normalizedModelId, selection.shouldNormalize]); - - return { - resolvedCommitMessageModelId: selection.resolvedModelId, - onCommitMessageModelChange: persistCommitMessageModelId, - }; -} diff --git a/src/features/git/components/GitDiffPanel.tsx b/src/features/git/components/GitDiffPanel.tsx index 9fa6f4330..179376e2b 100644 --- a/src/features/git/components/GitDiffPanel.tsx +++ b/src/features/git/components/GitDiffPanel.tsx @@ -1,4 +1,4 @@ -import type { GitHubIssue, GitHubPullRequest, GitLogEntry, ModelOption } from "../../../types"; +import type { GitHubIssue, GitHubPullRequest, GitLogEntry } from "../../../types"; import type { MouseEvent as ReactMouseEvent } from "react"; import { Menu, MenuItem } from "@tauri-apps/api/menu"; import { LogicalPosition } from "@tauri-apps/api/dpi"; @@ -127,9 +127,6 @@ type GitDiffPanelProps = { commitMessageError?: string | null; onCommitMessageChange?: (value: string) => void; onGenerateCommitMessage?: () => void | Promise; - models?: ModelOption[]; - commitMessageModelId?: string | null; - onCommitMessageModelChange?: (id: string | null) => void; // Git operations onCommit?: () => void | Promise; onCommitAndPush?: () => void | Promise; @@ -219,9 +216,6 @@ export function GitDiffPanel({ commitMessageError = null, onCommitMessageChange, onGenerateCommitMessage, - models = [], - commitMessageModelId = null, - onCommitMessageModelChange, onCommit, onCommitAndPush: _onCommitAndPush, onCommitAndSync: _onCommitAndSync, @@ -736,9 +730,6 @@ export function GitDiffPanel({ commitMessageLoading={commitMessageLoading} canGenerateCommitMessage={canGenerateCommitMessage} onGenerateCommitMessage={onGenerateCommitMessage} - models={models} - commitMessageModelId={commitMessageModelId} - onCommitMessageModelChange={onCommitMessageModelChange} stagedFiles={stagedFiles} unstagedFiles={unstagedFiles} commitLoading={commitLoading} diff --git a/src/features/git/components/GitDiffPanelModeContent.tsx b/src/features/git/components/GitDiffPanelModeContent.tsx index 443faad3e..bb26d6805 100644 --- a/src/features/git/components/GitDiffPanelModeContent.tsx +++ b/src/features/git/components/GitDiffPanelModeContent.tsx @@ -1,11 +1,10 @@ -import type { GitHubIssue, GitHubPullRequest, GitLogEntry, ModelOption } from "../../../types"; +import type { GitHubIssue, GitHubPullRequest, GitLogEntry } from "../../../types"; import type { MouseEvent as ReactMouseEvent } from "react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; import ArrowLeftRight from "lucide-react/dist/esm/icons/arrow-left-right"; import ChevronDown from "lucide-react/dist/esm/icons/chevron-down"; import ChevronRight from "lucide-react/dist/esm/icons/chevron-right"; -import Cpu from "lucide-react/dist/esm/icons/cpu"; import Download from "lucide-react/dist/esm/icons/download"; import RotateCcw from "lucide-react/dist/esm/icons/rotate-ccw"; import RotateCw from "lucide-react/dist/esm/icons/rotate-cw"; @@ -15,11 +14,6 @@ import { MagicSparkleIcon, MagicSparkleLoaderIcon, } from "@/features/shared/components/MagicSparkleIcon"; -import { - PopoverMenuItem, - PopoverSurface, -} from "../../design-system/components/popover/PopoverPrimitives"; -import { useDismissibleMenu } from "../../app/hooks/useDismissibleMenu"; import type { GitPanelMode } from "../types"; import type { PerFileDiffGroup } from "../utils/perFileThreadDiffs"; import { @@ -330,9 +324,6 @@ type GitDiffModeContentProps = { commitMessageLoading: boolean; canGenerateCommitMessage: boolean; onGenerateCommitMessage?: () => void | Promise; - models: ModelOption[]; - commitMessageModelId: string | null; - onCommitMessageModelChange?: (id: string | null) => void; stagedFiles: DiffFile[]; unstagedFiles: DiffFile[]; commitLoading: boolean; @@ -389,9 +380,6 @@ export function GitDiffModeContent({ commitMessageLoading, canGenerateCommitMessage, onGenerateCommitMessage, - models, - commitMessageModelId, - onCommitMessageModelChange, stagedFiles, unstagedFiles, commitLoading, @@ -416,20 +404,6 @@ export function GitDiffModeContent({ onShowFileMenu, onDiffListClick, }: GitDiffModeContentProps) { - const [commitModelOpen, setCommitModelOpen] = useState(false); - const commitModelRef = useRef(null); - useDismissibleMenu({ - isOpen: commitModelOpen, - containerRef: commitModelRef, - onClose: () => setCommitModelOpen(false), - }); - const selectedCommitModel = commitMessageModelId - ? models.find((m) => m.model === commitMessageModelId) ?? null - : models.find((m) => m.isDefault) ?? models[0] ?? null; - const commitModelLabel = selectedCommitModel?.displayName?.trim() - || selectedCommitModel?.model?.trim() - || "Model"; - const normalizedGitRoot = normalizeRootPath(gitRoot); const missingRepo = isMissingRepo(error); const gitRootNotFound = isGitRootNotFound(error); @@ -572,61 +546,6 @@ export function GitDiffModeContent({ )} - {models.length > 0 && ( -
-
- - -
- {commitModelOpen && ( - - {models.map((m) => { - const isActive = commitMessageModelId - ? m.model === commitMessageModelId - : m === selectedCommitModel; - return ( - { - onCommitMessageModelChange?.(m.model); - setCommitModelOpen(false); - }} - icon={} - active={isActive} - > - {m.displayName?.trim() || m.model} - - ); - })} - - )} -
- )} 0} diff --git a/src/features/git/utils/commitMessageModelSelection.test.ts b/src/features/git/utils/commitMessageModelSelection.test.ts index fa26f1b47..509288693 100644 --- a/src/features/git/utils/commitMessageModelSelection.test.ts +++ b/src/features/git/utils/commitMessageModelSelection.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ModelOption } from "@/types"; -import { resolveCommitMessageModelSelection } from "./commitMessageModelSelection"; +import { effectiveCommitMessageModelId } from "./commitMessageModelSelection"; const MODELS: ModelOption[] = [ { @@ -23,45 +23,20 @@ const MODELS: ModelOption[] = [ }, ]; -describe("resolveCommitMessageModelSelection", () => { - it("keeps null selection unchanged", () => { - expect(resolveCommitMessageModelSelection(MODELS, null)).toEqual({ - resolvedModelId: null, - normalizedModelId: null, - shouldNormalize: false, - }); +describe("effectiveCommitMessageModelId", () => { + it("passes through null when no model is saved", () => { + expect(effectiveCommitMessageModelId(MODELS, null)).toBeNull(); }); - it("keeps explicit selection when it still exists", () => { - expect(resolveCommitMessageModelSelection(MODELS, "gpt-5.1")).toEqual({ - resolvedModelId: "gpt-5.1", - normalizedModelId: "gpt-5.1", - shouldNormalize: false, - }); + it("returns the saved model when it exists in the workspace", () => { + expect(effectiveCommitMessageModelId(MODELS, "gpt-5.1")).toBe("gpt-5.1"); }); - it("falls back to the default model when selected model disappears", () => { - expect(resolveCommitMessageModelSelection(MODELS, "gpt-4.1")).toEqual({ - resolvedModelId: "gpt-5.2", - normalizedModelId: "gpt-5.2", - shouldNormalize: true, - }); + it("falls back to null when saved model is unavailable in the workspace", () => { + expect(effectiveCommitMessageModelId(MODELS, "gpt-4.1")).toBeNull(); }); - it("falls back to first model when no default exists", () => { - const noDefault = MODELS.map((model) => ({ ...model, isDefault: false })); - expect(resolveCommitMessageModelSelection(noDefault, "gpt-4.1")).toEqual({ - resolvedModelId: "gpt-5.1", - normalizedModelId: "gpt-5.1", - shouldNormalize: true, - }); - }); - - it("normalizes to null when no models are available", () => { - expect(resolveCommitMessageModelSelection([], "gpt-4.1")).toEqual({ - resolvedModelId: null, - normalizedModelId: null, - shouldNormalize: true, - }); + it("falls back to null when no models are available", () => { + expect(effectiveCommitMessageModelId([], "gpt-5.1")).toBeNull(); }); }); diff --git a/src/features/git/utils/commitMessageModelSelection.ts b/src/features/git/utils/commitMessageModelSelection.ts index 5a6e158d0..ef15e673f 100644 --- a/src/features/git/utils/commitMessageModelSelection.ts +++ b/src/features/git/utils/commitMessageModelSelection.ts @@ -1,40 +1,15 @@ import type { ModelOption } from "@/types"; -export type CommitMessageModelSelection = { - resolvedModelId: string | null; - normalizedModelId: string | null; - shouldNormalize: boolean; -}; - -function findFallbackModelId(models: ModelOption[]): string | null { - return (models.find((model) => model.isDefault) ?? models[0] ?? null)?.model ?? null; -} - -export function resolveCommitMessageModelSelection( +/** + * Returns the saved commit-message model ID when available for the active + * workspace, or `null` to let the backend fall back to the workspace default. + * + * This is a pure runtime guard — it never mutates the persisted setting. + */ +export function effectiveCommitMessageModelId( models: ModelOption[], - commitMessageModelId: string | null, -): CommitMessageModelSelection { - if (commitMessageModelId === null) { - return { - resolvedModelId: null, - normalizedModelId: null, - shouldNormalize: false, - }; - } - - const hasSelectedModel = models.some((model) => model.model === commitMessageModelId); - if (hasSelectedModel) { - return { - resolvedModelId: commitMessageModelId, - normalizedModelId: commitMessageModelId, - shouldNormalize: false, - }; - } - - const fallbackModelId = findFallbackModelId(models); - return { - resolvedModelId: fallbackModelId, - normalizedModelId: fallbackModelId, - shouldNormalize: true, - }; + savedModelId: string | null, +): string | null { + if (savedModelId == null) return null; + return models.some((m) => m.model === savedModelId) ? savedModelId : null; } diff --git a/src/features/layout/hooks/layoutNodes/buildGitNodes.tsx b/src/features/layout/hooks/layoutNodes/buildGitNodes.tsx index 5cd8d9116..71e83d349 100644 --- a/src/features/layout/hooks/layoutNodes/buildGitNodes.tsx +++ b/src/features/layout/hooks/layoutNodes/buildGitNodes.tsx @@ -142,9 +142,6 @@ export function buildGitNodes(options: LayoutNodesOptions): GitLayoutNodes { commitMessageError={options.commitMessageError} onCommitMessageChange={options.onCommitMessageChange} onGenerateCommitMessage={options.onGenerateCommitMessage} - models={options.models} - commitMessageModelId={options.commitMessageModelId} - onCommitMessageModelChange={options.onCommitMessageModelChange} onCommit={options.onCommit} onCommitAndPush={options.onCommitAndPush} onCommitAndSync={options.onCommitAndSync} diff --git a/src/features/layout/hooks/layoutNodes/types.ts b/src/features/layout/hooks/layoutNodes/types.ts index e97c7d5ce..9bb6647ca 100644 --- a/src/features/layout/hooks/layoutNodes/types.ts +++ b/src/features/layout/hooks/layoutNodes/types.ts @@ -336,8 +336,6 @@ export type LayoutNodesOptions = { commitMessageError: string | null; onCommitMessageChange: (value: string) => void; onGenerateCommitMessage: () => void | Promise; - commitMessageModelId: string | null; - onCommitMessageModelChange: (id: string | null) => void; onCommit?: () => void | Promise; onCommitAndPush?: () => void | Promise; onCommitAndSync?: () => void | Promise; diff --git a/src/features/settings/components/sections/SettingsGitSection.tsx b/src/features/settings/components/sections/SettingsGitSection.tsx index 8a6b1c166..38380a61b 100644 --- a/src/features/settings/components/sections/SettingsGitSection.tsx +++ b/src/features/settings/components/sections/SettingsGitSection.tsx @@ -1,8 +1,9 @@ -import type { AppSettings } from "@/types"; +import type { AppSettings, ModelOption } from "@/types"; type SettingsGitSectionProps = { appSettings: AppSettings; onUpdateAppSettings: (next: AppSettings) => Promise; + models: ModelOption[]; commitMessagePromptDraft: string; commitMessagePromptDirty: boolean; commitMessagePromptSaving: boolean; @@ -14,6 +15,7 @@ type SettingsGitSectionProps = { export function SettingsGitSection({ appSettings, onUpdateAppSettings, + models, commitMessagePromptDraft, commitMessagePromptDirty, commitMessagePromptSaving, @@ -103,6 +105,36 @@ export function SettingsGitSection({ + {models.length > 0 && ( +
+ +
+ The model used when generating commit messages. Leave on default to use the + workspace model. +
+ +
+ )} ); } diff --git a/src/features/settings/hooks/useSettingsGitSection.ts b/src/features/settings/hooks/useSettingsGitSection.ts index 1713ed1e4..e6b47c9d2 100644 --- a/src/features/settings/hooks/useSettingsGitSection.ts +++ b/src/features/settings/hooks/useSettingsGitSection.ts @@ -1,15 +1,17 @@ import { useCallback, useEffect, useState } from "react"; -import type { AppSettings } from "@/types"; +import type { AppSettings, ModelOption } from "@/types"; import { DEFAULT_COMMIT_MESSAGE_PROMPT } from "@utils/commitMessagePrompt"; type UseSettingsGitSectionArgs = { appSettings: AppSettings; onUpdateAppSettings: (next: AppSettings) => Promise; + models: ModelOption[]; }; export type SettingsGitSectionProps = { appSettings: AppSettings; onUpdateAppSettings: (next: AppSettings) => Promise; + models: ModelOption[]; commitMessagePromptDraft: string; commitMessagePromptDirty: boolean; commitMessagePromptSaving: boolean; @@ -21,6 +23,7 @@ export type SettingsGitSectionProps = { export const useSettingsGitSection = ({ appSettings, onUpdateAppSettings, + models, }: UseSettingsGitSectionArgs): SettingsGitSectionProps => { const [commitMessagePromptDraft, setCommitMessagePromptDraft] = useState( appSettings.commitMessagePrompt, @@ -74,6 +77,7 @@ export const useSettingsGitSection = ({ return { appSettings, onUpdateAppSettings, + models, commitMessagePromptDraft, commitMessagePromptDirty, commitMessagePromptSaving, diff --git a/src/features/settings/hooks/useSettingsViewOrchestration.ts b/src/features/settings/hooks/useSettingsViewOrchestration.ts index 52201598f..f1f9526b4 100644 --- a/src/features/settings/hooks/useSettingsViewOrchestration.ts +++ b/src/features/settings/hooks/useSettingsViewOrchestration.ts @@ -185,11 +185,6 @@ export function useSettingsViewOrchestration({ onTestSystemNotification, }); - const gitSectionProps = useSettingsGitSection({ - appSettings, - onUpdateAppSettings, - }); - const serverSectionProps = useSettingsServerSection({ appSettings, onUpdateAppSettings, @@ -206,6 +201,12 @@ export function useSettingsViewOrchestration({ onUpdateWorkspaceSettings, }); + const gitSectionProps = useSettingsGitSection({ + appSettings, + onUpdateAppSettings, + models: codexSectionProps.defaultModels, + }); + const featuresSectionProps = useSettingsFeaturesSection({ appSettings, featureWorkspaceId, diff --git a/src/styles/diff.css b/src/styles/diff.css index 2215baf19..0f79a2f85 100644 --- a/src/styles/diff.css +++ b/src/styles/diff.css @@ -1179,36 +1179,6 @@ padding: 2px 0; } -/* Commit message model picker */ -.commit-model-menu { - align-self: flex-start; -} - -.commit-model-menu .open-app-button { - font-size: 11px; -} - -.commit-model-menu .open-app-action { - padding: 4px 8px; -} - -.commit-model-menu .open-app-toggle { - padding: 4px 6px; -} - -.commit-model-menu .open-app-action:disabled, -.commit-model-menu .open-app-toggle:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.commit-model-dropdown { - left: 0; - right: auto; - min-width: 190px; - padding: 6px; -} - /* Commit button */ .commit-button-container { margin-top: 4px;