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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 77 additions & 1 deletion src-tauri/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,46 @@ 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(any(target_os = "windows", test))]
fn windows_shell_args(shell: &str) -> Vec<&'static str> {
let shell = shell.to_ascii_lowercase();
if shell.contains("powershell") || shell.ends_with("pwsh.exe") || shell.ends_with("\\pwsh") {
vec!["-NoLogo", "-NoExit"]
} else if shell.ends_with("cmd.exe") || shell.ends_with("\\cmd") {
vec!["/K"]
} else {
Vec::new()
}
}

fn unix_shell_args() -> Vec<&'static str> {
vec!["-i"]
}

#[cfg(target_os = "windows")]
fn configure_shell_args(cmd: &mut CommandBuilder) {
for arg in windows_shell_args(&shell_path()) {
cmd.arg(arg);
}
}

#[cfg(not(target_os = "windows"))]
fn configure_shell_args(cmd: &mut CommandBuilder) {
for arg in unix_shell_args() {
cmd.arg(arg);
}
}

fn resolve_locale() -> String {
let candidate = std::env::var("LC_ALL")
.or_else(|_| std::env::var("LANG"))
Expand Down Expand Up @@ -195,7 +231,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);
Expand Down Expand Up @@ -335,3 +371,43 @@ pub(crate) async fn terminal_close(
.await;
Ok(())
}

#[cfg(test)]
mod tests {
use super::{unix_shell_args, windows_shell_args};

#[test]
fn windows_shell_args_match_powershell_variants() {
assert_eq!(
windows_shell_args(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"),
vec!["-NoLogo", "-NoExit"]
);
assert_eq!(
windows_shell_args(r"C:\Program Files\PowerShell\7\pwsh.exe"),
vec!["-NoLogo", "-NoExit"]
);
assert_eq!(
windows_shell_args(r"C:\Program Files\PowerShell\7\PwSh"),
vec!["-NoLogo", "-NoExit"]
);
}

#[test]
fn windows_shell_args_match_cmd_variants() {
assert_eq!(
windows_shell_args(r"C:\Windows\System32\cmd.exe"),
vec!["/K"]
);
assert_eq!(windows_shell_args(r"C:\Windows\System32\CMD"), vec!["/K"]);
}

#[test]
fn windows_shell_args_are_empty_for_other_shells() {
assert!(windows_shell_args("nu.exe").is_empty());
}

#[test]
fn unix_shell_args_stay_interactive() {
assert_eq!(unix_shell_args(), vec!["-i"]);
}
}
85 changes: 85 additions & 0 deletions src/features/threads/hooks/useThreadActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,46 @@ describe("useThreadActions", () => {
expect(updateThreadParent).toHaveBeenCalledWith("parent-thread", ["child-thread"]);
});

it("matches thread cwd on Windows paths even when path 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: {
Expand Down Expand Up @@ -723,6 +763,51 @@ describe("useThreadActions", () => {
});
});

it("loads older threads for Windows paths even when path casing differs", async () => {
const windowsWorkspace: WorkspaceInfo = {
...workspace,
path: "C:\\Dev\\CodexMon",
};
vi.mocked(listThreads).mockResolvedValue({
result: {
data: [
{
id: "thread-win-older",
cwd: "c:/dev/codexmon",
preview: "Older windows preview",
updated_at: 4000,
},
],
nextCursor: null,
},
});
vi.mocked(getThreadTimestamp).mockImplementation((thread) => {
const value = (thread as Record<string, unknown>).updated_at as number;
return value ?? 0;
});

const { result, dispatch } = renderActions({
threadsByWorkspace: {
"ws-1": [{ id: "thread-1", name: "Agent 1", updatedAt: 6000 }],
},
threadListCursorByWorkspace: { "ws-1": "cursor-1" },
});

await act(async () => {
await result.current.loadOlderThreadsForWorkspace(windowsWorkspace);
});

expect(dispatch).toHaveBeenCalledWith({
type: "setThreads",
workspaceId: "ws-1",
sortKey: "updated_at",
threads: [
{ id: "thread-1", name: "Agent 1", updatedAt: 6000 },
{ id: "thread-win-older", name: "Older windows preview", updatedAt: 4000 },
],
});
});

it("archives threads and reports errors", async () => {
vi.mocked(archiveThread).mockRejectedValue(new Error("nope"));
const onDebug = vi.fn();
Expand Down
17 changes: 16 additions & 1 deletion src/features/threads/utils/threadNormalize.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
12 changes: 11 additions & 1 deletion src/features/threads/utils/threadNormalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.toLowerCase();
}
if (normalized.startsWith("//")) {
return normalized.toLowerCase();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve case in UNC subpaths when normalizing

Lowercasing the entire UNC path (normalized.toLowerCase()) makes path comparison unsafe on case-sensitive UNC backends (for example Samba/WSL shares configured as case-sensitive), where //server/share/Repo and //server/share/repo can be different directories. With the current normalization, those distinct workspaces become indistinguishable and thread lists can bleed across them.

Useful? React with 👍 / 👎.

}
return normalized;
}

export function extractRpcErrorMessage(response: unknown) {
Expand Down
Loading