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/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/

View File

@ -1,7 +1,11 @@
# Claude Code Haha
<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>
<div align="center">

View File

@ -1,7 +1,11 @@
# Claude Code Haha
<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>
<div align="center">

View File

@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:sidecars": "bun run ./scripts/build-sidecars.ts",
"preview": "vite preview",
"tauri": "tauri",
"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": [
"core:default",
"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-save",
"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]
fn get_server_url() -> String {
let port = std::env::var("SERVER_PORT").unwrap_or_else(|_| "3456".to_string());
let host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
format!("http://{}:{}", host, port)
fn get_server_url(state: State<'_, ServerState>) -> Result<String, String> {
let guard = state
.0
.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)]
pub fn run() {
tauri::Builder::default()
let app = tauri::Builder::default()
.manage(ServerState::default())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init())
.invoke_handler(tauri::generate_handler![get_server_url])
.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();
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
.build(tauri::generate_context!())
.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": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "bun run dev",
"beforeBuildCommand": "bun run build"
"beforeDevCommand": "bun run build:sidecars && bun run dev",
"beforeBuildCommand": "bun run build && bun run build:sidecars"
},
"app": {
"windows": [
@ -17,7 +17,7 @@
"height": 800,
"minWidth": 900,
"minHeight": 600,
"decorations": false,
"decorations": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"transparent": false
@ -30,6 +30,19 @@
"bundle": {
"active": true,
"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": [
"icons/32x32.png",
"icons/128x128.png",

View File

@ -10,6 +10,10 @@ export function getBaseUrl() {
return baseUrl
}
export function getDefaultBaseUrl() {
return DEFAULT_BASE_URL
}
export class ApiError extends Error {
constructor(
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
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 (
<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 && (
<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) => {
const isActive = activeTab === i
const isActive = safeActiveTab === i
const isAnswered = selections[i] !== undefined
const tabLabel = q.header || `Q${i + 1}`
return (
@ -160,7 +163,7 @@ export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) {
return (
<button
key={optIndex}
onClick={() => handleSelect(activeTab, opt.label)}
onClick={() => handleSelect(safeActiveTab, opt.label)}
disabled={submitted}
className={`w-full text-left px-4 py-3 rounded-[var(--radius-md)] border transition-all duration-150 cursor-pointer ${
isSelected

View File

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

View File

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

View File

@ -1,23 +1,80 @@
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { Sidebar } from './Sidebar'
import { ContentRouter } from './ContentRouter'
import { ToastContainer } from '../shared/Toast'
import { useSettingsStore } from '../../stores/settingsStore'
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() {
const fetchSettings = useSettingsStore((s) => s.fetchAll)
const [ready, setReady] = useState(false)
const [startupError, setStartupError] = useState<string | null>(null)
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])
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 (
<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 />
<main className="flex-1 flex flex-col overflow-hidden">
<main className={`flex-1 flex flex-col overflow-hidden ${isTauri ? 'pt-[38px]' : ''}`}>
<ContentRouter />
</main>
<ToastContainer />

View File

@ -4,12 +4,23 @@ import { useUIStore } from '../../stores/uiStore'
import { ProjectFilter } from './ProjectFilter'
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'
const TIME_GROUP_ORDER: TimeGroup[] = ['Today', 'Yesterday', 'Last 7 days', 'Last 30 days', 'Older']
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 [searchQuery, setSearchQuery] = useState('')
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
@ -69,9 +80,27 @@ export function Sidebar() {
}, [renamingId, renameValue, renameSession])
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 */}
<div className="p-3 flex flex-col gap-0.5">
<div className="px-3 pb-3 flex flex-col gap-0.5">
<NavItem
active={activeView === 'code' && !activeSessionId}
onClick={() => { setActiveView('code'); setActiveSession(null) }}
@ -107,6 +136,18 @@ export function Sidebar() {
{/* Session list — grouped by time */}
<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 && (
<div className="px-3 py-4 text-xs text-[var(--color-text-tertiary)] text-center">
{searchQuery ? 'No matching sessions' : 'No sessions yet'}
@ -263,6 +304,14 @@ function formatRelativeTime(dateStr: string): string {
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() {
return (
<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>
)
}

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="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="mb-6 h-24 w-24 opacity-40 grayscale contrast-125">
<MascotIllustration />
</div>
<img src="/app-icon.jpg" alt="Claude Code Haha" className="mb-6 h-24 w-24 rounded-[18px]" />
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[#1B1C1A]" style={{ fontFamily: "'Manrope', sans-serif" }}>
New session
</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 type { PermissionMode, EffortLevel } from '../types/settings'
import { PROVIDER_PRESETS } from '../config/providerPresets'
import type { ProviderPreset } from '../config/providerPresets'
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping } from '../types/provider'
type SettingsTab = 'providers' | 'permissions' | 'general'
@ -214,14 +215,28 @@ type ProviderFormProps = {
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) {
const { createProvider, updateProvider, testConfig } = useProviderStore()
const fetchSettings = useSettingsStore((s) => s.fetchAll)
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 [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl)
const [apiKey, setApiKey] = useState('')
@ -263,7 +278,7 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedPreset.id])
const handlePresetChange = (preset: typeof initialPreset) => {
const handlePresetChange = (preset: ProviderPreset) => {
setSelectedPreset(preset)
setName(preset.name)
setBaseUrl(preset.baseUrl)
@ -442,7 +457,9 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
// Auto-switch to matching preset or Custom
if (mode === 'create') {
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) {
jsonPastedRef.current = true
setSelectedPreset(targetPreset)

View File

@ -3,6 +3,7 @@ import { wsManager } from '../api/websocket'
import { sessionsApi } from '../api/sessions'
import { useTeamStore } from './teamStore'
import { useCLITaskStore } from './cliTaskStore'
import { randomSpinnerVerb } from '../config/spinnerVerbs'
import type { MessageEntry } from '../types/session'
import type { PermissionMode } from '../types/settings'
import type { AttachmentRef, ChatState, UIAttachment, UIMessage, ServerMessage, TokenUsage } from '../types/chat'
@ -147,6 +148,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
chatState: 'thinking',
elapsedSeconds: 0,
streamingText: '',
statusVerb: randomSpinnerVerb(),
}))
// Start elapsed timer
@ -200,7 +202,10 @@ export const useChatStore = create<ChatStore>((set, get) => ({
case 'status':
set({
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.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}),
})

View File

@ -10,6 +10,7 @@ type SettingsStore = {
availableModels: ModelInfo[]
activeProviderName: string | null
isLoading: boolean
error: string | null
fetchAll: () => Promise<void>
setPermissionMode: (mode: PermissionMode) => Promise<void>
@ -24,9 +25,10 @@ export const useSettingsStore = create<SettingsStore>((set) => ({
availableModels: [],
activeProviderName: null,
isLoading: false,
error: null,
fetchAll: async () => {
set({ isLoading: true })
set({ isLoading: true, error: null })
try {
const [{ mode }, modelsRes, { model }, { level }] = await Promise.all([
settingsApi.getPermissionMode(),
@ -41,9 +43,13 @@ export const useSettingsStore = create<SettingsStore>((set) => ({
currentModel: model,
effortLevel: level,
isLoading: false,
error: null,
})
} catch {
set({ isLoading: false })
} catch (error) {
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
tagline: 基于泄露源码修复,支持接入任意 Anthropic 兼容 APIMiniMax、OpenRouter 等)
image:
src: /images/banner.jpg
src: /images/app-icon.jpg
alt: Claude Code Haha
actions:
- theme: brand

View File

@ -12,8 +12,34 @@ import { requireAuth } from './middleware/auth.js'
import { teamWatcher } from './services/teamWatcher.js'
import { cronScheduler } from './services/cronScheduler.js'
const PORT = parseInt(process.env.SERVER_PORT || '3456', 10)
const HOST = process.env.SERVER_HOST || '127.0.0.1'
function readArgValue(flag: string): string | undefined {
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) {
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.
* - 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>({
port,
@ -132,7 +161,10 @@ export function startServer(port = PORT, host = HOST) {
// Health check
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 })

View File

@ -2,12 +2,13 @@
* 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> {
// Only allow localhost origins for security
// Allow localhost origins (http/https) and Tauri WebView origins
const allowedOrigin =
origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)
? origin
: 'http://localhost:3000'
origin && ALLOWED_ORIGIN_RE.test(origin) ? origin : 'http://localhost:3000'
return {
'Access-Control-Allow-Origin': allowedOrigin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',

View File

@ -447,8 +447,24 @@ export class ConversationService {
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[] {
const cliCommand = process.env.CLAUDE_CLI_PATH
const cliCommand = process.env.CLAUDE_CLI_PATH || this.resolveBundledCliPath()
if (!cliCommand) {
return [path.resolve(import.meta.dir, '../../../bin/claude-haha'), ...baseArgs]
}
@ -457,6 +473,18 @@ export class ConversationService {
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]
}