diff --git a/CHANGELOG.md b/CHANGELOG.md index 3770025..e0448f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- macOS close traffic light shows × on hover and a pressed state +- Clicking the pet again reopens chat beside the icon instead of restoring the last screen position +- Chat window height no longer snaps when starting a new conversation or sending the first message; resize stays bottom-anchored and eases over 360ms +- Model and connector menus stay inside the chat window and scroll instead of growing the OS window +- Queued follow-up turns no longer mark the finished reply as “连接意外断开” +- Saving settings now logs in and stores the token; testing connection also stores the password +- Chat can silently log in with the saved password when the access token is missing +- HTTP errors show the server message (e.g. “认证失败。”) instead of raw JSON +- Connection failures in chat offer a retry button +- Retrying an assistant turn reuses the original text, attachments, and model +- Settings leftover notices no longer linger on other tabs; shortcut hint and mascot labels are clearer +- Tray “检查更新” spacing and pet menu “与 Octop 对话” wording + ### Added +- Model and agent popovers grow with the chat window so a tall window can show the full list +- Empty chat shows a gray logo in the message area +- Agent picker uses the same popover as the model list +- Setting to keep chat and settings visible when clicking another app (on by default) +- Settings tabs: 常规 (connection + mascot), 窗口, 快捷键, 关于 - `hooks/useChatController.ts`, `hooks/useWindowChrome.ts` — chat/settings window logic extracted from UI - `lib/octopTypes.ts`, `lib/tauriWindowApi.ts`, `lib/chatHelpers.ts` — clearer module boundaries - `components/ChatChrome.tsx`, `components/ChatResizeChrome.tsx` @@ -20,6 +40,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Chat window starts at 400px and stays resizable; empty compact state fills the window instead of shrink-wrapping +- Sending a message no longer changes chat window height +- Narrow chat windows (~300px) keep the send button on-screen; toolbar chips shrink and wrap +- Debug builds store credentials in a local app-data file so macOS Keychain does not prompt on every rebuild +- Chat and settings close buttons follow the OS: traffic-light dot on the left on macOS, × on the right on Windows and Linux +- Settings window hides after a successful save - `ChatWindow.tsx` slimmed to layout shell (~90 lines); logic in `useChatController` - `SettingsWindow` uses shared auto-fit + Escape hooks; events via `tauriApi` - Removed unused Tauri scaffold `App.tsx`; `index.html` title/icon → OctopPet diff --git a/README.md b/README.md index 09670c9..ec52e5b 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,12 @@ -

- Octop Pet -

- -

- A desktop companion for your self-hosted Octop — floating mascot, compact chat, system tray. -

- -

- Node.js LTS - Rust stable - Tauri 2 - License: MIT -

- -

- Highlights · - Overview · - Quick Start · - Usage · - Development · - Contents -

- -

- English · 中文 -

+![Octop Pet](assets/octop-app-icon.png) + +**A desktop companion for your self-hosted Octop — floating mascot, compact chat, system tray.** + +![Node.js LTS](https://img.shields.io/badge/node-LTS-green?logo=node.js&logoColor=white)![Rust stable](https://img.shields.io/badge/rust-stable-orange?logo=rust&logoColor=white)![Tauri 2](https://img.shields.io/badge/Tauri-2-blue?logo=tauri&logoColor=white)![License: MIT](https://img.shields.io/badge/license-MIT-green) + +[Highlights](#-highlights) · [Overview](#-overview) · [Quick Start](#-quick-start) · [Usage](#-usage) · [Development](#-development) · [Contents](#-contents) + +**English** · [中文](README_CN.md) --- @@ -96,7 +78,7 @@ In a separate terminal, start your Octop server: octop run --host 127.0.0.1 --port 8088 ``` -Open **http://127.0.0.1:8088** and create at least one agent if you have none yet. +Open **[http://127.0.0.1:8088](http://127.0.0.1:8088)** and create at least one agent if you have none yet. ### 3. Connect Octop Pet @@ -107,6 +89,8 @@ In **Settings**: 3. Click **测试连接** (Test connection) to verify credentials 4. Click **保存** (Save) +Optional: in the **窗口** tab, uncheck **点击其他应用时保持窗口显示** to hide chat and settings when clicking the desktop or another app. The pet always stays visible. + The service URL and username are stored in app config; passwords and access tokens use the OS keyring. ### 4. Chat @@ -232,7 +216,7 @@ src-tauri/src/ ## 📦 Release -Push a version tag to trigger [`.github/workflows/release.yml`](.github/workflows/release.yml). Keep versions in sync: +Push a version tag to trigger `[.github/workflows/release.yml](.github/workflows/release.yml)`. Keep versions in sync: ```sh make sync-version VERSION=0.2.0 diff --git a/README_CN.md b/README_CN.md index 5647896..b717d5d 100644 --- a/README_CN.md +++ b/README_CN.md @@ -91,6 +91,8 @@ octop run --host 127.0.0.1 --port 8088 3. 点击 **测试连接** 验证凭据 4. 点击 **保存** +可选:在 **窗口** 中取消「点击其他应用时保持窗口显示」,点到桌面或其他应用时会隐藏聊天和设置;桌宠始终显示。 + 服务地址与用户名保存在应用配置中;密码与访问令牌通过系统钥匙串存储。 ### 4. 开始聊天 @@ -128,7 +130,7 @@ octop run --host 127.0.0.1 --port 8088 ### 聊天窗口 -- **关闭圆点** 仅隐藏窗口,应用继续在托盘运行 +- **关闭按钮** 仅隐藏窗口,应用继续在托盘运行(macOS 为左上红点,Windows / Linux 为右上 ×) - **新建会话** 为当前 Agent 创建新线程 - **切换 Agent** 恢复该 Agent 已映射的线程(若远程仍有效) diff --git a/SECURITY.md b/SECURITY.md index 7ef14e4..2c8781f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,7 +22,7 @@ Octop Pet is a **desktop client** for a remote Octop server. Security responsibi ### Octop Pet (this repo) -- Passwords and access tokens are stored in the **OS keyring** (macOS Keychain / Windows Credential Manager), not in plain-text config files +- Passwords and access tokens are stored in the **OS keyring** (macOS Keychain / Windows Credential Manager), not in `config.json`. Debug/`tauri dev` builds use a 0600 file under the app data directory instead, so unsigned rebuilds do not trigger Keychain ACL prompts - `config.json` holds non-secret settings only (`baseUrl`, username, mascot, thread map, window position, shortcuts) - HTTP/WebSocket calls go to the user-configured Octop base URL — the app does not phone home to third parties - Never commit `.env` files, signing certificates (`.p12`, `.pfx`), or captured tokens in issues or PRs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9b45555..220d25a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2578,6 +2578,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2585,9 +2596,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.1", + "block2", + "libc", "objc2", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-video", "objc2-foundation", + "objc2-metal", ] [[package]] @@ -2642,6 +2658,7 @@ dependencies = [ "keyring", "objc2", "objc2-app-kit", + "objc2-quartz-core", "serde", "serde_json", "tauri", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 863e8d4..bdd8080 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,4 +29,11 @@ tauri-plugin-http = "2" [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" -objc2-app-kit = { version = "0.3", features = ["NSColor", "NSWindow"] } +objc2-app-kit = { version = "0.3", features = [ + "NSAnimation", + "NSAnimationContext", + "NSColor", + "NSWindow", + "objc2-quartz-core", +] } +objc2-quartz-core = { version = "0.3", features = ["CAMediaTimingFunction"] } diff --git a/src-tauri/src/config_cmd.rs b/src-tauri/src/config_cmd.rs index 63fd3d2..6d456f7 100644 --- a/src-tauri/src/config_cmd.rs +++ b/src-tauri/src/config_cmd.rs @@ -20,6 +20,7 @@ pub struct AppConfig { pub pet_y: Option, pub shortcut_open_pet: String, pub shortcut_open_home: String, + pub keep_windows_visible: bool, } impl Default for AppConfig { @@ -34,6 +35,7 @@ impl Default for AppConfig { pet_y: None, shortcut_open_pet: "CmdOrCtrl+Shift+O".into(), shortcut_open_home: "CmdOrCtrl+Shift+H".into(), + keep_windows_visible: true, } } } @@ -97,6 +99,7 @@ fn merge_patch(cfg: &mut AppConfig, patch: Value) -> Result<(), String> { "petY" => cfg.pet_y = patch_field(key, value.clone())?, "shortcutOpenPet" => cfg.shortcut_open_pet = patch_field(key, value.clone())?, "shortcutOpenHome" => cfg.shortcut_open_home = patch_field(key, value.clone())?, + "keepWindowsVisible" => cfg.keep_windows_visible = patch_field(key, value.clone())?, _ => return Err(format!("unsupported config field: {key}")), } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 02d93a7..5d84508 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -21,9 +21,10 @@ pub fn run() { } } } - tauri::WindowEvent::Focused(_) - | tauri::WindowEvent::ThemeChanged(_) - | tauri::WindowEvent::ScaleFactorChanged { .. } + tauri::WindowEvent::Focused(focused) => { + window_cmd::handle_window_focus_change(window, *focused); + } + tauri::WindowEvent::ThemeChanged(_) | tauri::WindowEvent::ScaleFactorChanged { .. } if window.label() == "pet" => { window_cmd::ensure_pet_transparent(window.app_handle()); @@ -39,10 +40,8 @@ pub fn run() { } tray::setup(app)?; window_cmd::ensure_pet_transparent(app.handle()); + window_cmd::apply_window_deactivate_policy(app.handle().clone())?; window_cmd::spawn_pet_transparency_watchdog(app.handle()); - if let Some(chat) = app.get_webview_window("chat") { - let _ = chat.set_resizable(false); - } Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -59,6 +58,8 @@ pub fn run() { window_cmd::show_settings, window_cmd::place_window_bottom_center, window_cmd::place_window_centered, + window_cmd::apply_bottom_anchored_size, + window_cmd::apply_window_deactivate_policy, tray::reload_hotkeys, ]) .run(tauri::generate_context!()) diff --git a/src-tauri/src/secrets_cmd.rs b/src-tauri/src/secrets_cmd.rs index 2efd960..48e7cfe 100644 --- a/src-tauri/src/secrets_cmd.rs +++ b/src-tauri/src/secrets_cmd.rs @@ -1,9 +1,21 @@ -use keyring::{Entry, Error}; -use tauri::AppHandle; +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, + sync::Mutex, +}; + +use tauri::{AppHandle, Manager}; use crate::config_cmd; +#[cfg(not(debug_assertions))] +use keyring::{Entry, Error}; + +#[cfg(not(debug_assertions))] const KEYRING_SERVICE: &str = "com.octop.pet"; +const DEV_SECRETS_FILE: &str = "dev-secrets.json"; +static SECRETS_WRITE_LOCK: Mutex<()> = Mutex::new(()); pub fn validate_secret_key(key: &str) -> Result<(), String> { match key { @@ -20,12 +32,75 @@ pub fn secret_account(username: &str, key: &str) -> Result { Ok(format!("{username}:{key}")) } +pub fn load_secrets_file(path: &Path) -> Result, String> { + if !path.exists() { + return Ok(HashMap::new()); + } + let json = fs::read_to_string(path) + .map_err(|error| format!("failed to read secrets {}: {error}", path.display()))?; + serde_json::from_str(&json) + .map_err(|error| format!("failed to parse secrets {}: {error}", path.display())) +} + +pub fn save_secrets_file(path: &Path, secrets: &HashMap) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "failed to create secrets directory {}: {error}", + parent.display() + ) + })?; + } + let json = serde_json::to_string_pretty(secrets) + .map_err(|error| format!("failed to serialize secrets: {error}"))?; + fs::write(path, json) + .map_err(|error| format!("failed to write secrets {}: {error}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600)); + } + Ok(()) +} + +pub fn get_secret_from_file(path: &Path, account: &str) -> Result, String> { + Ok(load_secrets_file(path)?.get(account).cloned()) +} + +pub fn set_secret_in_file(path: &Path, account: &str, value: &str) -> Result<(), String> { + let _guard = SECRETS_WRITE_LOCK + .lock() + .map_err(|_| "secrets write lock is poisoned".to_string())?; + let mut secrets = load_secrets_file(path)?; + secrets.insert(account.to_string(), value.to_string()); + save_secrets_file(path, &secrets) +} + +pub fn delete_secret_from_file(path: &Path, account: &str) -> Result<(), String> { + let _guard = SECRETS_WRITE_LOCK + .lock() + .map_err(|_| "secrets write lock is poisoned".to_string())?; + let mut secrets = load_secrets_file(path)?; + secrets.remove(account); + save_secrets_file(path, &secrets) +} + +#[cfg(debug_assertions)] +fn secrets_path(app: &AppHandle) -> Result { + app.path() + .app_config_dir() + .map(|dir| dir.join(DEV_SECRETS_FILE)) + .map_err(|error| format!("failed to resolve app config directory: {error}")) +} + +#[cfg(not(debug_assertions))] fn entry_for_username(username: &str, key: &str) -> Result { let account = secret_account(username, key)?; Entry::new(KEYRING_SERVICE, &account) .map_err(|error| format!("failed to access system keyring: {error}")) } +#[cfg(not(debug_assertions))] fn entry_for(app: &AppHandle, key: &str) -> Result { let username = config_cmd::load_config(app.clone())?.username; entry_for_username(&username, key) @@ -34,11 +109,18 @@ fn entry_for(app: &AppHandle, key: &str) -> Result { #[tauri::command] pub fn get_secret(app: AppHandle, key: String) -> Result, String> { validate_secret_key(&key)?; - let username = config_cmd::load_config(app)?.username; + let username = config_cmd::load_config(app.clone())?.username; if username.trim().is_empty() { return Ok(None); } + let account = secret_account(&username, &key)?; + + #[cfg(debug_assertions)] + { + get_secret_from_file(&secrets_path(&app)?, &account) + } + #[cfg(not(debug_assertions))] match entry_for_username(&username, &key)?.get_password() { Ok(value) => Ok(Some(value)), Err(Error::NoEntry) => Ok(None), @@ -48,6 +130,15 @@ pub fn get_secret(app: AppHandle, key: String) -> Result, String> #[tauri::command] pub fn set_secret(app: AppHandle, key: String, value: String) -> Result<(), String> { + let username = config_cmd::load_config(app.clone())?.username; + let account = secret_account(&username, &key)?; + + #[cfg(debug_assertions)] + { + set_secret_in_file(&secrets_path(&app)?, &account, &value) + } + + #[cfg(not(debug_assertions))] entry_for(&app, &key)? .set_password(&value) .map_err(|error| format!("failed to store secret: {error}")) @@ -55,6 +146,15 @@ pub fn set_secret(app: AppHandle, key: String, value: String) -> Result<(), Stri #[tauri::command] pub fn delete_secret(app: AppHandle, key: String) -> Result<(), String> { + let username = config_cmd::load_config(app.clone())?.username; + let account = secret_account(&username, &key)?; + + #[cfg(debug_assertions)] + { + delete_secret_from_file(&secrets_path(&app)?, &account) + } + + #[cfg(not(debug_assertions))] match entry_for(&app, &key)?.delete_credential() { Ok(()) | Err(Error::NoEntry) => Ok(()), Err(error) => Err(format!("failed to delete secret: {error}")), diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 2426853..922b044 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -100,11 +100,8 @@ fn build_menu(app: &AppHandle, cfg: &AppConfig) -> tauri::Result< let separator = PredefinedMenuItem::separator(app)?; let settings = MenuItemBuilder::with_id(SETTINGS_ID, "设置").build(app)?; let version = env!("CARGO_PKG_VERSION"); - let check_update = MenuItemBuilder::with_id( - CHECK_UPDATE_ID, - format!("检查更新 V{version}"), - ) - .build(app)?; + let check_update = + MenuItemBuilder::with_id(CHECK_UPDATE_ID, format!("检查更新 V{version}")).build(app)?; let quit = MenuItemBuilder::with_id(QUIT_ID, "退出").build(app)?; MenuBuilder::new(app) .items(&[ diff --git a/src-tauri/src/window_cmd.rs b/src-tauri/src/window_cmd.rs index 33b474d..18693d9 100644 --- a/src-tauri/src/window_cmd.rs +++ b/src-tauri/src/window_cmd.rs @@ -1,6 +1,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; -use tauri::{window::Color, AppHandle, Emitter, Manager, PhysicalPosition}; +use tauri::{ + window::Color, AppHandle, Emitter, LogicalSize, Manager, PhysicalPosition, WebviewWindow, +}; use tauri_plugin_opener::OpenerExt; /// Logical pixels between the chat window bottom edge and the work-area bottom. @@ -9,13 +11,99 @@ pub const CHAT_BOTTOM_GAP_LOGICAL: f64 = 96.0; /// Back-compat alias used by older call sites / docs. pub const BOTTOM_GAP_LOGICAL: f64 = CHAT_BOTTOM_GAP_LOGICAL; -static CHAT_HAS_BEEN_SHOWN: AtomicBool = AtomicBool::new(false); static SETTINGS_HAS_BEEN_SHOWN: AtomicBool = AtomicBool::new(false); +static KEEP_WINDOWS_VISIBLE: AtomicBool = AtomicBool::new(true); pub fn should_hide_on_close(label: &str) -> bool { matches!(label, "chat" | "settings") } +/// Chat and settings hide on click-away only when the setting is off. +/// The pet never hides this way. +pub fn should_hide_on_unfocus(label: &str, keep_windows_visible: bool) -> bool { + !keep_windows_visible && matches!(label, "chat" | "settings") +} + +fn keep_windows_visible() -> bool { + KEEP_WINDOWS_VISIBLE.load(Ordering::SeqCst) +} + +/// Apply macOS hide-on-deactivate / Transient vs Stationary. +fn apply_macos_deactivate_behavior(window: &WebviewWindow, keep_visible: bool) { + #[cfg(target_os = "macos")] + { + let _ = window.with_webview(move |webview| { + use objc2_app_kit::{NSWindow, NSWindowCollectionBehavior}; + unsafe { + let ns_window: &NSWindow = &*webview.ns_window().cast(); + ns_window.setHidesOnDeactivate(!keep_visible); + let mut behavior = ns_window.collectionBehavior(); + if keep_visible { + behavior.remove(NSWindowCollectionBehavior::Transient); + behavior.insert(NSWindowCollectionBehavior::Stationary); + } else { + behavior.remove(NSWindowCollectionBehavior::Stationary); + behavior.insert(NSWindowCollectionBehavior::Transient); + } + ns_window.setCollectionBehavior(behavior); + } + }); + } + #[cfg(not(target_os = "macos"))] + { + let _ = window; + let _ = keep_visible; + } +} + +/// Keep a window on screen after the user clicks another app. +pub fn keep_visible_on_app_deactivate(window: &WebviewWindow) { + apply_macos_deactivate_behavior(window, true); +} + +/// Load the setting and apply it to chat/settings. Pet always stays visible. +#[tauri::command] +pub fn apply_window_deactivate_policy(app: AppHandle) -> Result<(), String> { + let keep = crate::config_cmd::load_config(app.clone()) + .map(|cfg| cfg.keep_windows_visible) + .unwrap_or(true); + KEEP_WINDOWS_VISIBLE.store(keep, Ordering::SeqCst); + + if let Some(pet) = app.get_webview_window("pet") { + keep_visible_on_app_deactivate(&pet); + } + if let Some(chat) = app.get_webview_window("chat") { + let _ = chat.set_always_on_top(keep); + apply_macos_deactivate_behavior(&chat, keep); + } + if let Some(settings) = app.get_webview_window("settings") { + apply_macos_deactivate_behavior(&settings, keep); + } + Ok(()) +} + +pub fn handle_window_focus_change(window: &tauri::Window, focused: bool) { + let label = window.label(); + if label == "pet" { + ensure_pet_transparent(window.app_handle()); + return; + } + + if keep_windows_visible() { + if let Some(win) = window.app_handle().get_webview_window(label) { + apply_macos_deactivate_behavior(&win, true); + if label == "chat" { + let _ = win.set_always_on_top(true); + } + } + return; + } + + if !focused && should_hide_on_unfocus(label, false) { + let _ = window.hide(); + } +} + /// macOS often paints an opaque gray canvas / OS shadow on the pet window /// when it enters drag mode or resigns key (e.g. chat takes focus). /// Re-assert clear background / no OS shadow on the main thread. @@ -46,6 +134,7 @@ pub fn ensure_pet_transparent(app: &AppHandle) { } }); } + keep_visible_on_app_deactivate(&pet); // Force the page canvas clear in case CSS left a gray layer. let _ = pet.eval( @@ -104,6 +193,122 @@ pub fn home_url(base_url: &str) -> Result { Ok(format!("{base_url}/")) } +/// macOS window resize animation. AppKit's default `setFrame:animate:` is ~0.2s +/// and reads as a snap for compact ↔ expanded chat. +pub const RESIZE_ANIMATION_DURATION: f64 = 0.36; + +/// Keep the top-left origin's x, and shift y so the bottom edge stays put. +pub fn bottom_anchored_position( + old_position: (i32, i32), + old_outer: (u32, u32), + new_outer: (u32, u32), +) -> (i32, i32) { + let dy = old_outer.1 as i32 - new_outer.1 as i32; + (old_position.0, old_position.1 + dy) +} + +fn apply_bottom_anchored_size_fallback( + window: &WebviewWindow, + width: f64, + height: f64, +) -> Result<(), String> { + let old_outer = window + .outer_size() + .map_err(|error| format!("failed to read window size: {error}"))?; + let old_pos = window + .outer_position() + .map_err(|error| format!("failed to read window position: {error}"))?; + window + .set_size(LogicalSize::new(width, height)) + .map_err(|error| format!("failed to resize window: {error}"))?; + let new_outer = window + .outer_size() + .map_err(|error| format!("failed to read window size: {error}"))?; + let (x, y) = bottom_anchored_position( + (old_pos.x, old_pos.y), + (old_outer.width, old_outer.height), + (new_outer.width, new_outer.height), + ); + if x != old_pos.x || y != old_pos.y { + window + .set_position(PhysicalPosition::new(x, y)) + .map_err(|error| format!("failed to position window: {error}"))?; + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn apply_bottom_anchored_size_macos( + window: &WebviewWindow, + width: f64, + height: f64, + animate: bool, +) -> Result<(), String> { + let scale = window + .scale_factor() + .map_err(|error| format!("failed to read scale factor: {error}"))?; + let inner = window + .inner_size() + .map_err(|error| format!("failed to read inner size: {error}"))?; + let outer = window + .outer_size() + .map_err(|error| format!("failed to read window size: {error}"))?; + let extra_w = (outer.width as f64 - inner.width as f64) / scale; + let extra_h = (outer.height as f64 - inner.height as f64) / scale; + let frame_w = width + extra_w; + let frame_h = height + extra_h; + window + .with_webview(move |webview| { + use objc2_app_kit::{NSAnimatablePropertyContainer, NSAnimationContext, NSWindow}; + use objc2_quartz_core::{kCAMediaTimingFunctionEaseInEaseOut, CAMediaTimingFunction}; + unsafe { + let ns_window: &NSWindow = &*webview.ns_window().cast(); + let mut frame = ns_window.frame(); + // AppKit origin is bottom-left, so keeping origin.y pins the bottom edge. + frame.size.width = frame_w; + frame.size.height = frame_h; + if !animate { + ns_window.setFrame_display(frame, true); + return; + } + NSAnimationContext::beginGrouping(); + let ctx = NSAnimationContext::currentContext(); + ctx.setDuration(RESIZE_ANIMATION_DURATION); + ctx.setTimingFunction(Some(&CAMediaTimingFunction::functionWithName( + kCAMediaTimingFunctionEaseInEaseOut, + ))); + ns_window.animator().setFrame_display(frame, true); + NSAnimationContext::endGrouping(); + } + }) + .map_err(|error| format!("failed to resize window: {error}")) +} + +/// Resize the calling window while keeping its bottom edge in place. +/// On macOS this uses a single `NSWindow` frame change (optionally animated). +#[tauri::command] +pub fn apply_bottom_anchored_size( + window: WebviewWindow, + width: f64, + height: f64, + animate: Option, +) -> Result<(), String> { + if width < 1.0 || height < 1.0 { + return Err("window size must be positive".into()); + } + let animate = animate.unwrap_or(false); + #[cfg(target_os = "macos")] + { + match apply_bottom_anchored_size_macos(&window, width, height, animate) { + Ok(()) => return Ok(()), + Err(error) => eprintln!("macos bottom-anchored resize failed: {error}"), + } + } + #[cfg(not(target_os = "macos"))] + let _ = animate; + apply_bottom_anchored_size_fallback(&window, width, height) +} + /// Horizontally center a window and pin it near the bottom of the work area. pub fn bottom_centered_position( window_size: (u32, u32), @@ -130,6 +335,69 @@ pub fn bottom_centered_position( ) } +/// Place the chat window beside the pet: prefer the pet's right side, else left. +/// Clamp into the monitor work area so the chat stays on-screen. +pub fn chat_position( + pet_position: (i32, i32), + pet_size: (u32, u32), + chat_size: (u32, u32), + work_position: (i32, i32), + work_size: (u32, u32), +) -> (i32, i32) { + let pet_width = pet_size.0 as i64; + let chat_width = chat_size.0 as i64; + let chat_height = chat_size.1 as i64; + let work_left = work_position.0 as i64; + let work_top = work_position.1 as i64; + let work_right = work_left + work_size.0 as i64; + let work_bottom = work_top + work_size.1 as i64; + let pet_x = pet_position.0 as i64; + let pet_y = pet_position.1 as i64; + + let right_x = pet_x + pet_width; + let desired_x = if right_x + chat_width <= work_right { + right_x + } else { + pet_x - chat_width + }; + let max_x = (work_right - chat_width).max(work_left); + let max_y = (work_bottom - chat_height).max(work_top); + + ( + desired_x.clamp(work_left, max_x) as i32, + pet_y.clamp(work_top, max_y) as i32, + ) +} + +fn place_chat_near_pet(app: &AppHandle, chat: &WebviewWindow) -> Result<(), String> { + let pet = app + .get_webview_window("pet") + .ok_or_else(|| "pet window not found".to_string())?; + let pet_position = pet + .outer_position() + .map_err(|error| format!("failed to read pet position: {error}"))?; + let pet_size = pet + .outer_size() + .map_err(|error| format!("failed to read pet size: {error}"))?; + let chat_size = chat + .outer_size() + .map_err(|error| format!("failed to read chat size: {error}"))?; + let monitor = pet + .current_monitor() + .map_err(|error| format!("failed to find pet monitor: {error}"))? + .ok_or_else(|| "pet window is not on an available monitor".to_string())?; + let work_area = monitor.work_area(); + let (x, y) = chat_position( + (pet_position.x, pet_position.y), + (pet_size.width, pet_size.height), + (chat_size.width, chat_size.height), + (work_area.position.x, work_area.position.y), + (work_area.size.width, work_area.size.height), + ); + chat.set_position(PhysicalPosition::new(x, y)) + .map_err(|error| format!("failed to position chat window: {error}")) +} + /// Horizontally and vertically center a window in the monitor work area. pub fn centered_position( window_size: (u32, u32), @@ -224,13 +492,11 @@ pub fn show_chat_near_pet(app: AppHandle) -> Result<(), String> { .get_webview_window("chat") .ok_or_else(|| "chat window not found".to_string())?; + // Always dock beside the pet: hide/re-open and pet moves should follow the icon. + place_chat_near_pet(&app, &chat)?; chat.show() .map_err(|error| format!("failed to show chat window: {error}"))?; - // First open only: place bottom-center. Later opens keep the last position - // (including after hide), so double-clicks / re-opens do not jump. - if !CHAT_HAS_BEEN_SHOWN.swap(true, Ordering::SeqCst) { - place_bottom_centered(&chat, CHAT_BOTTOM_GAP_LOGICAL)?; - } + apply_window_deactivate_policy(app.clone())?; chat.set_focus() .map_err(|error| format!("failed to focus chat window: {error}"))?; let _ = chat.emit("chat-shown", ()); @@ -264,6 +530,7 @@ pub fn show_settings(app: AppHandle) -> Result<(), String> { settings .show() .map_err(|error| format!("failed to show settings window: {error}"))?; + apply_window_deactivate_policy(app.clone())?; // First open only: true center. Later opens (and tab refits) keep position. if !SETTINGS_HAS_BEEN_SHOWN.swap(true, Ordering::SeqCst) { place_centered(&settings)?; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 52e07ce..8e8bf1a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -29,14 +29,14 @@ "label": "chat", "title": "Octop Chat", "width": 400, - "height": 220, - "minWidth": 320, - "minHeight": 120, + "height": 400, + "minWidth": 280, + "minHeight": 200, "visible": false, "decorations": false, "transparent": true, "shadow": true, - "resizable": false, + "resizable": true, "backgroundColor": "#00000000" }, { diff --git a/src-tauri/tests/command_logic.rs b/src-tauri/tests/command_logic.rs index 96e4f53..7c35d5f 100644 --- a/src-tauri/tests/command_logic.rs +++ b/src-tauri/tests/command_logic.rs @@ -6,10 +6,14 @@ use std::{ use octop_pet_lib::{ config_cmd::{load_from_path, patch_at_path, save_to_path, select_mascot, AppConfig}, - secrets_cmd::{secret_account, validate_secret_key}, + secrets_cmd::{ + delete_secret_from_file, get_secret_from_file, secret_account, set_secret_in_file, + validate_secret_key, + }, window_cmd::{ - bottom_centered_position, centered_position, home_url, should_hide_on_close, - CHAT_BOTTOM_GAP_LOGICAL, + bottom_anchored_position, bottom_centered_position, centered_position, chat_position, + home_url, should_hide_on_close, should_hide_on_unfocus, CHAT_BOTTOM_GAP_LOGICAL, + RESIZE_ANIMATION_DURATION, }, }; @@ -27,6 +31,7 @@ fn app_config_defaults_match_the_frontend() { pet_y: None, shortcut_open_pet: "CmdOrCtrl+Shift+O".into(), shortcut_open_home: "CmdOrCtrl+Shift+H".into(), + keep_windows_visible: true, } ); } @@ -43,6 +48,7 @@ fn app_config_serializes_with_frontend_field_names() { assert!(value.get("petY").is_some()); assert!(value.get("shortcutOpenPet").is_some()); assert!(value.get("shortcutOpenHome").is_some()); + assert!(value.get("keepWindowsVisible").is_some()); } #[test] @@ -116,10 +122,26 @@ fn config_patch_updates_only_owned_fields() { ..original } ); + patch_at_path(&path, serde_json::json!({ "keepWindowsVisible": false })).unwrap(); + assert!(!load_from_path(&path).unwrap().keep_windows_visible); assert!(patch_at_path(&path, serde_json::json!({ "unknown": true })).is_err()); fs::remove_dir_all(dir).unwrap(); } +#[test] +fn keep_windows_visible_defaults_when_missing_from_file() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("octop-pet-keep-visible-{unique}")); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.json"); + fs::write(&path, r#"{"baseUrl":"https://x.example"}"#).unwrap(); + assert!(load_from_path(&path).unwrap().keep_windows_visible); + fs::remove_dir_all(dir).unwrap(); +} + #[test] fn secrets_are_scoped_by_username_and_restricted_to_known_keys() { assert_eq!( @@ -132,6 +154,28 @@ fn secrets_are_scoped_by_username_and_restricted_to_known_keys() { assert!(secret_account("", "password").is_err()); } +#[test] +fn debug_secrets_file_round_trips_without_keyring() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("octop-pet-secrets-{unique}")); + let path = dir.join("dev-secrets.json"); + + assert_eq!(get_secret_from_file(&path, "alice:password").unwrap(), None); + set_secret_in_file(&path, "alice:password", "s3cret").unwrap(); + assert_eq!( + get_secret_from_file(&path, "alice:password") + .unwrap() + .as_deref(), + Some("s3cret") + ); + delete_secret_from_file(&path, "alice:password").unwrap(); + assert_eq!(get_secret_from_file(&path, "alice:password").unwrap(), None); + fs::remove_dir_all(dir).unwrap(); +} + #[test] fn chat_and_settings_close_requests_are_hidden() { assert!(should_hide_on_close("chat")); @@ -139,6 +183,16 @@ fn chat_and_settings_close_requests_are_hidden() { assert!(!should_hide_on_close("pet")); } +#[test] +fn chat_and_settings_hide_on_unfocus_only_when_setting_is_off() { + assert!(!should_hide_on_unfocus("chat", true)); + assert!(!should_hide_on_unfocus("settings", true)); + assert!(!should_hide_on_unfocus("pet", true)); + assert!(should_hide_on_unfocus("chat", false)); + assert!(should_hide_on_unfocus("settings", false)); + assert!(!should_hide_on_unfocus("pet", false)); +} + #[test] fn home_url_has_exactly_one_trailing_slash() { assert_eq!( @@ -148,6 +202,32 @@ fn home_url_has_exactly_one_trailing_slash() { assert!(home_url(" ").is_err()); } +#[test] +fn chat_position_prefers_right_and_falls_back_to_left() { + assert_eq!( + chat_position((100, 80), (160, 160), (420, 560), (0, 0), (1200, 900)), + (260, 80) + ); + assert_eq!( + chat_position((1050, 80), (160, 160), (420, 560), (0, 0), (1200, 900)), + (630, 80) + ); +} + +#[test] +fn chat_position_is_clamped_to_monitor_work_area() { + assert_eq!( + chat_position( + (-1700, -200), + (160, 160), + (420, 560), + (-1440, 25), + (1440, 875), + ), + (-1440, 25) + ); +} + #[test] fn windows_open_bottom_centered_with_gap() { assert_eq!( @@ -173,6 +253,24 @@ fn bottom_centered_position_is_clamped_to_monitor_work_area() { ); } +#[test] +fn resize_animation_is_slower_than_appkit_default() { + assert!(RESIZE_ANIMATION_DURATION > 0.2); + assert!(RESIZE_ANIMATION_DURATION <= 0.45); +} + +#[test] +fn bottom_anchored_resize_keeps_the_bottom_edge() { + assert_eq!( + bottom_anchored_position((400, 300), (400, 560), (400, 160)), + (400, 700) // grow-shrink: y += 560-160 + ); + assert_eq!( + bottom_anchored_position((100, 500), (400, 160), (400, 560)), + (100, 100) // expand upward: y += 160-560 + ); +} + #[test] fn chat_bottom_gap_is_ninety_six_logical_pixels() { assert!((CHAT_BOTTOM_GAP_LOGICAL - 96.0).abs() < f64::EPSILON); diff --git a/src/assets/logo.svg b/src/assets/logo.svg new file mode 100644 index 0000000..3e78cb0 --- /dev/null +++ b/src/assets/logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/AgentSelect.tsx b/src/components/AgentSelect.tsx index e2e50fe..b9f33fc 100644 --- a/src/components/AgentSelect.tsx +++ b/src/components/AgentSelect.tsx @@ -1,9 +1,14 @@ +import type { RefObject } from "react"; + import type { AgentSummary } from "../lib/types"; interface AgentSelectProps { agents: AgentSummary[]; value: string; disabled?: boolean; + open: boolean; + menuRef: RefObject; + onOpenChange: (open: boolean) => void; onChange: (agentId: string) => void; } @@ -11,23 +16,45 @@ export default function AgentSelect({ agents, value, disabled = false, + open, + menuRef, + onOpenChange, onChange, }: AgentSelectProps) { + const selected = agents.find((agent) => agent.id === value); + const label = selected?.name ?? "选择代理"; + return ( - + {label} + + {open ? ( +
+ {agents.map((agent) => ( + + ))} +
+ ) : null} + ); } diff --git a/src/components/ChatChrome.test.tsx b/src/components/ChatChrome.test.tsx new file mode 100644 index 0000000..d70947f --- /dev/null +++ b/src/components/ChatChrome.test.tsx @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import ChatChrome from "./ChatChrome"; + +vi.mock("../lib/tauriWindowApi", () => ({ + hideCurrentWindow: vi.fn().mockResolvedValue(undefined), +})); + +function setPlatform(platform: string) { + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }); +} + +const originalPlatform = navigator.platform; + +describe("ChatChrome", () => { + afterEach(() => { + Object.defineProperty(navigator, "platform", { + configurable: true, + value: originalPlatform, + }); + cleanup(); + }); + + it("places close on the left and new session on the right on macOS", () => { + setPlatform("MacIntel"); + const { container } = render( undefined} />); + const header = container.querySelector(".chat-chrome"); + + expect(header).toHaveAttribute("data-close-side", "start"); + expect(header?.firstElementChild).toBe( + screen.getByRole("button", { name: "关闭" }), + ); + expect(header?.lastElementChild).toBe( + screen.getByRole("button", { name: "新建会话" }), + ); + }); + + it("places new session on the left and close on the right on Windows", () => { + setPlatform("Win32"); + const { container } = render( undefined} />); + const header = container.querySelector(".chat-chrome"); + + expect(header).toHaveAttribute("data-close-side", "end"); + expect(header?.firstElementChild).toBe( + screen.getByRole("button", { name: "新建会话" }), + ); + expect(header?.lastElementChild).toBe( + screen.getByRole("button", { name: "关闭" }), + ); + }); +}); diff --git a/src/components/ChatChrome.tsx b/src/components/ChatChrome.tsx index 6e9e53d..efbf1cf 100644 --- a/src/components/ChatChrome.tsx +++ b/src/components/ChatChrome.tsx @@ -1,4 +1,6 @@ -import { hideCurrentWindow } from "../lib/tauriWindowApi"; +import { windowCloseSide } from "../lib/platform"; + +import WindowCloseButton from "./WindowCloseButton"; export default function ChatChrome({ onNewSession, @@ -7,46 +9,49 @@ export default function ChatChrome({ onNewSession?: () => void; newSessionDisabled?: boolean; }) { + const closeSide = windowCloseSide(); + const close = ; + const newSession = onNewSession ? ( + + ) : ( + + ); + return ( -
- - ) : ( - - )} + {closeSide === "start" ? newSession : close}
); } diff --git a/src/components/Composer.test.tsx b/src/components/Composer.test.tsx index d7dae0c..9a4e54b 100644 --- a/src/components/Composer.test.tsx +++ b/src/components/Composer.test.tsx @@ -60,4 +60,105 @@ describe("Composer", () => { mcpServers: [], }); }); + + it("opens the model menu and notifies layout change", () => { + const onLayoutChange = vi.fn(); + render( + , + ); + + onLayoutChange.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "选择模型" })); + + expect(screen.getByRole("menu")).toBeInTheDocument(); + expect( + screen.getByRole("menuitemradio", { name: "openai / GPT-4o" }), + ).toBeInTheDocument(); + expect(onLayoutChange).toHaveBeenCalled(); + }); + + it("opens the agent menu like the model list", () => { + const onAgentChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "选择代理" })); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Research" })); + + expect(onAgentChange).toHaveBeenCalledWith("agent-2"); + }); + + it("clamps an open model menu to the window instead of overflowing", () => { + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: 400, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: 200, + }); + vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation( + function (this: Element) { + const className = (this as HTMLElement).className ?? ""; + if (String(className).includes("composer-popover")) { + return { + width: 220, + height: 180, + top: -20, + left: 200, + right: 420, + bottom: 160, + x: 200, + y: -20, + toJSON: () => ({}), + } as DOMRect; + } + return { + width: 72, + height: 28, + top: 150, + left: 210, + right: 282, + bottom: 178, + x: 210, + y: 150, + toJSON: () => ({}), + } as DOMRect; + }, + ); + + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "选择模型" })); + + const menu = screen.getByRole("menu"); + expect(Number.parseInt(menu.style.maxHeight, 10)).toBeLessThanOrEqual(136); + expect(menu.style.right).toBe("0px"); + }); }); diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index 07aa78e..1e25ac4 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -1,14 +1,15 @@ import { Link2, Paperclip, X } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import AgentSelect from "./AgentSelect"; -import type { AgentSummary } from "../lib/types"; +import { applyPopoverPlacement, popoverPlacement } from "../lib/chatHelpers"; import type { ChatAttachment, ConnectorOption, ResolvedModel, } from "../lib/octopTypes"; import { modelOptionLabel, modelOptionValue } from "../lib/octopTypes"; +import type { AgentSummary } from "../lib/types"; export type ComposerSendOptions = { attachments: ChatAttachment[]; @@ -61,9 +62,11 @@ export default function Composer({ const [selectedConnectors, setSelectedConnectors] = useState([]); const [modelOpen, setModelOpen] = useState(false); const [connectorOpen, setConnectorOpen] = useState(false); + const [agentOpen, setAgentOpen] = useState(false); const composingRef = useRef(false); const ignoreEnterUntilRef = useRef(0); const fileRef = useRef(null); + const agentMenuRef = useRef(null); const modelMenuRef = useRef(null); const connectorMenuRef = useRef(null); const textareaRef = useRef(null); @@ -77,6 +80,9 @@ export default function Composer({ useEffect(() => { function onDocClick(event: MouseEvent) { const target = event.target as Node; + if (agentMenuRef.current && !agentMenuRef.current.contains(target)) { + setAgentOpen(false); + } if (modelMenuRef.current && !modelMenuRef.current.contains(target)) { setModelOpen(false); } @@ -91,6 +97,41 @@ export default function Composer({ return () => document.removeEventListener("mousedown", onDocClick); }, []); + useLayoutEffect(() => { + if (!agentOpen && !modelOpen && !connectorOpen) return; + const fit = () => { + for (const menu of [ + agentMenuRef.current, + modelMenuRef.current, + connectorMenuRef.current, + ]) { + const popover = menu?.querySelector(".composer-popover"); + if (!menu || !popover) continue; + applyPopoverPlacement( + popover, + popoverPlacement({ + anchor: menu.getBoundingClientRect(), + viewport: { + width: window.innerWidth, + height: window.innerHeight, + }, + popoverWidth: popover.getBoundingClientRect().width, + }), + ); + } + }; + fit(); + window.addEventListener("resize", fit); + return () => window.removeEventListener("resize", fit); + }, [ + agentOpen, + modelOpen, + connectorOpen, + agents.length, + models.length, + connectors.length, + ]); + useEffect(() => { const textarea = textareaRef.current; if (textarea) { @@ -99,7 +140,14 @@ export default function Composer({ textarea.style.height = `${next}px`; } onLayoutChange?.(); - }, [text, attachments.length, onLayoutChange]); + }, [ + text, + attachments.length, + agentOpen, + modelOpen, + connectorOpen, + onLayoutChange, + ]); function submitDraft() { if (streaming) { @@ -131,7 +179,7 @@ export default function Composer({ (model) => modelOptionValue(model) === selectedModel, ); const selectedModelLabel = selectedModelRow - ? modelOptionLabel(selectedModelRow) + ? selectedModelRow.name || selectedModelRow.model : "自动"; const submitMode = streaming ? (hasContent ? "queue" : "stop") : "send"; @@ -239,6 +287,15 @@ export default function Composer({ agents={agents} value={agentId} disabled={agentDisabled || streaming} + open={agentOpen} + menuRef={agentMenuRef} + onOpenChange={(next) => { + setAgentOpen(next); + if (next) { + setModelOpen(false); + setConnectorOpen(false); + } + }} onChange={onAgentChange} /> @@ -252,10 +309,11 @@ export default function Composer({ onClick={() => { setConnectorOpen((open) => !open); setModelOpen(false); + setAgentOpen(false); }} > - + {selectedConnectors.length ? `连接器 ${selectedConnectors.length}` : "连接器"} @@ -302,6 +360,7 @@ export default function Composer({ onClick={() => { setModelOpen((open) => !open); setConnectorOpen(false); + setAgentOpen(false); }} > {selectedModelLabel} diff --git a/src/components/MessageList.tsx b/src/components/MessageList.tsx index 6735f26..56297d6 100644 --- a/src/components/MessageList.tsx +++ b/src/components/MessageList.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; +import logoUrl from "../assets/logo.svg"; import type { ChatMessage } from "../lib/types"; import AssistantMarkdown from "./AssistantMarkdown"; import GeneratingIndicator from "./GeneratingIndicator"; @@ -181,8 +182,15 @@ export default function MessageList({ endRef.current?.scrollIntoView?.({ block: "end" }); }, [messages, statusLabel]); - if (!loading && messages.length === 0 && !statusLabel) { - return null; + if (messages.length === 0 && !statusLabel) { + return ( +
+ +
+ ); } return ( diff --git a/src/components/ShortcutRecorder.tsx b/src/components/ShortcutRecorder.tsx index 92a2934..2b76424 100644 --- a/src/components/ShortcutRecorder.tsx +++ b/src/components/ShortcutRecorder.tsx @@ -72,7 +72,7 @@ export default function ShortcutRecorder({ id={id} type="button" className={`shortcut-recorder${recording ? " is-recording" : ""}`} - aria-label={recording ? "正在录制快捷键" : "录制快捷键"} + aria-label={recording ? "正在录制快捷键" : undefined} aria-pressed={recording} disabled={disabled} onClick={startRecording} diff --git a/src/components/WindowCloseButton.test.tsx b/src/components/WindowCloseButton.test.tsx new file mode 100644 index 0000000..16a4def --- /dev/null +++ b/src/components/WindowCloseButton.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { hideCurrentWindow } from "../lib/tauriWindowApi"; + +import WindowCloseButton from "./WindowCloseButton"; + +vi.mock("../lib/tauriWindowApi", () => ({ + hideCurrentWindow: vi.fn().mockResolvedValue(undefined), +})); + +function setPlatform(platform: string) { + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }); +} + +const originalPlatform = navigator.platform; + +describe("WindowCloseButton", () => { + afterEach(() => { + Object.defineProperty(navigator, "platform", { + configurable: true, + value: originalPlatform, + }); + cleanup(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders a start-edge traffic light on macOS", () => { + setPlatform("MacIntel"); + render(); + + expect(screen.getByRole("button", { name: "关闭" })).toHaveAttribute( + "data-close-side", + "start", + ); + expect( + screen.getByRole("button", { name: "关闭" }).querySelector("svg"), + ).toBeInTheDocument(); + }); + + it("renders an end-edge × on Windows", () => { + setPlatform("Win32"); + render(); + + expect(screen.getByRole("button", { name: "关闭" })).toHaveAttribute( + "data-close-side", + "end", + ); + }); + + it("hides the current window", () => { + setPlatform("Win32"); + render(); + + fireEvent.click(screen.getByRole("button", { name: "关闭" })); + + expect(hideCurrentWindow).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/components/WindowCloseButton.tsx b/src/components/WindowCloseButton.tsx new file mode 100644 index 0000000..e7cb9d0 --- /dev/null +++ b/src/components/WindowCloseButton.tsx @@ -0,0 +1,26 @@ +import { windowCloseSide } from "../lib/platform"; +import { hideCurrentWindow } from "../lib/tauriWindowApi"; + +export default function WindowCloseButton() { + const side = windowCloseSide(); + + return ( + + ); +} diff --git a/src/hooks/useChatController.ts b/src/hooks/useChatController.ts index e84f6d2..e520ad9 100644 --- a/src/hooks/useChatController.ts +++ b/src/hooks/useChatController.ts @@ -15,11 +15,13 @@ import { } from "../lib/chatStream"; import { CHAT_COMPACT_MIN_HEIGHT, - CHAT_EXPANDED_HEIGHT, + CHAT_INITIAL_HEIGHT, CHAT_MIN_HEIGHT, CHAT_MIN_WIDTH, CHAT_WIDTH, + chatWindowWidth, chatErrorText, + compactWindowHeight, historyMessages, nextChatMessageId, } from "../lib/chatHelpers"; @@ -78,6 +80,8 @@ export function useChatController() { const rootRef = useRef(null); const wasExpandedRef = useRef(false); const lastCompactHeightRef = useRef(0); + const [layoutExpanded, setLayoutExpanded] = useState(false); + const layoutExpandedRef = useRef(false); const [speakingId, setSpeakingId] = useState(null); const [models, setModels] = useState([]); @@ -103,6 +107,10 @@ export function useChatController() { const socketRef = useRef(null); const assistantIdRef = useRef(""); const streamFinishedRef = useRef(true); + const settleStreamRef = useRef<(() => void) | null>(null); + const retryTurnsRef = useRef< + Record + >({}); const loadSequenceRef = useRef(0); const mountedRef = useRef(true); const connectionRef = useRef(connection); @@ -112,7 +120,11 @@ export function useChatController() { const messagesRef = useRef([]); messagesRef.current = messages; - const expanded = messages.length > 0 || loadingHistory || queue.length > 0; + const expanded = messages.length > 0 || queue.length > 0; + layoutExpandedRef.current = layoutExpanded; + if (expanded && !layoutExpanded) { + setLayoutExpanded(true); + } const requireSettings = useCallback(async () => { tokenRef.current = ""; @@ -171,6 +183,8 @@ export function useChatController() { const stopStream = useCallback(() => { const socket = socketRef.current; const thread = threadRef.current; + settleStreamRef.current?.(); + settleStreamRef.current = null; streamFinishedRef.current = true; if (socket && socket.readyState === WebSocket.OPEN && thread) { socket.send(JSON.stringify(buildCancelPayload(thread.id))); @@ -315,20 +329,34 @@ export function useChatController() { const initialize = useCallback(async () => { const sequence = ++loadSequenceRef.current; + threadRef.current = null; setConnection("loading"); setError(""); try { - const [config, token] = await Promise.all([ + const [config, storedToken] = await Promise.all([ tauriApi.loadConfig(), tauriApi.getSecret("access_token"), ]); if (sequence !== loadSequenceRef.current || !mountedRef.current) return; configRef.current = config; + let token = storedToken; if (!token) { - setNeedsSettings(true); - setConnection("disconnected"); - return; + const password = await tauriApi.getSecret("password"); + if (!config.username.trim() || !password) { + setNeedsSettings(true); + setConnection("disconnected"); + return; + } + try { + const result = await login(config.baseUrl, config.username, password); + await tauriApi.setSecret("access_token", result.access_token); + token = result.access_token; + } catch { + setNeedsSettings(true); + setConnection("disconnected"); + return; + } } tokenRef.current = token; @@ -405,6 +433,8 @@ export function useChatController() { mountedRef.current = false; loadSequenceRef.current += 1; streamFinishedRef.current = true; + settleStreamRef.current?.(); + settleStreamRef.current = null; socketRef.current?.close(); socketRef.current = null; unlisten?.(); @@ -455,6 +485,11 @@ export function useChatController() { stopSpeaking(); assistantIdRef.current = assistantId; + let settled = false; + const settle = () => { + settled = true; + }; + settleStreamRef.current = settle; streamFinishedRef.current = false; flushAfterStreamRef.current = true; setConnection("streaming"); @@ -469,8 +504,10 @@ export function useChatController() { let status = beginStreamStatus(); const finish = (streamError?: string) => { - if (streamFinishedRef.current) return; + if (settled) return; + settle(); streamFinishedRef.current = true; + if (settleStreamRef.current === settle) settleStreamRef.current = null; setStreamStatus(idleStreamStatus()); setMessages((current) => current.map((message) => @@ -485,8 +522,13 @@ export function useChatController() { ), ); setConnection(streamError ? "disconnected" : "connected"); - socketRef.current = null; - socket.close(); + if (socketRef.current === socket) socketRef.current = null; + if ( + socket.readyState === WebSocket.OPEN || + socket.readyState === WebSocket.CONNECTING + ) { + socket.close(); + } }; socket.onopen = () => { @@ -524,7 +566,7 @@ export function useChatController() { }; socket.onerror = () => finish("流式连接失败"); socket.onclose = () => { - if (!streamFinishedRef.current) finish("连接意外断开"); + if (!settled) finish("连接意外断开"); }; }, [agentId, stopSpeaking], @@ -540,6 +582,7 @@ export function useChatController() { : ""); const userId = nextChatMessageId("user"); const assistantId = nextChatMessageId("assistant"); + retryTurnsRef.current[assistantId] = { text, options }; setMessages((current) => [ ...current, { id: userId, role: "user", content: displayText }, @@ -608,16 +651,20 @@ export function useChatController() { ); if (index < 0) return; - let prompt = ""; - for (let i = index - 1; i >= 0; i -= 1) { - if (current[i].role === "user" && current[i].content.trim()) { - prompt = current[i].content; - break; + const stored = retryTurnsRef.current[assistantMessageId]; + let prompt = stored?.text ?? ""; + if (!prompt) { + for (let i = index - 1; i >= 0; i -= 1) { + if (current[i].role === "user" && current[i].content.trim()) { + prompt = current[i].content; + break; + } } } if (!prompt) return; const nextAssistantId = nextChatMessageId("assistant"); + if (stored) retryTurnsRef.current[nextAssistantId] = stored; setMessages([ ...current.slice(0, index), { @@ -627,7 +674,7 @@ export function useChatController() { pending: true, }, ]); - startAssistantStream(prompt, nextAssistantId); + startAssistantStream(prompt, nextAssistantId, stored?.options); }, [connection, startAssistantStream], ); @@ -662,47 +709,67 @@ export function useChatController() { useEffect(() => () => stopSpeaking(), [stopSpeaking]); - const fitCompactWindow = useCallback(() => { - const root = rootRef.current; - if (!root || expanded || needsSettings) return; - const height = Math.max( - CHAT_COMPACT_MIN_HEIGHT, - Math.ceil(root.getBoundingClientRect().height), - ); - if (height === lastCompactHeightRef.current) return; - lastCompactHeightRef.current = height; - void applyBottomAnchoredSize({ width: CHAT_WIDTH, height }); - }, [expanded, needsSettings]); + const fitCompactWindow = useCallback( + (animate = false) => { + const root = rootRef.current; + if (!root || expanded || layoutExpandedRef.current || needsSettings) + return; + const needed = compactWindowHeight(root, CHAT_COMPACT_MIN_HEIGHT); + const current = Math.ceil(window.innerHeight); + if (needed <= current) return; + lastCompactHeightRef.current = needed; + void applyBottomAnchoredSize({ + width: chatWindowWidth(), + height: needed, + ...(animate ? { animate: true } : {}), + }); + }, + [expanded, needsSettings], + ); useLayoutEffect(() => { - document.documentElement.classList.toggle("chat-expanded", expanded); - void setCurrentWindowResizable(expanded); + document.documentElement.classList.toggle("chat-expanded", layoutExpanded); + return () => document.documentElement.classList.remove("chat-expanded"); + }, [layoutExpanded]); + + useLayoutEffect(() => { + void setCurrentWindowResizable(true); void clearCurrentWindowMaxSize(); - void setCurrentWindowMinSize( - CHAT_MIN_WIDTH, - expanded ? CHAT_MIN_HEIGHT : CHAT_COMPACT_MIN_HEIGHT, - ); - if (expanded || needsSettings) { - if (!wasExpandedRef.current) { + let cancelled = false; + async function syncWindow() { + await setCurrentWindowMinSize(CHAT_MIN_WIDTH, CHAT_MIN_HEIGHT); + if (cancelled) return; + + if (expanded || needsSettings) { wasExpandedRef.current = true; + setLayoutExpanded(true); + return; + } + + const shrinking = wasExpandedRef.current; + wasExpandedRef.current = false; + if (shrinking) { + lastCompactHeightRef.current = Math.ceil(window.innerHeight); + setLayoutExpanded(false); + return; + } + + setLayoutExpanded(false); + if (lastCompactHeightRef.current === 0) { + lastCompactHeightRef.current = CHAT_INITIAL_HEIGHT; void applyBottomAnchoredSize({ width: CHAT_WIDTH, - height: CHAT_EXPANDED_HEIGHT, + height: CHAT_INITIAL_HEIGHT, }); } - } else if (wasExpandedRef.current) { - wasExpandedRef.current = false; - lastCompactHeightRef.current = 0; - requestAnimationFrame(() => fitCompactWindow()); - } else { - requestAnimationFrame(() => fitCompactWindow()); } + void syncWindow(); return () => { - document.documentElement.classList.remove("chat-expanded"); + cancelled = true; }; - }, [expanded, needsSettings, fitCompactWindow]); + }, [expanded, needsSettings]); const statusLabel = connection === "streaming" @@ -713,6 +780,7 @@ export function useChatController() { rootRef, needsSettings, expanded, + layoutExpanded, error, agents, agentId, @@ -727,6 +795,12 @@ export function useChatController() { queue, statusLabel, threadReady: Boolean(threadRef.current), + canRetryInit: + !needsSettings && + connection === "disconnected" && + Boolean(error) && + !threadRef.current, + retryInitialize: initialize, openAgent, startNewSession, stopStream, diff --git a/src/lib/chatHelpers.test.ts b/src/lib/chatHelpers.test.ts index e73ce49..0e05b6d 100644 --- a/src/lib/chatHelpers.test.ts +++ b/src/lib/chatHelpers.test.ts @@ -1,9 +1,32 @@ import { describe, expect, it } from "vitest"; -import { chatErrorText, nextChatMessageId } from "./chatHelpers"; +import { + CHAT_MIN_WIDTH, + CHAT_WIDTH, + chatErrorText, + chatWindowWidth, + compactWindowHeight, + nextChatMessageId, + popoverPlacement, +} from "./chatHelpers"; import { OctopHttpError } from "./octopHttp"; describe("chatHelpers", () => { + it("uses the current window width when it is at least the chat minimum", () => { + const width = window.innerWidth; + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: 300, + }); + expect(chatWindowWidth()).toBe(300); + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: width, + }); + expect(CHAT_MIN_WIDTH).toBeLessThanOrEqual(300); + expect(CHAT_WIDTH).toBe(400); + }); + it("generates unique message ids", () => { expect(nextChatMessageId("user")).toMatch(/^user-\d+$/); expect(nextChatMessageId("user")).not.toBe(nextChatMessageId("user")); @@ -14,4 +37,75 @@ describe("chatHelpers", () => { "登录已失效,请重新设置账号", ); }); + + it("surfaces parsed API error text for non-401 failures", () => { + expect( + chatErrorText(new OctopHttpError(500, '{"message":"模型不可用"}')), + ).toBe("服务请求失败:模型不可用"); + }); + + it("does not grow compact height for overflowing popovers", () => { + const root = { + getBoundingClientRect: () => ({ top: 100, bottom: 250 }), + querySelector: () => null, + classList: { contains: () => false }, + } as unknown as HTMLElement; + expect(compactWindowHeight(root, 120)).toBe(150); + }); + + it("clamps a popover to the remaining viewport and flips when needed", () => { + expect( + popoverPlacement({ + anchor: { top: 160, bottom: 188, left: 40 }, + viewport: { width: 400, height: 220 }, + popoverWidth: 180, + }), + ).toEqual({ maxHeight: 146, placeAbove: true, alignRight: false }); + expect( + popoverPlacement({ + anchor: { top: 24, bottom: 52, left: 40 }, + viewport: { width: 400, height: 220 }, + popoverWidth: 180, + }), + ).toEqual({ maxHeight: 154, placeAbove: false, alignRight: false }); + expect( + popoverPlacement({ + anchor: { top: 160, bottom: 188, left: 280 }, + viewport: { width: 400, height: 220 }, + popoverWidth: 220, + }).alignRight, + ).toBe(true); + }); + + it("lets a popover use remaining window height instead of a 12rem cap", () => { + expect( + popoverPlacement({ + anchor: { top: 480, bottom: 508, left: 40 }, + viewport: { width: 400, height: 640 }, + popoverWidth: 180, + }), + ).toEqual({ maxHeight: 466, placeAbove: true, alignRight: false }); + }); + + it("excludes the message body when measuring an expanded card", () => { + const body = { getBoundingClientRect: () => ({ height: 300 }) }; + const root = { + getBoundingClientRect: () => ({ top: 0, bottom: 560 }), + querySelector: (sel: string) => (sel === ".chat-body" ? body : null), + querySelectorAll: () => [], + classList: { contains: () => false }, + } as unknown as HTMLElement; + expect(compactWindowHeight(root, 120)).toBe(260); + }); + + it("keeps a compact error body in the measured height", () => { + const body = { getBoundingClientRect: () => ({ height: 80 }) }; + const root = { + getBoundingClientRect: () => ({ top: 0, bottom: 200 }), + querySelector: () => body, + querySelectorAll: () => [], + classList: { contains: (name: string) => name === "is-compact" }, + } as unknown as HTMLElement; + expect(compactWindowHeight(root, 120)).toBe(200); + }); }); diff --git a/src/lib/chatHelpers.ts b/src/lib/chatHelpers.ts index 2ddb369..24cbb70 100644 --- a/src/lib/chatHelpers.ts +++ b/src/lib/chatHelpers.ts @@ -2,10 +2,83 @@ import { OctopHttpError, extractTextContent } from "./octopHttp"; import type { ChatMessage } from "./types"; export const CHAT_WIDTH = 400; +export const CHAT_INITIAL_HEIGHT = 400; export const CHAT_EXPANDED_HEIGHT = 560; export const CHAT_COMPACT_MIN_HEIGHT = 120; -export const CHAT_MIN_WIDTH = 320; +export const CHAT_MIN_WIDTH = 280; + +export function chatWindowWidth(): number { + const inner = Math.ceil(window.innerWidth); + return Number.isFinite(inner) && inner >= CHAT_MIN_WIDTH ? inner : CHAT_WIDTH; +} export const CHAT_MIN_HEIGHT = 200; +/** Keep in sync with `RESIZE_ANIMATION_DURATION` in window_cmd.rs. */ +export const CHAT_RESIZE_DURATION_MS = 360; +export const POPOVER_GAP_PX = 6; +export const POPOVER_VIEWPORT_PAD_PX = 8; + +export type PopoverPlacement = { + maxHeight: number; + placeAbove: boolean; + alignRight: boolean; +}; + +export function popoverPlacement(args: { + anchor: { top: number; bottom: number; left: number }; + viewport: { width: number; height: number }; + popoverWidth: number; +}): PopoverPlacement { + const spaceAbove = args.anchor.top - POPOVER_VIEWPORT_PAD_PX - POPOVER_GAP_PX; + const spaceBelow = + args.viewport.height - + args.anchor.bottom - + POPOVER_VIEWPORT_PAD_PX - + POPOVER_GAP_PX; + const placeAbove = spaceAbove >= spaceBelow; + const available = placeAbove ? spaceAbove : spaceBelow; + return { + maxHeight: Math.max(0, Math.floor(available)), + placeAbove, + alignRight: + args.anchor.left + args.popoverWidth + POPOVER_VIEWPORT_PAD_PX > + args.viewport.width, + }; +} + +export function applyPopoverPlacement( + popover: HTMLElement, + placement: PopoverPlacement, +): void { + popover.style.maxHeight = `${placement.maxHeight}px`; + if (placement.placeAbove) { + popover.style.bottom = `calc(100% + ${POPOVER_GAP_PX}px)`; + popover.style.top = "auto"; + } else { + popover.style.top = `calc(100% + ${POPOVER_GAP_PX}px)`; + popover.style.bottom = "auto"; + } + if (placement.alignRight) { + popover.style.left = "auto"; + popover.style.right = "0"; + } else { + popover.style.left = "0"; + popover.style.right = "auto"; + } +} + +export function compactWindowHeight( + root: HTMLElement, + minHeight = CHAT_COMPACT_MIN_HEIGHT, +): number { + const rootRect = root.getBoundingClientRect(); + const top = rootRect.top; + let bottom = rootRect.bottom; + const body = root.querySelector(".chat-body"); + if (body && !root.classList.contains("is-compact")) { + bottom -= body.getBoundingClientRect().height; + } + return Math.max(minHeight, Math.ceil(bottom - top)); +} let nextMessageId = 0; @@ -34,6 +107,9 @@ export function historyMessages( export function chatErrorText(error: unknown): string { if (error instanceof OctopHttpError) { if (error.status === 401) return "登录已失效,请重新设置账号"; + if (!error.message.startsWith("HTTP ")) { + return `服务请求失败:${error.message}`; + } return `服务请求失败(${error.status})`; } return error instanceof Error ? error.message : "连接服务失败"; diff --git a/src/lib/configLogic.test.ts b/src/lib/configLogic.test.ts index b197810..2f2b270 100644 --- a/src/lib/configLogic.test.ts +++ b/src/lib/configLogic.test.ts @@ -37,3 +37,9 @@ describe("withMascot", () => { expect(withMascot(DEFAULT_APP_CONFIG, "type").mascotId).toBe("type"); }); }); + +describe("defaults", () => { + it("keeps windows visible after clicking another app", () => { + expect(DEFAULT_APP_CONFIG.keepWindowsVisible).toBe(true); + }); +}); diff --git a/src/lib/configLogic.ts b/src/lib/configLogic.ts index 24811a9..f53fd4b 100644 --- a/src/lib/configLogic.ts +++ b/src/lib/configLogic.ts @@ -10,6 +10,7 @@ export const DEFAULT_APP_CONFIG: AppConfig = { petY: null, shortcutOpenPet: "CmdOrCtrl+Shift+O", shortcutOpenHome: "CmdOrCtrl+Shift+H", + keepWindowsVisible: true, }; export const MASCOT_SRC: Record = { diff --git a/src/lib/octopHttp.test.ts b/src/lib/octopHttp.test.ts index e2b768b..6609d63 100644 --- a/src/lib/octopHttp.test.ts +++ b/src/lib/octopHttp.test.ts @@ -4,6 +4,8 @@ import { listAgents, createThread, extractTextContent, + parseApiErrorMessage, + OctopHttpError, buildUserMessageContent, listResolvedModels, listConnectors, @@ -95,6 +97,21 @@ describe("octopHttp", () => { expect(t.thread_id).toBe("th1"); }); + it("parseApiErrorMessage reads Octop error.message", () => { + expect( + parseApiErrorMessage( + '{"error":{"code":"AUTH_FAILED","message":"认证失败。","details":{}}}', + ), + ).toBe("认证失败。"); + expect(parseApiErrorMessage('{"detail":"not found"}')).toBe("not found"); + expect( + new OctopHttpError( + 401, + '{"error":{"code":"AUTH_FAILED","message":"认证失败。"}}', + ).message, + ).toBe("认证失败。"); + }); + it("extractTextContent flattens string or text parts", () => { expect(extractTextContent("hi")).toBe("hi"); expect( diff --git a/src/lib/octopHttp.ts b/src/lib/octopHttp.ts index 0eeb598..9d5bab2 100644 --- a/src/lib/octopHttp.ts +++ b/src/lib/octopHttp.ts @@ -17,11 +17,45 @@ export { modelOptionValue, modelRef, } from "./octopTypes"; +export function parseApiErrorMessage(body: string): string | null { + const trimmed = body.trim(); + if (!trimmed) return null; + try { + const data = JSON.parse(trimmed) as { + detail?: unknown; + message?: unknown; + error?: { message?: unknown } | string; + }; + if (data && typeof data === "object") { + if ( + data.error && + typeof data.error === "object" && + typeof data.error.message === "string" && + data.error.message.trim() + ) { + return data.error.message.trim(); + } + if (typeof data.detail === "string" && data.detail.trim()) { + return data.detail.trim(); + } + if (typeof data.message === "string" && data.message.trim()) { + return data.message.trim(); + } + if (typeof data.error === "string" && data.error.trim()) { + return data.error.trim(); + } + } + } catch { + if (trimmed.length < 160 && !trimmed.startsWith("{")) return trimmed; + } + return null; +} + export class OctopHttpError extends Error { status: number; body: string; constructor(status: number, body: string) { - super(`HTTP ${status}: ${body}`); + super(parseApiErrorMessage(body) || `HTTP ${status}: ${body}`); this.status = status; this.body = body; } diff --git a/src/lib/petContextMenu.ts b/src/lib/petContextMenu.ts index a772e2a..d83aa30 100644 --- a/src/lib/petContextMenu.ts +++ b/src/lib/petContextMenu.ts @@ -7,7 +7,7 @@ export async function showPetContextMenu(): Promise { items: [ await MenuItem.new({ id: "pet-chat", - text: "与Octop 对话", + text: "与 Octop 对话", action: () => { void tauriApi .showChatNearPet() diff --git a/src/lib/platform.test.ts b/src/lib/platform.test.ts new file mode 100644 index 0000000..ffd9adb --- /dev/null +++ b/src/lib/platform.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { isMacPlatform, windowCloseSide } from "./platform"; + +describe("isMacPlatform", () => { + it("matches macOS and iOS platform strings", () => { + expect(isMacPlatform("MacIntel")).toBe(true); + expect(isMacPlatform("iPhone")).toBe(true); + expect(isMacPlatform("iPad")).toBe(true); + }); + + it("rejects Windows and Linux", () => { + expect(isMacPlatform("Win32")).toBe(false); + expect(isMacPlatform("Linux x86_64")).toBe(false); + }); +}); + +describe("windowCloseSide", () => { + it("puts the close control on the start edge on macOS", () => { + expect(windowCloseSide("MacIntel")).toBe("start"); + }); + + it("puts the close control on the end edge on Windows and Linux", () => { + expect(windowCloseSide("Win32")).toBe("end"); + expect(windowCloseSide("Linux x86_64")).toBe("end"); + }); +}); diff --git a/src/lib/platform.ts b/src/lib/platform.ts new file mode 100644 index 0000000..64a2c4a --- /dev/null +++ b/src/lib/platform.ts @@ -0,0 +1,12 @@ +export function isMacPlatform(platform = navigator.platform): boolean { + return /mac|iphone|ipad|ipod/i.test(platform); +} + +export type WindowCloseSide = "start" | "end"; + +/** macOS: traffic-light close on the left. Windows/Linux: × on the right. */ +export function windowCloseSide( + platform = navigator.platform, +): WindowCloseSide { + return isMacPlatform(platform) ? "start" : "end"; +} diff --git a/src/lib/tauriApi.ts b/src/lib/tauriApi.ts index f00880e..e1c3477 100644 --- a/src/lib/tauriApi.ts +++ b/src/lib/tauriApi.ts @@ -21,7 +21,11 @@ export const tauriApi = { invoke("place_window_bottom_center", { label }), placeWindowCentered: (label: string) => invoke("place_window_centered", { label }), + applyBottomAnchoredSize: (width: number, height: number, animate = false) => + invoke("apply_bottom_anchored_size", { width, height, animate }), reloadHotkeys: () => invoke("reload_hotkeys"), + applyWindowDeactivatePolicy: () => + invoke("apply_window_deactivate_policy"), emitAuthUpdated: () => emit("auth-updated"), listenAuthUpdated: (handler: () => void) => listen("auth-updated", handler), listenChatShown: (handler: () => void) => listen("chat-shown", handler), diff --git a/src/lib/tauriWindowApi.ts b/src/lib/tauriWindowApi.ts index 4a936fc..23428c9 100644 --- a/src/lib/tauriWindowApi.ts +++ b/src/lib/tauriWindowApi.ts @@ -4,6 +4,8 @@ import { getCurrentWindow, } from "@tauri-apps/api/window"; +import { tauriApi } from "./tauriApi"; + export type ResizeEdge = "South" | "East" | "SouthEast"; export function getWindowLabel(): string { @@ -17,20 +19,13 @@ export async function hideCurrentWindow(): Promise { export async function applyBottomAnchoredSize(size: { width: number; height: number; + animate?: boolean; }): Promise { - const win = getCurrentWindow(); - const [oldSize, pos] = await Promise.all([ - win.outerSize(), - win.outerPosition(), - ]); - await win.setSize(new LogicalSize(size.width, size.height)); - const newSize = await win.outerSize(); - const dy = oldSize.height - newSize.height; - if (dy !== 0) { - await win - .setPosition(new PhysicalPosition(pos.x, pos.y + dy)) - .catch(() => undefined); - } + await tauriApi.applyBottomAnchoredSize( + size.width, + size.height, + Boolean(size.animate), + ); } export async function setCurrentWindowResizable( diff --git a/src/lib/types.ts b/src/lib/types.ts index d61c97e..dcabc1d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -12,6 +12,8 @@ export interface AppConfig { shortcutOpenPet: string; /** Tauri accelerator, e.g. CmdOrCtrl+Shift+H */ shortcutOpenHome: string; + /** When true, chat/settings stay visible after clicking another app. */ + keepWindowsVisible: boolean; } export interface AgentSummary { diff --git a/src/styles/base.css b/src/styles/base.css index ef957ec..cbc902f 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -85,3 +85,74 @@ button { background: #0077ed; border-color: transparent; } + +.window-close { + appearance: none; + position: relative; + display: grid; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: 7px; + background: transparent; + color: #8e8e93; + line-height: 0; +} + +.window-close[data-close-side="start"]::after, +.window-close[data-close-side="start"] .window-close-icon { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.window-close[data-close-side="start"]::after { + content: ""; + width: 12px; + height: 12px; + border-radius: 50%; + background: #ff5f57; + box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.2); +} + +.window-close[data-close-side="start"] .window-close-icon { + display: block; + width: 9px; + height: 9px; + color: #4d0000; + opacity: 0; + z-index: 1; +} + +.window-close[data-close-side="start"] .window-close-icon path { + stroke-width: 5; +} + +.window-close[data-close-side="start"]:hover .window-close-icon, +.window-close[data-close-side="start"]:active .window-close-icon, +.window-close[data-close-side="start"]:focus-visible .window-close-icon { + opacity: 1; +} + +.window-close[data-close-side="start"]:hover::after, +.window-close[data-close-side="start"]:focus-visible::after { + background: #ff4d44; +} + +.window-close[data-close-side="start"]:active::after { + background: #bf4943; +} + +.window-close[data-close-side="end"]:hover { + background: #3a3a3c; + color: #fff; +} + +.window-close-icon { + display: block; + width: 16px; + height: 16px; +} diff --git a/src/styles/chat.css b/src/styles/chat.css index f3a226c..efe1579 100644 --- a/src/styles/chat.css +++ b/src/styles/chat.css @@ -3,6 +3,7 @@ html[data-window-label="chat"], html[data-window-label="chat"] body, html[data-window-label="chat"] #root { + height: 100%; min-height: 0; overflow: hidden; background: transparent; @@ -10,16 +11,11 @@ html[data-window-label="chat"] #root { color-scheme: dark; } -/* Compact: shrink-wrap to chrome + composer so the OS window can grow up. */ -html[data-window-label="chat"]:not(.chat-expanded), -html[data-window-label="chat"]:not(.chat-expanded) body, -html[data-window-label="chat"]:not(.chat-expanded) #root { - height: auto; -} - html[data-window-label="chat"].chat-expanded, html[data-window-label="chat"].chat-expanded body, -html[data-window-label="chat"].chat-expanded #root { +html[data-window-label="chat"].chat-expanded #root, +html[data-window-label="chat"]:not(.chat-expanded) body, +html[data-window-label="chat"]:not(.chat-expanded) #root { height: 100%; } @@ -64,6 +60,7 @@ html[data-window-label="chat"] *::-webkit-scrollbar-button { position: relative; box-sizing: border-box; width: 100%; + min-width: 0; display: grid; padding: 0; overflow: hidden; @@ -73,10 +70,13 @@ html[data-window-label="chat"] *::-webkit-scrollbar-button { color: #f5f5f7; } -/* Compact: content-sized; no message pane / spacer. */ +/* Compact: fill the OS window so the empty state can start taller + and the user can resize; composer stays on the last rows. */ .chat-window.is-compact { - height: auto; - grid-auto-rows: auto; + height: 100%; + width: 100%; + overflow: hidden; + grid-template-rows: auto minmax(0, 1fr) auto auto auto; } .chat-window.is-expanded { @@ -108,21 +108,6 @@ html[data-window-label="chat"] *::-webkit-scrollbar-button { height: 28px; } -.chat-close-dot { - appearance: none; - width: 12px; - height: 12px; - margin-left: 4px; - border: 0; - border-radius: 50%; - background: #ff5f57; - box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.2); -} - -.chat-close-dot:hover { - filter: brightness(1.08); -} - .chat-new-session { appearance: none; display: grid; @@ -150,6 +135,10 @@ html[data-window-label="chat"] *::-webkit-scrollbar-button { } .chat-error { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; margin: 0.15rem 1.15rem 0.55rem; padding: 0.45rem 0.7rem; border-radius: 0.65rem; @@ -158,6 +147,27 @@ html[data-window-label="chat"] *::-webkit-scrollbar-button { font-size: 0.78rem; } +.chat-error p { + margin: 0; + min-width: 0; +} + +.chat-error-retry { + appearance: none; + flex: none; + padding: 0.15rem 0.5rem; + border: 1px solid rgba(255, 138, 128, 0.45); + border-radius: 0.4rem; + background: transparent; + color: #ffb4ae; + font-size: 0.72rem; +} + +.chat-error-retry:hover { + background: rgba(255, 69, 58, 0.2); + color: #fff; +} + .message-list { flex: 1 1 auto; min-height: 0; @@ -206,6 +216,25 @@ html[data-window-label="chat"] *::-webkit-scrollbar-button { font-size: 0.86rem; } +.message-list-empty { + display: grid; + place-items: center; + min-height: 0; + overflow: hidden; +} + +.chat-empty-logo { + display: block; + width: auto; + height: auto; + max-width: min(8rem, 60%); + max-height: 42%; + object-fit: contain; + /* SVG-as-image is black; invert so it reads as a gray lighter than #1c1c1e. */ + filter: invert(1); + opacity: 0.28; +} + .message { width: fit-content; max-width: 86%; @@ -415,10 +444,14 @@ html[data-window-label="chat"] *::-webkit-scrollbar-button { } .composer { + position: relative; + z-index: 8; flex: none; align-self: stretch; + min-width: 0; margin: 0.15rem 0.95rem 0; padding: 0.7rem 0.8rem 0.6rem; + overflow: visible; border: 1px solid #3a3a3c; border-radius: 1.35rem; background: #2c2c2e; @@ -492,22 +525,27 @@ html[data-window-label="chat"] *::-webkit-scrollbar-button { display: flex; align-items: center; justify-content: space-between; - gap: 0.55rem; + gap: 0.45rem; + min-width: 0; } .composer-toolbar-left, .composer-toolbar-right { display: flex; align-items: center; - gap: 0.35rem; + gap: 0.3rem; min-width: 0; } .composer-toolbar-left { - flex: 1 1 auto; + flex: 1 1 0; flex-wrap: wrap; } +.composer-toolbar-right { + flex: 0 0 auto; +} + .composer-icon-btn { appearance: none; display: grid; @@ -540,13 +578,15 @@ html[data-window-label="chat"] *::-webkit-scrollbar-button { display: inline-flex; align-items: center; gap: 0.28rem; - max-width: 8.5rem; + min-width: 0; + max-width: 7.5rem; padding: 0.28rem 0.55rem; border: 0; border-radius: 999px; background: #3a3a3c; color: #f5f5f7; font-size: 0.72rem; + overflow: hidden; white-space: nowrap; } @@ -565,6 +605,7 @@ html[data-window-label="chat"] *::-webkit-scrollbar-button { } .composer-model-label { + min-width: 0; overflow: hidden; text-overflow: ellipsis; } @@ -573,10 +614,11 @@ html[data-window-label="chat"] *::-webkit-scrollbar-button { position: absolute; left: 0; bottom: calc(100% + 0.4rem); - z-index: 20; + z-index: 40; min-width: 10rem; max-width: 14rem; - max-height: 12rem; + /* JS sets max-height to remaining window space so a tall chat can show the full list. */ + max-height: calc(100vh - 1.5rem); overflow-y: auto; padding: 0.3rem; border: 1px solid #3a3a3c; @@ -614,23 +656,6 @@ html[data-window-label="chat"] *::-webkit-scrollbar-button { color: #64b5ff; } -.agent-select select { - max-width: 7.5rem; - padding: 0.28rem 1.5rem 0.28rem 0.55rem; - border: 0; - border-radius: 999px; - color: #f5f5f7; - background: #3a3a3c - url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23aeaeb2' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E") - right 0.55rem center / 0.7rem no-repeat; - appearance: none; - font-size: 0.72rem; -} - -.agent-select select:disabled { - opacity: 0.55; -} - .composer-send { display: grid; place-items: center; @@ -808,6 +833,7 @@ html[data-window-label="chat"] *::-webkit-scrollbar-button { height: 16px; cursor: nwse-resize; touch-action: none; + border-radius: 0 0 14px 0; } .chat-resize-handle::before { @@ -815,11 +841,11 @@ html[data-window-label="chat"] *::-webkit-scrollbar-button { position: absolute; right: 3px; bottom: 3px; - width: 9px; - height: 9px; + width: 10px; + height: 10px; border-right: 2px solid #636366; border-bottom: 2px solid #636366; - border-radius: 0 0 2px 0; + border-radius: 0 0 8px 0; opacity: 0.85; } @@ -849,6 +875,29 @@ html[data-window-label="chat"] *::-webkit-scrollbar-button { cursor: ew-resize; } +@media (max-width: 360px) { + .composer { + margin-left: 0.55rem; + margin-right: 0.55rem; + padding-left: 0.55rem; + padding-right: 0.55rem; + } + + .composer-chip-text { + display: none; + } + + .composer-chip-btn:not(:has(.composer-model-label)) { + max-width: 2.15rem; + padding: 0.28rem; + justify-content: center; + } + + .composer-chip-btn { + max-width: 5.75rem; + } +} + .chat-gate-body { display: flex; flex-direction: column; diff --git a/src/styles/settings.css b/src/styles/settings.css index bd65324..6c6c1d8 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -46,7 +46,7 @@ html[data-window-label="settings"] #root { appearance: none; border: 0; border-radius: 5px; - padding: 0.3rem 0.85rem; + padding: 0.3rem 0.65rem; background: transparent; color: #f5f5f7; font-size: 0.82rem; @@ -58,25 +58,18 @@ html[data-window-label="settings"] #root { color: #fff; } -.settings-close { +.settings-tabs-bar .window-close { position: absolute; top: 50%; - right: 0; transform: translateY(-50%); - appearance: none; - width: 26px; - height: 26px; - border: 0; - border-radius: 5px; - background: transparent; - color: #8e8e93; - font-size: 1.15rem; - line-height: 1; } -.settings-close:hover { - background: #3a3a3c; - color: #fff; +.settings-tabs-bar .window-close[data-close-side="start"] { + left: 0; +} + +.settings-tabs-bar .window-close[data-close-side="end"] { + right: 0; } .settings-frame { @@ -194,6 +187,25 @@ html[data-window-label="settings"] #root { color: #fff; } +.settings-check { + display: flex; + align-items: flex-start; + gap: 8px; + color: #f5f5f7; + font-size: 0.84rem; + line-height: 1.35; +} + +.settings-check input { + flex: none; + margin-top: 0.15rem; + accent-color: #0a84ff; +} + +.settings-subgroup .settings-hint { + margin-top: 8px; +} + .settings-footer { display: flex; align-items: center; diff --git a/src/windows/ChatWindow.test.tsx b/src/windows/ChatWindow.test.tsx index 5277783..3643c23 100644 --- a/src/windows/ChatWindow.test.tsx +++ b/src/windows/ChatWindow.test.tsx @@ -10,6 +10,12 @@ import { import { StrictMode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + CHAT_INITIAL_HEIGHT, + CHAT_MIN_HEIGHT, + CHAT_MIN_WIDTH, + CHAT_WIDTH, +} from "../lib/chatHelpers"; import { DEFAULT_APP_CONFIG } from "../lib/configLogic"; import { OctopHttpError } from "../lib/octopHttp"; import ChatWindow from "./ChatWindow"; @@ -32,6 +38,7 @@ const mocks = vi.hoisted(() => ({ listConnectors: vi.fn(), uploadAttachment: vi.fn(), setMinSize: vi.fn(), + setResizable: vi.fn(), applyBottomAnchoredSize: vi.fn(), hideCurrentWindow: vi.fn(), })); @@ -40,7 +47,7 @@ vi.mock("../lib/tauriWindowApi", () => ({ hideCurrentWindow: mocks.hideCurrentWindow.mockResolvedValue(undefined), applyBottomAnchoredSize: mocks.applyBottomAnchoredSize.mockResolvedValue(undefined), - setCurrentWindowResizable: vi.fn().mockResolvedValue(undefined), + setCurrentWindowResizable: mocks.setResizable.mockResolvedValue(undefined), clearCurrentWindowMaxSize: vi.fn().mockResolvedValue(undefined), setCurrentWindowMinSize: mocks.setMinSize.mockResolvedValue(undefined), startCurrentWindowResize: vi.fn().mockResolvedValue(undefined), @@ -93,6 +100,10 @@ class FakeWebSocket { close() { this.readyState = WebSocket.CLOSED; + } + + emitClose() { + this.readyState = WebSocket.CLOSED; this.onclose?.(); } @@ -157,36 +168,56 @@ describe("ChatWindow", () => { }); }); - it("紧凑窗口贴合内容高度,不留出画阴影的空白", async () => { - const contentHeight = 155; - const rect = vi - .spyOn(Element.prototype, "getBoundingClientRect") - .mockReturnValue({ - width: 400, - height: contentHeight, - top: 0, - left: 0, - right: 400, - bottom: contentHeight, - x: 0, - y: 0, - toJSON: () => ({}), - } as DOMRect); - + it("紧凑窗口使用更高的初始高度,并允许缩放", async () => { render(); await waitFor(() => - expect(mocks.applyBottomAnchoredSize).toHaveBeenCalled(), + expect(mocks.applyBottomAnchoredSize).toHaveBeenCalledWith({ + width: CHAT_WIDTH, + height: CHAT_INITIAL_HEIGHT, + }), + ); + expect(mocks.setResizable).toHaveBeenCalledWith(true); + expect(mocks.setMinSize).toHaveBeenCalledWith( + CHAT_MIN_WIDTH, + CHAT_MIN_HEIGHT, ); - expect(mocks.applyBottomAnchoredSize).toHaveBeenCalledWith({ - width: 400, - height: contentHeight, + expect(screen.getByLabelText("调整窗口大小")).toBeInTheDocument(); + expect( + await screen.findByRole("region", { name: "暂无消息" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("region", { name: "暂无消息" }).querySelector("img"), + ).toHaveClass("chat-empty-logo"); + }); + + it("没有令牌但有密码时会静默登录后继续初始化", async () => { + mocks.getSecret.mockImplementation(async (key: string) => + key === "password" ? "secret-password" : null, + ); + mocks.login.mockResolvedValue({ + access_token: "token-from-password", + expires_in: 3600, }); - const minHeight = mocks.setMinSize.mock.calls.at(-1)?.[1] as number; - expect(minHeight).toBeLessThanOrEqual(contentHeight); + render(); - rect.mockRestore(); + expect( + await screen.findByRole("button", { name: "选择代理" }), + ).toBeInTheDocument(); + expect(mocks.login).toHaveBeenCalledWith( + "https://octop.example", + "juba", + "secret-password", + ); + expect(mocks.setSecret).toHaveBeenCalledWith( + "access_token", + "token-from-password", + ); + expect(mocks.listAgents).toHaveBeenCalledWith( + "https://octop.example", + "token-from-password", + ); }); it("缺少访问令牌时提示并打开设置", async () => { @@ -202,9 +233,10 @@ describe("ChatWindow", () => { it("设置更新事件到达后重新初始化聊天", async () => { let authUpdated: (() => void) | undefined; - mocks.getSecret - .mockResolvedValueOnce(null) - .mockResolvedValueOnce("token-after-settings"); + let token: string | null = null; + mocks.getSecret.mockImplementation(async (key: string) => + key === "access_token" ? token : null, + ); mocks.listenAuthUpdated.mockImplementation(async (handler: () => void) => { authUpdated = handler; return vi.fn(); @@ -214,10 +246,11 @@ describe("ChatWindow", () => { expect(await screen.findByText("需要先完成登录设置")).toBeInTheDocument(); await waitFor(() => expect(authUpdated).toBeDefined()); + token = "token-after-settings"; authUpdated?.(); expect( - await screen.findByRole("combobox", { name: "选择代理" }), + await screen.findByRole("button", { name: "选择代理" }), ).toBeInTheDocument(); expect(mocks.listAgents).toHaveBeenCalledWith( "https://octop.example", @@ -248,7 +281,7 @@ describe("ChatWindow", () => { chatShown?.(); expect( - await screen.findByRole("combobox", { name: "选择代理" }), + await screen.findByRole("button", { name: "选择代理" }), ).toBeInTheDocument(); expect(mocks.listAgents).toHaveBeenCalledTimes(2); }); @@ -320,8 +353,9 @@ describe("ChatWindow", () => { }); render(); - const select = await screen.findByRole("combobox", { name: "选择代理" }); - fireEvent.change(select, { target: { value: "a2" } }); + const trigger = await screen.findByRole("button", { name: "选择代理" }); + fireEvent.click(trigger); + fireEvent.click(screen.getByRole("menuitemradio", { name: "助手二" })); await waitFor(() => expect(mocks.createThread).toHaveBeenCalledWith( @@ -348,7 +382,7 @@ describe("ChatWindow", () => { render(); expect( - await screen.findByRole("combobox", { name: "选择代理" }), + await screen.findByRole("button", { name: "选择代理" }), ).toBeInTheDocument(); expect(mocks.login).toHaveBeenCalledWith( "https://octop.example", @@ -388,7 +422,7 @@ describe("ChatWindow", () => { ); expect( - await screen.findByRole("combobox", { name: "选择代理" }), + await screen.findByRole("button", { name: "选择代理" }), ).toBeInTheDocument(); expect(mocks.listAgents).toHaveBeenCalledOnce(); }); @@ -410,12 +444,12 @@ describe("ChatWindow", () => { render(); expect( - await screen.findByRole("combobox", { name: "选择代理" }), + await screen.findByRole("button", { name: "选择代理" }), ).toBeInTheDocument(); expect(screen.queryByText("开始和代理聊聊吧")).not.toBeInTheDocument(); expect( - screen.queryByRole("region", { name: "聊天消息" }), - ).not.toBeInTheDocument(); + screen.getByRole("region", { name: "暂无消息" }), + ).toBeInTheDocument(); expect(screen.getByRole("button", { name: "关闭" })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "新建会话" })); @@ -433,6 +467,51 @@ describe("ChatWindow", () => { }); }); + it("新建会话立即收起消息区,不显示空的加载态", async () => { + mocks.loadConfig.mockResolvedValue({ + ...DEFAULT_APP_CONFIG, + baseUrl: "https://octop.example", + username: "juba", + lastAgentId: "a1", + threadIdByAgent: { a1: "thread-1" }, + }); + mocks.getHistory.mockResolvedValue({ + messages: [ + { role: "user", content: "旧问题" }, + { role: "assistant", content: "旧回答" }, + ], + }); + let finishCreate: (value: { + thread_id: string; + session_key: string; + }) => void = () => undefined; + mocks.createThread.mockReturnValue( + new Promise((resolve) => { + finishCreate = resolve; + }), + ); + + render(); + expect(await screen.findByText("旧问题")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "新建会话" })); + + expect(screen.queryByText("旧问题")).not.toBeInTheDocument(); + expect(screen.queryByText("正在加载对话…")).not.toBeInTheDocument(); + expect( + screen.getByRole("region", { name: "暂无消息" }), + ).toBeInTheDocument(); + + finishCreate({ thread_id: "thread-fresh", session_key: "session-fresh" }); + await waitFor(() => + expect(mocks.createThread).toHaveBeenCalledWith( + "https://octop.example", + "token-1", + "a1", + ), + ); + }); + it("助手消息提供复制/重试/语音,重试会重新发起流式请求", async () => { mocks.loadConfig.mockResolvedValue({ ...DEFAULT_APP_CONFIG, @@ -525,6 +604,13 @@ describe("ChatWindow", () => { }); fireEvent.click(screen.getByRole("button", { name: "发送" })); + await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); + expect( + mocks.applyBottomAnchoredSize.mock.calls.some( + ([size]) => size?.height === 560, + ), + ).toBe(false); + const socket = FakeWebSocket.instances[0]; socket.open(); expect(JSON.parse(socket.sent[0])).toMatchObject({ @@ -546,6 +632,20 @@ describe("ChatWindow", () => { }, ], }); + + socket.message({ type: "token", content: "看到了" }); + socket.message({ type: "done" }); + expect(await screen.findByRole("button", { name: "重试" })).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "重试" })); + await waitFor(() => expect(FakeWebSocket.instances.length).toBe(2)); + const retrySocket = FakeWebSocket.instances[1]; + retrySocket.open(); + expect(JSON.parse(retrySocket.sent[0])).toMatchObject({ + type: "user_turn", + text: "看图", + model: "openai/gpt-4o", + mcp_servers: ["github"], + }); }); it("流式期间可继续输入并排队,状态提示会随工具事件切换", async () => { @@ -559,7 +659,7 @@ describe("ChatWindow", () => { render(); expect( - await screen.findByRole("combobox", { name: "选择代理" }), + await screen.findByRole("button", { name: "选择代理" }), ).toBeInTheDocument(); fireEvent.change(screen.getByLabelText("消息"), { @@ -591,5 +691,24 @@ describe("ChatWindow", () => { text: "排队的第二句", thread_id: "thread-1", }); + + socket.emitClose(); + expect(screen.queryByText("连接意外断开")).not.toBeInTheDocument(); + }); + + it("连接失败后显示重试并重新初始化", async () => { + mocks.listAgents + .mockRejectedValueOnce(new Error("无法连接服务,请检查地址是否可访问")) + .mockResolvedValueOnce([{ id: "a1", name: "助手一", state: "online" }]); + + render(); + + expect(await screen.findByRole("alert")).toHaveTextContent("无法连接服务"); + fireEvent.click(screen.getByRole("button", { name: "重试" })); + + expect( + await screen.findByRole("button", { name: "选择代理" }), + ).toBeInTheDocument(); + expect(mocks.listAgents).toHaveBeenCalledTimes(2); }); }); diff --git a/src/windows/ChatWindow.tsx b/src/windows/ChatWindow.tsx index e549a90..9564493 100644 --- a/src/windows/ChatWindow.tsx +++ b/src/windows/ChatWindow.tsx @@ -32,7 +32,7 @@ export default function ChatWindow() { return (
- {chat.expanded || chat.error ? ( -
- {chat.error ? ( -

- {chat.error} -

- ) : null} - -
- ) : null} +
+ {chat.error ? ( +
+

{chat.error}

+ {chat.canRetryInit ? ( + + ) : null} +
+ ) : null} + +
@@ -81,10 +88,12 @@ export default function ChatWindow() { onSend={chat.sendMessage} onQueue={chat.queueMessage} onStop={chat.stopStream} - onLayoutChange={chat.expanded ? undefined : chat.fitCompactWindow} + onLayoutChange={ + chat.expanded ? undefined : () => chat.fitCompactWindow() + } />

内容由 AI 生成,仅供参考

- {chat.expanded ? : null} +
); } diff --git a/src/windows/SettingsWindow.test.tsx b/src/windows/SettingsWindow.test.tsx index 1566aad..f23174a 100644 --- a/src/windows/SettingsWindow.test.tsx +++ b/src/windows/SettingsWindow.test.tsx @@ -25,6 +25,8 @@ const mocks = vi.hoisted(() => ({ emitMascotChanged: vi.fn(), listenMascotChanged: vi.fn(), listenWindowShown: vi.fn(), + hideCurrentWindow: vi.fn(), + applyWindowDeactivatePolicy: vi.fn(), })); vi.mock("../lib/tauriApi", () => ({ @@ -40,11 +42,12 @@ vi.mock("../lib/tauriApi", () => ({ emitMascotChanged: mocks.emitMascotChanged, listenMascotChanged: mocks.listenMascotChanged, listenWindowShown: mocks.listenWindowShown, + applyWindowDeactivatePolicy: mocks.applyWindowDeactivatePolicy, }, })); vi.mock("../lib/tauriWindowApi", () => ({ - hideCurrentWindow: vi.fn().mockResolvedValue(undefined), + hideCurrentWindow: mocks.hideCurrentWindow.mockResolvedValue(undefined), setCurrentWindowSize: vi.fn().mockResolvedValue(undefined), })); @@ -52,8 +55,16 @@ vi.mock("../lib/octopHttp", () => ({ login: mocks.login, })); +const originalPlatform = navigator.platform; + describe("SettingsWindow", () => { - afterEach(cleanup); + afterEach(() => { + Object.defineProperty(navigator, "platform", { + configurable: true, + value: originalPlatform, + }); + cleanup(); + }); beforeEach(() => { vi.clearAllMocks(); @@ -75,12 +86,39 @@ describe("SettingsWindow", () => { mocks.emitMascotChanged.mockResolvedValue(undefined); mocks.listenMascotChanged.mockResolvedValue(vi.fn()); mocks.listenWindowShown.mockResolvedValue(vi.fn()); + mocks.applyWindowDeactivatePolicy.mockResolvedValue(undefined); mocks.login.mockResolvedValue({ access_token: "hidden-access-token", expires_in: 3600, }); }); + it("places the close control on the left on macOS", async () => { + Object.defineProperty(navigator, "platform", { + configurable: true, + value: "MacIntel", + }); + render(); + + expect(await screen.findByRole("button", { name: "关闭" })).toHaveAttribute( + "data-close-side", + "start", + ); + }); + + it("places the close control on the right on Windows", async () => { + Object.defineProperty(navigator, "platform", { + configurable: true, + value: "Win32", + }); + render(); + + expect(await screen.findByRole("button", { name: "关闭" })).toHaveAttribute( + "data-close-side", + "end", + ); + }); + it("loads config and password without rendering an access token", async () => { render(); @@ -89,10 +127,15 @@ describe("SettingsWindow", () => { ); expect(screen.getByLabelText("用户")).toHaveValue("octopus"); expect(screen.getByLabelText("密码")).toHaveValue("secret-password"); + expect(screen.getByText("连接")).toBeInTheDocument(); + expect(screen.getByText("桌宠形象")).toBeInTheDocument(); expect(screen.getByRole("option", { name: "Type" })).toHaveAttribute( "aria-selected", "true", ); + expect( + screen.queryByLabelText("点击其他应用时保持窗口显示"), + ).not.toBeInTheDocument(); expect(mocks.getSecret).toHaveBeenCalledWith("password"); expect(screen.queryByText("hidden-access-token")).not.toBeInTheDocument(); }); @@ -144,12 +187,23 @@ describe("SettingsWindow", () => { mascotId: "type", shortcutOpenPet: "CmdOrCtrl+Shift+O", shortcutOpenHome: "CmdOrCtrl+Shift+H", + keepWindowsVisible: true, }), ); expect(mocks.setSecret).toHaveBeenCalledWith("password", "new-password"); + expect(mocks.login).toHaveBeenCalledWith( + "https://new.example", + "new-user", + "new-password", + ); + expect(mocks.setSecret).toHaveBeenCalledWith( + "access_token", + "hidden-access-token", + ); expect(mocks.reloadHotkeys).toHaveBeenCalledOnce(); + expect(mocks.applyWindowDeactivatePolicy).toHaveBeenCalledOnce(); expect(mocks.emitAuthUpdated).toHaveBeenCalledOnce(); - expect(await screen.findByText("设置已保存")).toBeInTheDocument(); + await waitFor(() => expect(mocks.hideCurrentWindow).toHaveBeenCalledOnce()); }); it("records a shortcut after clicking the hotkey field", async () => { @@ -192,6 +246,7 @@ describe("SettingsWindow", () => { expect(mocks.patchConfig.mock.invocationCallOrder[0]).toBeLessThan( mocks.setSecret.mock.invocationCallOrder[0], ); + expect(mocks.setSecret).toHaveBeenCalledWith("password", "secret-password"); expect(mocks.setSecret).toHaveBeenCalledWith( "access_token", "hidden-access-token", @@ -207,4 +262,22 @@ describe("SettingsWindow", () => { await screen.findByText("连接失败:用户名或密码错误"), ).toBeInTheDocument(); }); + + it("toggles click-away visibility immediately", async () => { + render(); + await screen.findByLabelText("服务地址"); + fireEvent.click(screen.getByRole("tab", { name: "窗口" })); + const checkbox = await screen.findByLabelText("点击其他应用时保持窗口显示"); + expect(checkbox).toBeChecked(); + + fireEvent.click(checkbox); + + await waitFor(() => + expect(mocks.patchConfig).toHaveBeenCalledWith({ + keepWindowsVisible: false, + }), + ); + expect(mocks.applyWindowDeactivatePolicy).toHaveBeenCalledOnce(); + expect(checkbox).not.toBeChecked(); + }); }); diff --git a/src/windows/SettingsWindow.tsx b/src/windows/SettingsWindow.tsx index d4d138b..19f39e9 100644 --- a/src/windows/SettingsWindow.tsx +++ b/src/windows/SettingsWindow.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import ShortcutRecorder from "../components/ShortcutRecorder"; +import WindowCloseButton from "../components/WindowCloseButton"; import { useAutoFitWindow, useEscapeHidesWindow, @@ -10,13 +11,14 @@ import { MASCOT_SRC, normalizeBaseUrl, } from "../lib/configLogic"; +import { version as appVersion } from "../../package.json"; import { login } from "../lib/octopHttp"; import { tauriApi } from "../lib/tauriApi"; import { hideCurrentWindow } from "../lib/tauriWindowApi"; import type { AppConfig, MascotId } from "../lib/types"; type Notice = { kind: "success" | "error"; text: string } | null; -type SettingsTab = "general" | "hotkeys" | "about"; +type SettingsTab = "general" | "window" | "hotkeys" | "about"; const SETTINGS_WIDTH = 480; @@ -27,6 +29,7 @@ const MASCOT_OPTIONS: Array<{ id: MascotId; label: string }> = [ const TABS: Array<{ id: SettingsTab; label: string }> = [ { id: "general", label: "常规" }, + { id: "window", label: "窗口" }, { id: "hotkeys", label: "快捷键" }, { id: "about", label: "关于" }, ]; @@ -48,6 +51,9 @@ export default function SettingsWindow() { const [shortcutOpenHome, setShortcutOpenHome] = useState( DEFAULT_APP_CONFIG.shortcutOpenHome, ); + const [keepWindowsVisible, setKeepWindowsVisible] = useState( + DEFAULT_APP_CONFIG.keepWindowsVisible, + ); const [notice, setNotice] = useState(null); const [busy, setBusy] = useState<"save" | "test" | null>(null); const rootRef = useRef(null); @@ -72,6 +78,7 @@ export default function SettingsWindow() { setShortcutOpenHome( loadedConfig.shortcutOpenHome || DEFAULT_APP_CONFIG.shortcutOpenHome, ); + setKeepWindowsVisible(loadedConfig.keepWindowsVisible); if (!loadedConfig.username.trim()) return; const savedPassword = await tauriApi @@ -131,6 +138,45 @@ export default function SettingsWindow() { } } + async function toggleKeepWindowsVisible(next: boolean) { + const previous = keepWindowsVisible; + setKeepWindowsVisible(next); + try { + await tauriApi.patchConfig({ keepWindowsVisible: next }); + await tauriApi.applyWindowDeactivatePolicy(); + setConfig((current) => + current ? { ...current, keepWindowsVisible: next } : current, + ); + } catch (error) { + setKeepWindowsVisible(previous); + setNotice({ + kind: "error", + text: `更新窗口设置失败:${errorMessage(error)}`, + }); + } + } + + async function persistCredentials( + normalizedBaseUrl: string, + extra: Partial<{ + shortcutOpenPet: string; + shortcutOpenHome: string; + keepWindowsVisible: boolean; + }> = {}, + ) { + const patch = { + baseUrl: normalizedBaseUrl, + username, + mascotId, + ...extra, + }; + await tauriApi.patchConfig(patch); + if (username.trim()) { + await tauriApi.setSecret("password", password); + } + return patch; + } + async function saveSettings() { if (!config) return; @@ -138,27 +184,43 @@ export default function SettingsWindow() { setNotice(null); try { const normalizedBaseUrl = normalizeBaseUrl(baseUrl); - const patch = { - baseUrl: normalizedBaseUrl, - username, - mascotId, + const patch = await persistCredentials(normalizedBaseUrl, { shortcutOpenPet: shortcutOpenPet.trim() || DEFAULT_APP_CONFIG.shortcutOpenPet, shortcutOpenHome: shortcutOpenHome.trim() || DEFAULT_APP_CONFIG.shortcutOpenHome, - }; - await tauriApi.patchConfig(patch); - if (username.trim()) { - await tauriApi.setSecret("password", password); - } + keepWindowsVisible, + }); await tauriApi.reloadHotkeys(); + await tauriApi.applyWindowDeactivatePolicy(); const nextConfig = { ...config, ...patch }; setConfig(nextConfig); setBaseUrl(normalizedBaseUrl); - setShortcutOpenPet(patch.shortcutOpenPet); - setShortcutOpenHome(patch.shortcutOpenHome); - await tauriApi.emitAuthUpdated(); - setNotice({ kind: "success", text: "设置已保存" }); + setShortcutOpenPet( + patch.shortcutOpenPet || DEFAULT_APP_CONFIG.shortcutOpenPet, + ); + setShortcutOpenHome( + patch.shortcutOpenHome || DEFAULT_APP_CONFIG.shortcutOpenHome, + ); + setKeepWindowsVisible( + patch.keepWindowsVisible ?? DEFAULT_APP_CONFIG.keepWindowsVisible, + ); + try { + const { access_token } = await login( + normalizedBaseUrl, + username, + password, + ); + await tauriApi.setSecret("access_token", access_token); + await tauriApi.emitAuthUpdated(); + await hideCurrentWindow(); + } catch (loginError) { + await tauriApi.emitAuthUpdated(); + setNotice({ + kind: "error", + text: `设置已保存,但连接失败:${errorMessage(loginError)}`, + }); + } } catch (error) { setNotice({ kind: "error", @@ -179,8 +241,7 @@ export default function SettingsWindow() { username, password, ); - const patch = { baseUrl: normalizedBaseUrl, username, mascotId }; - await tauriApi.patchConfig(patch); + const patch = await persistCredentials(normalizedBaseUrl); await tauriApi.setSecret("access_token", access_token); if (config) setConfig({ ...config, ...patch }); setBaseUrl(normalizedBaseUrl); @@ -207,20 +268,16 @@ export default function SettingsWindow() { role="tab" aria-selected={tab === item.id} className={`settings-tab${tab === item.id ? " is-active" : ""}`} - onClick={() => setTab(item.id)} + onClick={() => { + setTab(item.id); + setNotice(null); + }} > {item.label} ))} - +
@@ -232,36 +289,40 @@ export default function SettingsWindow() { void saveSettings(); }} > -
-
- - setBaseUrl(event.currentTarget.value)} - placeholder="http://localhost:8088" - autoComplete="url" - /> -
-
- - setUsername(event.currentTarget.value)} - autoComplete="username" - /> -
-
- - setPassword(event.currentTarget.value)} - autoComplete="current-password" - /> +
+
连接
+
+
+ + setBaseUrl(event.currentTarget.value)} + placeholder="http://localhost:8088" + autoComplete="url" + /> +
+
+ + setUsername(event.currentTarget.value)} + autoComplete="username" + /> +
+
+ + setPassword(event.currentTarget.value)} + autoComplete="current-password" + /> +
@@ -281,6 +342,7 @@ export default function SettingsWindow() { role="option" aria-selected={selected} className={`mascot-option${selected ? " is-selected" : ""}`} + aria-label={option.label} onClick={() => void chooseMascot(option.id)} > ) : null} + {tab === "window" ? ( +
+ +

+ 关闭后,点到桌面或其他应用会隐藏聊天和设置。桌宠始终显示。 +

+ {notice ? ( +

+ {notice.text} +

+ ) : null} +
+ ) : null} + {tab === "hotkeys" ? (
@@ -351,7 +442,7 @@ export default function SettingsWindow() {

- 点击输入框后按下组合键即可录制;按 Esc 取消,Backspace 清除。 + 点击后按下组合键即可录制;按 Esc 取消,Backspace 清除。

{notice ? ( @@ -388,7 +479,7 @@ export default function SettingsWindow() {
版本 - V0.1.0 + V{appVersion}
{notice ? (