From 93dc6e708ad87cd05a91c657d3f3607e5852afab Mon Sep 17 00:00:00 2001 From: aa Date: Sun, 2 Aug 2026 04:53:25 +0900 Subject: [PATCH 01/51] fix(app): add Linux shell logging and menu support --- .github/workflows/tests.yml | 74 +++++++++++ app/src-tauri/src/lib.rs | 250 +++++++++++++++++++++++++++++------- 2 files changed, 279 insertions(+), 45 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index dba53c762..c2f19e079 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -473,6 +473,80 @@ jobs: if: needs.changes.outputs.app_changed != 'false' run: cargo test --manifest-path src-tauri/Cargo.toml + # Keep the Linux test leg on the same Ubuntu generation as local validation. + # The explicit 24.04 label is intentional: it catches Linux cfg regressions + # against the Ubuntu 24 environment we support, while ubuntu-latest may move + # to 26.04. Revisit this pin when the real Ubuntu target is upgraded or when + # GitHub retires the label. + app-check-linux: + name: app typecheck (ubuntu-24.04) + needs: changes + if: ${{ !cancelled() }} + runs-on: ubuntu-24.04 + timeout-minutes: 25 + defaults: + run: + working-directory: app + steps: + - uses: actions/checkout@v4 + + - name: No app changes — skipping Linux typecheck + if: needs.changes.outputs.app_changed == 'false' + run: echo "Diff does not touch app/ — reporting green without running the Linux typecheck." + + - name: Install Tauri Linux dependencies + if: needs.changes.outputs.app_changed != 'false' + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev \ + librsvg2-dev patchelf libssl-dev libsoup-3.0-dev libxdo-dev \ + build-essential curl wget file + + - uses: pnpm/action-setup@v4 + if: needs.changes.outputs.app_changed != 'false' + with: + version: 10 + + - uses: actions/setup-node@v4 + if: needs.changes.outputs.app_changed != 'false' + with: + node-version: 20 + cache: pnpm + cache-dependency-path: app/pnpm-lock.yaml + + - uses: dtolnay/rust-toolchain@stable + if: needs.changes.outputs.app_changed != 'false' + + - uses: Swatinem/rust-cache@v2 + if: needs.changes.outputs.app_changed != 'false' + with: + workspaces: app/src-tauri + + - name: Install frontend deps + if: needs.changes.outputs.app_changed != 'false' + run: pnpm install --frozen-lockfile + + - name: TypeScript typecheck + if: needs.changes.outputs.app_changed != 'false' + run: pnpm exec tsc --noEmit + + - name: Frontend tests + if: needs.changes.outputs.app_changed != 'false' + run: pnpm test + + - name: Bundle pinned agmsg-core + if: needs.changes.outputs.app_changed != 'false' + run: scripts/bundle-core.sh + + - name: Rust check + if: needs.changes.outputs.app_changed != 'false' + run: cargo check --manifest-path src-tauri/Cargo.toml + + - name: Rust tests + if: needs.changes.outputs.app_changed != 'false' + run: cargo test --manifest-path src-tauri/Cargo.toml + # Windows leg for the app: the command layer's bash resolution and path # conversion are all behind cfg(windows) and had never run in CI — the very # 0.1.1→0.1.3 regressions (WSL bash.exe, backslash argv). This runs cargo test diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 23e8b2620..9c8eab9a0 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -104,18 +104,18 @@ fn save_zoom(app: &AppHandle, zoom: f64) { /// (MOTD, prompts) — wrapping the $PATH readout in unique markers and /// extracting just what's between them keeps that noise from corrupting it. #[cfg(unix)] -fn import_login_shell_path() { +fn import_login_shell_path(app: &AppHandle) { const START: &str = "__AGMSG_PATH_START__"; const END: &str = "__AGMSG_PATH_END__"; let shell = resolve_login_shell(); - log_path_import(&format!("resolved login shell: {shell}")); + log_path_import(app, &format!("resolved login shell: {shell}")); let script = format!("printf '{START}%s{END}' \"$PATH\""); let output = match std::process::Command::new(&shell).args(["-ilc", &script]).output() { Ok(o) => o, Err(e) => { let msg = format!("couldn't run login shell ({shell}) to import PATH: {e}"); eprintln!("warning: {msg}"); - log_path_import(&msg); + log_path_import(app, &msg); return; } }; @@ -129,7 +129,7 @@ fn import_login_shell_path() { }); match parsed { Some(path) if !path.is_empty() => { - log_path_import(&format!("imported PATH: {path}")); + log_path_import(app, &format!("imported PATH: {path}")); std::env::set_var("PATH", path); let _ = IMPORTED_PATH.set(path.to_string()); } @@ -139,42 +139,92 @@ fn import_login_shell_path() { stdout.trim() ); eprintln!("warning: {msg}"); - log_path_import(&msg); + log_path_import(app, &msg); } } } -/// Resolves the user's login shell for import_login_shell_path() above. -/// $SHELL isn't reliably set for a Finder/LaunchServices-launched GUI -/// process — confirmed on real hardware: present in the same user's -/// Terminal session, absent (or stale) when the app itself is launched via -/// Finder. `dscl` asks Directory Services directly for the account's -/// configured shell, independent of whatever this process's own -/// environment happens to have inherited. /bin/zsh (macOS's default shell -/// since Catalina) is the last-resort fallback if even that comes up empty. -#[cfg(unix)] -fn resolve_login_shell() -> String { - if let Ok(s) = std::env::var("SHELL") { - if !s.is_empty() { - return s; +/// Selects the first non-empty shell source in the platform-specific +/// priority order. The callers gather the sources (environment, account +/// database, and fallback files) outside this function so the precedence can +/// be tested without invoking external commands or reading the host system. +fn select_login_shell( + shell: Option<&str>, + account_shell: Option<&str>, + passwd_shell: Option<&str>, + fallback: &str, +) -> String { + [shell, account_shell, passwd_shell] + .into_iter() + .flatten() + .find(|candidate| !candidate.is_empty()) + .unwrap_or(fallback) + .to_string() +} + +/// Extracts the login shell (the seventh colon-separated field) for `user` +/// from a passwd/getent response. Keeping this parser pure also lets the +/// Linux lookup order be tested with representative records. +fn passwd_shell_for_user(contents: &str, user: &str) -> Option { + contents.lines().find_map(|line| { + let mut fields = line.split(':'); + let name = fields.next()?; + if name != user { + return None; } - } + fields.nth(5).filter(|shell| !shell.is_empty()).map(str::to_owned) + }) +} + +/// Resolves the user's login shell for import_login_shell_path() on Linux. +/// Ubuntu does not provide macOS's `dscl`; use the account's configured shell +/// from `getent`, then `/etc/passwd`, before the stable `/bin/bash` fallback. +#[cfg(target_os = "linux")] +fn resolve_login_shell() -> String { + let shell = std::env::var("SHELL").ok(); let user = std::env::var("USER").unwrap_or_default(); - if !user.is_empty() { - if let Ok(output) = - std::process::Command::new("dscl").args([".", "-read", &format!("/Users/{user}"), "UserShell"]).output() - { - if output.status.success() { + let (getent_shell, passwd_shell) = if user.is_empty() { + (None, None) + } else { + let getent_shell = std::process::Command::new("getent") + .args(["passwd", &user]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| passwd_shell_for_user(&String::from_utf8_lossy(&output.stdout), &user)); + let passwd_shell = std::fs::read_to_string("/etc/passwd") + .ok() + .and_then(|contents| passwd_shell_for_user(&contents, &user)); + (getent_shell, passwd_shell) + }; + select_login_shell(shell.as_deref(), getent_shell.as_deref(), passwd_shell.as_deref(), "/bin/bash") +} + +/// Resolves the user's login shell for import_login_shell_path() on macOS. +/// `$SHELL` remains the fastest path; `dscl` reads the configured account +/// shell when a Finder-launched process did not inherit that variable, and +/// `/bin/zsh` preserves the existing macOS fallback. +#[cfg(all(unix, not(target_os = "linux")))] +fn resolve_login_shell() -> String { + let shell = std::env::var("SHELL").ok(); + let user = std::env::var("USER").unwrap_or_default(); + let account_shell = if user.is_empty() { + None + } else { + std::process::Command::new("dscl") + .args([".", "-read", &format!("/Users/{user}"), "UserShell"]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| { let text = String::from_utf8_lossy(&output.stdout); - if let Some(shell) = text.trim().strip_prefix("UserShell: ") { - if !shell.is_empty() { - return shell.to_string(); - } - } - } - } - } - "/bin/zsh".into() + text.trim() + .strip_prefix("UserShell: ") + .filter(|shell| !shell.is_empty()) + .map(str::to_owned) + }) + }; + select_login_shell(shell.as_deref(), account_shell.as_deref(), None, "/bin/zsh") } /// What to spawn for the free-shell tab (App.tsx's "+" tab and a tab's "Open @@ -213,22 +263,44 @@ fn login_shell() -> LoginShellInfo { } } -/// Appends a timestamped line to ~/Library/Logs/agmsg/path-import.log. The -/// only real diagnostic available for import_login_shell_path(): it runs -/// before the webview (and thus DevTools) exists, and its failure mode was -/// otherwise silent — a prior Finder-launch gate failure took a slow -/// back-and-forth to root-cause because all it did on failure was warn to -/// stderr, which nothing launched from Finder is around to see. +/// Resolves the path-import log file without touching Tauri state. macOS keeps +/// the existing `~/Library/Logs/agmsg` path; Linux uses the directory Tauri +/// derives from the app identifier (`app_log_dir`). +fn path_import_log_path( + is_linux: bool, + home: &std::path::Path, + app_log_dir: Option<&std::path::Path>, +) -> Option { + if is_linux { + app_log_dir.map(|dir| dir.join("path-import.log")) + } else { + Some(home.join("Library/Logs/agmsg/path-import.log")) + } +} + +/// Appends a timestamped line to the platform-specific path-import log. This +/// runs before the webview (and thus DevTools) exists, so the file is the only +/// durable diagnostic for a failed login-shell PATH import. #[cfg(unix)] -fn log_path_import(message: &str) { - let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); - let dir = std::path::PathBuf::from(home).join("Library/Logs/agmsg"); - if std::fs::create_dir_all(&dir).is_err() { +fn log_path_import(app: &AppHandle, message: &str) { + let home = std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from("/tmp")); + #[cfg(target_os = "linux")] + let app_log_dir = app.path().app_log_dir().ok(); + #[cfg(not(target_os = "linux"))] + let app_log_dir = None; + let Some(path) = path_import_log_path(cfg!(target_os = "linux"), &home, app_log_dir.as_deref()) else { return; + }; + if let Some(dir) = path.parent() { + if std::fs::create_dir_all(dir).is_err() { + return; + } } use std::io::Write; let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(dir.join("path-import.log")) { + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) { let _ = writeln!(f, "[{now}] {message}"); } } @@ -281,6 +353,23 @@ fn make_menu(app: &AppHandle, lang: &str) -> tauri::Result<(Menu, CheckMenu )?; let check_updates = MenuItem::with_id(app, CHECK_UPDATES_ID, m("checkForUpdates"), true, None::<&str>)?; + #[cfg(target_os = "linux")] + let app_menu = Submenu::with_items( + app, + name, + true, + &[ + &about, + &PredefinedMenuItem::separator(app)?, + &check_updates, + &PredefinedMenuItem::separator(app)?, + // GTK does not implement muda's predefined quit item reliably. + // Keep the operation visible as a regular item and handle it in + // on_menu_event below so startup and shutdown stay deterministic. + &MenuItem::with_id(app, QUIT_MENU_ID, m_name("quit"), true, None::<&str>)?, + ], + )?; + #[cfg(not(target_os = "linux"))] let app_menu = Submenu::with_items( app, name, @@ -299,6 +388,19 @@ fn make_menu(app: &AppHandle, lang: &str) -> tauri::Result<(Menu, CheckMenu &PredefinedMenuItem::quit(app, Some(&m_name("quit")))?, ], )?; + #[cfg(target_os = "linux")] + let edit_menu = Submenu::with_items( + app, + m("editMenu"), + true, + &[ + &PredefinedMenuItem::cut(app, Some(&m("cut")))?, + &PredefinedMenuItem::copy(app, Some(&m("copy")))?, + &PredefinedMenuItem::paste(app, Some(&m("paste")))?, + &PredefinedMenuItem::select_all(app, Some(&m("selectAll")))?, + ], + )?; + #[cfg(not(target_os = "linux"))] let edit_menu = Submenu::with_items( app, m("editMenu"), @@ -362,6 +464,7 @@ fn make_menu(app: &AppHandle, lang: &str) -> tauri::Result<(Menu, CheckMenu &MenuItem::with_id(app, ZOOM_RESET_ID, m("actualSize"), true, Some("CmdOrCtrl+0"))?, ], )?; + #[cfg(not(target_os = "linux"))] let window_menu = Submenu::with_items( app, m("windowMenu"), @@ -372,6 +475,9 @@ fn make_menu(app: &AppHandle, lang: &str) -> tauri::Result<(Menu, CheckMenu &PredefinedMenuItem::close_window(app, Some(&m("closeWindow")))?, ], )?; + #[cfg(target_os = "linux")] + let menu = Menu::with_items(app, &[&app_menu, &edit_menu, &view_menu])?; + #[cfg(not(target_os = "linux"))] let menu = Menu::with_items(app, &[&app_menu, &edit_menu, &view_menu, &window_menu])?; Ok((menu, team_room_item, user_chat_item)) } @@ -382,6 +488,7 @@ const ZOOM_IN_ID: &str = "zoom_in"; const ZOOM_OUT_ID: &str = "zoom_out"; const ZOOM_RESET_ID: &str = "zoom_reset"; const CHECK_UPDATES_ID: &str = "check_updates"; +const QUIT_MENU_ID: &str = "quit_app"; const PANE_LAYOUT_VERTICAL_ID: &str = "pane_layout_vertical"; const PANE_LAYOUT_HORIZONTAL_ID: &str = "pane_layout_horizontal"; const PANE_LAYOUT_TILE_ID: &str = "pane_layout_tile"; @@ -588,6 +695,8 @@ pub fn run() { _ => "tile", }; let _ = app.emit("set-pane-layout", layout); + } else if id == QUIT_MENU_ID { + app.exit(0); } else if id == CHECK_UPDATES_ID { let app_handle = app.clone(); tauri::async_runtime::spawn(async move { @@ -606,7 +715,7 @@ pub fn run() { // Windows doesn't have this problem (PATH comes from the // registry regardless of launch method), hence unix-only. #[cfg(unix)] - import_login_shell_path(); + import_login_shell_path(app.handle()); // Restore the zoom level saved on the last quit/change — .manage() // above only had 1.0 to work with (no AppHandle yet to read the @@ -658,3 +767,54 @@ pub fn run() { .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +#[cfg(test)] +mod tests { + use super::{passwd_shell_for_user, path_import_log_path, select_login_shell}; + use std::path::Path; + + #[test] + fn login_shell_precedence_is_environment_then_account_then_passwd_then_fallback() { + assert_eq!( + select_login_shell(Some("/bin/zsh"), Some("/bin/fish"), Some("/bin/bash"), "/bin/sh"), + "/bin/zsh" + ); + assert_eq!( + select_login_shell(Some(""), Some("/bin/fish"), Some("/bin/bash"), "/bin/sh"), + "/bin/fish" + ); + assert_eq!( + select_login_shell(None, Some(""), Some("/bin/bash"), "/bin/sh"), + "/bin/bash" + ); + assert_eq!(select_login_shell(None, None, None, "/bin/sh"), "/bin/sh"); + } + + #[test] + fn passwd_shell_parser_reads_the_seventh_field_for_the_requested_user() { + let records = "root:x:0:0:root:/root:/bin/bash\nalice:x:1000:1000:Alice:/home/alice:/bin/fish\n"; + assert_eq!(passwd_shell_for_user(records, "alice"), Some("/bin/fish".into())); + assert_eq!(passwd_shell_for_user(records, "nobody"), None); + } + + #[test] + fn macos_log_path_keeps_the_existing_location() { + let home = Path::new("/home/alice"); + let app_log_dir = Path::new("/home/alice/.local/share/cc.agmsg.app/logs"); + assert_eq!( + path_import_log_path(false, home, Some(app_log_dir)), + Some(Path::new("/home/alice/Library/Logs/agmsg/path-import.log").into()) + ); + } + + #[test] + fn linux_log_path_uses_tauri_app_log_dir() { + let home = Path::new("/home/alice"); + let app_log_dir = Path::new("/home/alice/.local/share/cc.agmsg.app/logs"); + assert_eq!( + path_import_log_path(true, home, Some(app_log_dir)), + Some(Path::new("/home/alice/.local/share/cc.agmsg.app/logs/path-import.log").into()) + ); + assert_eq!(path_import_log_path(true, home, None), None); + } +} From f23f73a733f30fe256affffd3ddb7a44c1f295c3 Mon Sep 17 00:00:00 2001 From: aa Date: Sun, 2 Aug 2026 05:17:22 +0900 Subject: [PATCH 02/51] fix(app): short-circuit login shell probes --- .github/workflows/tests.yml | 2 +- app/src-tauri/src/lib.rs | 190 +++++++++++++++++++++++++++++------- 2 files changed, 154 insertions(+), 38 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c2f19e079..3189449eb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -479,7 +479,7 @@ jobs: # to 26.04. Revisit this pin when the real Ubuntu target is upgraded or when # GitHub retires the label. app-check-linux: - name: app typecheck (ubuntu-24.04) + name: app typecheck (linux) needs: changes if: ${{ !cancelled() }} runs-on: ubuntu-24.04 diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 9c8eab9a0..1a9e07b1c 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -148,6 +148,7 @@ fn import_login_shell_path(app: &AppHandle) { /// priority order. The callers gather the sources (environment, account /// database, and fallback files) outside this function so the precedence can /// be tested without invoking external commands or reading the host system. +#[cfg_attr(not(unix), allow(dead_code))] fn select_login_shell( shell: Option<&str>, account_shell: Option<&str>, @@ -165,6 +166,7 @@ fn select_login_shell( /// Extracts the login shell (the seventh colon-separated field) for `user` /// from a passwd/getent response. Keeping this parser pure also lets the /// Linux lookup order be tested with representative records. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] fn passwd_shell_for_user(contents: &str, user: &str) -> Option { contents.lines().find_map(|line| { let mut fields = line.split(':'); @@ -176,28 +178,64 @@ fn passwd_shell_for_user(contents: &str, user: &str) -> Option { }) } +/// Resolves a login shell while keeping account-file probes lazy. A process +/// launched from a terminal normally has a valid `$SHELL`; in that case no +/// `getent`, `dscl`, or passwd-file lookup should run at all. When `$SHELL` is +/// empty, the account probe wins and the fallback-file probe runs only when +/// the account probe has no usable value. The closures make those guarantees +/// observable in unit tests without shelling out from the test process. +#[cfg_attr(not(unix), allow(dead_code))] +fn resolve_login_shell_with_probes( + shell: Option<&str>, + account_probe: AccountProbe, + passwd_probe: PasswdProbe, + fallback: &str, +) -> String +where + AccountProbe: FnOnce() -> Option, + PasswdProbe: FnOnce() -> Option, +{ + if let Some(shell) = shell.filter(|value| !value.is_empty()) { + return shell.to_string(); + } + let account_shell = account_probe().filter(|value| !value.is_empty()); + if let Some(shell) = account_shell { + return shell; + } + let passwd_shell = passwd_probe().filter(|value| !value.is_empty()); + select_login_shell(None, None, passwd_shell.as_deref(), fallback) +} + /// Resolves the user's login shell for import_login_shell_path() on Linux. /// Ubuntu does not provide macOS's `dscl`; use the account's configured shell /// from `getent`, then `/etc/passwd`, before the stable `/bin/bash` fallback. #[cfg(target_os = "linux")] fn resolve_login_shell() -> String { - let shell = std::env::var("SHELL").ok(); + let shell = std::env::var("SHELL").ok().filter(|value| !value.is_empty()); let user = std::env::var("USER").unwrap_or_default(); - let (getent_shell, passwd_shell) = if user.is_empty() { - (None, None) - } else { - let getent_shell = std::process::Command::new("getent") - .args(["passwd", &user]) - .output() - .ok() - .filter(|output| output.status.success()) - .and_then(|output| passwd_shell_for_user(&String::from_utf8_lossy(&output.stdout), &user)); - let passwd_shell = std::fs::read_to_string("/etc/passwd") - .ok() - .and_then(|contents| passwd_shell_for_user(&contents, &user)); - (getent_shell, passwd_shell) - }; - select_login_shell(shell.as_deref(), getent_shell.as_deref(), passwd_shell.as_deref(), "/bin/bash") + resolve_login_shell_with_probes( + shell.as_deref(), + || { + if user.is_empty() { + return None; + } + std::process::Command::new("getent") + .args(["passwd", &user]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| passwd_shell_for_user(&String::from_utf8_lossy(&output.stdout), &user)) + }, + || { + if user.is_empty() { + return None; + } + std::fs::read_to_string("/etc/passwd") + .ok() + .and_then(|contents| passwd_shell_for_user(&contents, &user)) + }, + "/bin/bash", + ) } /// Resolves the user's login shell for import_login_shell_path() on macOS. @@ -206,25 +244,30 @@ fn resolve_login_shell() -> String { /// `/bin/zsh` preserves the existing macOS fallback. #[cfg(all(unix, not(target_os = "linux")))] fn resolve_login_shell() -> String { - let shell = std::env::var("SHELL").ok(); + let shell = std::env::var("SHELL").ok().filter(|value| !value.is_empty()); let user = std::env::var("USER").unwrap_or_default(); - let account_shell = if user.is_empty() { - None - } else { - std::process::Command::new("dscl") - .args([".", "-read", &format!("/Users/{user}"), "UserShell"]) - .output() - .ok() - .filter(|output| output.status.success()) - .and_then(|output| { - let text = String::from_utf8_lossy(&output.stdout); - text.trim() - .strip_prefix("UserShell: ") - .filter(|shell| !shell.is_empty()) - .map(str::to_owned) - }) - }; - select_login_shell(shell.as_deref(), account_shell.as_deref(), None, "/bin/zsh") + resolve_login_shell_with_probes( + shell.as_deref(), + || { + if user.is_empty() { + return None; + } + std::process::Command::new("dscl") + .args([".", "-read", &format!("/Users/{user}"), "UserShell"]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| { + let text = String::from_utf8_lossy(&output.stdout); + text.trim() + .strip_prefix("UserShell: ") + .filter(|shell| !shell.is_empty()) + .map(str::to_owned) + }) + }, + || None, + "/bin/zsh", + ) } /// What to spawn for the free-shell tab (App.tsx's "+" tab and a tab's "Open @@ -266,6 +309,7 @@ fn login_shell() -> LoginShellInfo { /// Resolves the path-import log file without touching Tauri state. macOS keeps /// the existing `~/Library/Logs/agmsg` path; Linux uses the directory Tauri /// derives from the app identifier (`app_log_dir`). +#[cfg_attr(not(unix), allow(dead_code))] fn path_import_log_path( is_linux: bool, home: &std::path::Path, @@ -287,7 +331,13 @@ fn log_path_import(app: &AppHandle, message: &str) { .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::PathBuf::from("/tmp")); #[cfg(target_os = "linux")] - let app_log_dir = app.path().app_log_dir().ok(); + let app_log_dir = match app.path().app_log_dir() { + Ok(dir) => Some(dir), + Err(error) => { + eprintln!("warning: couldn't resolve Linux app log directory: {error}"); + None + } + }; #[cfg(not(target_os = "linux"))] let app_log_dir = None; let Some(path) = path_import_log_path(cfg!(target_os = "linux"), &home, app_log_dir.as_deref()) else { @@ -366,7 +416,13 @@ fn make_menu(app: &AppHandle, lang: &str) -> tauri::Result<(Menu, CheckMenu // GTK does not implement muda's predefined quit item reliably. // Keep the operation visible as a regular item and handle it in // on_menu_event below so startup and shutdown stay deterministic. - &MenuItem::with_id(app, QUIT_MENU_ID, m_name("quit"), true, None::<&str>)?, + &MenuItem::with_id( + app, + QUIT_MENU_ID, + m_name("quit"), + true, + Some("CmdOrCtrl+Q"), + )?, ], )?; #[cfg(not(target_os = "linux"))] @@ -770,7 +826,10 @@ pub fn run() { #[cfg(test)] mod tests { - use super::{passwd_shell_for_user, path_import_log_path, select_login_shell}; + use super::{ + passwd_shell_for_user, path_import_log_path, resolve_login_shell_with_probes, select_login_shell, + }; + use std::cell::Cell; use std::path::Path; #[test] @@ -790,6 +849,63 @@ mod tests { assert_eq!(select_login_shell(None, None, None, "/bin/sh"), "/bin/sh"); } + #[test] + fn login_shell_probes_are_lazy_and_short_circuit() { + let getent_calls = Cell::new(0); + let passwd_calls = Cell::new(0); + let shell = resolve_login_shell_with_probes( + Some("/bin/zsh"), + || { + getent_calls.set(getent_calls.get() + 1); + Some("/bin/fish".into()) + }, + || { + passwd_calls.set(passwd_calls.get() + 1); + Some("/bin/bash".into()) + }, + "/bin/sh", + ); + assert_eq!(shell, "/bin/zsh"); + assert_eq!(getent_calls.get(), 0, "SHELL must avoid the account probe"); + assert_eq!(passwd_calls.get(), 0, "SHELL must avoid the passwd probe"); + + let getent_calls = Cell::new(0); + let passwd_calls = Cell::new(0); + let shell = resolve_login_shell_with_probes( + None, + || { + getent_calls.set(getent_calls.get() + 1); + Some("/bin/fish".into()) + }, + || { + passwd_calls.set(passwd_calls.get() + 1); + Some("/bin/bash".into()) + }, + "/bin/sh", + ); + assert_eq!(shell, "/bin/fish"); + assert_eq!(getent_calls.get(), 1); + assert_eq!(passwd_calls.get(), 0, "a valid getent result must avoid passwd"); + + let getent_calls = Cell::new(0); + let passwd_calls = Cell::new(0); + let shell = resolve_login_shell_with_probes( + None, + || { + getent_calls.set(getent_calls.get() + 1); + Some(String::new()) + }, + || { + passwd_calls.set(passwd_calls.get() + 1); + Some("/bin/bash".into()) + }, + "/bin/sh", + ); + assert_eq!(shell, "/bin/bash"); + assert_eq!(getent_calls.get(), 1); + assert_eq!(passwd_calls.get(), 1, "an empty getent result must fall back to passwd"); + } + #[test] fn passwd_shell_parser_reads_the_seventh_field_for_the_requested_user() { let records = "root:x:0:0:root:/root:/bin/bash\nalice:x:1000:1000:Alice:/home/alice:/bin/fish\n"; From 3e67577668dd5ab04ae638df5c2d27910f37300d Mon Sep 17 00:00:00 2001 From: aa Date: Sun, 2 Aug 2026 06:48:48 +0900 Subject: [PATCH 03/51] fix(app): finish Linux menu and platform styling --- app/src-tauri/src/lib.rs | 33 +++++++++++++++++++-------------- app/src/App.css | 33 ++++++++++++++++++++++++++------- app/src/App.test.ts | 14 ++++++++++++++ app/src/App.tsx | 13 ++++++++++++- app/src/TerminalPane.tsx | 2 +- 5 files changed, 72 insertions(+), 23 deletions(-) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 1a9e07b1c..4d0e3c8f1 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -148,6 +148,10 @@ fn import_login_shell_path(app: &AppHandle) { /// priority order. The callers gather the sources (environment, account /// database, and fallback files) outside this function so the precedence can /// be tested without invoking external commands or reading the host system. +/// +/// The production Linux/macOS priority is owned by +/// `resolve_login_shell_with_probes`; this helper is only the final pure +/// fold over already-probed values (D-9). #[cfg_attr(not(unix), allow(dead_code))] fn select_login_shell( shell: Option<&str>, @@ -416,12 +420,16 @@ fn make_menu(app: &AppHandle, lang: &str) -> tauri::Result<(Menu, CheckMenu // GTK does not implement muda's predefined quit item reliably. // Keep the operation visible as a regular item and handle it in // on_menu_event below so startup and shutdown stay deterministic. + // Ctrl+Q is a terminal control character (and was observed to + // terminate the app while an xterm.js pane had focus), so the + // native accelerator deliberately uses the non-conflicting + // CmdOrCtrl+Shift+Q chord (D-10). &MenuItem::with_id( app, QUIT_MENU_ID, m_name("quit"), true, - Some("CmdOrCtrl+Q"), + Some("CmdOrCtrl+Shift+Q"), )?, ], )?; @@ -444,18 +452,6 @@ fn make_menu(app: &AppHandle, lang: &str) -> tauri::Result<(Menu, CheckMenu &PredefinedMenuItem::quit(app, Some(&m_name("quit")))?, ], )?; - #[cfg(target_os = "linux")] - let edit_menu = Submenu::with_items( - app, - m("editMenu"), - true, - &[ - &PredefinedMenuItem::cut(app, Some(&m("cut")))?, - &PredefinedMenuItem::copy(app, Some(&m("copy")))?, - &PredefinedMenuItem::paste(app, Some(&m("paste")))?, - &PredefinedMenuItem::select_all(app, Some(&m("selectAll")))?, - ], - )?; #[cfg(not(target_os = "linux"))] let edit_menu = Submenu::with_items( app, @@ -532,7 +528,16 @@ fn make_menu(app: &AppHandle, lang: &str) -> tauri::Result<(Menu, CheckMenu ], )?; #[cfg(target_os = "linux")] - let menu = Menu::with_items(app, &[&app_menu, &edit_menu, &view_menu])?; + // Linux intentionally has no native Edit submenu. muda's GTK + // PredefinedMenuItem edit actions are inert without its optional libxdo + // feature, while enabling that feature would register accelerators that + // can steal terminal control bytes. xterm.js supplies the tested + // right-click Copy/Paste, primary-selection middle-click, and + // Ctrl+Shift+C/V paths instead (Option B). + // The Window submenu is likewise omitted on Linux (D-3); its only prior + // entries were minimize/close_window, which GTK exposes through the + // window manager and the title bar without an app-menu replacement. + let menu = Menu::with_items(app, &[&app_menu, &view_menu])?; #[cfg(not(target_os = "linux"))] let menu = Menu::with_items(app, &[&app_menu, &edit_menu, &view_menu, &window_menu])?; Ok((menu, team_room_item, user_chat_item)) diff --git a/app/src/App.css b/app/src/App.css index daa7f6144..e59c662d6 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -31,7 +31,7 @@ body, flex-direction: column; background: var(--bg); color: var(--fg); - font: 13px/1.5 -apple-system, "SF Pro Text", Helvetica, Arial, sans-serif; + font: 13px/1.5 system-ui, Ubuntu, Cantarell, -apple-system, "SF Pro Text", Helvetica, Arial, sans-serif; } /* These sit at the very top of the window, same row as the overlaid macOS @@ -42,7 +42,7 @@ body, align-items: center; justify-content: space-between; gap: 12px; - padding: 8px 14px 8px 80px; + padding: 8px 14px; background: #4a1f1f; color: #ffd7d7; font-size: 12px; @@ -61,7 +61,7 @@ body, .startup-installing-banner { display: flex; align-items: center; - padding: 8px 14px 8px 80px; + padding: 8px 14px; background: var(--panel-2); color: var(--muted); font-size: 12px; @@ -73,7 +73,7 @@ body, align-items: center; justify-content: space-between; gap: 12px; - padding: 8px 14px 8px 80px; + padding: 8px 14px; background: #4a3a1f; color: #ffe7b3; font-size: 12px; @@ -114,7 +114,7 @@ body, align-items: center; justify-content: space-between; gap: 12px; - padding: 8px 14px 8px 80px; + padding: 8px 14px; background: #1f4a2c; color: #c8f7d4; font-size: 12px; @@ -130,6 +130,16 @@ body, flex-shrink: 0; } +/* The overlay title bar exists only on macOS (see App.tsx's + platformClassForUserAgent). Linux/Windows use a normal title bar and must + not inherit the 80px traffic-light clearance. */ +.platform-macos .startup-error-banner, +.platform-macos .startup-installing-banner, +.platform-macos .startup-outdated-banner, +.platform-macos .startup-success-banner { + padding-left: 80px; +} + /* Top bar */ .topbar { display: flex; @@ -508,8 +518,14 @@ body.resizing-row { display: flex; justify-content: flex-end; align-items: center; + height: 0; + padding: 0; + overflow: hidden; +} +.platform-macos .sidebar-toggle-row { height: 28px; padding: 0 6px; + overflow: visible; } .sidebar-collapse-toggle { display: flex; @@ -537,7 +553,10 @@ body.resizing-row { /* No .sidebar-toggle-row above this in the collapsed state (it would overlap the traffic lights at only 44px wide) — this provides its own traffic-light clearance directly. */ - padding: 34px 0 12px; + padding: 0 0 12px; +} +.platform-macos .sidebar-collapsed-rail { + padding-top: 34px; } .rail-logo-mark-btn { background: none; @@ -996,7 +1015,7 @@ body.resizing-row { inset: 0; overflow-y: auto; padding: 8px 12px; - font-family: Menlo, Monaco, "Courier New", monospace; + font-family: "Ubuntu Mono", "DejaVu Sans Mono", Menlo, Monaco, "Courier New", monospace; font-size: 12px; } /* Shared name coloring for both layouts. */ diff --git a/app/src/App.test.ts b/app/src/App.test.ts index 05ea8ffac..6146f3b9a 100644 --- a/app/src/App.test.ts +++ b/app/src/App.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { hasUnsafeDropPath, joinDroppedPaths, + platformClassForUserAgent, resolveFileDropTarget, shellPaneFrom, shellSplitStillValid, @@ -11,6 +12,19 @@ import { type LoginShellInfo, } from "./App"; +describe("platformClassForUserAgent", () => { + it("marks macOS webviews for the overlay title-bar layout", () => { + expect(platformClassForUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15")).toBe( + "platform-macos", + ); + }); + + it("leaves Linux and Windows webviews on the normal title-bar layout", () => { + expect(platformClassForUserAgent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")).toBe(""); + expect(platformClassForUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")).toBe(""); + }); +}); + describe("shouldShowOutdatedBanner", () => { it("shows when outdated, not updating, and not dismissed", () => { expect(shouldShowOutdatedBanner({ installed: "1.1.0", pinned: "1.1.8" }, false, false)).toBe(true); diff --git a/app/src/App.tsx b/app/src/App.tsx index 68fc2dbee..76e6f34b7 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -120,6 +120,16 @@ export function shellPaneFrom(info: LoginShellInfo | null, id: string, label: st return { id, label, cmd: info.cmd, args: info.args, cwd, native: false, shell: true }; } +/** + * Returns the root CSS class needed for the macOS overlay title-bar layout. + * Tauri's webview user agent is the only platform signal already available to + * this frontend; keeping the check pure makes the intentional Windows/Linux + * non-overlay layout testable without a DOM or an OS plugin dependency. + */ +export function platformClassForUserAgent(userAgent: string): string { + return /\b(?:macintosh|mac os x)\b/i.test(userAgent) ? "platform-macos" : ""; +} + // Whether openShellTab's new window should still be committed after its // getLoginShell await — false if the user switched teams while it was in // flight. Committing anyway would silently add a window under the stale @@ -305,6 +315,7 @@ export function shouldShowOutdatedBanner( export default function App() { const { t } = useTranslation(); + const platformClass = platformClassForUserAgent(navigator.userAgent); // Set when a startup call that the whole app depends on (loading teams) // fails outright — most commonly agmsg isn't installed at // ~/.agents/skills/agmsg. Without this the app would just render an empty @@ -1789,7 +1800,7 @@ export default function App() { }, []); return ( -
+
{dragPointer && swapSource && ( // Follows the cursor during a pane-header pointer-drag — the visible // replacement for the old HTML5 setDragImage ghost (which relied on diff --git a/app/src/TerminalPane.tsx b/app/src/TerminalPane.tsx index fe4d43535..4a91e492b 100644 --- a/app/src/TerminalPane.tsx +++ b/app/src/TerminalPane.tsx @@ -76,7 +76,7 @@ export function TerminalPane({ let disposed = false; const term = new Terminal({ fontSize, - fontFamily: "Menlo, Monaco, 'Courier New', monospace", + fontFamily: "'Ubuntu Mono', 'DejaVu Sans Mono', Menlo, Monaco, 'Courier New', monospace", cursorBlink: true, theme: { background: "#0b0e14", foreground: "#c5c8c6" }, }); From c2b74f005fd7b292b71da34da810834e567db389 Mon Sep 17 00:00:00 2001 From: aa Date: Sun, 2 Aug 2026 06:52:42 +0900 Subject: [PATCH 04/51] fix(app): type annotate macOS log path fallback --- app/src-tauri/src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 4d0e3c8f1..4b01f0d88 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -343,7 +343,10 @@ fn log_path_import(app: &AppHandle, message: &str) { } }; #[cfg(not(target_os = "linux"))] - let app_log_dir = None; + // Keep the cfg-only fallback's type explicit: macOS compiles this branch + // without the Linux `app_log_dir()` expression that would otherwise + // provide inference (E0282 under the macOS CI target). + let app_log_dir: Option = None; let Some(path) = path_import_log_path(cfg!(target_os = "linux"), &home, app_log_dir.as_deref()) else { return; }; From f57a4d98900928af33f017a0db17c2c8e192c5d5 Mon Sep 17 00:00:00 2001 From: aa Date: Sun, 2 Aug 2026 06:57:11 +0900 Subject: [PATCH 05/51] fix(app): keep sidebar toggle available on Linux --- app/src/App.css | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/app/src/App.css b/app/src/App.css index e59c662d6..a383893c5 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -510,22 +510,16 @@ body.resizing-row { .sidebar.collapsed { width: 44px; } -/* Collapse/expand toggle — its own slim strip, level with the overlaid - macOS traffic lights (koit: not a tall empty row below them, and not - crowded into brand-row either — its own row, just short and right by the - lights rather than adding real vertical space). */ +/* Collapse/expand toggle — its own slim strip in every platform layout. + The control needs a real hit area even when there are no macOS overlay + traffic lights; only the collapsed rail's extra top clearance below is + platform-specific. */ .sidebar-toggle-row { display: flex; justify-content: flex-end; align-items: center; - height: 0; - padding: 0; - overflow: hidden; -} -.platform-macos .sidebar-toggle-row { height: 28px; padding: 0 6px; - overflow: visible; } .sidebar-collapse-toggle { display: flex; From ec7803d19d29c770fdac687d10885c5679983f36 Mon Sep 17 00:00:00 2001 From: aa Date: Sun, 2 Aug 2026 07:12:55 +0900 Subject: [PATCH 06/51] fix(app): scope Linux font fallbacks --- app/src/App.css | 14 ++++++++++++-- app/src/App.test.ts | 21 +++++++++++++++++++-- app/src/App.tsx | 16 ++++++++++++---- app/src/TerminalPane.tsx | 11 ++++++++++- 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/app/src/App.css b/app/src/App.css index a383893c5..a77d6cb55 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -31,7 +31,13 @@ body, flex-direction: column; background: var(--bg); color: var(--fg); - font: 13px/1.5 system-ui, Ubuntu, Cantarell, -apple-system, "SF Pro Text", Helvetica, Arial, sans-serif; + font: 13px/1.5 -apple-system, "SF Pro Text", Helvetica, Arial, sans-serif; +} + +/* Linux gets an explicit Ubuntu-compatible UI stack. Keep the base stack + above unchanged so macOS and Windows retain their existing font choice. */ +.platform-linux { + font-family: system-ui, Ubuntu, Cantarell, -apple-system, "SF Pro Text", Helvetica, Arial, sans-serif; } /* These sit at the very top of the window, same row as the overlaid macOS @@ -1009,9 +1015,13 @@ body.resizing-row { inset: 0; overflow-y: auto; padding: 8px 12px; - font-family: "Ubuntu Mono", "DejaVu Sans Mono", Menlo, Monaco, "Courier New", monospace; + font-family: Menlo, Monaco, "Courier New", monospace; font-size: 12px; } +.platform-linux .room { + /* Keep this list in sync with LINUX_TERMINAL_FONT_FAMILY in App.tsx. */ + font-family: "Ubuntu Mono", "DejaVu Sans Mono", Menlo, Monaco, "Courier New", monospace; +} /* Shared name coloring for both layouts. */ .mf { color: var(--accent); diff --git a/app/src/App.test.ts b/app/src/App.test.ts index 6146f3b9a..d61c6b93f 100644 --- a/app/src/App.test.ts +++ b/app/src/App.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { + DEFAULT_TERMINAL_FONT_FAMILY, hasUnsafeDropPath, joinDroppedPaths, + LINUX_TERMINAL_FONT_FAMILY, platformClassForUserAgent, resolveFileDropTarget, shellPaneFrom, @@ -19,12 +21,27 @@ describe("platformClassForUserAgent", () => { ); }); - it("leaves Linux and Windows webviews on the normal title-bar layout", () => { - expect(platformClassForUserAgent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")).toBe(""); + it("marks Linux webviews for the Linux-only platform styling", () => { + expect(platformClassForUserAgent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")).toBe("platform-linux"); + }); + + it("leaves Windows webviews without a platform-specific class", () => { expect(platformClassForUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")).toBe(""); }); }); +describe("Linux terminal font fallback", () => { + it("keeps the non-Linux fallback from the pre-Linux implementation", () => { + expect(DEFAULT_TERMINAL_FONT_FAMILY).toBe("Menlo, Monaco, 'Courier New', monospace"); + }); + + it("keeps Ubuntu Mono and DejaVu Sans Mono ahead of the legacy stack", () => { + expect(LINUX_TERMINAL_FONT_FAMILY).toBe( + "'Ubuntu Mono', 'DejaVu Sans Mono', Menlo, Monaco, 'Courier New', monospace", + ); + }); +}); + describe("shouldShowOutdatedBanner", () => { it("shows when outdated, not updating, and not dismissed", () => { expect(shouldShowOutdatedBanner({ installed: "1.1.0", pinned: "1.1.8" }, false, false)).toBe(true); diff --git a/app/src/App.tsx b/app/src/App.tsx index 76e6f34b7..a046d1e6f 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -121,15 +121,23 @@ export function shellPaneFrom(info: LoginShellInfo | null, id: string, label: st } /** - * Returns the root CSS class needed for the macOS overlay title-bar layout. + * Returns the root CSS class needed for the platform-specific window layout. * Tauri's webview user agent is the only platform signal already available to - * this frontend; keeping the check pure makes the intentional Windows/Linux - * non-overlay layout testable without a DOM or an OS plugin dependency. + * this frontend; keeping the check pure makes the platform branches testable + * without a DOM or an OS plugin dependency. */ export function platformClassForUserAgent(userAgent: string): string { - return /\b(?:macintosh|mac os x)\b/i.test(userAgent) ? "platform-macos" : ""; + if (/\b(?:macintosh|mac os x)\b/i.test(userAgent)) return "platform-macos"; + if (/\blinux\b/i.test(userAgent)) return "platform-linux"; + return ""; } +// Keep the xterm.js stacks in one place so TerminalPane cannot accidentally +// change the non-Linux fallback while adding a Linux-only font preference. +export const DEFAULT_TERMINAL_FONT_FAMILY = "Menlo, Monaco, 'Courier New', monospace"; +export const LINUX_TERMINAL_FONT_FAMILY = + "'Ubuntu Mono', 'DejaVu Sans Mono', Menlo, Monaco, 'Courier New', monospace"; + // Whether openShellTab's new window should still be committed after its // getLoginShell await — false if the user switched teams while it was in // flight. Committing anyway would silently add a window under the stale diff --git a/app/src/TerminalPane.tsx b/app/src/TerminalPane.tsx index 4a91e492b..2d39531cc 100644 --- a/app/src/TerminalPane.tsx +++ b/app/src/TerminalPane.tsx @@ -6,6 +6,11 @@ import { WebglAddon } from "@xterm/addon-webgl"; import "@xterm/xterm/css/xterm.css"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; +import { + DEFAULT_TERMINAL_FONT_FAMILY, + LINUX_TERMINAL_FONT_FAMILY, + platformClassForUserAgent, +} from "./App"; import { createWriteBatcher } from "./writeBatcher"; import { attachWebglAddon } from "./webglAttach"; @@ -74,9 +79,13 @@ export function TerminalPane({ useEffect(() => { let disposed = false; + const fontFamily = + platformClassForUserAgent(navigator.userAgent) === "platform-linux" + ? LINUX_TERMINAL_FONT_FAMILY + : DEFAULT_TERMINAL_FONT_FAMILY; const term = new Terminal({ fontSize, - fontFamily: "'Ubuntu Mono', 'DejaVu Sans Mono', Menlo, Monaco, 'Courier New', monospace", + fontFamily, cursorBlink: true, theme: { background: "#0b0e14", foreground: "#c5c8c6" }, }); From facd6fc472ed278e9c0940ab9d39cb5e1a1d59b2 Mon Sep 17 00:00:00 2001 From: aa Date: Sun, 2 Aug 2026 14:28:24 +0900 Subject: [PATCH 07/51] fix(app): add Linux select keyboard hint --- app/src/App.css | 9 +++++++++ app/src/App.tsx | 1 + app/src/i18n/locales/de.json | 1 + app/src/i18n/locales/en.json | 1 + app/src/i18n/locales/es.json | 1 + app/src/i18n/locales/fr.json | 1 + app/src/i18n/locales/ja.json | 1 + app/src/i18n/locales/ko.json | 1 + app/src/i18n/locales/pt-BR.json | 1 + app/src/i18n/locales/zh-CN.json | 1 + app/src/i18n/locales/zh-TW.json | 1 + 11 files changed, 19 insertions(+) diff --git a/app/src/App.css b/app/src/App.css index a77d6cb55..551efe4ec 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -1522,6 +1522,15 @@ body.resizing-row { background-color: var(--panel); border-color: var(--border); } +.composer-target-hint { + display: none; +} +.platform-linux .composer-target-hint { + display: inline; + color: var(--muted); + font-size: 10px; + white-space: nowrap; +} .composer button { background: var(--accent); color: #0b0e14; diff --git a/app/src/App.tsx b/app/src/App.tsx index a046d1e6f..1e49da30b 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -2536,6 +2536,7 @@ export default function App() { ))} + {t("composer.targetKeyboardHint")} Date: Sun, 2 Aug 2026 14:33:42 +0900 Subject: [PATCH 08/51] fix(app): replace Linux native selects --- app/src/App.css | 120 +++++++++++- app/src/App.tsx | 23 ++- app/src/PlatformSelect.test.ts | 26 +++ app/src/PlatformSelect.tsx | 320 ++++++++++++++++++++++++++++++++ app/src/i18n/locales/de.json | 1 - app/src/i18n/locales/en.json | 1 - app/src/i18n/locales/es.json | 1 - app/src/i18n/locales/fr.json | 1 - app/src/i18n/locales/ja.json | 1 - app/src/i18n/locales/ko.json | 1 - app/src/i18n/locales/pt-BR.json | 1 - app/src/i18n/locales/zh-CN.json | 1 - app/src/i18n/locales/zh-TW.json | 1 - app/src/modals.tsx | 36 ++-- 14 files changed, 490 insertions(+), 44 deletions(-) create mode 100644 app/src/PlatformSelect.test.ts create mode 100644 app/src/PlatformSelect.tsx diff --git a/app/src/App.css b/app/src/App.css index 551efe4ec..46c1ae085 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -196,6 +196,99 @@ select:focus { border-color: var(--accent); } +/* Linux's replacement for native option popups. The list itself is rendered + into document.body with position:fixed (see PlatformSelect.tsx), so modal + and composer overflow/stacking contexts cannot clip it. */ +.platform-select-custom { + min-width: 0; +} +.platform-select-trigger { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-width: 0; + appearance: none; + -webkit-appearance: none; + background: var(--panel-2); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 28px 6px 10px; + font: inherit; + text-align: left; + cursor: pointer; +} +.platform-select-trigger:hover, +.platform-select-trigger:focus { + outline: none; + border-color: var(--accent); +} +.platform-select-trigger:disabled { + opacity: 0.4; + cursor: default; +} +.platform-select-trigger-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.platform-select-chevron { + position: absolute; + right: 10px; + width: 8px; + height: 8px; + border-right: 1.5px solid var(--muted); + border-bottom: 1.5px solid var(--muted); + transform: translateY(-2px) rotate(45deg); + pointer-events: none; +} +.platform-select-trigger[aria-expanded="true"] .platform-select-chevron { + transform: translateY(2px) rotate(225deg); +} +.platform-select-popup { + position: fixed; + z-index: 1000; + overflow-y: auto; + overscroll-behavior: contain; + padding: 4px 0; + background: var(--panel-2); + color: var(--fg); + font: 13px/1.5 system-ui, Ubuntu, Cantarell, -apple-system, "SF Pro Text", Helvetica, Arial, sans-serif; + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); +} +.platform-select-option { + display: block; + width: 100%; + padding: 6px 10px; + color: var(--fg); + cursor: pointer; + white-space: nowrap; +} +.platform-select-option:hover, +.platform-select-option.active { + background: var(--bg); +} +.platform-select-option[aria-selected="true"] { + color: var(--accent); + font-weight: 600; +} +.platform-select-option.active[aria-selected="true"] { + background: var(--accent); + color: #0b0e14; +} +.platform-select-option[aria-disabled="true"] { + opacity: 0.4; + cursor: default; +} +.modal .platform-select-custom { + width: 100%; +} + /* + New menu (collapsed-rail only — the expanded sidebar has its own direct + buttons per section instead, see .section-add-btn) */ .new-wrap { @@ -1522,15 +1615,6 @@ body.resizing-row { background-color: var(--panel); border-color: var(--border); } -.composer-target-hint { - display: none; -} -.platform-linux .composer-target-hint { - display: inline; - color: var(--muted); - font-size: 10px; - white-space: nowrap; -} .composer button { background: var(--accent); color: #0b0e14; @@ -1544,3 +1628,21 @@ body.resizing-row { opacity: 0.4; cursor: default; } +/* .composer button styles apply to every button in the card; restore the + dropdown trigger's select-like appearance after that broad rule. */ +.composer .platform-select-trigger { + width: auto; + max-width: 220px; + background: transparent; + color: var(--fg); + border-color: transparent; + border-radius: 6px; + padding: 6px 28px 6px 10px; + font-weight: 400; +} +.composer .platform-select-trigger:hover, +.composer .platform-select-trigger:focus, +.composer .platform-select-trigger[aria-expanded="true"] { + background: var(--panel); + border-color: var(--border); +} diff --git a/app/src/App.tsx b/app/src/App.tsx index 1e49da30b..d0ba6907e 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -15,6 +15,7 @@ import { Users, } from "lucide-react"; import { TerminalPane } from "./TerminalPane"; +import { PlatformSelect } from "./PlatformSelect"; import { aggregateTeamStatus, applyStateChange, type PaneStatusMap, type RawState } from "./agentStatus"; import { AUTO_TIMEZONE, formatMessageTime, isValidTimeZone, resolveTimeZone } from "./time"; import { @@ -2528,15 +2529,17 @@ export default function App() { ); })()} - - {t("composer.targetKeyboardHint")} + ({ value: m.name, label: m.name })), + ]} + /> t.name)} + linux={platformClass === "platform-linux"} /> )} {modal?.kind === "rename" && ( @@ -2608,6 +2612,7 @@ export default function App() { onTerminalFontSizeChange={setTerminalFontSize} timezone={timezone} onTimezoneChange={setTimezone} + linux={platformClass === "platform-linux"} /> )} {modal?.kind === "closeWindow" && diff --git a/app/src/PlatformSelect.test.ts b/app/src/PlatformSelect.test.ts new file mode 100644 index 000000000..7103f084f --- /dev/null +++ b/app/src/PlatformSelect.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { nextPlatformSelectIndex, type PlatformSelectOption } from "./PlatformSelect"; + +const options: PlatformSelectOption[] = [ + { value: "placeholder", label: "Choose one" }, + { value: "hidden", label: "Unavailable", disabled: true }, + { value: "first", label: "First" }, + { value: "last", label: "Last" }, +]; + +describe("nextPlatformSelectIndex", () => { + it("moves in either direction while skipping disabled options", () => { + expect(nextPlatformSelectIndex(options, 0, 1)).toBe(2); + expect(nextPlatformSelectIndex(options, 2, -1)).toBe(0); + expect(nextPlatformSelectIndex(options, 2, 1)).toBe(3); + }); + + it("does not wrap beyond either end", () => { + expect(nextPlatformSelectIndex(options, 0, -1)).toBe(0); + expect(nextPlatformSelectIndex(options, 3, 1)).toBe(3); + }); + + it("returns -1 when there are no options", () => { + expect(nextPlatformSelectIndex([], 0, 1)).toBe(-1); + }); +}); diff --git a/app/src/PlatformSelect.tsx b/app/src/PlatformSelect.tsx new file mode 100644 index 000000000..82a120659 --- /dev/null +++ b/app/src/PlatformSelect.tsx @@ -0,0 +1,320 @@ +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type CSSProperties, + type FocusEvent, + type KeyboardEvent, +} from "react"; +import { createPortal } from "react-dom"; + +export type PlatformSelectOption = { + value: string; + label: string; + disabled?: boolean; +}; + +export type PlatformSelectProps = { + /** Render the custom control only for Linux; other platforms keep native select. */ + linux: boolean; + value: string; + onChange: (value: string) => void; + options: readonly PlatformSelectOption[]; + className?: string; + ariaLabel?: string; + disabled?: boolean; +}; + +function firstSelectableIndex(options: readonly PlatformSelectOption[], value: string): number { + const selected = options.findIndex((option) => option.value === value && !option.disabled); + if (selected >= 0) return selected; + return options.findIndex((option) => !option.disabled); +} + +/** Move to the next selectable option without wrapping at either end. */ +export function nextPlatformSelectIndex( + options: readonly PlatformSelectOption[], + current: number, + direction: -1 | 1, +): number { + if (!options.length) return -1; + let index = current; + while (true) { + index += direction; + if (index < 0 || index >= options.length) return current; + if (!options[index]?.disabled) return index; + } +} + +type PopupPosition = { + top: number; + left: number; + width: number; + maxHeight: number; + placement: "above" | "below"; +}; + +function NativeSelect(props: PlatformSelectProps) { + return ( + + ); +} + +function LinuxSelect(props: PlatformSelectProps) { + const rootRef = useRef(null); + const triggerRef = useRef(null); + const popupRef = useRef(null); + const listboxId = useId(); + const selectedIndex = useMemo( + () => firstSelectableIndex(props.options, props.value), + [props.options, props.value], + ); + const [activeIndex, setActiveIndex] = useState(selectedIndex); + const [open, setOpen] = useState(false); + const [popupPosition, setPopupPosition] = useState(null); + + useEffect(() => { + if (!open) setActiveIndex(selectedIndex); + }, [open, selectedIndex]); + + const positionPopup = useCallback(() => { + const trigger = triggerRef.current; + if (!trigger || typeof window === "undefined") return; + const rect = trigger.getBoundingClientRect(); + const viewportPadding = 8; + const estimatedHeight = Math.min(280, Math.max(48, props.options.length * 32 + 8)); + const belowSpace = Math.max(0, window.innerHeight - rect.bottom - viewportPadding); + const aboveSpace = Math.max(0, rect.top - viewportPadding); + const placement: PopupPosition["placement"] = + belowSpace >= estimatedHeight || belowSpace >= aboveSpace ? "below" : "above"; + const availableSpace = placement === "below" ? belowSpace : aboveSpace; + const maxHeight = Math.max(48, Math.min(280, availableSpace)); + const unclampedTop = placement === "below" ? rect.bottom : rect.top - maxHeight; + const top = Math.max( + viewportPadding, + Math.min(unclampedTop, window.innerHeight - viewportPadding - maxHeight), + ); + const width = Math.min(rect.width, Math.max(0, window.innerWidth - viewportPadding * 2)); + const left = Math.max( + viewportPadding, + Math.min(rect.left, window.innerWidth - viewportPadding - width), + ); + setPopupPosition({ top, left, width, maxHeight, placement }); + }, [props.options.length]); + + const closeMenu = useCallback( + (commit: boolean) => { + if (commit && activeIndex >= 0) { + const option = props.options[activeIndex]; + if (option && !option.disabled) props.onChange(option.value); + } else { + setActiveIndex(selectedIndex); + } + setOpen(false); + setPopupPosition(null); + }, + [activeIndex, props.onChange, props.options, selectedIndex], + ); + + const openMenu = useCallback( + (index = selectedIndex) => { + if (props.disabled) return; + const nextIndex = index >= 0 ? index : firstSelectableIndex(props.options, props.value); + if (nextIndex < 0) return; + setActiveIndex(nextIndex); + setOpen(true); + positionPopup(); + if (typeof window !== "undefined") window.requestAnimationFrame(positionPopup); + }, + [positionPopup, props.disabled, props.options, props.value, selectedIndex], + ); + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (rootRef.current?.contains(target) || popupRef.current?.contains(target)) return; + closeMenu(false); + }; + const closeOnViewportChange = () => closeMenu(false); + document.addEventListener("pointerdown", onPointerDown, true); + window.addEventListener("resize", closeOnViewportChange); + window.addEventListener("scroll", closeOnViewportChange, true); + return () => { + document.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("resize", closeOnViewportChange); + window.removeEventListener("scroll", closeOnViewportChange, true); + }; + }, [closeMenu, open]); + + const selectIndex = useCallback( + (index: number) => { + const option = props.options[index]; + if (!option || option.disabled) return; + setActiveIndex(index); + props.onChange(option.value); + setOpen(false); + setPopupPosition(null); + }, + [props.onChange, props.options], + ); + + const move = useCallback( + (direction: -1 | 1) => { + const base = activeIndex >= 0 ? activeIndex : selectedIndex; + const next = nextPlatformSelectIndex(props.options, base, direction); + if (next < 0) return; + if (!open) { + props.onChange(props.options[next].value); + openMenu(next); + } else { + setActiveIndex(next); + } + }, + [activeIndex, open, openMenu, props.onChange, props.options, selectedIndex], + ); + + const moveToBoundary = useCallback( + (toEnd: boolean) => { + const index = toEnd + ? [...props.options].map((option, index) => ({ option, index })).reverse().find(({ option }) => !option.disabled)?.index ?? -1 + : props.options.findIndex((option) => !option.disabled); + if (index < 0) return; + if (!open) { + props.onChange(props.options[index].value); + openMenu(index); + } else { + setActiveIndex(index); + } + }, + [open, openMenu, props.onChange, props.options], + ); + + const onKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + move(1); + break; + case "ArrowUp": + event.preventDefault(); + move(-1); + break; + case "Home": + event.preventDefault(); + moveToBoundary(false); + break; + case "End": + event.preventDefault(); + moveToBoundary(true); + break; + case "Enter": + case " ": + event.preventDefault(); + if (open) closeMenu(true); + else openMenu(); + break; + case "Escape": + if (open) { + event.preventDefault(); + closeMenu(false); + } + break; + case "Tab": + if (open) closeMenu(true); + break; + } + }; + + const onBlur = (event: FocusEvent) => { + const related = event.relatedTarget; + if (related instanceof Node && (rootRef.current?.contains(related) || popupRef.current?.contains(related))) { + return; + } + if (open) closeMenu(false); + }; + + const selectedLabel = props.options.find((option) => option.value === props.value)?.label ?? ""; + const triggerClassName = ["platform-select-trigger", props.className].filter(Boolean).join(" "); + const popupStyle: CSSProperties | undefined = popupPosition + ? { + top: popupPosition.top, + left: popupPosition.left, + width: popupPosition.width, + maxHeight: popupPosition.maxHeight, + } + : undefined; + + return ( +
+ + {open && popupPosition && typeof document !== "undefined" + ? createPortal( +
+ {props.options.map((option, index) => ( +
event.preventDefault()} + onMouseEnter={() => !option.disabled && setActiveIndex(index)} + onClick={() => selectIndex(index)} + > + {option.label} +
+ ))} +
, + document.body, + ) + : null} +
+ ); +} + +export function PlatformSelect(props: PlatformSelectProps) { + return props.linux ? : ; +} diff --git a/app/src/i18n/locales/de.json b/app/src/i18n/locales/de.json index 896521849..0729955f4 100644 --- a/app/src/i18n/locales/de.json +++ b/app/src/i18n/locales/de.json @@ -87,7 +87,6 @@ "composer": { "asLabel": "als {{appUser}}", "targetPlaceholder": "an…", - "targetKeyboardHint": "↑/↓, dann Enter", "messagePlaceholder": "Nachricht", "sendButton": "senden", "noAppUser": "kein App-User — füge einen hinzu, um zu senden/empfangen", diff --git a/app/src/i18n/locales/en.json b/app/src/i18n/locales/en.json index a70feee75..d24b8502f 100644 --- a/app/src/i18n/locales/en.json +++ b/app/src/i18n/locales/en.json @@ -97,7 +97,6 @@ "composer": { "asLabel": "as {{appUser}}", "targetPlaceholder": "to…", - "targetKeyboardHint": "Use ↑/↓, then Enter", "messagePlaceholder": "message", "sendButton": "send", "noAppUser": "no app-user — add one to send/receive", diff --git a/app/src/i18n/locales/es.json b/app/src/i18n/locales/es.json index a16fdfd74..ee6100720 100644 --- a/app/src/i18n/locales/es.json +++ b/app/src/i18n/locales/es.json @@ -87,7 +87,6 @@ "composer": { "asLabel": "como {{appUser}}", "targetPlaceholder": "para…", - "targetKeyboardHint": "Usa ↑/↓ y luego Enter", "messagePlaceholder": "mensaje", "sendButton": "enviar", "noAppUser": "sin app-user — agregue uno para enviar/recibir", diff --git a/app/src/i18n/locales/fr.json b/app/src/i18n/locales/fr.json index 9c40796d3..09504325f 100644 --- a/app/src/i18n/locales/fr.json +++ b/app/src/i18n/locales/fr.json @@ -87,7 +87,6 @@ "composer": { "asLabel": "en tant que {{appUser}}", "targetPlaceholder": "à…", - "targetKeyboardHint": "↑/↓, puis Entrée", "messagePlaceholder": "message", "sendButton": "envoyer", "noAppUser": "aucun app-user — ajoutez-en un pour envoyer/recevoir", diff --git a/app/src/i18n/locales/ja.json b/app/src/i18n/locales/ja.json index a206d9f56..b4d8d6721 100644 --- a/app/src/i18n/locales/ja.json +++ b/app/src/i18n/locales/ja.json @@ -87,7 +87,6 @@ "composer": { "asLabel": "{{appUser}} として", "targetPlaceholder": "宛先…", - "targetKeyboardHint": "↑/↓で選択、Enterで決定", "messagePlaceholder": "メッセージ", "sendButton": "送信", "noAppUser": "app-userがいません — 送受信するには追加してください", diff --git a/app/src/i18n/locales/ko.json b/app/src/i18n/locales/ko.json index bd84b88b0..05b2724b1 100644 --- a/app/src/i18n/locales/ko.json +++ b/app/src/i18n/locales/ko.json @@ -87,7 +87,6 @@ "composer": { "asLabel": "{{appUser}}(으)로", "targetPlaceholder": "받는 사람…", - "targetKeyboardHint": "↑/↓ 선택 후 Enter", "messagePlaceholder": "메시지", "sendButton": "보내기", "noAppUser": "app-user 없음 — 송수신하려면 추가하세요", diff --git a/app/src/i18n/locales/pt-BR.json b/app/src/i18n/locales/pt-BR.json index 2bae34f91..55f86add2 100644 --- a/app/src/i18n/locales/pt-BR.json +++ b/app/src/i18n/locales/pt-BR.json @@ -87,7 +87,6 @@ "composer": { "asLabel": "como {{appUser}}", "targetPlaceholder": "para…", - "targetKeyboardHint": "Use ↑/↓ e depois Enter", "messagePlaceholder": "mensagem", "sendButton": "enviar", "noAppUser": "nenhum app-user — adicione um para enviar/receber", diff --git a/app/src/i18n/locales/zh-CN.json b/app/src/i18n/locales/zh-CN.json index bec674f9c..a007f04de 100644 --- a/app/src/i18n/locales/zh-CN.json +++ b/app/src/i18n/locales/zh-CN.json @@ -87,7 +87,6 @@ "composer": { "asLabel": "以 {{appUser}} 身份", "targetPlaceholder": "发送至…", - "targetKeyboardHint": "使用 ↑/↓,然后按 Enter", "messagePlaceholder": "消息", "sendButton": "发送", "noAppUser": "无 app-user — 添加一个以收发消息", diff --git a/app/src/i18n/locales/zh-TW.json b/app/src/i18n/locales/zh-TW.json index 74f818d1d..c2b3d7964 100644 --- a/app/src/i18n/locales/zh-TW.json +++ b/app/src/i18n/locales/zh-TW.json @@ -87,7 +87,6 @@ "composer": { "asLabel": "以 {{appUser}} 身分", "targetPlaceholder": "傳送給…", - "targetKeyboardHint": "使用 ↑/↓,再按 Enter", "messagePlaceholder": "訊息", "sendButton": "傳送", "noAppUser": "沒有 app-user — 請新增一個以傳送/接收訊息", diff --git a/app/src/modals.tsx b/app/src/modals.tsx index 367c0ff7e..d5aef90ed 100644 --- a/app/src/modals.tsx +++ b/app/src/modals.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { invoke } from "@tauri-apps/api/core"; import { SUPPORTED_LANGUAGES } from "./i18n"; +import { PlatformSelect } from "./PlatformSelect"; import { AUTO_TIMEZONE, detectTimeZone, isValidTimeZone, listTimeZones } from "./time"; type BrowseDir = (current: string) => Promise; @@ -234,6 +235,8 @@ export function AgentModal(props: { defaultProject?: string; /** Spawnable agent types, from agmsg's registry. */ types: string[]; + /** Linux uses the portal-backed select; other platforms keep native select. */ + linux: boolean; }) { const { t } = useTranslation(); const [type, setType] = useState(props.types[0] ?? ""); @@ -261,13 +264,13 @@ export function AgentModal(props: { >