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 @@
-
-
-
-
-
- A desktop companion for your self-hosted Octop — floating mascot, compact chat, system tray.
-
-
-
-
-
-
-
-
-
-
- Highlights ·
- Overview ·
- Quick Start ·
- Usage ·
- Development ·
- Contents
-
-
-
- English · 中文
-
+
+
+**A desktop companion for your self-hosted Octop — floating mascot, compact chat, system tray.**
+
+
+
+[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 (
-
- 选择代理
-
+ onChange(event.currentTarget.value)}
+ onClick={() => onOpenChange(!open)}
>
- {agents.map((agent) => (
-
- {agent.name}
-
- ))}
-
-
+ {label}
+
+ {open ? (
+
+ {agents.map((agent) => (
+ {
+ onChange(agent.id);
+ onOpenChange(false);
+ }}
+ >
+ {agent.name}
+
+ ))}
+
+ ) : 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 ? (
+ onNewSession()}
+ >
+
+
+
+
+
+ ) : (
+
+ );
+
return (
-