Skip to content

Commit ccf0024

Browse files
committed
feat: implement close behavior settings and enhance tray functionality
- Added a new close behavior setting allowing users to choose between "ask", "minimize to tray", or "quit" when closing the application. - Updated the GeneralSettings component to include a dropdown for selecting the close behavior. - Introduced a system tray feature that retains the application icon and provides a context menu based on the current task. - Enhanced the App component to integrate the TrayBridge for managing tray interactions. - Updated localization files to include new strings related to close behavior and tray functionality. - Refactored tests to validate the new close behavior settings and ensure proper integration with the tray functionality.
1 parent b2d7fea commit ccf0024

24 files changed

Lines changed: 1223 additions & 9 deletions

docs/design/shell-and-workspace-ui-spec.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
|------|------|
55
| **用途** | 定义主窗口混合壳结构、任务侧栏、对话页顶栏、设置页与工作区布局语义。 |
66
| **受众** | 负责 `AppShell``UnifiedTopBar``SessionPanel``SettingsSidebar``ChatPage``WorkspaceBar``SettingsPage` 及相关布局的前端开发者。 |
7-
| **最后审阅** | 2026-07-23(v22|
7+
| **最后审阅** | 2026-07-23(v24|
88

99
## 相关文档
1010

@@ -66,6 +66,16 @@ Session / Settings 左栏 / Main 使用 `--sidebar`、`--border`、`--background
6666

6767
---
6868

69+
### 1.4 系统托盘与窗口关闭
70+
71+
- 系统托盘属于桌面壳层,不以 React Popover 替代原生菜单。启动后保留品牌图标;左键恢复并聚焦主窗口,右键使用系统原生菜单。
72+
- 托盘菜单的项目上下文以当前活动任务的 `working_directory` 为唯一来源,名称可使用 `project_name` 作为展示值。目录不存在或无活动任务时,项目说明项保持禁用,“打开当前项目”禁用;“新建任务”改为打开现有工作目录选择器。
73+
- 原生菜单顺序固定为:打开应用、禁用的当前项目说明、新建任务、打开当前项目文件夹、设置、退出;菜单文本随应用语言更新。项目级置顶和移除仍属于任务栏内项目右键菜单,不重复到托盘。
74+
- 主窗口关闭策略必须由持久化的 `close_behavior` 控制:`ask` 弹出应用内 Dialog,`minimize_to_tray` 仅隐藏窗口,`quit` 才结束进程和 Sidecar。询问 Dialog 复用标准 Dialog、Switch 与按钮焦点/动效规范,不添加缩放或弹跳动效。
75+
- 通用设置使用 `FieldRow` 右侧 Select 暴露三种关闭策略。用户在询问 Dialog 勾选“记住”后,所选最小化或退出策略应立即持久化,并在下次启动时生效。
76+
77+
---
78+
6979
## 2. 任务列表(SessionPanel)
7080

7181
### 2.1 Quick actions
@@ -84,7 +94,8 @@ Sidecar 状态**不**放在底栏(见 §1 / 关于页)。
8494
### 2.3 任务行(SessionItem)
8595

8696
- 行高 `h-8``rounded-xl``px-3`、标题 `text-[13px]` 单行截断。
87-
- 右侧时间与 ⋯ 菜单共用固定宽约 `38px` 的尾部容器;hover、选中或菜单打开时仅显示菜单按钮,其他状态仅显示时间,二者不得重叠。
97+
- 右侧时间与 ⋯ 菜单共用固定宽 `52px` 的尾部容器(需容纳中文日期如「12月31日」与英文 `Dec 31`);时间文案必须 `whitespace-nowrap` + `leading-none`,禁止换行。
98+
- hover、选中或菜单打开时仅显示菜单按钮,其他状态仅显示时间,二者不得重叠;菜单按钮仍右对齐于尾部容器。
8899
- 激活:`bg-sidebar-accent`;过渡 `duration-150`
89100

90101
### 2.4 分区标题

src-tauri/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ pub mod session;
77
pub mod settings;
88
pub mod sidecar;
99
pub mod skills;
10+
pub mod tray;
1011
pub mod workspace;

src-tauri/src/commands/settings.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::collections::HashMap;
22

3-
use tauri::State;
3+
use tauri::{AppHandle, State};
44

55
use crate::config::AppConfig;
66
use crate::db::repository::SettingsRepo;
@@ -14,6 +14,7 @@ pub fn get_settings(state: State<'_, AppState>) -> Result<String, String> {
1414

1515
#[tauri::command]
1616
pub fn update_setting(
17+
app: AppHandle,
1718
state: State<'_, AppState>,
1819
key: String,
1920
value: serde_json::Value,
@@ -35,6 +36,9 @@ pub fn update_setting(
3536
}
3637

3738
crate::config::save_config(&config).map_err(|e| e.to_string())?;
39+
let updated_config = config.clone();
40+
drop(config);
41+
crate::tray::refresh_labels(&app, &updated_config);
3842
Ok(())
3943
}
4044

@@ -45,10 +49,17 @@ pub fn get_app_config(state: State<'_, AppState>) -> Result<AppConfig, String> {
4549
}
4650

4751
#[tauri::command]
48-
pub fn update_app_config(state: State<'_, AppState>, config: AppConfig) -> Result<(), String> {
52+
pub fn update_app_config(
53+
app: AppHandle,
54+
state: State<'_, AppState>,
55+
config: AppConfig,
56+
) -> Result<(), String> {
4957
let mut current = state.config.lock().map_err(|e| e.to_string())?;
5058
*current = config;
5159
crate::config::save_config(&current).map_err(|e| e.to_string())?;
60+
let updated_config = current.clone();
61+
drop(current);
62+
crate::tray::refresh_labels(&app, &updated_config);
5263
Ok(())
5364
}
5465

src-tauri/src/commands/tray.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
use serde::Deserialize;
2+
use tauri::{AppHandle, State};
3+
4+
use crate::{config::CloseBehavior, tray, AppState};
5+
6+
#[derive(Debug, Deserialize)]
7+
#[serde(rename_all = "snake_case")]
8+
pub enum CloseRequestAction {
9+
MinimizeToTray,
10+
Quit,
11+
}
12+
13+
#[tauri::command]
14+
pub fn update_tray_context(
15+
app: AppHandle,
16+
state: State<'_, AppState>,
17+
working_directory: Option<String>,
18+
project_name: Option<String>,
19+
workspace_kind: Option<String>,
20+
) -> Result<(), String> {
21+
let language = state
22+
.config
23+
.lock()
24+
.map_err(|error| error.to_string())?
25+
.language
26+
.clone();
27+
tray::update_context(
28+
&app,
29+
working_directory,
30+
project_name,
31+
workspace_kind,
32+
&language,
33+
)
34+
}
35+
36+
#[tauri::command]
37+
pub fn resolve_close_request(
38+
app: AppHandle,
39+
state: State<'_, AppState>,
40+
action: CloseRequestAction,
41+
remember: bool,
42+
) -> Result<(), String> {
43+
let close_behavior = match action {
44+
CloseRequestAction::MinimizeToTray => CloseBehavior::MinimizeToTray,
45+
CloseRequestAction::Quit => CloseBehavior::Quit,
46+
};
47+
48+
if remember {
49+
let updated_config = {
50+
let mut config = state.config.lock().map_err(|error| error.to_string())?;
51+
config.close_behavior = close_behavior;
52+
crate::config::save_config(&config).map_err(|error| error.to_string())?;
53+
config.clone()
54+
};
55+
tray::refresh_labels(&app, &updated_config);
56+
}
57+
58+
match action {
59+
CloseRequestAction::MinimizeToTray => tray::hide_main_window(&app),
60+
CloseRequestAction::Quit => {
61+
app.exit(0);
62+
Ok(())
63+
}
64+
}
65+
}

src-tauri/src/config.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@ pub fn default_ui_font_size() -> u8 {
66
14
77
}
88

9+
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
10+
#[serde(rename_all = "snake_case")]
11+
pub enum CloseBehavior {
12+
Ask,
13+
MinimizeToTray,
14+
Quit,
15+
}
16+
17+
impl Default for CloseBehavior {
18+
fn default() -> Self {
19+
Self::Ask
20+
}
21+
}
22+
923
/// Application configuration, persisted to ~/.misakax/config.yaml
1024
#[derive(Debug, Clone, Serialize, Deserialize)]
1125
#[serde(default)]
@@ -36,6 +50,9 @@ pub struct AppConfig {
3650
/// Local MCP HTTP bridge port (Python Sidecar → Rust MCP)
3751
#[serde(default = "default_mcp_bridge_port")]
3852
pub mcp_bridge_port: u16,
53+
/// Behavior for the primary window close button.
54+
#[serde(default)]
55+
pub close_behavior: CloseBehavior,
3956
}
4057

4158
fn default_mcp_bridge_port() -> u16 {
@@ -60,6 +77,7 @@ impl Default for AppConfig {
6077
auto_start_sidecar: true,
6178
use_sidecar: true,
6279
mcp_bridge_port: 9528,
80+
close_behavior: CloseBehavior::Ask,
6381
}
6482
}
6583
}

src-tauri/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub mod sidecar;
1414
mod sidecar_ownership;
1515
#[cfg(feature = "test-private")]
1616
pub mod sidecar_ownership;
17+
pub mod tray;
1718

1819
use config::AppConfig;
1920
use db::repository::SessionRepo;
@@ -103,6 +104,7 @@ pub fn run() {
103104
stream_registry: StreamRegistry::new(),
104105
mcp_manager: Arc::clone(&mcp_manager),
105106
})
107+
.manage(tray::TrayState::default())
106108
.invoke_handler(tauri::generate_handler![
107109
commands::settings::get_settings,
108110
commands::settings::update_setting,
@@ -112,6 +114,8 @@ pub fn run() {
112114
commands::settings::set_setting,
113115
commands::settings::get_all_settings,
114116
commands::settings::get_system_info,
117+
commands::tray::update_tray_context,
118+
commands::tray::resolve_close_request,
115119
commands::router_configs::list_router_configs,
116120
commands::router_configs::create_router_config,
117121
commands::router_configs::create_router_config_with_models,
@@ -184,9 +188,12 @@ pub fn run() {
184188
commands::skills::skills_set_enabled,
185189
commands::skills::skills_uninstall,
186190
])
191+
.on_window_event(tray::handle_window_event)
187192
.setup(move |app| {
188193
tracing::info!("MisakaX initialized successfully");
189194

195+
tray::setup(app.handle())?;
196+
190197
// Backfill sessions that still lack a working directory.
191198
{
192199
let state = app.state::<AppState>();

0 commit comments

Comments
 (0)