diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 0d125c083..c43240d92 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -182,6 +182,7 @@ impl DaemonState { let settings_path = config.data_dir.join("settings.json"); let workspaces = read_workspaces(&storage_path).unwrap_or_default(); let app_settings = read_settings(&settings_path).unwrap_or_default(); + settings_core::apply_default_codex_home_env(&app_settings, None); let daemon_mode = if config.orbit_url.is_some() { "orbit".to_string() } else { diff --git a/src-tauri/src/shared/codex_core.rs b/src-tauri/src/shared/codex_core.rs index 92c217aa5..051d7b1fe 100644 --- a/src-tauri/src/shared/codex_core.rs +++ b/src-tauri/src/shared/codex_core.rs @@ -108,7 +108,7 @@ pub(crate) async fn list_threads_core( "sortKey": sort_key, // Keep spawned sub-agent sessions visible in thread/list so UI refreshes // do not drop parent -> child sidebar relationships. - "sourceKinds": ["cli", "vscode", "subAgentThreadSpawn"] + "sourceKinds": ["cli", "vscode", "jetbrains", "subAgentThreadSpawn"] }); session.send_request("thread/list", params).await } diff --git a/src-tauri/src/shared/settings_core.rs b/src-tauri/src/shared/settings_core.rs index 1e42e26a2..81cc7a34d 100644 --- a/src-tauri/src/shared/settings_core.rs +++ b/src-tauri/src/shared/settings_core.rs @@ -47,6 +47,8 @@ pub(crate) async fn update_app_settings_core( app_settings: &Mutex, settings_path: &PathBuf, ) -> Result { + let previous = app_settings.lock().await.clone(); + apply_default_codex_home_env(&settings, Some(&previous)); let _ = codex_config::write_collab_enabled(settings.experimental_collab_enabled); let _ = codex_config::write_collaboration_modes_enabled(settings.collaboration_modes_enabled); let _ = codex_config::write_steer_enabled(settings.steer_enabled); @@ -59,6 +61,30 @@ pub(crate) async fn update_app_settings_core( Ok(settings) } +pub(crate) fn apply_default_codex_home_env( + settings: &AppSettings, + previous: Option<&AppSettings>, +) { + let next = settings + .default_codex_home + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if let Some(next) = next { + std::env::set_var("CODEX_HOME", next); + return; + } + + let should_clear = previous + .and_then(|prior| prior.default_codex_home.as_ref()) + .map(|value| value.trim()) + .is_some_and(|value| !value.is_empty()); + if should_clear { + std::env::remove_var("CODEX_HOME"); + } +} + pub(crate) async fn update_remote_backend_token_core( app_settings: &Mutex, settings_path: &PathBuf, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index beee51050..98f31e037 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -79,6 +79,7 @@ impl AppState { let settings_path = data_dir.join("settings.json"); let workspaces = read_workspaces(&storage_path).unwrap_or_default(); let app_settings = read_settings(&settings_path).unwrap_or_default(); + crate::shared::settings_core::apply_default_codex_home_env(&app_settings, None); Self { workspaces: Mutex::new(workspaces), sessions: Mutex::new(HashMap::new()), diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index 53c94300b..fbe4971a3 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -49,10 +49,32 @@ async fn get_terminal_session( .ok_or_else(|| "Terminal session not found".to_string()) } +#[cfg(target_os = "windows")] +fn shell_path() -> String { + std::env::var("COMSPEC").unwrap_or_else(|_| "powershell.exe".to_string()) +} + +#[cfg(not(target_os = "windows"))] fn shell_path() -> String { std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()) } +#[cfg(target_os = "windows")] +fn configure_shell_args(cmd: &mut CommandBuilder) { + let shell = shell_path().to_ascii_lowercase(); + if shell.contains("powershell") || shell.ends_with("pwsh.exe") { + cmd.arg("-NoLogo"); + cmd.arg("-NoExit"); + } else if shell.ends_with("cmd.exe") || shell.ends_with("\\cmd") { + cmd.arg("/K"); + } +} + +#[cfg(not(target_os = "windows"))] +fn configure_shell_args(cmd: &mut CommandBuilder) { + cmd.arg("-i"); +} + fn resolve_locale() -> String { let candidate = std::env::var("LC_ALL") .or_else(|_| std::env::var("LANG")) @@ -195,7 +217,7 @@ pub(crate) async fn terminal_open( let mut cmd = CommandBuilder::new(shell_path()); cmd.cwd(cwd); - cmd.arg("-i"); + configure_shell_args(&mut cmd); cmd.env("TERM", "xterm-256color"); let locale = resolve_locale(); cmd.env("LANG", &locale); diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 96d0088ea..d57071f43 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -445,6 +445,8 @@ pub(crate) struct AppSettings { pub(crate) codex_bin: Option, #[serde(default, rename = "codexArgs")] pub(crate) codex_args: Option, + #[serde(default, rename = "defaultCodexHome")] + pub(crate) default_codex_home: Option, #[serde(default, rename = "backendMode")] pub(crate) backend_mode: BackendMode, #[serde(default, rename = "remoteBackendProvider")] @@ -1150,6 +1152,7 @@ impl Default for AppSettings { Self { codex_bin: None, codex_args: None, + default_codex_home: None, backend_mode: default_backend_mode(), remote_backend_provider: RemoteBackendProvider::Tcp, remote_backend_host: default_remote_backend_host(), diff --git a/src/features/settings/components/sections/SettingsCodexSection.tsx b/src/features/settings/components/sections/SettingsCodexSection.tsx index 84ebdd243..4d9e38666 100644 --- a/src/features/settings/components/sections/SettingsCodexSection.tsx +++ b/src/features/settings/components/sections/SettingsCodexSection.tsx @@ -20,6 +20,7 @@ type SettingsCodexSectionProps = { onRefreshDefaultModels: () => void; codexPathDraft: string; codexArgsDraft: string; + codexHomeDraft: string; codexDirty: boolean; isSavingSettings: boolean; doctorState: { @@ -50,6 +51,7 @@ type SettingsCodexSectionProps = { codexArgsOverrideDrafts: Record; onSetCodexPathDraft: Dispatch>; onSetCodexArgsDraft: Dispatch>; + onSetCodexHomeDraft: Dispatch>; onSetGlobalAgentsContent: (value: string) => void; onSetGlobalConfigContent: (value: string) => void; onSetCodexBinOverrideDrafts: Dispatch>>; @@ -129,6 +131,7 @@ export function SettingsCodexSection({ onRefreshDefaultModels, codexPathDraft, codexArgsDraft, + codexHomeDraft, codexDirty, isSavingSettings, doctorState, @@ -153,6 +156,7 @@ export function SettingsCodexSection({ codexArgsOverrideDrafts, onSetCodexPathDraft, onSetCodexArgsDraft, + onSetCodexHomeDraft, onSetGlobalAgentsContent, onSetGlobalConfigContent, onSetCodexBinOverrideDrafts, @@ -286,6 +290,30 @@ export function SettingsCodexSection({
Leave empty to use the system PATH resolution.
+ +
+ onSetCodexHomeDraft(event.target.value)} + /> + +
+
+ Optional default for session discovery. Useful for IntelliJ Codex threads when they live + outside ~/.codex. Workspace CODEX_HOME overrides still take + priority. +
diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index 684d1e1a7..fdc150633 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -28,6 +28,7 @@ function buildDefaultSettings(): AppSettings { return { codexBin: null, codexArgs: null, + defaultCodexHome: null, backendMode: isMobile ? "remote" : "local", remoteBackendProvider: "tcp", remoteBackendHost: "127.0.0.1:4732", @@ -130,6 +131,9 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { ...settings, codexBin: settings.codexBin?.trim() ? settings.codexBin.trim() : null, codexArgs: settings.codexArgs?.trim() ? settings.codexArgs.trim() : null, + defaultCodexHome: settings.defaultCodexHome?.trim() + ? settings.defaultCodexHome.trim() + : null, uiScale: clampUiScale(settings.uiScale), theme: allowedThemes.has(settings.theme) ? settings.theme : "system", uiFontFamily: normalizeFontFamily( diff --git a/src/features/settings/hooks/useSettingsCodexSection.ts b/src/features/settings/hooks/useSettingsCodexSection.ts index d755c4303..5f098f45e 100644 --- a/src/features/settings/hooks/useSettingsCodexSection.ts +++ b/src/features/settings/hooks/useSettingsCodexSection.ts @@ -45,6 +45,7 @@ export type SettingsCodexSectionProps = { onRefreshDefaultModels: () => void; codexPathDraft: string; codexArgsDraft: string; + codexHomeDraft: string; codexDirty: boolean; isSavingSettings: boolean; doctorState: { @@ -75,6 +76,7 @@ export type SettingsCodexSectionProps = { codexArgsOverrideDrafts: Record; onSetCodexPathDraft: Dispatch>; onSetCodexArgsDraft: Dispatch>; + onSetCodexHomeDraft: Dispatch>; onSetGlobalAgentsContent: (value: string) => void; onSetGlobalConfigContent: (value: string) => void; onSetCodexBinOverrideDrafts: Dispatch>>; @@ -106,6 +108,7 @@ export const useSettingsCodexSection = ({ }: UseSettingsCodexSectionArgs): SettingsCodexSectionProps => { const [codexPathDraft, setCodexPathDraft] = useState(appSettings.codexBin ?? ""); const [codexArgsDraft, setCodexArgsDraft] = useState(appSettings.codexArgs ?? ""); + const [codexHomeDraft, setCodexHomeDraft] = useState(appSettings.defaultCodexHome ?? ""); const [codexBinOverrideDrafts, setCodexBinOverrideDrafts] = useState< Record >({}); @@ -183,6 +186,10 @@ export const useSettingsCodexSection = ({ setCodexArgsDraft(appSettings.codexArgs ?? ""); }, [appSettings.codexArgs]); + useEffect(() => { + setCodexHomeDraft(appSettings.defaultCodexHome ?? ""); + }, [appSettings.defaultCodexHome]); + useEffect(() => { setCodexBinOverrideDrafts((prev) => buildWorkspaceOverrideDrafts(projects, prev, (workspace) => workspace.codex_bin ?? null), @@ -205,9 +212,11 @@ export const useSettingsCodexSection = ({ const nextCodexBin = codexPathDraft.trim() ? codexPathDraft.trim() : null; const nextCodexArgs = codexArgsDraft.trim() ? codexArgsDraft.trim() : null; + const nextDefaultCodexHome = codexHomeDraft.trim() ? codexHomeDraft.trim() : null; const codexDirty = nextCodexBin !== (appSettings.codexBin ?? null) || - nextCodexArgs !== (appSettings.codexArgs ?? null); + nextCodexArgs !== (appSettings.codexArgs ?? null) || + nextDefaultCodexHome !== (appSettings.defaultCodexHome ?? null); const handleBrowseCodex = async () => { const selection = await open({ multiple: false, directory: false }); @@ -224,6 +233,7 @@ export const useSettingsCodexSection = ({ ...appSettings, codexBin: nextCodexBin, codexArgs: nextCodexArgs, + defaultCodexHome: nextDefaultCodexHome, }); } finally { setIsSavingSettings(false); @@ -304,6 +314,7 @@ export const useSettingsCodexSection = ({ }, codexPathDraft, codexArgsDraft, + codexHomeDraft, codexDirty, isSavingSettings, doctorState, @@ -328,6 +339,7 @@ export const useSettingsCodexSection = ({ codexArgsOverrideDrafts, onSetCodexPathDraft: setCodexPathDraft, onSetCodexArgsDraft: setCodexArgsDraft, + onSetCodexHomeDraft: setCodexHomeDraft, onSetGlobalAgentsContent: setGlobalAgentsContent, onSetGlobalConfigContent: setGlobalConfigContent, onSetCodexBinOverrideDrafts: setCodexBinOverrideDrafts, diff --git a/src/features/threads/hooks/useThreadActions.test.tsx b/src/features/threads/hooks/useThreadActions.test.tsx index 12a467940..0fddae407 100644 --- a/src/features/threads/hooks/useThreadActions.test.tsx +++ b/src/features/threads/hooks/useThreadActions.test.tsx @@ -632,6 +632,46 @@ describe("useThreadActions", () => { expect(updateThreadParent).toHaveBeenCalledWith("parent-thread", ["child-thread"]); }); + it("matches thread cwd on Windows paths even when drive-letter casing differs", async () => { + const windowsWorkspace: WorkspaceInfo = { + ...workspace, + path: "C:\\dev\\codexMon", + }; + vi.mocked(listThreads).mockResolvedValue({ + result: { + data: [ + { + id: "thread-win-1", + cwd: "c:/dev/codexMon", + preview: "Windows thread", + updated_at: 5000, + }, + ], + nextCursor: null, + }, + }); + vi.mocked(getThreadTimestamp).mockReturnValue(5000); + + const { result, dispatch } = renderActions(); + + await act(async () => { + await result.current.listThreadsForWorkspace(windowsWorkspace); + }); + + expect(dispatch).toHaveBeenCalledWith({ + type: "setThreads", + workspaceId: "ws-1", + sortKey: "updated_at", + threads: [ + { + id: "thread-win-1", + name: "Windows thread", + updatedAt: 5000, + }, + ], + }); + }); + it("preserves list state when requested", async () => { vi.mocked(listThreads).mockResolvedValue({ result: { diff --git a/src/features/threads/utils/threadNormalize.test.ts b/src/features/threads/utils/threadNormalize.test.ts index 5f1e83eb9..e49779305 100644 --- a/src/features/threads/utils/threadNormalize.test.ts +++ b/src/features/threads/utils/threadNormalize.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { normalizePlanUpdate } from "./threadNormalize"; +import { normalizePlanUpdate, normalizeRootPath } from "./threadNormalize"; describe("normalizePlanUpdate", () => { it("normalizes a plan when the payload uses an array", () => { @@ -29,3 +29,18 @@ describe("normalizePlanUpdate", () => { expect(normalizePlanUpdate("turn-3", "", { steps: [] })).toBeNull(); }); }); + +describe("normalizeRootPath", () => { + it("preserves significant leading and trailing whitespace", () => { + expect(normalizeRootPath(" /tmp/repo ")).toBe(" /tmp/repo "); + }); + + it("normalizes Windows drive-letter paths case-insensitively", () => { + expect(normalizeRootPath("C:\\Dev\\Repo\\")).toBe("c:/Dev/Repo"); + expect(normalizeRootPath("c:/Dev/Repo")).toBe("c:/Dev/Repo"); + }); + + it("normalizes UNC paths case-insensitively", () => { + expect(normalizeRootPath("\\\\SERVER\\Share\\Repo\\")).toBe("//server/share/repo"); + }); +}); diff --git a/src/features/threads/utils/threadNormalize.ts b/src/features/threads/utils/threadNormalize.ts index 87d257cff..4228134cd 100644 --- a/src/features/threads/utils/threadNormalize.ts +++ b/src/features/threads/utils/threadNormalize.ts @@ -33,7 +33,17 @@ export function normalizeStringList(value: unknown) { } export function normalizeRootPath(value: string) { - return value.replace(/\\/g, "/").replace(/\/+$/, ""); + const normalized = value.replace(/\\/g, "/").replace(/\/+$/, ""); + if (!normalized) { + return ""; + } + if (/^[A-Za-z]:\//.test(normalized)) { + return `${normalized.charAt(0).toLowerCase()}${normalized.slice(1)}`; + } + if (normalized.startsWith("//")) { + return normalized.toLowerCase(); + } + return normalized; } export function extractRpcErrorMessage(response: unknown) { diff --git a/src/types.ts b/src/types.ts index 1cb08b092..95d59b827 100644 --- a/src/types.ts +++ b/src/types.ts @@ -173,6 +173,7 @@ export type OpenAppTarget = { export type AppSettings = { codexBin: string | null; codexArgs: string | null; + defaultCodexHome?: string | null; backendMode: BackendMode; remoteBackendProvider: RemoteBackendProvider; remoteBackendHost: string;