feat: Tauri desktop app with sidecar, brand identity, and CORS fix

- Add Tauri sidecar architecture: Rust shell spawns claude-server binary,
  dynamic port allocation, health-check wait loop, graceful shutdown
- Fix CORS middleware to accept `tauri://localhost` and `https://tauri.localhost`
  origins from Tauri WebView, and add CORS headers to /health endpoint
- Enable native macOS window decorations (traffic lights) with Overlay title bar,
  add data-tauri-drag-region on sidebar for window dragging
- Conditionally apply desktop-only padding (44px for traffic lights) vs web (12px)
- Generate brand identity: light-background app icon, horizontal logo, full icon
  set (icns/ico/png) for Tauri bundle
- Add brand mark + GitHub link in sidebar, replace mascot SVG with app icon
  in EmptySession page
- Update README (zh/en) and docs hero image with new branding
- Add sidecar build scripts and launcher entry points
- Gitignore Rust target/, Tauri gen/, and brand-assets candidates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-07 16:07:38 +08:00
parent 352b547bff
commit f9c42c3b40
85 changed files with 6140 additions and 122 deletions

6
.gitignore vendored
View File

@ -18,6 +18,12 @@ e2e-*.png
# Desktop build output # Desktop build output
desktop/dist/ desktop/dist/
desktop/src-tauri/binaries/
desktop/src-tauri/target/
desktop/src-tauri/gen/
# Desktop brand asset candidates (keep only selected ones in public/)
desktop/brand-assets/
# Claude worktrees # Claude worktrees
.claude/worktrees/ .claude/worktrees/

View File

@ -1,7 +1,11 @@
# Claude Code Haha # Claude Code Haha
<p align="center"> <p align="center">
<img src="docs/images/banner.jpg" alt="Claude Code Haha Banner" width="800"> <img src="docs/images/app-icon.jpg" alt="Claude Code Haha" width="128" style="border-radius: 22px;">
</p>
<p align="center">
<img src="docs/images/logo-horizontal.jpg" alt="Claude Code Haha" width="480">
</p> </p>
<div align="center"> <div align="center">

View File

@ -1,7 +1,11 @@
# Claude Code Haha # Claude Code Haha
<p align="center"> <p align="center">
<img src="docs/images/banner.jpg" alt="Claude Code Haha Banner" width="800"> <img src="docs/images/app-icon.jpg" alt="Claude Code Haha" width="128" style="border-radius: 22px;">
</p>
<p align="center">
<img src="docs/images/logo-horizontal.jpg" alt="Claude Code Haha" width="480">
</p> </p>
<div align="center"> <div align="center">

View File

@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"build:sidecars": "bun run ./scripts/build-sidecars.ts",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri", "tauri": "tauri",
"test": "vitest", "test": "vitest",

BIN
desktop/public/app-icon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

View File

@ -0,0 +1,120 @@
import { mkdir } from 'node:fs/promises'
import path from 'node:path'
const desktopRoot = path.resolve(import.meta.dir, '..')
const repoRoot = path.resolve(desktopRoot, '..')
const binariesDir = path.join(desktopRoot, 'src-tauri', 'binaries')
const targetTriple =
process.env.TAURI_ENV_TARGET_TRIPLE ||
process.env.CARGO_BUILD_TARGET ||
(await detectHostTriple())
const bunTarget = mapTargetTripleToBun(targetTriple)
await mkdir(binariesDir, { recursive: true })
await compileExecutable({
entrypoint: path.join(desktopRoot, 'sidecars/server-launcher.ts'),
outfileBase: path.join(binariesDir, `claude-server-${targetTriple}`),
productName: 'Claude Code Server',
bunTarget,
})
await compileExecutable({
entrypoint: path.join(desktopRoot, 'sidecars/cli-launcher.ts'),
outfileBase: path.join(binariesDir, `claude-cli-${targetTriple}`),
productName: 'Claude Code CLI',
bunTarget,
})
console.log(`[build-sidecars] Built desktop sidecars for ${targetTriple} (${bunTarget})`)
async function detectHostTriple() {
const proc = Bun.spawn(['rustc', '-vV'], {
cwd: repoRoot,
stdout: 'pipe',
stderr: 'pipe',
})
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const exitCode = await proc.exited
if (exitCode !== 0) {
throw new Error(`[build-sidecars] rustc -vV failed: ${stderr || stdout}`)
}
const hostLine = stdout
.split('\n')
.map((line) => line.trim())
.find((line) => line.startsWith('host: '))
if (!hostLine) {
throw new Error('[build-sidecars] Could not detect Rust host triple')
}
return hostLine.replace('host: ', '')
}
function mapTargetTripleToBun(triple: string) {
switch (triple) {
case 'aarch64-apple-darwin':
return 'bun-darwin-arm64'
case 'x86_64-apple-darwin':
return 'bun-darwin-x64'
case 'x86_64-pc-windows-msvc':
return 'bun-windows-x64'
case 'aarch64-pc-windows-msvc':
return 'bun-windows-arm64'
case 'x86_64-unknown-linux-gnu':
return 'bun-linux-x64-baseline'
case 'aarch64-unknown-linux-gnu':
return 'bun-linux-arm64'
case 'x86_64-unknown-linux-musl':
return 'bun-linux-x64-musl'
case 'aarch64-unknown-linux-musl':
return 'bun-linux-arm64-musl'
default:
throw new Error(`[build-sidecars] Unsupported target triple: ${triple}`)
}
}
async function compileExecutable({
entrypoint,
outfileBase,
productName,
bunTarget,
}: {
entrypoint: string
outfileBase: string
productName: string
bunTarget: string
}) {
const result = await Bun.build({
entrypoints: [entrypoint],
minify: false,
sourcemap: 'none',
target: 'bun',
compile: {
target: bunTarget,
outfile: outfileBase,
autoloadTsconfig: true,
autoloadPackageJson: true,
windows: {
title: productName,
publisher: 'Claude Code',
description: productName,
hideConsole: true,
},
},
})
if (!result.success) {
const logs = result.logs.map((log) => log.message).join('\n')
throw new Error(`[build-sidecars] Failed to compile ${productName}:\n${logs}`)
}
const outputPath = result.outputs[0]?.path
console.log(`[build-sidecars] ${productName} -> ${outputPath ?? outfileBase}`)
}

View File

@ -0,0 +1,35 @@
import path from 'node:path'
import { pathToFileURL } from 'node:url'
const { appRoot, args } = parseLauncherArgs()
process.env.CLAUDE_APP_ROOT = appRoot
process.env.CALLER_DIR ||= process.cwd()
process.argv = [process.argv[0]!, process.argv[1]!, ...args]
const preloadEntrypoint = pathToFileURL(path.join(appRoot, 'preload.ts')).href
const cliEntrypoint = pathToFileURL(path.join(appRoot, 'src/entrypoints/cli.tsx')).href
await import(preloadEntrypoint)
await import(cliEntrypoint)
function parseLauncherArgs() {
const rawArgs = process.argv.slice(2)
const nextArgs: string[] = []
let appRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null
for (let index = 0; index < rawArgs.length; index++) {
const arg = rawArgs[index]
if (arg === '--app-root') {
appRoot = rawArgs[index + 1] ?? null
index += 1
continue
}
nextArgs.push(arg)
}
if (!appRoot) {
throw new Error('Missing --app-root for claude-cli sidecar')
}
return { appRoot, args: nextArgs }
}

View File

@ -0,0 +1,37 @@
import path from 'node:path'
import { pathToFileURL } from 'node:url'
const { appRoot, args } = parseLauncherArgs()
process.env.CLAUDE_APP_ROOT = appRoot
process.chdir(appRoot)
process.argv = [process.argv[0]!, process.argv[1]!, ...args]
const preloadEntrypoint = pathToFileURL(path.join(appRoot, 'preload.ts')).href
const serverEntrypoint = pathToFileURL(path.join(appRoot, 'src/server/index.ts')).href
await import(preloadEntrypoint)
const { startServer } = await import(serverEntrypoint)
startServer()
function parseLauncherArgs() {
const rawArgs = process.argv.slice(2)
const nextArgs: string[] = []
let appRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null
for (let index = 0; index < rawArgs.length; index++) {
const arg = rawArgs[index]
if (arg === '--app-root') {
appRoot = rawArgs[index + 1] ?? null
index += 1
continue
}
nextArgs.push(arg)
}
if (!appRoot) {
throw new Error('Missing --app-root for claude-server sidecar')
}
return { appRoot, args: nextArgs }
}

5172
desktop/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,13 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" rx="132" fill="#1F1714"/>
<rect x="42" y="42" width="428" height="428" rx="112" fill="url(#surface)"/>
<path d="M352 178C327 153 292 140 252 140C166 140 108 197 108 256C108 314 166 372 252 372C291 372 326 360 351 335L316 298C300 315 278 324 253 324C197 324 159 287 159 256C159 224 197 188 253 188C279 188 301 198 317 215L352 178Z" fill="#FFF7F0"/>
<path d="M381 166L401 146L438 183L418 203L381 166Z" fill="#D07A57"/>
<path d="M369 224L395 198L419 222L393 248L369 224Z" fill="#F0B08E"/>
<defs>
<linearGradient id="surface" x1="66" y1="78" x2="432" y2="436" gradientUnits="userSpaceOnUse">
<stop stop-color="#A85E42"/>
<stop offset="1" stop-color="#7A4330"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 855 B

View File

@ -6,7 +6,26 @@
"permissions": [ "permissions": [
"core:default", "core:default",
"shell:allow-open", "shell:allow-open",
"shell:allow-execute", {
"identifier": "shell:allow-execute",
"allow": [
{
"args": true,
"name": "binaries/claude-server",
"sidecar": true
}
]
},
{
"identifier": "shell:allow-spawn",
"allow": [
{
"args": true,
"name": "binaries/claude-server",
"sidecar": true
}
]
},
"dialog:allow-open", "dialog:allow-open",
"dialog:allow-save", "dialog:allow-save",
"process:allow-exit", "process:allow-exit",

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -1,24 +1,185 @@
use tauri::Manager; use std::{
io::{Error as IoError, ErrorKind},
net::{SocketAddr, TcpListener, TcpStream},
path::PathBuf,
sync::Mutex,
time::{Duration, Instant},
};
use tauri::{path::BaseDirectory, AppHandle, Manager, RunEvent, State};
use tauri_plugin_shell::{
process::{CommandChild, CommandEvent},
ShellExt,
};
#[derive(Default)]
struct ServerState(Mutex<ServerStatus>);
struct ServerRuntime {
url: String,
child: CommandChild,
}
#[derive(Default)]
struct ServerStatus {
runtime: Option<ServerRuntime>,
startup_error: Option<String>,
}
#[tauri::command] #[tauri::command]
fn get_server_url() -> String { fn get_server_url(state: State<'_, ServerState>) -> Result<String, String> {
let port = std::env::var("SERVER_PORT").unwrap_or_else(|_| "3456".to_string()); let guard = state
let host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); .0
format!("http://{}:{}", host, port) .lock()
.map_err(|_| "desktop server state is unavailable".to_string())?;
if let Some(runtime) = guard.runtime.as_ref() {
return Ok(runtime.url.clone());
}
Err(guard
.startup_error
.clone()
.unwrap_or_else(|| "desktop server did not start".to_string()))
}
fn reserve_local_port() -> Result<u16, String> {
let listener =
TcpListener::bind("127.0.0.1:0").map_err(|err| format!("bind local port: {err}"))?;
let port = listener
.local_addr()
.map_err(|err| format!("read local port: {err}"))?
.port();
drop(listener);
Ok(port)
}
fn wait_for_server(url_host: &str, port: u16) -> Result<(), String> {
let addr: SocketAddr = format!("{url_host}:{port}")
.parse()
.map_err(|err| format!("parse server address: {err}"))?;
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() {
return Ok(());
}
std::thread::sleep(Duration::from_millis(150));
}
Err(format!(
"desktop server did not start listening on {url_host}:{port} within 10 seconds"
))
}
fn resolve_app_root(app: &AppHandle) -> Result<PathBuf, String> {
if cfg!(debug_assertions) {
return PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.map_err(|err| format!("resolve development app root: {err}"));
}
app.path()
.resolve("app", BaseDirectory::Resource)
.map_err(|err| format!("resolve bundled app root: {err}"))
}
fn start_server_sidecar(app: &AppHandle) -> Result<ServerRuntime, String> {
let host = "127.0.0.1";
let port = reserve_local_port()?;
let url = format!("http://{host}:{port}");
let app_root = resolve_app_root(app)?;
let app_root_arg = app_root.to_string_lossy().to_string();
let sidecar = app
.shell()
.sidecar("claude-server")
.map_err(|err| format!("resolve server sidecar: {err}"))?
.args([
"--app-root",
&app_root_arg,
"--host",
host,
"--port",
&port.to_string(),
]);
let (mut rx, child) = sidecar
.spawn()
.map_err(|err| format!("spawn server sidecar: {err}"))?;
tauri::async_runtime::spawn(async move {
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Stdout(line) => {
let line = String::from_utf8_lossy(&line);
println!("[claude-server] {}", line.trim_end());
}
CommandEvent::Stderr(line) => {
let line = String::from_utf8_lossy(&line);
eprintln!("[claude-server] {}", line.trim_end());
}
_ => {}
}
}
});
wait_for_server(host, port)?;
Ok(ServerRuntime { url, child })
}
fn stop_server_sidecar(app: &AppHandle) {
let Some(state) = app.try_state::<ServerState>() else {
return;
};
let Ok(mut guard) = state.0.lock() else {
return;
};
if let Some(runtime) = guard.runtime.take() {
let _ = runtime.child.kill();
}
} }
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() let app = tauri::Builder::default()
.manage(ServerState::default())
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.invoke_handler(tauri::generate_handler![get_server_url]) .invoke_handler(tauri::generate_handler![get_server_url])
.setup(|app| { .setup(|app| {
// Window is configured in tauri.conf.json let state = app.state::<ServerState>();
let mut guard = state
.0
.lock()
.map_err(|_| IoError::new(ErrorKind::Other, "server state lock poisoned"))?;
match start_server_sidecar(&app.handle()) {
Ok(runtime) => {
guard.runtime = Some(runtime);
guard.startup_error = None;
}
Err(err) => {
eprintln!("[desktop] failed to start local server: {err}");
guard.runtime = None;
guard.startup_error = Some(err);
}
}
let _window = app.get_webview_window("main").unwrap(); let _window = app.get_webview_window("main").unwrap();
Ok(()) Ok(())
}) })
.run(tauri::generate_context!()) .build(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while building tauri application");
app.run(|app_handle, event| {
if matches!(event, RunEvent::Exit | RunEvent::ExitRequested { .. }) {
stop_server_sidecar(app_handle);
}
});
} }

View File

@ -6,8 +6,8 @@
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420",
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run build:sidecars && bun run dev",
"beforeBuildCommand": "bun run build" "beforeBuildCommand": "bun run build && bun run build:sidecars"
}, },
"app": { "app": {
"windows": [ "windows": [
@ -17,7 +17,7 @@
"height": 800, "height": 800,
"minWidth": 900, "minWidth": 900,
"minHeight": 600, "minHeight": 600,
"decorations": false, "decorations": true,
"titleBarStyle": "Overlay", "titleBarStyle": "Overlay",
"hiddenTitle": true, "hiddenTitle": true,
"transparent": false "transparent": false
@ -30,6 +30,19 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "all",
"externalBin": [
"binaries/claude-server",
"binaries/claude-cli"
],
"resources": {
"../../src/": "app/src/",
"../../node_modules/": "app/node_modules/",
"../../package.json": "app/package.json",
"../../tsconfig.json": "app/tsconfig.json",
"../../bunfig.toml": "app/bunfig.toml",
"../../preload.ts": "app/preload.ts",
"../../stubs/": "app/stubs/"
},
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",

View File

@ -10,6 +10,10 @@ export function getBaseUrl() {
return baseUrl return baseUrl
} }
export function getDefaultBaseUrl() {
return DEFAULT_BASE_URL
}
export class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(
public status: number, public status: number,

View File

@ -85,7 +85,10 @@ export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) {
// All questions must be answered (via selection or free text) to enable submit // All questions must be answered (via selection or free text) to enable submit
const allAnswered = freeText.trim().length > 0 || questions.every((_, i) => selections[i] !== undefined) const allAnswered = freeText.trim().length > 0 || questions.every((_, i) => selections[i] !== undefined)
const activeQuestion = questions[activeTab] const safeActiveTab = Math.min(activeTab, questions.length - 1)
const activeQuestion = questions[safeActiveTab]
if (!activeQuestion) return null
return ( return (
<div className={`mb-4 ml-10 rounded-[var(--radius-lg)] border overflow-hidden ${ <div className={`mb-4 ml-10 rounded-[var(--radius-lg)] border overflow-hidden ${
@ -120,7 +123,7 @@ export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) {
{questions.length > 1 && ( {questions.length > 1 && (
<div className="flex px-4 border-b border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-container-low)] overflow-x-auto"> <div className="flex px-4 border-b border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-container-low)] overflow-x-auto">
{questions.map((q, i) => { {questions.map((q, i) => {
const isActive = activeTab === i const isActive = safeActiveTab === i
const isAnswered = selections[i] !== undefined const isAnswered = selections[i] !== undefined
const tabLabel = q.header || `Q${i + 1}` const tabLabel = q.header || `Q${i + 1}`
return ( return (
@ -160,7 +163,7 @@ export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) {
return ( return (
<button <button
key={optIndex} key={optIndex}
onClick={() => handleSelect(activeTab, opt.label)} onClick={() => handleSelect(safeActiveTab, opt.label)}
disabled={submitted} disabled={submitted}
className={`w-full text-left px-4 py-3 rounded-[var(--radius-md)] border transition-all duration-150 cursor-pointer ${ className={`w-full text-left px-4 py-3 rounded-[var(--radius-md)] border transition-all duration-150 cursor-pointer ${
isSelected isSelected

View File

@ -117,10 +117,11 @@ export function MessageList() {
<AssistantMessage content={streamingText} isStreaming /> <AssistantMessage content={streamingText} isStreaming />
)} )}
{/* Only show StreamingIndicator for tool_executing state. {/* Show StreamingIndicator when:
During 'thinking', the active ThinkingBlock already shows animation. - tool_executing: tool is running
During 'streaming', the AssistantMessage shows the cursor. */} - thinking but no active ThinkingBlock yet: the gap between
{chatState === 'tool_executing' && ( sending a message and receiving the first thinking delta */}
{(chatState === 'tool_executing' || (chatState === 'thinking' && !activeThinkingId)) && (
<StreamingIndicator /> <StreamingIndicator />
)} )}

View File

@ -29,56 +29,59 @@ export function SessionTaskBar() {
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0 const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0
return ( return (
<div className="border-t border-[var(--color-border)] bg-[var(--color-surface)] shrink-0"> <div className="shrink-0 px-8">
{/* Header — always visible, clickable to toggle */} <div className="mx-auto max-w-[860px] rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-lowest)] overflow-hidden mb-2">
<button {/* Header — always visible, clickable to toggle */}
onClick={toggleExpanded} <button
className="w-full flex items-center gap-3 px-4 py-2 hover:bg-[var(--color-surface-hover)] transition-colors" onClick={toggleExpanded}
> className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-[var(--color-surface-container-low)] transition-colors bg-[var(--color-surface-container)]"
<span
className="material-symbols-outlined text-[16px]"
style={{ color: 'var(--color-text-secondary)' }}
> >
checklist <div className="flex items-center justify-center w-6 h-6 rounded-[var(--radius-md)] bg-[var(--color-secondary)]/10">
</span> <span
className="material-symbols-outlined text-[14px] text-[var(--color-secondary)]"
>
checklist
</span>
</div>
<span className="text-xs font-semibold text-[var(--color-text-primary)]"> <span className="text-xs font-semibold text-[var(--color-text-primary)]">
Tasks Tasks
</span> </span>
{/* Progress bar */} {/* Progress bar */}
<div className="flex-1 h-1.5 rounded-full bg-[var(--color-border)] overflow-hidden max-w-[200px]"> <div className="flex-1 h-1.5 rounded-full bg-[var(--color-border)] overflow-hidden max-w-[200px]">
<div <div
className="h-full rounded-full transition-all duration-300" className="h-full rounded-full transition-all duration-300"
style={{ style={{
width: `${progressPercent}%`, width: `${progressPercent}%`,
backgroundColor: completedCount === totalCount backgroundColor: completedCount === totalCount
? 'var(--color-success)' ? 'var(--color-success)'
: 'var(--color-brand)', : 'var(--color-brand)',
}} }}
/> />
</div> </div>
<span className="text-[10px] text-[var(--color-text-tertiary)] tabular-nums"> <span className="text-[10px] text-[var(--color-text-tertiary)] tabular-nums">
{completedCount}/{totalCount} {completedCount}/{totalCount}
</span> </span>
<span <span
className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)] transition-transform duration-200" className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)] transition-transform duration-200"
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }} style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }}
> >
expand_less expand_less
</span> </span>
</button> </button>
{/* Expanded task list */} {/* Expanded task list */}
{expanded && ( {expanded && (
<div className="px-4 pb-2 flex flex-col gap-0.5 max-h-[240px] overflow-y-auto"> <div className="px-4 pb-2 pt-1 flex flex-col gap-0.5 max-h-[240px] overflow-y-auto border-t border-[var(--color-outline-variant)]/20">
{tasks.map((task) => ( {tasks.map((task) => (
<TaskItem key={task.id} task={task} /> <TaskItem key={task.id} task={task} />
))} ))}
</div> </div>
)} )}
</div>
</div> </div>
) )
} }

View File

@ -1,23 +1,80 @@
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { Sidebar } from './Sidebar' import { Sidebar } from './Sidebar'
import { ContentRouter } from './ContentRouter' import { ContentRouter } from './ContentRouter'
import { ToastContainer } from '../shared/Toast' import { ToastContainer } from '../shared/Toast'
import { useSettingsStore } from '../../stores/settingsStore' import { useSettingsStore } from '../../stores/settingsStore'
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts' import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
import { initializeDesktopServerUrl } from '../../lib/desktopRuntime'
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
export function AppShell() { export function AppShell() {
const fetchSettings = useSettingsStore((s) => s.fetchAll) const fetchSettings = useSettingsStore((s) => s.fetchAll)
const [ready, setReady] = useState(false)
const [startupError, setStartupError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
fetchSettings() let cancelled = false
const bootstrap = async () => {
try {
await initializeDesktopServerUrl()
await fetchSettings()
if (!cancelled) {
setReady(true)
}
} catch (error) {
if (!cancelled) {
setStartupError(error instanceof Error ? error.message : String(error))
setReady(false)
}
}
}
void bootstrap()
return () => {
cancelled = true
}
}, [fetchSettings]) }, [fetchSettings])
useKeyboardShortcuts() useKeyboardShortcuts()
if (startupError) {
return (
<div className="h-screen flex items-center justify-center bg-[var(--color-surface)] px-6">
<div className="max-w-xl rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-6">
<h1 className="text-lg font-semibold text-[var(--color-text-primary)]">
Local server failed to start
</h1>
<p className="mt-2 text-sm text-[var(--color-text-secondary)]">
{startupError}
</p>
</div>
</div>
)
}
if (!ready) {
return (
<div className="h-screen flex items-center justify-center bg-[var(--color-surface)] text-[var(--color-text-secondary)]">
Launching local workspace...
</div>
)
}
return ( return (
<div className="h-screen flex overflow-hidden"> <div className="h-screen flex overflow-hidden relative">
{/* Drag region for macOS Overlay title bar — only relevant in Tauri */}
{isTauri && (
<div
data-tauri-drag-region
className="absolute top-0 right-0 h-[38px] z-10"
style={{ left: 'var(--sidebar-width)' }}
/>
)}
<Sidebar /> <Sidebar />
<main className="flex-1 flex flex-col overflow-hidden"> <main className={`flex-1 flex flex-col overflow-hidden ${isTauri ? 'pt-[38px]' : ''}`}>
<ContentRouter /> <ContentRouter />
</main> </main>
<ToastContainer /> <ToastContainer />

View File

@ -4,12 +4,23 @@ import { useUIStore } from '../../stores/uiStore'
import { ProjectFilter } from './ProjectFilter' import { ProjectFilter } from './ProjectFilter'
import type { SessionListItem } from '../../types/session' import type { SessionListItem } from '../../types/session'
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
type TimeGroup = 'Today' | 'Yesterday' | 'Last 7 days' | 'Last 30 days' | 'Older' type TimeGroup = 'Today' | 'Yesterday' | 'Last 7 days' | 'Last 30 days' | 'Older'
const TIME_GROUP_ORDER: TimeGroup[] = ['Today', 'Yesterday', 'Last 7 days', 'Last 30 days', 'Older'] const TIME_GROUP_ORDER: TimeGroup[] = ['Today', 'Yesterday', 'Last 7 days', 'Last 30 days', 'Older']
export function Sidebar() { export function Sidebar() {
const { sessions, activeSessionId, selectedProjects, setActiveSession, fetchSessions, deleteSession, renameSession } = useSessionStore() const {
sessions,
activeSessionId,
selectedProjects,
error,
setActiveSession,
fetchSessions,
deleteSession,
renameSession,
} = useSessionStore()
const { activeView, setActiveView } = useUIStore() const { activeView, setActiveView } = useUIStore()
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null) const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
@ -69,9 +80,27 @@ export function Sidebar() {
}, [renamingId, renameValue, renameSession]) }, [renamingId, renameValue, renameSession])
return ( return (
<aside className="w-[var(--sidebar-width)] h-full flex flex-col bg-[var(--color-surface-sidebar)] border-r border-[var(--color-border)] select-none"> <aside data-tauri-drag-region className="w-[var(--sidebar-width)] h-full flex flex-col bg-[var(--color-surface-sidebar)] border-r border-[var(--color-border)] select-none">
{/* Brand logo — extra top padding in desktop to clear macOS traffic lights */}
<div className={`px-3 pb-1.5 flex items-center justify-between ${isTauri ? 'pt-[44px]' : 'pt-3'}`}>
<div className="flex items-center gap-2.5">
<img src="/app-icon.jpg" alt="" className="h-8 w-8 rounded-lg flex-shrink-0" />
<span className="text-[13px] font-semibold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: "'Manrope', sans-serif" }}>
Claude Code <span className="text-[#D97757]">Haha</span>
</span>
</div>
<a
href="https://github.com/NanmiCoder/cc-haha"
target="_blank"
rel="noopener noreferrer"
className="rounded-md p-1 text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
title="GitHub"
>
<GitHubIcon />
</a>
</div>
{/* Navigation */} {/* Navigation */}
<div className="p-3 flex flex-col gap-0.5"> <div className="px-3 pb-3 flex flex-col gap-0.5">
<NavItem <NavItem
active={activeView === 'code' && !activeSessionId} active={activeView === 'code' && !activeSessionId}
onClick={() => { setActiveView('code'); setActiveSession(null) }} onClick={() => { setActiveView('code'); setActiveSession(null) }}
@ -107,6 +136,18 @@ export function Sidebar() {
{/* Session list — grouped by time */} {/* Session list — grouped by time */}
<div className="flex-1 overflow-y-auto px-3"> <div className="flex-1 overflow-y-auto px-3">
{error && (
<div className="mx-1 mt-2 rounded-[var(--radius-md)] border border-[var(--color-error)]/20 bg-[var(--color-error)]/5 px-3 py-2">
<div className="text-xs font-medium text-[var(--color-error)]">Session list failed to load</div>
<div className="mt-1 text-[11px] text-[var(--color-text-secondary)] break-words">{error}</div>
<button
onClick={() => fetchSessions()}
className="mt-2 text-[11px] font-medium text-[var(--color-brand)] hover:underline"
>
Retry
</button>
</div>
)}
{filteredSessions.length === 0 && ( {filteredSessions.length === 0 && (
<div className="px-3 py-4 text-xs text-[var(--color-text-tertiary)] text-center"> <div className="px-3 py-4 text-xs text-[var(--color-text-tertiary)] text-center">
{searchQuery ? 'No matching sessions' : 'No sessions yet'} {searchQuery ? 'No matching sessions' : 'No sessions yet'}
@ -263,6 +304,14 @@ function formatRelativeTime(dateStr: string): string {
return `${Math.floor(day / 30)}mo` return `${Math.floor(day / 30)}mo`
} }
function GitHubIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</svg>
)
}
function PlusIcon() { function PlusIcon() {
return ( return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@ -280,4 +329,3 @@ function ClockIcon() {
</svg> </svg>
) )
} }

View File

@ -0,0 +1,193 @@
export const SPINNER_VERBS = [
'Accomplishing',
'Actioning',
'Actualizing',
'Architecting',
'Baking',
'Beaming',
"Beboppin'",
'Befuddling',
'Billowing',
'Blanching',
'Bloviating',
'Boogieing',
'Boondoggling',
'Booping',
'Bootstrapping',
'Brewing',
'Bunning',
'Burrowing',
'Calculating',
'Canoodling',
'Caramelizing',
'Cascading',
'Catapulting',
'Cerebrating',
'Channeling',
'Channelling',
'Choreographing',
'Churning',
'Clauding',
'Coalescing',
'Cogitating',
'Combobulating',
'Composing',
'Computing',
'Concocting',
'Considering',
'Contemplating',
'Cooking',
'Crafting',
'Creating',
'Crunching',
'Crystallizing',
'Cultivating',
'Deciphering',
'Deliberating',
'Determining',
'Dilly-dallying',
'Discombobulating',
'Doing',
'Doodling',
'Drizzling',
'Ebbing',
'Effecting',
'Elucidating',
'Embellishing',
'Enchanting',
'Envisioning',
'Evaporating',
'Fermenting',
'Fiddle-faddling',
'Finagling',
'Flambéing',
'Flibbertigibbeting',
'Flowing',
'Flummoxing',
'Fluttering',
'Forging',
'Forming',
'Frolicking',
'Frosting',
'Gallivanting',
'Galloping',
'Garnishing',
'Generating',
'Gesticulating',
'Germinating',
'Gitifying',
'Grooving',
'Gusting',
'Harmonizing',
'Hashing',
'Hatching',
'Herding',
'Honking',
'Hullaballooing',
'Hyperspacing',
'Ideating',
'Imagining',
'Improvising',
'Incubating',
'Inferring',
'Infusing',
'Ionizing',
'Jitterbugging',
'Julienning',
'Kneading',
'Leavening',
'Levitating',
'Lollygagging',
'Manifesting',
'Marinating',
'Meandering',
'Metamorphosing',
'Misting',
'Moonwalking',
'Moseying',
'Mulling',
'Mustering',
'Musing',
'Nebulizing',
'Nesting',
'Newspapering',
'Noodling',
'Nucleating',
'Orbiting',
'Orchestrating',
'Osmosing',
'Perambulating',
'Percolating',
'Perusing',
'Philosophising',
'Photosynthesizing',
'Pollinating',
'Pondering',
'Pontificating',
'Pouncing',
'Precipitating',
'Prestidigitating',
'Processing',
'Proofing',
'Propagating',
'Puttering',
'Puzzling',
'Quantumizing',
'Razzle-dazzling',
'Razzmatazzing',
'Recombobulating',
'Reticulating',
'Roosting',
'Ruminating',
'Sautéing',
'Scampering',
'Schlepping',
'Scurrying',
'Seasoning',
'Shenaniganing',
'Shimmying',
'Simmering',
'Skedaddling',
'Sketching',
'Slithering',
'Smooshing',
'Sock-hopping',
'Spelunking',
'Spinning',
'Sprouting',
'Stewing',
'Sublimating',
'Swirling',
'Swooping',
'Symbioting',
'Synthesizing',
'Tempering',
'Thinking',
'Thundering',
'Tinkering',
'Tomfoolering',
'Topsy-turvying',
'Transfiguring',
'Transmuting',
'Twisting',
'Undulating',
'Unfurling',
'Unravelling',
'Vibing',
'Waddling',
'Wandering',
'Warping',
'Whatchamacalliting',
'Whirlpooling',
'Whirring',
'Whisking',
'Wibbling',
'Working',
'Wrangling',
'Zesting',
'Zigzagging',
]
export function randomSpinnerVerb(): string {
return SPINNER_VERBS[Math.floor(Math.random() * SPINNER_VERBS.length)] ?? 'Thinking'
}

View File

@ -0,0 +1,54 @@
import { getDefaultBaseUrl, setBaseUrl } from '../api/client'
function isTauriRuntime() {
if (typeof window === 'undefined') return false
return '__TAURI_INTERNALS__' in window || '__TAURI__' in window
}
export async function initializeDesktopServerUrl() {
const fallbackUrl = getDefaultBaseUrl()
if (!isTauriRuntime()) {
setBaseUrl(fallbackUrl)
return fallbackUrl
}
try {
const { invoke } = await import(/* @vite-ignore */ '@tauri-apps/api/core')
const serverUrl = await invoke<string>('get_server_url')
setBaseUrl(serverUrl)
await waitForHealth(serverUrl)
return serverUrl
} catch (error) {
const message =
error instanceof Error ? error.message : `desktop server startup failed: ${String(error)}`
console.error('[desktop] Failed to initialize desktop server URL', error)
throw new Error(message || `desktop server startup failed (fallback would be ${fallbackUrl})`)
}
}
async function waitForHealth(serverUrl: string) {
let lastError: unknown
for (let attempt = 0; attempt < 30; attempt++) {
try {
const response = await fetch(`${serverUrl}/health`, {
cache: 'no-store',
})
if (response.ok) {
return
}
lastError = new Error(`healthcheck returned ${response.status}`)
} catch (error) {
lastError = error
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
throw new Error(
lastError instanceof Error
? `Local server healthcheck failed: ${lastError.message}`
: 'Local server healthcheck failed',
)
}

View File

@ -266,9 +266,7 @@ export function EmptySession() {
<div className="relative flex flex-1 flex-col overflow-hidden bg-[#FAF9F5]"> <div className="relative flex flex-1 flex-col overflow-hidden bg-[#FAF9F5]">
<div className="flex flex-1 flex-col items-center justify-center p-8 pb-32"> <div className="flex flex-1 flex-col items-center justify-center p-8 pb-32">
<div className="flex max-w-md flex-col items-center text-center"> <div className="flex max-w-md flex-col items-center text-center">
<div className="mb-6 h-24 w-24 opacity-40 grayscale contrast-125"> <img src="/app-icon.jpg" alt="Claude Code Haha" className="mb-6 h-24 w-24 rounded-[18px]" />
<MascotIllustration />
</div>
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[#1B1C1A]" style={{ fontFamily: "'Manrope', sans-serif" }}> <h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[#1B1C1A]" style={{ fontFamily: "'Manrope', sans-serif" }}>
New session New session
</h1> </h1>
@ -390,29 +388,3 @@ export function EmptySession() {
) )
} }
function MascotIllustration() {
return (
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="24" y="30" width="48" height="36" rx="8" fill="#C47A5A" />
<rect x="28" y="18" width="40" height="16" rx="6" fill="#B86B4A" />
<line x1="48" y1="10" x2="48" y2="18" stroke="#A0796A" strokeWidth="2" strokeLinecap="round" />
<circle cx="48" cy="8" r="3" fill="#D4936F" />
<circle cx="40" cy="26" r="2.5" fill="#1B1C1A" />
<circle cx="56" cy="26" r="2.5" fill="#1B1C1A" />
<circle cx="41" cy="25" r="1" fill="white" opacity="0.7" />
<circle cx="57" cy="25" r="1" fill="white" opacity="0.7" />
<path d="M43 31 Q48 35 53 31" stroke="#1B1C1A" strokeWidth="1.5" strokeLinecap="round" fill="none" />
<rect x="36" y="40" width="24" height="16" rx="4" fill="#D4936F" opacity="0.5" />
<line x1="40" y1="44" x2="56" y2="44" stroke="#B86B4A" strokeWidth="1" strokeLinecap="round" opacity="0.6" />
<line x1="40" y1="48" x2="56" y2="48" stroke="#B86B4A" strokeWidth="1" strokeLinecap="round" opacity="0.6" />
<line x1="40" y1="52" x2="52" y2="52" stroke="#B86B4A" strokeWidth="1" strokeLinecap="round" opacity="0.6" />
<rect x="14" y="36" width="10" height="22" rx="5" fill="#C47A5A" />
<rect x="72" y="36" width="10" height="22" rx="5" fill="#C47A5A" />
<rect x="32" y="66" width="12" height="16" rx="5" fill="#C47A5A" />
<rect x="52" y="66" width="12" height="16" rx="5" fill="#C47A5A" />
<rect x="30" y="78" width="16" height="6" rx="3" fill="#B86B4A" />
<rect x="50" y="78" width="16" height="6" rx="3" fill="#B86B4A" />
<rect x="28" y="30" width="40" height="4" rx="2" fill="#D4936F" opacity="0.3" />
</svg>
)
}

View File

@ -7,6 +7,7 @@ import { Input } from '../components/shared/Input'
import { Button } from '../components/shared/Button' import { Button } from '../components/shared/Button'
import type { PermissionMode, EffortLevel } from '../types/settings' import type { PermissionMode, EffortLevel } from '../types/settings'
import { PROVIDER_PRESETS } from '../config/providerPresets' import { PROVIDER_PRESETS } from '../config/providerPresets'
import type { ProviderPreset } from '../config/providerPresets'
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping } from '../types/provider' import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping } from '../types/provider'
type SettingsTab = 'providers' | 'permissions' | 'general' type SettingsTab = 'providers' | 'permissions' | 'general'
@ -214,14 +215,28 @@ type ProviderFormProps = {
provider?: SavedProvider provider?: SavedProvider
} }
function requirePreset(preset: ProviderPreset | undefined): ProviderPreset {
if (!preset) {
throw new Error('Provider presets are not configured')
}
return preset
}
function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) { function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) {
const { createProvider, updateProvider, testConfig } = useProviderStore() const { createProvider, updateProvider, testConfig } = useProviderStore()
const fetchSettings = useSettingsStore((s) => s.fetchAll) const fetchSettings = useSettingsStore((s) => s.fetchAll)
const availablePresets = PROVIDER_PRESETS.filter((p) => p.id !== 'official') const availablePresets = PROVIDER_PRESETS.filter((p) => p.id !== 'official')
const initialPreset = provider ? availablePresets.find((p) => p.id === provider.presetId) || availablePresets.at(-1)! : availablePresets[0] const fallbackPreset = requirePreset(
availablePresets[availablePresets.length - 1] ?? PROVIDER_PRESETS[0],
)
const initialPreset = requirePreset(
provider
? availablePresets.find((p) => p.id === provider.presetId) ?? fallbackPreset
: availablePresets[0] ?? fallbackPreset,
)
const [selectedPreset, setSelectedPreset] = useState(initialPreset) const [selectedPreset, setSelectedPreset] = useState<ProviderPreset>(initialPreset)
const [name, setName] = useState(provider?.name ?? initialPreset.name) const [name, setName] = useState(provider?.name ?? initialPreset.name)
const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl) const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl)
const [apiKey, setApiKey] = useState('') const [apiKey, setApiKey] = useState('')
@ -263,7 +278,7 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedPreset.id]) }, [selectedPreset.id])
const handlePresetChange = (preset: typeof initialPreset) => { const handlePresetChange = (preset: ProviderPreset) => {
setSelectedPreset(preset) setSelectedPreset(preset)
setName(preset.name) setName(preset.name)
setBaseUrl(preset.baseUrl) setBaseUrl(preset.baseUrl)
@ -442,7 +457,9 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
// Auto-switch to matching preset or Custom // Auto-switch to matching preset or Custom
if (mode === 'create') { if (mode === 'create') {
const matchedPreset = availablePresets.find((p) => p.id !== 'custom' && p.baseUrl === env.ANTHROPIC_BASE_URL) const matchedPreset = availablePresets.find((p) => p.id !== 'custom' && p.baseUrl === env.ANTHROPIC_BASE_URL)
const targetPreset = matchedPreset || availablePresets.find((p) => p.id === 'custom')! const targetPreset = requirePreset(
matchedPreset ?? availablePresets.find((p) => p.id === 'custom'),
)
if (targetPreset.id !== selectedPreset.id) { if (targetPreset.id !== selectedPreset.id) {
jsonPastedRef.current = true jsonPastedRef.current = true
setSelectedPreset(targetPreset) setSelectedPreset(targetPreset)

View File

@ -3,6 +3,7 @@ import { wsManager } from '../api/websocket'
import { sessionsApi } from '../api/sessions' import { sessionsApi } from '../api/sessions'
import { useTeamStore } from './teamStore' import { useTeamStore } from './teamStore'
import { useCLITaskStore } from './cliTaskStore' import { useCLITaskStore } from './cliTaskStore'
import { randomSpinnerVerb } from '../config/spinnerVerbs'
import type { MessageEntry } from '../types/session' import type { MessageEntry } from '../types/session'
import type { PermissionMode } from '../types/settings' import type { PermissionMode } from '../types/settings'
import type { AttachmentRef, ChatState, UIAttachment, UIMessage, ServerMessage, TokenUsage } from '../types/chat' import type { AttachmentRef, ChatState, UIAttachment, UIMessage, ServerMessage, TokenUsage } from '../types/chat'
@ -147,6 +148,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
chatState: 'thinking', chatState: 'thinking',
elapsedSeconds: 0, elapsedSeconds: 0,
streamingText: '', streamingText: '',
statusVerb: randomSpinnerVerb(),
})) }))
// Start elapsed timer // Start elapsed timer
@ -200,7 +202,10 @@ export const useChatStore = create<ChatStore>((set, get) => ({
case 'status': case 'status':
set({ set({
chatState: msg.state, chatState: msg.state,
...(msg.verb ? { statusVerb: msg.verb } : {}), // Only override statusVerb if the server sends something other than
// the generic 'Thinking' — otherwise keep the random verb we picked
// in sendMessage so the user sees fun loading text.
...(msg.verb && msg.verb !== 'Thinking' ? { statusVerb: msg.verb } : {}),
...(msg.tokens ? { tokenUsage: { ...get().tokenUsage, output_tokens: msg.tokens } } : {}), ...(msg.tokens ? { tokenUsage: { ...get().tokenUsage, output_tokens: msg.tokens } } : {}),
...(msg.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}), ...(msg.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}),
}) })

View File

@ -10,6 +10,7 @@ type SettingsStore = {
availableModels: ModelInfo[] availableModels: ModelInfo[]
activeProviderName: string | null activeProviderName: string | null
isLoading: boolean isLoading: boolean
error: string | null
fetchAll: () => Promise<void> fetchAll: () => Promise<void>
setPermissionMode: (mode: PermissionMode) => Promise<void> setPermissionMode: (mode: PermissionMode) => Promise<void>
@ -24,9 +25,10 @@ export const useSettingsStore = create<SettingsStore>((set) => ({
availableModels: [], availableModels: [],
activeProviderName: null, activeProviderName: null,
isLoading: false, isLoading: false,
error: null,
fetchAll: async () => { fetchAll: async () => {
set({ isLoading: true }) set({ isLoading: true, error: null })
try { try {
const [{ mode }, modelsRes, { model }, { level }] = await Promise.all([ const [{ mode }, modelsRes, { model }, { level }] = await Promise.all([
settingsApi.getPermissionMode(), settingsApi.getPermissionMode(),
@ -41,9 +43,13 @@ export const useSettingsStore = create<SettingsStore>((set) => ({
currentModel: model, currentModel: model,
effortLevel: level, effortLevel: level,
isLoading: false, isLoading: false,
error: null,
}) })
} catch { } catch (error) {
set({ isLoading: false }) const message =
error instanceof Error ? error.message : 'Failed to load desktop settings'
set({ isLoading: false, error: message })
throw error
} }
}, },

View File

@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/__tests__/pages.test.tsx","./src/api/client.ts","./src/api/filesystem.ts","./src/api/models.ts","./src/api/search.ts","./src/api/sessions.ts","./src/api/settings.ts","./src/api/tasks.ts","./src/api/teams.ts","./src/api/websocket.ts","./src/components/chat/askuserquestion.tsx","./src/components/chat/assistantmessage.tsx","./src/components/chat/attachmentgallery.tsx","./src/components/chat/chatinput.tsx","./src/components/chat/codeviewer.tsx","./src/components/chat/diffviewer.tsx","./src/components/chat/imagegallerymodal.tsx","./src/components/chat/messagelist.tsx","./src/components/chat/permissiondialog.tsx","./src/components/chat/streamingindicator.tsx","./src/components/chat/terminalchrome.tsx","./src/components/chat/thinkingblock.tsx","./src/components/chat/toolcallblock.tsx","./src/components/chat/toolresultblock.tsx","./src/components/chat/usermessage.tsx","./src/components/chat/clipboard.ts","./src/components/chat/composerutils.test.ts","./src/components/chat/composerutils.ts","./src/components/chat/highlightcode.ts","./src/components/controls/modelselector.tsx","./src/components/controls/permissionmodeselector.tsx","./src/components/layout/appshell.tsx","./src/components/layout/contentrouter.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/statusbar.tsx","./src/components/layout/titlebar.tsx","./src/components/markdown/markdownrenderer.tsx","./src/components/shared/button.tsx","./src/components/shared/copybutton.tsx","./src/components/shared/directorypicker.tsx","./src/components/shared/dropdown.tsx","./src/components/shared/input.tsx","./src/components/shared/modal.tsx","./src/components/shared/spinner.tsx","./src/components/shared/textarea.tsx","./src/components/shared/toast.tsx","./src/components/tasks/newtaskmodal.tsx","./src/components/tasks/taskemptystate.tsx","./src/components/tasks/tasklist.tsx","./src/components/tasks/taskrow.tsx","./src/components/teams/backtoleader.tsx","./src/components/teams/membertag.tsx","./src/components/teams/teamstatusbar.tsx","./src/components/teams/transcriptview.tsx","./src/hooks/usekeyboardshortcuts.ts","./src/mocks/data.ts","./src/pages/activesession.tsx","./src/pages/agentteams.tsx","./src/pages/agenttranscript.tsx","./src/pages/emptysession.tsx","./src/pages/newtaskmodal.tsx","./src/pages/scheduledtasks.tsx","./src/pages/scheduledtasksempty.tsx","./src/pages/scheduledtaskslist.tsx","./src/pages/sessioncontrols.tsx","./src/pages/settings.tsx","./src/pages/toolinspection.tsx","./src/stores/chatstore.ts","./src/stores/sessionstore.ts","./src/stores/settingsstore.ts","./src/stores/taskstore.ts","./src/stores/teamstore.ts","./src/stores/uistore.ts","./src/types/chat.ts","./src/types/session.ts","./src/types/settings.ts","./src/types/task.ts","./src/types/team.ts"],"version":"5.9.3"} {"root":["./src/app.tsx","./src/main.tsx","./src/__tests__/pages.test.tsx","./src/api/clitasks.ts","./src/api/client.ts","./src/api/filesystem.ts","./src/api/models.ts","./src/api/providers.ts","./src/api/search.ts","./src/api/sessions.ts","./src/api/settings.ts","./src/api/tasks.ts","./src/api/teams.ts","./src/api/websocket.ts","./src/components/chat/askuserquestion.tsx","./src/components/chat/assistantmessage.tsx","./src/components/chat/attachmentgallery.tsx","./src/components/chat/chatinput.tsx","./src/components/chat/codeviewer.tsx","./src/components/chat/diffviewer.tsx","./src/components/chat/imagegallerymodal.tsx","./src/components/chat/messagelist.tsx","./src/components/chat/permissiondialog.tsx","./src/components/chat/sessiontaskbar.tsx","./src/components/chat/streamingindicator.tsx","./src/components/chat/terminalchrome.tsx","./src/components/chat/thinkingblock.tsx","./src/components/chat/toolcallblock.tsx","./src/components/chat/toolcallgroup.tsx","./src/components/chat/toolresultblock.tsx","./src/components/chat/usermessage.tsx","./src/components/chat/chatblocks.test.tsx","./src/components/chat/clipboard.ts","./src/components/chat/composerutils.test.ts","./src/components/chat/composerutils.ts","./src/components/controls/modelselector.tsx","./src/components/controls/permissionmodeselector.tsx","./src/components/layout/appshell.tsx","./src/components/layout/contentrouter.tsx","./src/components/layout/projectfilter.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/statusbar.tsx","./src/components/layout/titlebar.tsx","./src/components/markdown/markdownrenderer.tsx","./src/components/shared/button.tsx","./src/components/shared/copybutton.tsx","./src/components/shared/directorypicker.tsx","./src/components/shared/dropdown.tsx","./src/components/shared/input.tsx","./src/components/shared/modal.tsx","./src/components/shared/projectcontextchip.tsx","./src/components/shared/spinner.tsx","./src/components/shared/textarea.tsx","./src/components/shared/toast.tsx","./src/components/tasks/newtaskmodal.tsx","./src/components/tasks/taskemptystate.tsx","./src/components/tasks/tasklist.tsx","./src/components/tasks/taskrow.tsx","./src/components/teams/backtoleader.tsx","./src/components/teams/membertag.tsx","./src/components/teams/teamstatusbar.tsx","./src/components/teams/transcriptview.tsx","./src/config/providerpresets.ts","./src/config/spinnerverbs.ts","./src/hooks/usekeyboardshortcuts.ts","./src/lib/desktopruntime.ts","./src/mocks/data.ts","./src/pages/activesession.tsx","./src/pages/agentteams.tsx","./src/pages/agenttranscript.tsx","./src/pages/emptysession.tsx","./src/pages/newtaskmodal.tsx","./src/pages/scheduledtasks.tsx","./src/pages/scheduledtasksempty.tsx","./src/pages/scheduledtaskslist.tsx","./src/pages/sessioncontrols.tsx","./src/pages/settings.tsx","./src/pages/toolinspection.tsx","./src/stores/chatstore.test.ts","./src/stores/chatstore.ts","./src/stores/clitaskstore.ts","./src/stores/providerstore.ts","./src/stores/sessionstore.ts","./src/stores/settingsstore.ts","./src/stores/taskstore.ts","./src/stores/teamstore.ts","./src/stores/uistore.ts","./src/types/chat.ts","./src/types/clitask.ts","./src/types/provider.ts","./src/types/session.ts","./src/types/settings.ts","./src/types/task.ts","./src/types/team.ts"],"version":"5.9.3"}

BIN
docs/images/app-icon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

View File

@ -6,7 +6,7 @@ hero:
text: 本地可运行的 Claude Code text: 本地可运行的 Claude Code
tagline: 基于泄露源码修复,支持接入任意 Anthropic 兼容 APIMiniMax、OpenRouter 等) tagline: 基于泄露源码修复,支持接入任意 Anthropic 兼容 APIMiniMax、OpenRouter 等)
image: image:
src: /images/banner.jpg src: /images/app-icon.jpg
alt: Claude Code Haha alt: Claude Code Haha
actions: actions:
- theme: brand - theme: brand

View File

@ -12,8 +12,34 @@ import { requireAuth } from './middleware/auth.js'
import { teamWatcher } from './services/teamWatcher.js' import { teamWatcher } from './services/teamWatcher.js'
import { cronScheduler } from './services/cronScheduler.js' import { cronScheduler } from './services/cronScheduler.js'
const PORT = parseInt(process.env.SERVER_PORT || '3456', 10) function readArgValue(flag: string): string | undefined {
const HOST = process.env.SERVER_HOST || '127.0.0.1' const args = process.argv.slice(2)
const index = args.indexOf(flag)
if (index === -1) return undefined
return args[index + 1]
}
function hasArgFlag(flag: string): boolean {
return process.argv.slice(2).includes(flag)
}
function resolveServerOptions() {
const portArg = readArgValue('--port')
const port = Number.parseInt(portArg || process.env.SERVER_PORT || '3456', 10)
const host = readArgValue('--host') || process.env.SERVER_HOST || '127.0.0.1'
const cliPath = readArgValue('--cli-path')
const authRequired = hasArgFlag('--auth-required')
if (cliPath) {
process.env.CLAUDE_CLI_PATH = cliPath
}
return { port, host, authRequired }
}
const SERVER_OPTIONS = resolveServerOptions()
const PORT = SERVER_OPTIONS.port
const HOST = SERVER_OPTIONS.host
export function startServer(port = PORT, host = HOST) { export function startServer(port = PORT, host = HOST) {
const localConnectHost = const localConnectHost =
@ -27,7 +53,10 @@ export function startServer(port = PORT, host = HOST) {
* - Production / non-localhost (e.g. 0.0.0.0): auth enforced automatically. * - Production / non-localhost (e.g. 0.0.0.0): auth enforced automatically.
* - Explicit opt-in: SERVER_AUTH_REQUIRED=1 forces auth even on localhost. * - Explicit opt-in: SERVER_AUTH_REQUIRED=1 forces auth even on localhost.
*/ */
const authRequired = process.env.SERVER_AUTH_REQUIRED === '1' || host !== '127.0.0.1' const authRequired =
SERVER_OPTIONS.authRequired ||
process.env.SERVER_AUTH_REQUIRED === '1' ||
host !== '127.0.0.1'
const server = Bun.serve<WebSocketData>({ const server = Bun.serve<WebSocketData>({
port, port,
@ -132,7 +161,10 @@ export function startServer(port = PORT, host = HOST) {
// Health check // Health check
if (url.pathname === '/health') { if (url.pathname === '/health') {
return Response.json({ status: 'ok', timestamp: new Date().toISOString() }) return Response.json(
{ status: 'ok', timestamp: new Date().toISOString() },
{ headers: corsHeaders(origin) },
)
} }
return new Response('Not Found', { status: 404 }) return new Response('Not Found', { status: 404 })

View File

@ -2,12 +2,13 @@
* CORS middleware for local desktop app communication * CORS middleware for local desktop app communication
*/ */
const ALLOWED_ORIGIN_RE =
/^(?:https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?|tauri:\/\/localhost|https:\/\/tauri\.localhost)$/
export function corsHeaders(origin?: string | null): Record<string, string> { export function corsHeaders(origin?: string | null): Record<string, string> {
// Only allow localhost origins for security // Allow localhost origins (http/https) and Tauri WebView origins
const allowedOrigin = const allowedOrigin =
origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin) origin && ALLOWED_ORIGIN_RE.test(origin) ? origin : 'http://localhost:3000'
? origin
: 'http://localhost:3000'
return { return {
'Access-Control-Allow-Origin': allowedOrigin, 'Access-Control-Allow-Origin': allowedOrigin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',

View File

@ -447,8 +447,24 @@ export class ConversationService {
return args return args
} }
private resolveBundledCliPath(): string | null {
const execPath = process.execPath
const execName = path.basename(execPath)
if (!execName.startsWith('claude-server')) {
return null
}
const bundledCliPath = path.join(
path.dirname(execPath),
execName.replace(/^claude-server/, 'claude-cli'),
)
return fs.existsSync(bundledCliPath) ? bundledCliPath : null
}
private resolveCliArgs(baseArgs: string[]): string[] { private resolveCliArgs(baseArgs: string[]): string[] {
const cliCommand = process.env.CLAUDE_CLI_PATH const cliCommand = process.env.CLAUDE_CLI_PATH || this.resolveBundledCliPath()
if (!cliCommand) { if (!cliCommand) {
return [path.resolve(import.meta.dir, '../../../bin/claude-haha'), ...baseArgs] return [path.resolve(import.meta.dir, '../../../bin/claude-haha'), ...baseArgs]
} }
@ -457,6 +473,18 @@ export class ConversationService {
return ['bun', cliCommand, ...baseArgs] return ['bun', cliCommand, ...baseArgs]
} }
if (
process.env.CLAUDE_APP_ROOT &&
path.basename(cliCommand).startsWith('claude-cli')
) {
return [
cliCommand,
'--app-root',
process.env.CLAUDE_APP_ROOT,
...baseArgs,
]
}
return [cliCommand, ...baseArgs] return [cliCommand, ...baseArgs]
} }