Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions desktop/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"shell:allow-open",
{
"identifier": "shell:allow-execute",
Expand Down
6 changes: 0 additions & 6 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,12 +394,6 @@ pub fn run() {
// 让 adapter 连上动态端口。
spawn_and_track_adapters_sidecar(&app.handle());

let _window = app.get_webview_window("main").unwrap();

// Windows: hide native decorations — frontend renders custom titlebar
#[cfg(target_os = "windows")]
let _ = _window.set_decorations(false);

Ok(())
})
.build(tauri::generate_context!())
Expand Down
2 changes: 0 additions & 2 deletions desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@
"minWidth": 960,
"minHeight": 640,
"decorations": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"transparent": false,
"acceptFirstMouse": true
}
Expand Down
18 changes: 18 additions & 0 deletions desktop/src-tauri/tauri.macos.conf.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"app": {
"windows": [
{
"title": "Claude Code Haha",
"width": 1440,
"height": 960,
"minWidth": 960,
"minHeight": 640,
"decorations": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"transparent": false,
"acceptFirstMouse": true
}
]
}
}
16 changes: 16 additions & 0 deletions desktop/src-tauri/tauri.windows.conf.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"app": {
"windows": [
{
"title": "Claude Code Haha",
"width": 1440,
"height": 960,
"minWidth": 960,
"minHeight": 640,
"decorations": false,
"transparent": false,
"acceptFirstMouse": true
}
]
}
}
28 changes: 28 additions & 0 deletions desktop/src/components/chat/MessageList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,4 +281,32 @@ describe('MessageList nested tool calls', () => {
'先看 CLI 和服务端入口。\n再看 desktop 前后端边界。'
)
})

it('shows raw startup details under translated CLI startup errors', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'error-1',
type: 'error',
code: 'CLI_START_FAILED',
message:
'CLI exited during startup (code 1): Claude Code on Windows requires git-bash (https://git-scm.com/downloads/win).',
timestamp: 1,
},
],
}),
},
})

render(<MessageList />)

expect(screen.getByText('Failed to start CLI process.')).toBeTruthy()
expect(
screen.getByText(
'CLI exited during startup (code 1): Claude Code on Windows requires git-bash (https://git-scm.com/downloads/win).',
),
).toBeTruthy()
})
})
9 changes: 9 additions & 0 deletions desktop/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,9 +222,18 @@ export const MessageBlock = memo(function MessageBlock({
const errorKey = message.code ? `error.${message.code}` as TranslationKey : null
const errorText = errorKey ? t(errorKey) : null
const displayMessage = (errorText && errorText !== errorKey) ? errorText : message.message
const showRawDetail =
Boolean(message.message) &&
message.message.trim() !== '' &&
message.message !== displayMessage
return (
<div className="mb-3 px-4 py-2.5 rounded-lg bg-red-50 border border-red-200 text-sm text-[var(--color-error)]">
<strong>Error:</strong> {displayMessage}
{showRawDetail && (
<div className="mt-1 whitespace-pre-wrap text-xs text-red-700/85">
{message.message}
</div>
)}
</div>
)
}
Expand Down
69 changes: 69 additions & 0 deletions desktop/src/components/layout/WindowControls.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'

const minimize = vi.fn().mockResolvedValue(undefined)
const toggleMaximize = vi.fn().mockResolvedValue(undefined)
const close = vi.fn().mockResolvedValue(undefined)
const isMaximized = vi.fn().mockResolvedValue(false)
const onResized = vi.fn().mockResolvedValue(() => {})

vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => ({
minimize,
toggleMaximize,
close,
isMaximized,
onResized,
}),
}))

describe('WindowControls', () => {
const originalPlatform = navigator.platform

beforeEach(async () => {
minimize.mockClear()
toggleMaximize.mockClear()
close.mockClear()
isMaximized.mockClear()
onResized.mockClear()

Object.defineProperty(window, '__TAURI_INTERNALS__', {
configurable: true,
value: {},
})
Object.defineProperty(navigator, 'platform', {
configurable: true,
value: 'Win32',
})
vi.resetModules()
})

afterEach(() => {
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
Object.defineProperty(navigator, 'platform', {
configurable: true,
value: originalPlatform,
})
})

it('invokes Tauri window APIs for custom controls on Windows', async () => {
const { WindowControls } = await import('./WindowControls')

render(<WindowControls />)

await waitFor(() => {
expect(screen.getByRole('button', { name: 'Minimize window' })).toBeInTheDocument()
})

fireEvent.click(screen.getByRole('button', { name: 'Minimize window' }))
fireEvent.click(screen.getByRole('button', { name: 'Maximize window' }))
fireEvent.click(screen.getByRole('button', { name: 'Close window' }))

await waitFor(() => {
expect(minimize).toHaveBeenCalledTimes(1)
expect(toggleMaximize).toHaveBeenCalledTimes(1)
expect(close).toHaveBeenCalledTimes(1)
})
})
})
15 changes: 12 additions & 3 deletions desktop/src/components/layout/WindowControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,20 @@ export function WindowControls() {
return () => { unlisten?.() }
}, [])

const runWindowAction = (action: () => Promise<void>) => {
void action().catch((error) => {
console.error('Window control action failed', error)
})
}

if (!showWindowControls || !win) return null

return (
<div className="flex items-stretch flex-shrink-0 -my-px">
{/* Minimize */}
<button
onClick={() => win.minimize()}
onClick={() => runWindowAction(() => win.minimize())}
aria-label="Minimize window"
className="w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors"
>
<svg width="10" height="1" viewBox="0 0 10 1">
Expand All @@ -50,7 +57,8 @@ export function WindowControls() {

{/* Maximize / Restore */}
<button
onClick={() => win.toggleMaximize()}
onClick={() => runWindowAction(() => win.toggleMaximize())}
aria-label={maximized ? 'Restore window' : 'Maximize window'}
className="w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors"
>
{maximized ? (
Expand All @@ -67,7 +75,8 @@ export function WindowControls() {

{/* Close */}
<button
onClick={() => win.close()}
onClick={() => runWindowAction(() => win.close())}
aria-label="Close window"
className="w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] hover:bg-[#e81123] hover:text-white transition-colors"
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2">
Expand Down
46 changes: 32 additions & 14 deletions src/utils/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import {
} from './sessionEnvironment.js'
import { subprocessEnv } from './subprocessEnv.js'
import { getPlatform } from './platform.js'
import { findGitBashPath, windowsPathToPosixPath } from './windowsPaths.js'
import {
tryFindGitBashPath,
windowsPathToPosixPath,
} from './windowsPaths.js'
import { getCachedPowerShellPath } from './shell/powershellDetection.js'
import { DEFAULT_HOOK_SHELL } from './shell/shellProvider.js'
import { buildPowerShellArgs } from './shell/powershellProvider.js'
Expand Down Expand Up @@ -787,9 +790,20 @@ async function execCommandHook(
// PowerShell path deliberately skips the Windows-specific bash
// accommodations (cygpath conversion, .sh auto-prepend, POSIX-quoted
// SHELL_PREFIX).
const shellType = hook.shell ?? DEFAULT_HOOK_SHELL
let shellType = hook.shell ?? DEFAULT_HOOK_SHELL

if (isWindows && !hook.shell && shellType === 'bash' && !tryFindGitBashPath()) {
const pwshPath = await getCachedPowerShellPath()
if (pwshPath) {
shellType = 'powershell'
logForDebugging(
`Hooks: Git Bash unavailable on Windows, falling back to PowerShell for default shell on hook "${hook.command}"`,
{ level: 'warn' },
)
}
}

const isPowerShell = shellType === 'powershell'
const usesPowerShell = shellType === 'powershell'

// --
// Windows bash path: hooks run via Git Bash (Cygwin), NOT cmd.exe.
Expand All @@ -806,7 +820,7 @@ async function execCommandHook(
// PowerShell expects Windows paths on Windows (and native paths on
// Unix where pwsh is also available).
const toHookPath =
isWindows && !isPowerShell
isWindows && !usesPowerShell
? (p: string) => windowsPathToPosixPath(p)
: (p: string) => p

Expand Down Expand Up @@ -859,7 +873,7 @@ async function execCommandHook(
// On Windows (bash only), auto-prepend `bash` for .sh scripts so they
// execute instead of opening in the default file handler. PowerShell
// runs .ps1 files natively — no prepend needed.
if (isWindows && !isPowerShell && command.trim().match(/\.sh(\s|$|")/)) {
if (isWindows && !usesPowerShell && command.trim().match(/\.sh(\s|$|")/)) {
if (!command.trim().startsWith('bash ')) {
command = `bash ${command}`
}
Expand All @@ -870,7 +884,7 @@ async function execCommandHook(
// PowerShell — see design §8.1. For now PS hooks ignore the prefix;
// a CLAUDE_CODE_PS_SHELL_PREFIX (or shell-aware prefix) is a follow-up.
const finalCommand =
!isPowerShell && process.env.CLAUDE_CODE_SHELL_PREFIX
!usesPowerShell && process.env.CLAUDE_CODE_SHELL_PREFIX
? formatShellPrefixCommand(process.env.CLAUDE_CODE_SHELL_PREFIX, command)
: command

Expand Down Expand Up @@ -915,7 +929,7 @@ async function execCommandHook(
// Skip for PS — consistent with how .sh prepend and SHELL_PREFIX are
// already bash-only above.
if (
!isPowerShell &&
!usesPowerShell &&
(hookEvent === 'SessionStart' ||
hookEvent === 'Setup' ||
hookEvent === 'CwdChanged' ||
Expand Down Expand Up @@ -948,12 +962,9 @@ async function execCommandHook(
// skips user profile scripts (faster, deterministic).
// -NonInteractive fails fast instead of prompting.
//
// The Git Bash hard-exit in findGitBashPath() is still in place for
// bash hooks. PowerShell hooks never call it, so a Windows user with
// only pwsh and shell: 'powershell' on every hook could in theory run
// without Git Bash — but init.ts still calls setShellIfWindows() on
// startup, which will exit first. Relaxing that is phase 1 of the
// design's implementation order (separate PR).
// On Windows, default-shell hooks now degrade to PowerShell when Git Bash
// is unavailable. Explicit bash hooks still surface an actionable error,
// but they no longer hard-exit the entire process.
let child: ChildProcessWithoutNullStreams
if (shellType === 'powershell') {
const pwshPath = await getCachedPowerShellPath()
Expand All @@ -973,7 +984,14 @@ async function execCommandHook(
} else {
// On Windows, use Git Bash explicitly (cmd.exe can't run bash syntax).
// On other platforms, shell: true uses /bin/sh.
const shell = isWindows ? findGitBashPath() : true
const shell = isWindows
? tryFindGitBashPath() ??
(() => {
throw new Error(
'Git Bash is required to run bash hooks on Windows. Install Git for Windows or configure the hook with shell: "powershell".',
)
})()
: true
child = spawn(finalCommand, [], {
env: envVars,
cwd: safeCwd,
Expand Down
Loading
Loading