diff --git a/README.en.md b/README.en.md
index 3d02b6ef..21319330 100644
--- a/README.en.md
+++ b/README.en.md
@@ -283,6 +283,20 @@ Thanks to the following open-source projects and community practices for referen
---
+## ⭐ Star History
+
+If this project helps you, please support it with a ⭐ Star so more people can discover Claude Code Haha.
+
+
+
+
+
+
+
+
+
+---
+
## Disclaimer
This repository is based on the Claude Code source leaked from the Anthropic npm registry on 2026-03-31. All original source code copyrights belong to [Anthropic](https://www.anthropic.com). It is provided for learning and research purposes only.
diff --git a/README.md b/README.md
index aefef042..f136251a 100644
--- a/README.md
+++ b/README.md
@@ -283,6 +283,20 @@ http://127.0.0.1:2024
---
+## ⭐ Star 趋势图
+
+如果这个项目对您有帮助,请给个 ⭐ Star 支持一下,让更多的人看到 Claude Code Haha!
+
+
+
+
+
+
+
+
+
+---
+
## Disclaimer
本仓库基于 2026-03-31 从 Anthropic npm registry 泄露的 Claude Code 源码。所有原始源码版权归 [Anthropic](https://www.anthropic.com) 所有。仅供学习和研究用途。
diff --git a/desktop/src-tauri/app-icon-macos.svg b/desktop/src-tauri/app-icon-macos.svg
index af11a9e2..1fd4682d 100644
--- a/desktop/src-tauri/app-icon-macos.svg
+++ b/desktop/src-tauri/app-icon-macos.svg
@@ -27,6 +27,7 @@
+
@@ -39,4 +40,5 @@
+
diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png
index 9c55c9b0..0c3f4c33 100644
Binary files a/desktop/src-tauri/icons/128x128.png and b/desktop/src-tauri/icons/128x128.png differ
diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png
index 3993a862..3ed6ca7f 100644
Binary files a/desktop/src-tauri/icons/128x128@2x.png and b/desktop/src-tauri/icons/128x128@2x.png differ
diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png
index 9e7fee4e..eb20b1ba 100644
Binary files a/desktop/src-tauri/icons/32x32.png and b/desktop/src-tauri/icons/32x32.png differ
diff --git a/desktop/src-tauri/icons/64x64.png b/desktop/src-tauri/icons/64x64.png
index 1af7f418..b687e89d 100644
Binary files a/desktop/src-tauri/icons/64x64.png and b/desktop/src-tauri/icons/64x64.png differ
diff --git a/desktop/src-tauri/icons/Square107x107Logo.png b/desktop/src-tauri/icons/Square107x107Logo.png
index 93827f1b..981c6993 100644
Binary files a/desktop/src-tauri/icons/Square107x107Logo.png and b/desktop/src-tauri/icons/Square107x107Logo.png differ
diff --git a/desktop/src-tauri/icons/Square142x142Logo.png b/desktop/src-tauri/icons/Square142x142Logo.png
index 2cb58edb..2ef24d23 100644
Binary files a/desktop/src-tauri/icons/Square142x142Logo.png and b/desktop/src-tauri/icons/Square142x142Logo.png differ
diff --git a/desktop/src-tauri/icons/Square150x150Logo.png b/desktop/src-tauri/icons/Square150x150Logo.png
index 296ecbfb..6e00e9d8 100644
Binary files a/desktop/src-tauri/icons/Square150x150Logo.png and b/desktop/src-tauri/icons/Square150x150Logo.png differ
diff --git a/desktop/src-tauri/icons/Square284x284Logo.png b/desktop/src-tauri/icons/Square284x284Logo.png
index 983a93a2..ff785d17 100644
Binary files a/desktop/src-tauri/icons/Square284x284Logo.png and b/desktop/src-tauri/icons/Square284x284Logo.png differ
diff --git a/desktop/src-tauri/icons/Square30x30Logo.png b/desktop/src-tauri/icons/Square30x30Logo.png
index 330ee213..26e58bbb 100644
Binary files a/desktop/src-tauri/icons/Square30x30Logo.png and b/desktop/src-tauri/icons/Square30x30Logo.png differ
diff --git a/desktop/src-tauri/icons/Square310x310Logo.png b/desktop/src-tauri/icons/Square310x310Logo.png
index c371943a..57cf5a6e 100644
Binary files a/desktop/src-tauri/icons/Square310x310Logo.png and b/desktop/src-tauri/icons/Square310x310Logo.png differ
diff --git a/desktop/src-tauri/icons/Square44x44Logo.png b/desktop/src-tauri/icons/Square44x44Logo.png
index 266ea978..1e7efbb9 100644
Binary files a/desktop/src-tauri/icons/Square44x44Logo.png and b/desktop/src-tauri/icons/Square44x44Logo.png differ
diff --git a/desktop/src-tauri/icons/Square71x71Logo.png b/desktop/src-tauri/icons/Square71x71Logo.png
index 4e5e1a81..2b6ea0c7 100644
Binary files a/desktop/src-tauri/icons/Square71x71Logo.png and b/desktop/src-tauri/icons/Square71x71Logo.png differ
diff --git a/desktop/src-tauri/icons/Square89x89Logo.png b/desktop/src-tauri/icons/Square89x89Logo.png
index cac6b571..432fe0d6 100644
Binary files a/desktop/src-tauri/icons/Square89x89Logo.png and b/desktop/src-tauri/icons/Square89x89Logo.png differ
diff --git a/desktop/src-tauri/icons/StoreLogo.png b/desktop/src-tauri/icons/StoreLogo.png
index dc2cc91d..6eef0c0d 100644
Binary files a/desktop/src-tauri/icons/StoreLogo.png and b/desktop/src-tauri/icons/StoreLogo.png differ
diff --git a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
index c37e2cda..f4693643 100644
Binary files a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ
diff --git a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
index 274bb8df..dce478fb 100644
Binary files a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
index c5a1d511..d89378a4 100644
Binary files a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
index 806a5227..0a1917d5 100644
Binary files a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ
diff --git a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
index ffa26a92..69b16963 100644
Binary files a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
index 671e6925..6b76d53d 100644
Binary files a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
index 2b73cacd..090793b7 100644
Binary files a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ
diff --git a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
index 6f8a6591..49f55cb2 100644
Binary files a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
index d14ea701..01ffd5c8 100644
Binary files a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
index ec497f3f..e9769d5a 100644
Binary files a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
index 4cd2cce0..3f3aeb26 100644
Binary files a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
index 686cba35..9577fc19 100644
Binary files a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
index 8b79823a..0297b63e 100644
Binary files a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png
index cc36933d..ed9c9114 100644
Binary files a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
index d991e6f6..e8d4a138 100644
Binary files a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns
index 398851de..cd21aa2e 100644
Binary files a/desktop/src-tauri/icons/icon.icns and b/desktop/src-tauri/icons/icon.icns differ
diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico
index f951ee90..b3e4fddb 100644
Binary files a/desktop/src-tauri/icons/icon.ico and b/desktop/src-tauri/icons/icon.ico differ
diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png
index bf3255e0..5cd4c0ea 100644
Binary files a/desktop/src-tauri/icons/icon.png and b/desktop/src-tauri/icons/icon.png differ
diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png
index a166f1f6..84b19f85 100644
Binary files a/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png and b/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png differ
diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
index f01debfa..f9f8bb3a 100644
Binary files a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ
diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png
index f01debfa..f9f8bb3a 100644
Binary files a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png differ
diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png
index 6d48bc00..0c71f6fd 100644
Binary files a/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png and b/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png differ
diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png
index 437dce50..cd4e0f45 100644
Binary files a/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png and b/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png differ
diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
index 9bfa4244..dd67f86a 100644
Binary files a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ
diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png
index 9bfa4244..dd67f86a 100644
Binary files a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png differ
diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png
index a43989b0..d2ff8a80 100644
Binary files a/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png and b/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png differ
diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png
index f01debfa..f9f8bb3a 100644
Binary files a/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png and b/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png differ
diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
index f63dac44..86a22651 100644
Binary files a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ
diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png
index f63dac44..86a22651 100644
Binary files a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png differ
diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png
index 8f04e2de..67e98dfc 100644
Binary files a/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png and b/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png differ
diff --git a/desktop/src-tauri/icons/ios/AppIcon-512@2x.png b/desktop/src-tauri/icons/ios/AppIcon-512@2x.png
index aaf24f03..bd440a4f 100644
Binary files a/desktop/src-tauri/icons/ios/AppIcon-512@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-512@2x.png differ
diff --git a/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png b/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png
index 8f04e2de..67e98dfc 100644
Binary files a/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png differ
diff --git a/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png b/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png
index 8a2c4614..1a03a61e 100644
Binary files a/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png and b/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png differ
diff --git a/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png b/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png
index caf57fd2..a73c4417 100644
Binary files a/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png and b/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png differ
diff --git a/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png b/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png
index 633238a4..28c440fc 100644
Binary files a/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png differ
diff --git a/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
index 3c01a7b1..a5bc775b 100644
Binary files a/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index c6a33d0f..5b0312ce 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -1,5 +1,6 @@
use std::{
collections::{HashMap, VecDeque},
+ fs,
io::{Error as IoError, ErrorKind, Read, Write},
net::{SocketAddr, TcpListener, TcpStream},
path::PathBuf,
@@ -14,11 +15,11 @@ use std::{
};
use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize};
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::Emitter;
-use tauri::{AppHandle, Manager, RunEvent, State, WindowEvent};
+use tauri::{AppHandle, Manager, PhysicalPosition, PhysicalSize, RunEvent, State, WindowEvent};
use tauri_plugin_shell::{
process::{CommandChild, CommandEvent},
ShellExt,
@@ -28,6 +29,10 @@ const SERVER_STARTUP_LOG_LIMIT: usize = 80;
const MAIN_WINDOW_LABEL: &str = "main";
const TRAY_SHOW_ID: &str = "tray_show";
const TRAY_QUIT_ID: &str = "tray_quit";
+const WINDOW_STATE_FILE: &str = "window-state.json";
+const MIN_WINDOW_WIDTH: u32 = 960;
+const MIN_WINDOW_HEIGHT: u32 = 640;
+const MIN_VISIBLE_PIXELS: i64 = 64;
#[derive(Default)]
struct ServerState(Mutex);
@@ -48,6 +53,15 @@ struct AppExitState {
is_quitting: Mutex,
}
+#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
+struct StoredWindowState {
+ x: i32,
+ y: i32,
+ width: u32,
+ height: u32,
+ maximized: bool,
+}
+
/// 与 ServerState 平级的 adapter 子进程状态。
///
/// adapter sidecar(claude-sidecar adapters --feishu --telegram)的生命周期
@@ -157,10 +171,187 @@ fn should_hide_to_tray(app: &AppHandle, label: &str) -> bool {
.unwrap_or(true)
}
+fn is_persistable_window_state(state: &StoredWindowState) -> bool {
+ state.width >= MIN_WINDOW_WIDTH && state.height >= MIN_WINDOW_HEIGHT
+}
+
+fn has_meaningful_intersection(
+ state: &StoredWindowState,
+ monitor_x: i32,
+ monitor_y: i32,
+ monitor_width: u32,
+ monitor_height: u32,
+) -> bool {
+ let left = state.x as i64;
+ let top = state.y as i64;
+ let right = left + state.width as i64;
+ let bottom = top + state.height as i64;
+
+ let monitor_left = monitor_x as i64;
+ let monitor_top = monitor_y as i64;
+ let monitor_right = monitor_left + monitor_width as i64;
+ let monitor_bottom = monitor_top + monitor_height as i64;
+
+ right > monitor_left + MIN_VISIBLE_PIXELS
+ && bottom > monitor_top + MIN_VISIBLE_PIXELS
+ && left < monitor_right - MIN_VISIBLE_PIXELS
+ && top < monitor_bottom - MIN_VISIBLE_PIXELS
+}
+
+fn is_window_state_visible_on_any_monitor(
+ state: &StoredWindowState,
+ monitors: &[tauri::Monitor],
+) -> bool {
+ if monitors.is_empty() {
+ return true;
+ }
+
+ monitors.iter().any(|monitor| {
+ let position = monitor.position();
+ let size = monitor.size();
+ has_meaningful_intersection(state, position.x, position.y, size.width, size.height)
+ })
+}
+
+fn window_state_path(app: &AppHandle) -> Option {
+ match app.path().app_config_dir() {
+ Ok(dir) => Some(dir.join(WINDOW_STATE_FILE)),
+ Err(err) => {
+ eprintln!("[desktop] failed to resolve app config dir: {err}");
+ None
+ }
+ }
+}
+
+fn read_stored_window_state(app: &AppHandle) -> Option {
+ let path = window_state_path(app)?;
+ let data = match fs::read_to_string(&path) {
+ Ok(data) => data,
+ Err(err) if err.kind() == ErrorKind::NotFound => return None,
+ Err(err) => {
+ eprintln!(
+ "[desktop] failed to read window state {}: {err}",
+ path.display()
+ );
+ return None;
+ }
+ };
+
+ match serde_json::from_str::(&data) {
+ Ok(state) if is_persistable_window_state(&state) => Some(state),
+ Ok(_) => None,
+ Err(err) => {
+ eprintln!(
+ "[desktop] failed to parse window state {}: {err}",
+ path.display()
+ );
+ None
+ }
+ }
+}
+
+fn write_stored_window_state(app: &AppHandle, state: &StoredWindowState) {
+ if !is_persistable_window_state(state) {
+ return;
+ }
+
+ let Some(path) = window_state_path(app) else {
+ return;
+ };
+
+ if let Some(parent) = path.parent() {
+ if let Err(err) = fs::create_dir_all(parent) {
+ eprintln!(
+ "[desktop] failed to create window state directory {}: {err}",
+ parent.display()
+ );
+ return;
+ }
+ }
+
+ let data = match serde_json::to_string_pretty(state) {
+ Ok(data) => data,
+ Err(err) => {
+ eprintln!("[desktop] failed to serialize window state: {err}");
+ return;
+ }
+ };
+
+ if let Err(err) = fs::write(&path, data) {
+ eprintln!(
+ "[desktop] failed to write window state {}: {err}",
+ path.display()
+ );
+ }
+}
+
+fn capture_window_state(window: &tauri::WebviewWindow) -> Option {
+ if window.is_minimized().unwrap_or(false) {
+ return None;
+ }
+
+ let position = match window.outer_position() {
+ Ok(position) => position,
+ Err(err) => {
+ eprintln!("[desktop] failed to read window position: {err}");
+ return None;
+ }
+ };
+ let size = match window.outer_size() {
+ Ok(size) => size,
+ Err(err) => {
+ eprintln!("[desktop] failed to read window size: {err}");
+ return None;
+ }
+ };
+
+ let state = StoredWindowState {
+ x: position.x,
+ y: position.y,
+ width: size.width,
+ height: size.height,
+ maximized: window.is_maximized().unwrap_or(false),
+ };
+
+ is_persistable_window_state(&state).then_some(state)
+}
+
+fn save_main_window_state(app: &AppHandle) {
+ let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
+ return;
+ };
+ let Some(state) = capture_window_state(&window) else {
+ return;
+ };
+
+ write_stored_window_state(app, &state);
+}
+
+fn restore_main_window_state(app: &AppHandle) {
+ let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
+ return;
+ };
+ let Some(state) = read_stored_window_state(app) else {
+ return;
+ };
+
+ let monitors = window.available_monitors().unwrap_or_default();
+ if !is_window_state_visible_on_any_monitor(&state, &monitors) {
+ return;
+ }
+
+ let _ = window.unmaximize();
+ let _ = window.set_size(PhysicalSize::new(state.width, state.height));
+ let _ = window.set_position(PhysicalPosition::new(state.x, state.y));
+ if state.maximized {
+ let _ = window.maximize();
+ }
+}
+
fn show_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
- let _ = window.unminimize();
let _ = window.show();
+ let _ = window.unminimize();
let _ = window.set_focus();
}
}
@@ -879,9 +1070,71 @@ fn kill_windows_sidecars() {
#[cfg(test)]
mod tests {
- use super::{decode_terminal_output, default_utf8_locale, ensure_utf8_locale, parse_env_block};
+ use super::{
+ decode_terminal_output, default_utf8_locale, ensure_utf8_locale,
+ has_meaningful_intersection, is_persistable_window_state, parse_env_block,
+ StoredWindowState,
+ };
use std::collections::HashMap;
+ #[test]
+ fn window_state_rejects_too_small_sizes() {
+ let valid = StoredWindowState {
+ x: 100,
+ y: 100,
+ width: 1200,
+ height: 800,
+ maximized: false,
+ };
+ let too_narrow = StoredWindowState {
+ width: 959,
+ ..valid.clone()
+ };
+ let too_short = StoredWindowState {
+ height: 639,
+ ..valid.clone()
+ };
+
+ assert!(is_persistable_window_state(&valid));
+ assert!(!is_persistable_window_state(&too_narrow));
+ assert!(!is_persistable_window_state(&too_short));
+ }
+
+ #[test]
+ fn window_state_requires_visible_monitor_intersection() {
+ let state = StoredWindowState {
+ x: 100,
+ y: 100,
+ width: 1200,
+ height: 800,
+ maximized: false,
+ };
+
+ assert!(has_meaningful_intersection(&state, 0, 0, 1920, 1080));
+ assert!(!has_meaningful_intersection(
+ &StoredWindowState {
+ x: -1200,
+ y: 100,
+ ..state.clone()
+ },
+ 0,
+ 0,
+ 1920,
+ 1080,
+ ));
+ assert!(!has_meaningful_intersection(
+ &StoredWindowState {
+ x: 1900,
+ y: 100,
+ ..state
+ },
+ 0,
+ 0,
+ 1920,
+ 1080,
+ ));
+ }
+
#[test]
fn terminal_output_decoder_preserves_split_chinese_characters() {
let mut pending = Vec::new();
@@ -1043,6 +1296,7 @@ pub fn run() {
let app = builder
.setup(|app| {
setup_system_tray(app)?;
+ restore_main_window_state(&app.handle());
let state = app.state::();
let mut guard = state
@@ -1080,10 +1334,18 @@ pub fn run() {
..
} if should_hide_to_tray(app_handle, &label) => {
api.prevent_close();
+ save_main_window_state(app_handle);
if let Some(window) = app_handle.get_webview_window(&label) {
let _ = window.hide();
}
}
+ RunEvent::WindowEvent {
+ label,
+ event: WindowEvent::Moved(_) | WindowEvent::Resized(_),
+ ..
+ } if label == MAIN_WINDOW_LABEL => {
+ save_main_window_state(app_handle);
+ }
#[cfg(target_os = "macos")]
RunEvent::Reopen {
has_visible_windows: false,
@@ -1093,11 +1355,13 @@ pub fn run() {
}
RunEvent::ExitRequested { .. } => {
mark_app_quitting(app_handle);
+ save_main_window_state(app_handle);
stop_server_sidecar(app_handle);
stop_adapters_sidecar(app_handle);
}
RunEvent::Exit => {
mark_app_quitting(app_handle);
+ save_main_window_state(app_handle);
stop_server_sidecar(app_handle);
stop_adapters_sidecar(app_handle);
}
diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts
index 48505ba6..af22e877 100644
--- a/src/server/__tests__/conversations.test.ts
+++ b/src/server/__tests__/conversations.test.ts
@@ -859,6 +859,32 @@ describe('WebSocket Chat Integration', () => {
expect(nextTurn.some((m) => m.type === 'message_complete')).toBe(true)
})
+ it('should include desktop service diagnostics when CLI startup fails', async () => {
+ const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-startup-missing-workdir-'))
+ const createRes = await fetch(`${baseUrl}/api/sessions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workDir }),
+ })
+ expect(createRes.status).toBe(201)
+ const { sessionId } = await createRes.json() as { sessionId: string }
+
+ await fs.rm(workDir, { recursive: true, force: true })
+
+ const messages = await runTurn(sessionId, 'trigger startup diagnostics', true)
+ const error = messages.find((msg) => msg.type === 'error')
+
+ expect(error).toMatchObject({
+ code: 'WORKDIR_INVALID',
+ })
+ expect(error?.message).toContain('Desktop service diagnostics:')
+ expect(error?.message).toContain(`sessionId: ${sessionId}`)
+ expect(error?.message).toContain(`workDir: ${workDir}`)
+ expect(error?.message).toContain('runtimeOverride: (none)')
+ expect(error?.message).toContain('activeProviderId:')
+ expect(error?.message).toContain('configuredProviders:')
+ })
+
it('should prewarm the CLI before the first user turn and reuse that process', async () => {
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
@@ -1143,6 +1169,108 @@ describe('WebSocket Chat Integration', () => {
}
}, 20_000)
+ it('should ignore stale persisted runtime provider ids when resuming old sessions', async () => {
+ const providerService = new ProviderService()
+ const activeProvider = await providerService.addProvider({
+ presetId: 'custom',
+ name: 'Current Valid Provider',
+ apiKey: 'key-current-valid',
+ baseUrl: 'http://127.0.0.1:1/anthropic',
+ apiFormat: 'anthropic',
+ models: {
+ main: 'current-main',
+ haiku: 'current-haiku',
+ sonnet: 'current-sonnet',
+ opus: 'current-opus',
+ },
+ })
+ await providerService.activateProvider(activeProvider.id)
+
+ const createRes = await fetch(`${baseUrl}/api/sessions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workDir: process.cwd() }),
+ })
+ expect(createRes.status).toBe(201)
+ const { sessionId } = await createRes.json() as { sessionId: string }
+
+ const staleProviderId = crypto.randomUUID()
+ const originalStartSession = conversationService.startSession.bind(conversationService)
+ const startCalls: Array<{
+ sessionId: string
+ options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
+ }> = []
+
+ conversationService.startSession = (async function patchedStartSession(
+ sid: string,
+ workDir: string,
+ sdkUrl: string,
+ options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null },
+ ) {
+ startCalls.push({ sessionId: sid, options })
+ return originalStartSession(sid, workDir, sdkUrl, options)
+ }) as typeof conversationService.startSession
+
+ const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
+ const messages: any[] = []
+ try {
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ ws.close()
+ reject(new Error(`Timed out waiting for stale runtime resume for session ${sessionId}`))
+ }, 10_000)
+
+ ws.onmessage = (event) => {
+ const msg = JSON.parse(event.data as string)
+ messages.push(msg)
+
+ if (msg.type === 'connected') {
+ ws.send(JSON.stringify({
+ type: 'set_runtime_config',
+ providerId: staleProviderId,
+ modelId: 'stale-model',
+ }))
+ ws.send(JSON.stringify({ type: 'user_message', content: 'resume old session' }))
+ return
+ }
+
+ if (msg.type === 'error') {
+ clearTimeout(timeout)
+ ws.close()
+ reject(new Error(msg.message))
+ return
+ }
+
+ if (msg.type === 'message_complete') {
+ clearTimeout(timeout)
+ ws.close()
+ resolve()
+ }
+ }
+
+ ws.onerror = () => {
+ clearTimeout(timeout)
+ reject(new Error(`WebSocket error for stale runtime resume session ${sessionId}`))
+ }
+ })
+
+ expect(startCalls).toHaveLength(1)
+ expect(startCalls[0]).toMatchObject({
+ sessionId,
+ options: {
+ providerId: activeProvider.id,
+ },
+ })
+ expect(startCalls[0]?.options?.model).not.toBe('stale-model')
+ expect(messages.some((msg) => msg.type === 'message_complete')).toBe(true)
+ } finally {
+ ws.close()
+ conversationService.startSession = originalStartSession
+ conversationService.stopSession(sessionId)
+ await providerService.activateOfficial()
+ }
+ }, 20_000)
+
it('should resume streaming to a reconnected client during an active turn', async () => {
await withMockStreamDelay(150, async () => {
const sessionId = `chat-reconnect-${crypto.randomUUID()}`
diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts
index 5d35c744..1c5ce276 100644
--- a/src/server/ws/handler.ts
+++ b/src/server/ws/handler.ts
@@ -61,6 +61,7 @@ const runtimeOverrides = new Map>()
const sessionStartupPromises = new Map>()
+const lastResolvedStartupWorkDirs = new Map()
const prewarmPendingSessions = new Set()
const prewarmedSessions = new Set()
const prewarmIdleTimers = new Map>()
@@ -265,7 +266,7 @@ async function handleUserMessage(
console.error(`[WS] CLI start failed for ${sessionId}: ${errMsg}`)
sendMessage(ws, {
type: 'error',
- message: errMsg,
+ message: await buildSessionStartupDiagnosticMessage(sessionId, errMsg),
code,
retryable:
err instanceof ConversationStartupError ? err.retryable : false,
@@ -511,7 +512,10 @@ async function restartSessionWithPermissionMode(
console.error(`[WS] Failed to restart CLI for ${sessionId}: ${errMsg}`)
sendMessage(ws, {
type: 'error',
- message: `Failed to restart session with new permission mode: ${errMsg}`,
+ message: await buildSessionStartupDiagnosticMessage(
+ sessionId,
+ `Failed to restart session with new permission mode: ${errMsg}`,
+ ),
code: 'CLI_RESTART_FAILED',
})
sendMessage(ws, { type: 'status', state: 'idle' })
@@ -545,7 +549,10 @@ async function restartSessionWithRuntimeConfig(
console.error(`[WS] Failed to restart CLI for ${sessionId} after runtime override: ${errMsg}`)
sendMessage(ws, {
type: 'error',
- message: `Failed to switch provider/model: ${errMsg}`,
+ message: await buildSessionStartupDiagnosticMessage(
+ sessionId,
+ `Failed to switch provider/model: ${errMsg}`,
+ ),
code: 'CLI_RESTART_FAILED',
})
sendMessage(ws, { type: 'status', state: 'idle' })
@@ -661,6 +668,7 @@ function cleanupSessionRuntimeState(sessionId: string) {
runtimeOverrides.delete(sessionId)
runtimeTransitionPromises.delete(sessionId)
sessionStartupPromises.delete(sessionId)
+ lastResolvedStartupWorkDirs.delete(sessionId)
clearPrewarmState(sessionId)
}
@@ -758,6 +766,7 @@ async function ensureCliSessionStarted(
const startup = (async () => {
const workDir = await resolveSessionWorkDir(sessionId)
+ lastResolvedStartupWorkDirs.set(sessionId, workDir)
const runtimeSettings = await getRuntimeSettings(sessionId)
const sdkUrl =
`ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` +
@@ -1223,14 +1232,28 @@ function rebindSessionOutput(
})
}
-async function getRuntimeSettings(sessionId?: string): Promise<{
+type RuntimeSettings = {
permissionMode?: string
model?: string
effort?: string
providerId?: string | null
-}> {
+}
+
+async function getRuntimeSettings(sessionId?: string): Promise {
const runtimeOverride = sessionId ? runtimeOverrides.get(sessionId) : undefined
if (runtimeOverride) {
+ if (typeof runtimeOverride.providerId === 'string') {
+ const { providers } = await providerService.listProviders()
+ const providerExists = providers.some((provider) => provider.id === runtimeOverride.providerId)
+ if (!providerExists) {
+ console.warn(
+ `[WS] Ignoring stale runtime provider id for ${sessionId}: ${runtimeOverride.providerId}`,
+ )
+ runtimeOverrides.delete(sessionId!)
+ return getDefaultRuntimeSettings()
+ }
+ }
+
const userSettings = await settingsService.getUserSettings()
const effort =
typeof userSettings.effort === 'string' && userSettings.effort.trim()
@@ -1245,10 +1268,21 @@ async function getRuntimeSettings(sessionId?: string): Promise<{
}
}
+ return getDefaultRuntimeSettings()
+}
+
+async function getDefaultRuntimeSettings(): Promise {
// Check if a custom provider is active
- const { activeId } = await providerService.listProviders()
+ const { providers, activeId } = await providerService.listProviders()
+ let resolvedActiveId = activeId
+ if (activeId && !providers.some((provider) => provider.id === activeId)) {
+ console.warn(`[WS] Active provider id is stale, falling back to official provider: ${activeId}`)
+ resolvedActiveId = null
+ await providerService.activateOfficial()
+ }
+
const userSettings = await settingsService.getUserSettings()
- const providerSettings = activeId
+ const providerSettings = resolvedActiveId
? await providerService.getManagedSettings()
: undefined
const modelSettings = providerSettings ?? userSettings
@@ -1262,7 +1296,7 @@ async function getRuntimeSettings(sessionId?: string): Promise<{
: undefined
let model: string | undefined
- if (activeId) {
+ if (resolvedActiveId) {
// Provider is active — only consult provider-managed cc-haha settings.
// Global ~/.claude/settings.json model values must not bleed into provider mode.
const baseModel =
@@ -1286,10 +1320,58 @@ async function getRuntimeSettings(sessionId?: string): Promise<{
permissionMode: await settingsService.getPermissionMode().catch(() => undefined),
model,
effort,
- providerId: activeId,
+ providerId: resolvedActiveId,
}
}
+async function buildSessionStartupDiagnosticMessage(
+ sessionId: string,
+ cause: string,
+): Promise {
+ const lines = [
+ cause,
+ '',
+ 'Desktop service diagnostics:',
+ `- sessionId: ${sessionId}`,
+ ]
+
+ try {
+ const recentWorkDir = lastResolvedStartupWorkDirs.get(sessionId)
+ const workDir =
+ recentWorkDir ||
+ conversationService.getSessionWorkDir(sessionId) ||
+ await sessionService.getSessionWorkDir(sessionId)
+ lines.push(`- workDir: ${workDir ?? '(unknown)'}`)
+ } catch (err) {
+ lines.push(`- workDir: failed to resolve (${err instanceof Error ? err.message : String(err)})`)
+ }
+
+ const runtimeOverride = runtimeOverrides.get(sessionId)
+ if (runtimeOverride) {
+ lines.push(`- runtimeOverride.providerId: ${runtimeOverride.providerId ?? '(official)'}`)
+ lines.push(`- runtimeOverride.modelId: ${runtimeOverride.modelId}`)
+ } else {
+ lines.push('- runtimeOverride: (none)')
+ }
+
+ try {
+ const { providers, activeId } = await providerService.listProviders()
+ lines.push(`- activeProviderId: ${activeId ?? '(official)'}`)
+ lines.push(`- configuredProviders: ${providers.length}`)
+ if (providers.length > 0) {
+ lines.push(
+ `- providerIndex: ${providers
+ .map((provider) => `${provider.name} (${provider.id})`)
+ .join(', ')}`,
+ )
+ }
+ } catch (err) {
+ lines.push(`- providers: failed to read (${err instanceof Error ? err.message : String(err)})`)
+ }
+
+ return lines.join('\n')
+}
+
function enqueueRuntimeTransition(
sessionId: string,
transition: () => Promise,