Merge branch 'feat/v0.1.5-post-release-test'

This commit is contained in:
程序员阿江(Relakkes) 2026-04-24 23:36:08 +08:00
commit f359806e5f
176 changed files with 6477 additions and 1553 deletions

View File

@ -1,7 +1,7 @@
# Claude Code Haha
<p align="center">
<img src="docs/images/logo-horizontal.jpg" alt="Claude Code Haha" width="480">
<img src="docs/images/app-icon.png" alt="Claude Code Haha" width="240">
</p>
<div align="center">

View File

@ -7,6 +7,8 @@
"dependencies": {
"@tailwindcss/typography": "^0.5.19",
"@types/dompurify": "^3.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"dompurify": "^3.3.3",
"lucide-react": "^0.469.0",
"marked": "^15.0.7",
@ -470,6 +472,10 @@
"@vitest/utils": ["@vitest/utils@3.2.4", "https://registry.npmmirror.com/@vitest/utils/-/utils-3.2.4.tgz", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "https://registry.npmmirror.com/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
"@xterm/xterm": ["@xterm/xterm@6.0.0", "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.0.0.tgz", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
"acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],

View File

@ -18,6 +18,8 @@
"dependencies": {
"@tailwindcss/typography": "^0.5.19",
"@types/dompurify": "^3.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"dompurify": "^3.3.3",
"lucide-react": "^0.469.0",
"marked": "^15.0.7",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1,17 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="accent" x1="684" y1="298" x2="834" y2="726" gradientUnits="userSpaceOnUse">
<stop stop-color="#F6B34C"/>
<stop offset="1" stop-color="#FF8A2A"/>
</linearGradient>
</defs>
<g>
<path d="M310 350 L510 512 L310 674" stroke="#20242E" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="440" y="690" width="120" height="48" rx="20" fill="#20242E"/>
<path d="M684 298 H834 V726 H684" stroke="url(#accent)" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="#F5A623"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 773 B

View File

@ -162,21 +162,25 @@ async function compileExecutable({
// that causes "load code signature error 4" and SIGKILL at launch.
// Fix: strip the broken signature, then ad-hoc sign.
if (process.platform === 'darwin') {
console.log(`[build-sidecars] ad-hoc signing ${outputPath} for macOS ...`)
const strip = Bun.spawn(['codesign', '--remove-signature', outputPath], {
stdout: 'inherit',
stderr: 'inherit',
})
await strip.exited
const sign = Bun.spawn(
['codesign', '--sign', '-', '--force', '--timestamp=none', outputPath],
{ stdout: 'inherit', stderr: 'inherit' },
)
const signExit = await sign.exited
if (signExit !== 0) {
throw new Error(`[build-sidecars] ad-hoc codesign failed for ${outputPath} (exit ${signExit})`)
}
console.log(`[build-sidecars] ad-hoc signed ${outputPath}`)
await adHocSignMacBinary(outputPath)
}
}
async function adHocSignMacBinary(outputPath: string) {
console.log(`[build-sidecars] ad-hoc signing ${outputPath} for macOS ...`)
const strip = Bun.spawn(['codesign', '--remove-signature', outputPath], {
stdout: 'inherit',
stderr: 'inherit',
})
await strip.exited
const sign = Bun.spawn(
['codesign', '--sign', '-', '--force', '--timestamp=none', outputPath],
{ stdout: 'inherit', stderr: 'inherit' },
)
const signExit = await sign.exited
if (signExit !== 0) {
throw new Error(`[build-sidecars] ad-hoc codesign failed for ${outputPath} (exit ${signExit})`)
}
console.log(`[build-sidecars] ad-hoc signed ${outputPath}`)
}

View File

@ -17,18 +17,21 @@
* launcher-only
*/
import { parseLauncherArgs, resolveSidecarInvocation } from './launcherRouting'
const rawArgs = process.argv.slice(2)
if (rawArgs.length === 0) {
const invocation = resolveSidecarInvocation(rawArgs)
if (!invocation.mode) {
console.error('claude-sidecar: missing mode argument (expected "server", "cli" or "adapters")')
process.exit(2)
}
const mode = rawArgs[0]!
const restArgs = rawArgs.slice(1)
const mode = invocation.mode
const restArgs = invocation.restArgs
if (mode === 'adapters') {
await runAdapters(restArgs)
} else {
const { appRoot, args } = parseLauncherArgs(restArgs)
const { appRoot, args } = parseLauncherArgs(restArgs, invocation.defaultAppRoot)
process.env.CLAUDE_APP_ROOT = appRoot
process.env.CALLER_DIR ||= process.cwd()
@ -133,24 +136,3 @@ async function runAdapters(rawArgs: string[]): Promise<void> {
// / grammY long-polling持有 event loop自然不会退出。这里不需要额外
// setInterval 兜底。两个 adapter 自己注册的 SIGINT handler 都会触发。
}
function parseLauncherArgs(rawArgs: string[]): { appRoot: string; args: string[] } {
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-sidecar')
}
return { appRoot, args: nextArgs }
}

View File

@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { parseLauncherArgs, resolveSidecarInvocation } from './launcherRouting'
describe('resolveSidecarInvocation', () => {
it('keeps explicit sidecar modes unchanged', () => {
expect(
resolveSidecarInvocation(
['server', '--host', '127.0.0.1'],
'/tmp/claude-sidecar',
),
).toEqual({
mode: 'server',
restArgs: ['--host', '127.0.0.1'],
defaultAppRoot: null,
})
})
it('defaults claude-haha invocations to cli mode', () => {
expect(
resolveSidecarInvocation(
['plugin', 'install', 'demo'],
'/Users/demo/.local/bin/claude-haha',
),
).toEqual({
mode: 'cli',
restArgs: ['plugin', 'install', 'demo'],
defaultAppRoot: '/Users/demo/.local/bin',
})
})
})
describe('parseLauncherArgs', () => {
it('falls back to the provided default app root', () => {
expect(
parseLauncherArgs(['plugin', 'install', 'demo'], '/Users/demo/.local/bin'),
).toEqual({
appRoot: '/Users/demo/.local/bin',
args: ['plugin', 'install', 'demo'],
})
})
it('lets explicit app root override the default', () => {
expect(
parseLauncherArgs(
['--app-root', '/tmp/app', 'plugin', 'install', 'demo'],
'/Users/demo/.local/bin',
),
).toEqual({
appRoot: '/tmp/app',
args: ['plugin', 'install', 'demo'],
})
})
})

View File

@ -0,0 +1,64 @@
import path from 'node:path'
export type SidecarMode = 'server' | 'cli' | 'adapters'
const EXPLICIT_MODES = new Set<SidecarMode>(['server', 'cli', 'adapters'])
const DESKTOP_CLI_NAMES = new Set(['claude-haha', 'claude-haha.exe'])
export function resolveSidecarInvocation(
rawArgs: string[],
execPath: string = process.execPath,
envAppRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null,
): {
mode: SidecarMode | null
restArgs: string[]
defaultAppRoot: string | null
} {
const explicitMode = rawArgs[0]
if (explicitMode && EXPLICIT_MODES.has(explicitMode as SidecarMode)) {
return {
mode: explicitMode as SidecarMode,
restArgs: rawArgs.slice(1),
defaultAppRoot: envAppRoot,
}
}
const execName = path.basename(execPath).toLowerCase()
if (DESKTOP_CLI_NAMES.has(execName)) {
return {
mode: 'cli',
restArgs: rawArgs,
defaultAppRoot: envAppRoot ?? path.dirname(execPath),
}
}
return {
mode: null,
restArgs: rawArgs,
defaultAppRoot: envAppRoot,
}
}
export function parseLauncherArgs(
rawArgs: string[],
defaultAppRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null,
): { appRoot: string; args: string[] } {
const nextArgs: string[] = []
let appRoot: string | null = defaultAppRoot
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-sidecar')
}
return { appRoot, args: nextArgs }
}

View File

@ -309,6 +309,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "chrono"
version = "0.4.44"
@ -325,6 +331,8 @@ dependencies = [
name = "claude-code-desktop"
version = "0.1.5"
dependencies = [
"anyhow",
"portable-pty",
"serde",
"serde_json",
"tauri",
@ -675,6 +683,12 @@ dependencies = [
"tendril 0.5.0",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dpi"
version = "0.1.2"
@ -722,7 +736,7 @@ dependencies = [
"rustc_version",
"toml 0.9.12+spec-1.1.0",
"vswhom",
"winreg",
"winreg 0.55.0",
]
[[package]]
@ -792,6 +806,17 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filedescriptor"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
dependencies = [
"libc",
"thiserror 1.0.69",
"winapi",
]
[[package]]
name = "filetime"
version = "0.2.27"
@ -1771,6 +1796,12 @@ dependencies = [
"selectors 0.24.0",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
@ -2009,6 +2040,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nodrop"
version = "0.1.14"
@ -2533,6 +2576,27 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "portable-pty"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
dependencies = [
"anyhow",
"bitflags 1.3.2",
"downcast-rs",
"filedescriptor",
"lazy_static",
"libc",
"log",
"nix",
"serial2",
"shared_library",
"shell-words",
"winapi",
"winreg 0.10.1",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@ -3291,6 +3355,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "serial2"
version = "0.2.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcdbc46aa3882ec3d48ec2b5abcb4f0d863a13d7599265f3faa6d851f23c12f3"
dependencies = [
"cfg-if",
"libc",
"winapi",
]
[[package]]
name = "serialize-to-javascript"
version = "0.1.2"
@ -3354,6 +3429,22 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "shared_library"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
dependencies = [
"lazy_static",
"libc",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]]
name = "shlex"
version = "1.3.0"
@ -5242,6 +5333,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "winreg"
version = "0.55.0"

View File

@ -18,3 +18,5 @@ tauri-plugin-process = "2"
tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1.0.102"
portable-pty = "0.9.0"

View File

@ -0,0 +1,42 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="panel" x1="190" y1="168" x2="842" y2="860" gradientUnits="userSpaceOnUse">
<stop stop-color="#231916"/>
<stop offset="1" stop-color="#18110F"/>
</linearGradient>
<linearGradient id="panelGlow" x1="256" y1="172" x2="744" y2="852" gradientUnits="userSpaceOnUse">
<stop stop-color="#7A3B20"/>
<stop offset="1" stop-color="#2B1813"/>
</linearGradient>
<radialGradient id="spark" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(592 402) rotate(90) scale(130)">
<stop stop-color="#FFD56A"/>
<stop offset="1" stop-color="#F6B23E"/>
</radialGradient>
<filter id="sparkGlow" x="470" y="280" width="244" height="244" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="18" result="blur"/>
<feColorMatrix in="blur" type="matrix" values="1 0 0 0 0.95 0 1 0 0 0.63 0 0 1 0 0.14 0 0 0 0.7 0"/>
<feBlend in="SourceGraphic"/>
</filter>
<linearGradient id="accent" x1="684" y1="298" x2="834" y2="726" gradientUnits="userSpaceOnUse">
<stop stop-color="#F6B34C"/>
<stop offset="1" stop-color="#FF8A2A"/>
</linearGradient>
<linearGradient id="sheen" x1="244" y1="224" x2="448" y2="430" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.22"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
<rect x="120" y="120" width="784" height="784" rx="180" fill="url(#panel)"/>
<rect x="124" y="124" width="776" height="776" rx="176" stroke="url(#panelGlow)" stroke-width="8"/>
<path d="M236 260C324 190 464 170 612 206" stroke="#5C2E1D" stroke-width="18" stroke-linecap="round" opacity="0.58"/>
<path d="M214 760C366 824 566 824 718 754" stroke="#5C2E1D" stroke-width="18" stroke-linecap="round" opacity="0.4"/>
<ellipse cx="370" cy="292" rx="136" ry="92" fill="url(#sheen)"/>
<path d="M310 350L510 512L310 674" stroke="#F5F0E6" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="440" y="690" width="120" height="48" rx="20" fill="#F5F0E6"/>
<path d="M684 298H834V726H684" stroke="url(#accent)" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
<g filter="url(#sparkGlow)">
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="url(#spark)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -1,67 +1,16 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="cardSurface" x1="108" y1="82" x2="387" y2="430" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFFEFC"/>
<stop offset="0.54" stop-color="#F5F2ED"/>
<stop offset="1" stop-color="#DDD8D2"/>
</linearGradient>
<linearGradient id="cardGloss" x1="256" y1="84" x2="256" y2="247" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFFFFF" stop-opacity="0.9"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
</linearGradient>
<linearGradient id="brandStroke" x1="156" y1="144" x2="372" y2="386" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9A72"/>
<stop offset="0.52" stop-color="#F18258"/>
<stop offset="1" stop-color="#C86439"/>
</linearGradient>
<linearGradient id="brandSpark" x1="256" y1="145" x2="256" y2="263" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFB08B"/>
<stop offset="0.58" stop-color="#FF8A62"/>
<stop offset="1" stop-color="#E26C45"/>
</linearGradient>
<filter id="cardShadow" x="64" y="76" width="384" height="392" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="18" stdDeviation="18" flood-color="#534330" flood-opacity="0.22"/>
</filter>
</defs>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#cardShadow)">
<rect x="96" y="92" width="320" height="320" rx="82" fill="url(#cardSurface)"/>
<rect x="96.75" y="92.75" width="318.5" height="318.5" rx="81.25" stroke="#F5F1EB" stroke-width="1.5"/>
<path d="M121 146C168 117 231 107 302 118C344 124 379 138 405 159V239C370 222 324 211 273 208C210 203 158 188 121 163V146Z" fill="url(#cardGloss)"/>
</g>
<defs>
<linearGradient id="accent" x1="684" y1="298" x2="834" y2="726" gradientUnits="userSpaceOnUse">
<stop stop-color="#F6B34C"/>
<stop offset="1" stop-color="#FF8A2A"/>
</linearGradient>
</defs>
<g transform="translate(-22 -18) scale(1.06)">
<path
d="M168 193L230 255L168 317"
stroke="url(#brandStroke)"
stroke-width="33"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M344 178L391 189L341 348L303 338"
stroke="url(#brandStroke)"
stroke-width="30"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M225 332C245 349 272 350 295 333"
stroke="url(#brandStroke)"
stroke-width="17"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M301 253C310 242 323 243 331 251"
stroke="url(#brandStroke)"
stroke-width="15"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M256 145L265 176L296 167L272 191L295 213L264 207L256 238L248 207L217 213L240 191L216 167L247 176L256 145Z"
fill="url(#brandSpark)"
/>
</g>
<g>
<path d="M310 350 L510 512 L310 674" stroke="#20242E" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="440" y="690" width="120" height="48" rx="20" fill="#20242E"/>
<path d="M684 298 H834 V726 H684" stroke="url(#accent)" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="#F5A623"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 783 B

After

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@ -1,14 +1,22 @@
use std::{
io::{Error as IoError, ErrorKind},
collections::HashMap,
io::{Error as IoError, ErrorKind, Read, Write},
net::{SocketAddr, TcpListener, TcpStream},
path::PathBuf,
sync::Mutex,
process::{Command as StdCommand, Stdio},
str,
sync::{
atomic::{AtomicU32, Ordering},
Mutex,
},
thread,
time::{Duration, Instant},
};
use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize};
use serde::Serialize;
#[cfg(target_os = "macos")]
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
#[cfg(target_os = "macos")]
use tauri::Emitter;
use tauri::{AppHandle, Manager, RunEvent, State};
use tauri_plugin_shell::{
@ -39,6 +47,38 @@ struct ServerStatus {
#[derive(Default)]
struct AdapterState(Mutex<Option<CommandChild>>);
#[derive(Default)]
struct TerminalState {
next_id: AtomicU32,
sessions: Mutex<HashMap<u32, TerminalSession>>,
}
struct TerminalSession {
master: Box<dyn MasterPty + Send>,
writer: Mutex<Box<dyn std::io::Write + Send>>,
killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
}
#[derive(Serialize, Clone)]
struct TerminalSpawnResult {
session_id: u32,
shell: String,
cwd: String,
}
#[derive(Serialize, Clone)]
struct TerminalOutputPayload {
session_id: u32,
data: String,
}
#[derive(Serialize, Clone)]
struct TerminalExitPayload {
session_id: u32,
code: u32,
signal: Option<String>,
}
#[tauri::command]
fn get_server_url(state: State<'_, ServerState>) -> Result<String, String> {
let guard = state
@ -72,6 +112,395 @@ fn restart_adapters_sidecar(app: AppHandle) -> Result<(), String> {
Ok(())
}
#[tauri::command]
fn terminal_spawn(
app: AppHandle,
state: State<'_, TerminalState>,
cols: u16,
rows: u16,
cwd: Option<String>,
) -> Result<TerminalSpawnResult, String> {
let cwd_path = resolve_terminal_cwd(cwd)?;
let shell = default_shell();
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows: rows.max(8),
cols: cols.max(20),
pixel_width: 0,
pixel_height: 0,
})
.map_err(|err| format!("open terminal pty: {err}"))?;
let mut cmd = CommandBuilder::new(&shell);
cmd.cwd(cwd_path.as_os_str());
for (key, value) in terminal_environment(&shell) {
cmd.env(key, value);
}
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
let mut child = pair
.slave
.spawn_command(cmd)
.map_err(|err| format!("spawn terminal shell: {err}"))?;
drop(pair.slave);
let mut reader = pair
.master
.try_clone_reader()
.map_err(|err| format!("clone terminal reader: {err}"))?;
let writer = pair
.master
.take_writer()
.map_err(|err| format!("open terminal writer: {err}"))?;
let killer = child.clone_killer();
let session_id = state.next_id.fetch_add(1, Ordering::Relaxed) + 1;
{
let mut sessions = state
.sessions
.lock()
.map_err(|_| "terminal state is unavailable".to_string())?;
sessions.insert(
session_id,
TerminalSession {
master: pair.master,
writer: Mutex::new(writer),
killer: Mutex::new(killer),
},
);
}
let output_app = app.clone();
thread::spawn(move || {
let mut buffer = [0_u8; 8192];
let mut pending_utf8 = Vec::new();
loop {
match reader.read(&mut buffer) {
Ok(0) => break,
Ok(n) => {
let data = decode_terminal_output(&mut pending_utf8, &buffer[..n]);
if !data.is_empty() {
let _ = output_app.emit(
"terminal-output",
TerminalOutputPayload { session_id, data },
);
}
}
Err(err) => {
let _ = output_app.emit(
"terminal-output",
TerminalOutputPayload {
session_id,
data: format!("\r\n[terminal read error: {err}]\r\n"),
},
);
break;
}
}
}
if !pending_utf8.is_empty() {
let data = String::from_utf8_lossy(&pending_utf8).to_string();
let _ = output_app.emit(
"terminal-output",
TerminalOutputPayload { session_id, data },
);
}
});
let exit_app = app.clone();
thread::spawn(move || {
let status = child.wait();
if let Some(state) = exit_app.try_state::<TerminalState>() {
if let Ok(mut sessions) = state.sessions.lock() {
sessions.remove(&session_id);
}
}
match status {
Ok(status) => {
let _ = exit_app.emit(
"terminal-exit",
TerminalExitPayload {
session_id,
code: status.exit_code(),
signal: status.signal().map(ToString::to_string),
},
);
}
Err(err) => {
let _ = exit_app.emit(
"terminal-output",
TerminalOutputPayload {
session_id,
data: format!("\r\n[terminal wait error: {err}]\r\n"),
},
);
}
}
});
Ok(TerminalSpawnResult {
session_id,
shell,
cwd: cwd_path.to_string_lossy().to_string(),
})
}
#[tauri::command]
fn terminal_write(
state: State<'_, TerminalState>,
session_id: u32,
data: String,
) -> Result<(), String> {
let sessions = state
.sessions
.lock()
.map_err(|_| "terminal state is unavailable".to_string())?;
let session = sessions
.get(&session_id)
.ok_or_else(|| "terminal session is not running".to_string())?;
let mut writer = session
.writer
.lock()
.map_err(|_| "terminal writer is unavailable".to_string())?;
writer
.write_all(data.as_bytes())
.map_err(|err| format!("write terminal input: {err}"))?;
writer
.flush()
.map_err(|err| format!("flush terminal input: {err}"))?;
Ok(())
}
#[tauri::command]
fn terminal_resize(
state: State<'_, TerminalState>,
session_id: u32,
cols: u16,
rows: u16,
) -> Result<(), String> {
let sessions = state
.sessions
.lock()
.map_err(|_| "terminal state is unavailable".to_string())?;
let session = sessions
.get(&session_id)
.ok_or_else(|| "terminal session is not running".to_string())?;
session
.master
.resize(PtySize {
rows: rows.max(8),
cols: cols.max(20),
pixel_width: 0,
pixel_height: 0,
})
.map_err(|err| format!("resize terminal: {err}"))?;
Ok(())
}
#[tauri::command]
fn terminal_kill(state: State<'_, TerminalState>, session_id: u32) -> Result<(), String> {
let session = {
let mut sessions = state
.sessions
.lock()
.map_err(|_| "terminal state is unavailable".to_string())?;
sessions.remove(&session_id)
};
if let Some(session) = session {
let mut killer = session
.killer
.lock()
.map_err(|_| "terminal killer is unavailable".to_string())?;
killer
.kill()
.map_err(|err| format!("kill terminal shell: {err}"))?;
}
Ok(())
}
fn decode_terminal_output(pending: &mut Vec<u8>, chunk: &[u8]) -> String {
pending.extend_from_slice(chunk);
let mut output = String::new();
loop {
match str::from_utf8(pending) {
Ok(text) => {
output.push_str(text);
pending.clear();
break;
}
Err(err) => {
let valid_up_to = err.valid_up_to();
if valid_up_to > 0 {
let text = str::from_utf8(&pending[..valid_up_to])
.expect("valid_up_to marks a valid UTF-8 prefix");
output.push_str(text);
pending.drain(..valid_up_to);
continue;
}
match err.error_len() {
Some(error_len) => {
output.push('\u{fffd}');
pending.drain(..error_len);
}
None => break,
}
}
}
}
output
}
fn terminal_environment(shell: &str) -> HashMap<String, String> {
let mut env: HashMap<String, String> = std::env::vars().collect();
env.extend(login_shell_environment(shell));
ensure_utf8_locale(&mut env);
env
}
fn ensure_utf8_locale(env: &mut HashMap<String, String>) {
let fallback = default_utf8_locale();
for key in ["LANG", "LC_CTYPE", "LC_ALL"] {
let needs_fallback = env
.get(key)
.map(|value| !is_utf8_locale(value))
.unwrap_or(true);
if needs_fallback {
env.insert(key.to_string(), fallback.to_string());
}
}
}
fn is_utf8_locale(value: &str) -> bool {
let normalized = value.trim().to_ascii_lowercase().replace('-', "");
normalized.contains("utf8")
}
fn default_utf8_locale() -> &'static str {
#[cfg(target_os = "macos")]
{
"en_US.UTF-8"
}
#[cfg(all(unix, not(target_os = "macos")))]
{
"C.UTF-8"
}
#[cfg(not(unix))]
{
"C.UTF-8"
}
}
#[cfg(not(target_os = "windows"))]
fn login_shell_environment(shell: &str) -> HashMap<String, String> {
let Ok(mut child) = StdCommand::new(shell)
.args(["-l", "-c", "env -0"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
else {
return HashMap::new();
};
let deadline = Instant::now() + Duration::from_secs(2);
loop {
match child.try_wait() {
Ok(Some(status)) => {
if !status.success() {
return HashMap::new();
}
let mut stdout = Vec::new();
if let Some(mut pipe) = child.stdout.take() {
let _ = pipe.read_to_end(&mut stdout);
}
return parse_env_block(&stdout);
}
Ok(None) if Instant::now() < deadline => {
thread::sleep(Duration::from_millis(25));
}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return HashMap::new();
}
Err(_) => return HashMap::new(),
}
}
}
#[cfg(target_os = "windows")]
fn login_shell_environment(_shell: &str) -> HashMap<String, String> {
HashMap::new()
}
fn parse_env_block(bytes: &[u8]) -> HashMap<String, String> {
bytes
.split(|byte| *byte == 0)
.filter_map(|entry| {
if entry.is_empty() {
return None;
}
let equals = entry.iter().position(|byte| *byte == b'=')?;
if equals == 0 {
return None;
}
let key = String::from_utf8_lossy(&entry[..equals]).to_string();
let value = String::from_utf8_lossy(&entry[equals + 1..]).to_string();
Some((key, value))
})
.collect()
}
fn resolve_terminal_cwd(cwd: Option<String>) -> Result<PathBuf, String> {
let path = match cwd.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(PathBuf::from(trimmed))
}
}) {
Some(path) => path,
None => home_dir().unwrap_or(
std::env::current_dir().map_err(|err| format!("resolve current directory: {err}"))?,
),
};
if path.is_dir() {
Ok(path)
} else {
Err(format!("terminal cwd does not exist: {}", path.display()))
}
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
fn default_shell() -> String {
#[cfg(target_os = "windows")]
{
std::env::var("COMSPEC").unwrap_or_else(|_| "powershell.exe".to_string())
}
#[cfg(not(target_os = "windows"))]
{
std::env::var("SHELL").unwrap_or_else(|_| {
if PathBuf::from("/bin/zsh").exists() {
"/bin/zsh".to_string()
} else {
"/bin/bash".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}"))?;
@ -114,8 +543,7 @@ fn resolve_app_root(_app: &AppHandle) -> Result<PathBuf, String> {
// 我们直接用当前可执行文件所在目录作为 app_root
// Dev: desktop/src-tauri/target/<profile>/ rust 跑出来的 binary 那一层)
// Prod: <App>.app/Contents/MacOS/ sidecar 二进制的同级目录)
let exe = std::env::current_exe()
.map_err(|err| format!("resolve current exe path: {err}"))?;
let exe = std::env::current_exe().map_err(|err| format!("resolve current exe path: {err}"))?;
let dir = exe
.parent()
.ok_or_else(|| "current exe has no parent dir".to_string())?
@ -293,26 +721,114 @@ fn stop_adapters_sidecar(app: &AppHandle) {
}
}
#[cfg(test)]
mod tests {
use super::{decode_terminal_output, default_utf8_locale, ensure_utf8_locale, parse_env_block};
use std::collections::HashMap;
#[test]
fn terminal_output_decoder_preserves_split_chinese_characters() {
let mut pending = Vec::new();
let bytes = "安装 Skills 成功\n".as_bytes();
assert_eq!(decode_terminal_output(&mut pending, &bytes[..2]), "");
assert_eq!(decode_terminal_output(&mut pending, &bytes[2..4]), "");
assert_eq!(
decode_terminal_output(&mut pending, &bytes[4..]),
"装 Skills 成功\n"
);
assert!(pending.is_empty());
}
#[test]
fn terminal_output_decoder_keeps_incomplete_suffix_pending() {
let mut pending = Vec::new();
let bytes = "中文".as_bytes();
assert_eq!(decode_terminal_output(&mut pending, &bytes[..4]), "");
assert_eq!(pending, bytes[3..4]);
assert_eq!(decode_terminal_output(&mut pending, &bytes[4..]), "");
assert!(pending.is_empty());
}
#[test]
fn parse_env_block_reads_nul_delimited_values() {
let env =
parse_env_block(b"PATH=/opt/homebrew/bin:/usr/bin\0NODE_PATH=/tmp/node\0EMPTY=\0");
assert_eq!(
env.get("PATH").map(String::as_str),
Some("/opt/homebrew/bin:/usr/bin")
);
assert_eq!(env.get("NODE_PATH").map(String::as_str), Some("/tmp/node"));
assert_eq!(env.get("EMPTY").map(String::as_str), Some(""));
}
#[test]
fn terminal_environment_forces_utf8_locale_when_shell_uses_c_locale() {
let mut env = HashMap::from([
("LANG".to_string(), "C".to_string()),
("LC_CTYPE".to_string(), "POSIX".to_string()),
("LC_ALL".to_string(), "C".to_string()),
]);
ensure_utf8_locale(&mut env);
assert_eq!(
env.get("LANG").map(String::as_str),
Some(default_utf8_locale())
);
assert_eq!(
env.get("LC_CTYPE").map(String::as_str),
Some(default_utf8_locale())
);
assert_eq!(
env.get("LC_ALL").map(String::as_str),
Some(default_utf8_locale())
);
}
#[test]
fn terminal_environment_keeps_existing_utf8_locale() {
let mut env = HashMap::from([
("LANG".to_string(), "zh_CN.UTF-8".to_string()),
("LC_CTYPE".to_string(), "en_US.UTF8".to_string()),
("LC_ALL".to_string(), "C.UTF-8".to_string()),
]);
ensure_utf8_locale(&mut env);
assert_eq!(env.get("LANG").map(String::as_str), Some("zh_CN.UTF-8"));
assert_eq!(env.get("LC_CTYPE").map(String::as_str), Some("en_US.UTF8"));
assert_eq!(env.get("LC_ALL").map(String::as_str), Some("C.UTF-8"));
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri::Builder::default()
.manage(ServerState::default())
.manage(AdapterState::default())
.manage(TerminalState::default())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.invoke_handler(tauri::generate_handler![
get_server_url,
restart_adapters_sidecar
restart_adapters_sidecar,
terminal_spawn,
terminal_write,
terminal_resize,
terminal_kill
]);
// macOS: native menu bar (traffic-light overlay style)
#[cfg(target_os = "macos")]
let builder = builder
.menu(|app| {
let about_item = MenuItemBuilder::with_id("nav_about", "关于 Claude Code Haha")
.build(app)?;
let about_item =
MenuItemBuilder::with_id("nav_about", "关于 Claude Code Haha").build(app)?;
let settings_item = MenuItemBuilder::with_id("nav_settings", "设置...")
.accelerator("CmdOrCtrl+,")
.build(app)?;
@ -341,9 +857,7 @@ pub fn run() {
.select_all()
.build()?;
let view_submenu = SubmenuBuilder::new(app, "View")
.fullscreen()
.build()?;
let view_submenu = SubmenuBuilder::new(app, "View").fullscreen().build()?;
let window_submenu = SubmenuBuilder::new(app, "Window")
.minimize()

View File

@ -22,8 +22,11 @@ vi.mock('../stores/providerStore', () => ({
useProviderStore: () => ({
providers: [],
activeId: null,
presets: [],
isLoading: false,
isPresetsLoading: false,
fetchProviders: vi.fn(),
fetchPresets: vi.fn(),
deleteProvider: vi.fn(),
activateProvider: vi.fn(),
activateOfficial: vi.fn(),

View File

@ -1,11 +1,30 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Settings } from '../pages/Settings'
import { useSettingsStore } from '../stores/settingsStore'
import { useUIStore } from '../stores/uiStore'
import { useUpdateStore } from '../stores/updateStore'
import type { SavedProvider } from '../types/provider'
const MOCK_DELETE_PROVIDER = vi.fn()
const providerStoreState = {
providers: [] as SavedProvider[],
activeId: null,
presets: [],
isLoading: false,
isPresetsLoading: false,
fetchProviders: vi.fn(),
fetchPresets: vi.fn(),
deleteProvider: MOCK_DELETE_PROVIDER,
activateProvider: vi.fn(),
activateOfficial: vi.fn(),
testProvider: vi.fn(),
createProvider: vi.fn(),
updateProvider: vi.fn(),
testConfig: vi.fn(),
}
vi.mock('../api/agents', () => ({
agentsApi: {
@ -14,19 +33,7 @@ vi.mock('../api/agents', () => ({
}))
vi.mock('../stores/providerStore', () => ({
useProviderStore: () => ({
providers: [],
activeId: null,
isLoading: false,
fetchProviders: vi.fn(),
deleteProvider: vi.fn(),
activateProvider: vi.fn(),
activateOfficial: vi.fn(),
testProvider: vi.fn(),
createProvider: vi.fn(),
updateProvider: vi.fn(),
testConfig: vi.fn(),
}),
useProviderStore: () => providerStoreState,
}))
vi.mock('../pages/AdapterSettings', () => ({
@ -64,6 +71,21 @@ vi.mock('../components/chat/CodeViewer', () => ({
describe('Settings > General tab', () => {
beforeEach(() => {
MOCK_DELETE_PROVIDER.mockReset()
providerStoreState.providers = []
providerStoreState.activeId = null
providerStoreState.presets = []
providerStoreState.isLoading = false
providerStoreState.isPresetsLoading = false
providerStoreState.fetchProviders = vi.fn()
providerStoreState.fetchPresets = vi.fn()
providerStoreState.activateProvider = vi.fn()
providerStoreState.activateOfficial = vi.fn()
providerStoreState.testProvider = vi.fn()
providerStoreState.createProvider = vi.fn()
providerStoreState.updateProvider = vi.fn()
providerStoreState.testConfig = vi.fn()
useSettingsStore.setState({
locale: 'en',
skipWebFetchPreflight: true,
@ -109,6 +131,53 @@ describe('Settings > General tab', () => {
expect(useSettingsStore.getState().setSkipWebFetchPreflight).toHaveBeenCalledWith(false)
})
it('keeps extension tabs available alongside the terminal tab', () => {
render(<Settings />)
expect(screen.queryByText('Install')).not.toBeInTheDocument()
expect(screen.getByText('Terminal')).toBeInTheDocument()
expect(screen.getByText('MCP')).toBeInTheDocument()
expect(screen.getByText('Plugins')).toBeInTheDocument()
})
})
describe('Settings > Providers tab', () => {
beforeEach(() => {
MOCK_DELETE_PROVIDER.mockReset()
providerStoreState.providers = [
{
id: 'provider-1',
name: 'MiniMax-M2.7-highspeed(openai)',
presetId: 'custom',
apiKey: '***',
baseUrl: 'https://api.minimaxi.com',
apiFormat: 'openai_chat',
models: {
main: 'MiniMax-M2.7-highspeed',
haiku: '',
sonnet: '',
opus: '',
},
notes: '',
},
]
})
it('requires confirmation before deleting a provider', async () => {
render(<Settings />)
fireEvent.click(screen.getAllByText('Delete')[0]!)
expect(MOCK_DELETE_PROVIDER).not.toHaveBeenCalled()
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('Delete provider "MiniMax-M2.7-highspeed(openai)"? This cannot be undone.')).toBeInTheDocument()
const dialog = screen.getByRole('dialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' }))
expect(MOCK_DELETE_PROVIDER).toHaveBeenCalledWith('provider-1')
})
})
describe('Settings > About tab', () => {

View File

@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { McpSettings } from '../pages/McpSettings'
@ -38,6 +38,7 @@ describe('McpSettings', () => {
})
useMcpStore.setState({
servers: [],
selectedServer: null,
isLoading: false,
error: null,
fetchServers: vi.fn(),
@ -46,6 +47,8 @@ describe('McpSettings', () => {
deleteServer: vi.fn(),
toggleServer: vi.fn(),
reconnectServer: vi.fn(),
refreshServerStatus: vi.fn(),
selectServer: vi.fn(),
})
})
@ -109,4 +112,166 @@ describe('McpSettings', () => {
expect(screen.getByText('plugin:telegram:telegram')).toBeInTheDocument()
expect(screen.getByText('global-user')).toBeInTheDocument()
})
it('starts background status refresh after the fast list render', async () => {
const server = {
name: 'deepwiki',
scope: 'user',
transport: 'http',
enabled: true,
status: 'checking' as const,
statusLabel: 'Checking',
configLocation: '/tmp/config',
summary: 'https://example.com/mcp',
canEdit: true,
canRemove: true,
canReconnect: true,
canToggle: true,
config: { type: 'http' as const, url: 'https://example.com/mcp', headers: {} },
}
const refreshServerStatus = vi.fn().mockResolvedValue({
...server,
status: 'connected' as const,
statusLabel: 'Connected',
})
useMcpStore.setState({
servers: [server],
refreshServerStatus,
})
render(<McpSettings />)
expect(screen.getByText('Checking')).toBeInTheDocument()
await waitFor(() => {
expect(refreshServerStatus).toHaveBeenCalledWith(server, '/workspace/project')
})
})
it('opens the delete confirmation modal from the edit view and deletes with the active cwd', async () => {
const deleteServer = vi.fn().mockResolvedValue(undefined)
const server = {
name: 'global-user',
scope: 'user',
transport: 'http',
enabled: true,
status: 'connected',
statusLabel: 'Connected',
configLocation: '/tmp/config',
summary: 'https://example.com/mcp',
canEdit: true,
canRemove: true,
canReconnect: true,
canToggle: true,
config: { type: 'http', url: 'https://example.com/mcp', headers: {} },
} as const
useMcpStore.setState({
servers: [server],
deleteServer,
})
render(<McpSettings />)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Open global-user' }))
})
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /uninstall/i }))
})
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('Delete MCP server')).toBeInTheDocument()
expect(screen.getByText('Delete MCP server "global-user"? This action cannot be undone.')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
})
expect(deleteServer).toHaveBeenCalledWith(server, '/workspace/project')
})
it('uses the active cwd when toggling a server', async () => {
const toggleServer = vi.fn().mockResolvedValue(undefined)
const server = {
name: 'global-user',
scope: 'user',
transport: 'http',
enabled: true,
status: 'connected',
statusLabel: 'Connected',
configLocation: '/tmp/config',
summary: 'https://example.com/mcp',
canEdit: true,
canRemove: true,
canReconnect: true,
canToggle: true,
config: { type: 'http', url: 'https://example.com/mcp', headers: {} },
} as const
useMcpStore.setState({
servers: [server],
toggleServer,
})
render(<McpSettings />)
await act(async () => {
fireEvent.click(screen.getByRole('switch'))
})
expect(toggleServer).toHaveBeenCalledWith(server, '/workspace/project')
})
it('shows reconnecting status immediately in the detail view', async () => {
let resolveReconnect: ((value: typeof server) => void) | null = null
const server = {
name: 'plugin:telegram:telegram',
scope: 'dynamic',
transport: 'stdio',
enabled: true,
status: 'failed' as 'connected' | 'needs-auth' | 'failed' | 'disabled' | 'checking',
statusLabel: 'Unavailable',
statusDetail: 'Timed out' as string | undefined,
configLocation: '/tmp/config',
summary: 'bun run start',
canEdit: false,
canRemove: false,
canReconnect: true,
canToggle: true,
config: { type: 'stdio' as const, command: 'bun', args: ['run', 'start'], env: {} },
}
const reconnectServer = vi.fn().mockImplementation(() => new Promise<typeof server>((resolve) => {
resolveReconnect = resolve
}))
useMcpStore.setState({
servers: [server],
reconnectServer,
})
render(<McpSettings />)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Open plugin:telegram:telegram' }))
})
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /reconnect/i }))
})
expect(screen.getAllByText('Reconnecting...').length).toBeGreaterThan(0)
expect(reconnectServer).toHaveBeenCalledWith(server, '/workspace/project')
await act(async () => {
resolveReconnect?.({
...server,
status: 'connected',
statusLabel: 'Connected',
statusDetail: undefined,
})
})
})
})

View File

@ -15,6 +15,23 @@ vi.mock('../api/skills', () => ({
vi.mock('../api/mcp', () => ({
mcpApi: {
list: vi.fn(async () => ({ servers: [] })),
status: vi.fn(async (name: string) => ({
server: {
name,
scope: 'user',
transport: 'http',
enabled: true,
status: 'connected',
statusLabel: 'Connected',
configLocation: 'User',
summary: 'https://mcp.example.com/mcp',
canEdit: true,
canRemove: true,
canReconnect: true,
canToggle: true,
config: { type: 'http', url: 'https://mcp.example.com/mcp', headers: {} },
},
})),
},
}))

View File

@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Settings } from '../pages/Settings'
@ -23,8 +23,11 @@ vi.mock('../stores/providerStore', () => ({
useProviderStore: () => ({
providers: [],
activeId: null,
presets: [],
isLoading: false,
isPresetsLoading: false,
fetchProviders: vi.fn(),
fetchPresets: vi.fn(),
deleteProvider: vi.fn(),
activateProvider: vi.fn(),
activateOfficial: vi.fn(),
@ -335,6 +338,59 @@ describe('Settings > Plugins tab', () => {
expect(screen.getByText('Uninstall')).toBeInTheDocument()
})
it('keeps plugin detail hook order stable while the selected plugin reloads', () => {
usePluginStore.setState({
selectedPlugin: {
id: 'github@claude-plugins-official',
name: 'github',
marketplace: 'claude-plugins-official',
scope: 'user',
enabled: false,
hasErrors: false,
isBuiltin: false,
description: 'GitHub integration',
componentCounts: {
commands: 1,
agents: 0,
skills: 0,
hooks: 0,
mcpServers: 0,
lspServers: 0,
},
capabilities: {
commands: ['review-pr'],
agents: [],
skills: [],
hooks: [],
mcpServers: [],
lspServers: [],
},
commandEntries: [
{
name: 'review-pr',
description: 'Review the current pull request.',
},
],
agentEntries: [],
hookEntries: [],
skillEntries: [],
mcpServerEntries: [],
errors: [],
},
})
const { container } = render(<Settings />)
switchToPluginsTab()
expect(screen.getByText('GitHub integration')).toBeInTheDocument()
act(() => {
usePluginStore.setState({ isDetailLoading: true })
})
expect(container.querySelector('.animate-spin')).toBeInTheDocument()
})
it('navigates plugin skills into the shared Skills page flow', () => {
usePluginStore.setState({
selectedPlugin: {

View File

@ -19,8 +19,11 @@ vi.mock('../stores/providerStore', () => ({
useProviderStore: () => ({
providers: [],
activeId: null,
presets: [],
isLoading: false,
isPresetsLoading: false,
fetchProviders: vi.fn(),
fetchPresets: vi.fn(),
deleteProvider: vi.fn(),
activateProvider: vi.fn(),
activateOfficial: vi.fn(),

View File

@ -9,6 +9,18 @@ const DEFAULT_BASE_URL = ENV_BASE_URL || 'http://127.0.0.1:3456'
let baseUrl = DEFAULT_BASE_URL
function getErrorMessage(status: number, body: unknown) {
if (body && typeof body === 'object' && 'message' in body && typeof body.message === 'string') {
return body.message
}
if (typeof body === 'string' && body.trim().length > 0) {
return body
}
return `API error ${status}`
}
export function setBaseUrl(url: string) {
baseUrl = url.replace(/\/$/, '')
}
@ -26,7 +38,7 @@ export class ApiError extends Error {
public status: number,
public body: unknown,
) {
super(`API error ${status}: ${typeof body === 'string' ? body : JSON.stringify(body)}`)
super(getErrorMessage(status, body))
this.name = 'ApiError'
}
}
@ -38,7 +50,8 @@ async function request<T>(method: string, path: string, body?: unknown, options?
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), options?.timeout ?? 30_000)
const timeoutMs = options?.timeout ?? 30_000
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetch(url, {
method,
@ -57,6 +70,9 @@ async function request<T>(method: string, path: string, body?: unknown, options?
return res.json() as Promise<T>
} catch (err) {
clearTimeout(timeout)
if (controller.signal.aborted) {
throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)
}
throw err
}
}

View File

@ -7,6 +7,11 @@ export const mcpApi = {
return api.get<{ servers: McpServerRecord[] }>(`/api/mcp${query}`)
},
status: (name: string, cwd?: string) => {
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
return api.get<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}/status${query}`)
},
create: (name: string, payload: McpUpsertPayload, cwd?: string) => {
return api.post<{ server: McpServerRecord }>('/api/mcp', {
name,
@ -36,4 +41,3 @@ export const mcpApi = {
return api.post<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}/reconnect`, cwd ? { cwd } : {})
},
}

View File

@ -38,6 +38,10 @@ export const pluginsApi = {
reload: (cwd?: string) => {
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
return api.post<{ ok: true; summary: PluginReloadSummary }>(`/api/plugins/reload${query}`)
return api.post<{ ok: true; summary: PluginReloadSummary }>(
`/api/plugins/reload${query}`,
undefined,
{ timeout: 120_000 },
)
},
}

View File

@ -8,7 +8,7 @@ import type {
TestProviderConfigInput,
ProviderTestResult,
} from '../types/provider'
import type { ProviderPreset } from '../config/providerPresets'
import type { ProviderPreset } from '../types/providerPreset'
type ProvidersResponse = { providers: SavedProvider[]; activeId: string | null }
type ProviderResponse = { provider: SavedProvider }
@ -33,6 +33,14 @@ export const providersApi = {
return api.get<AuthStatusResponse>('/api/providers/auth-status')
},
getSettings() {
return api.get<Record<string, unknown>>('/api/providers/settings')
},
updateSettings(settings: Record<string, unknown>) {
return api.put<{ ok: true }>('/api/providers/settings', settings)
},
create(input: CreateProviderInput) {
return api.post<ProviderResponse>('/api/providers', input)
},

View File

@ -1,6 +1,20 @@
import { api } from './client'
import type { PermissionMode, UserSettings } from '../types/settings'
export type CliLauncherStatus = {
supported: boolean
command: string
installed: boolean
launcherPath: string
binDir: string
pathConfigured: boolean
pathInCurrentShell: boolean
availableInNewTerminals: boolean
needsTerminalRestart: boolean
configTarget: string | null
lastError: string | null
}
export const settingsApi = {
getUser() {
return api.get<UserSettings>('/api/settings/user')
@ -17,4 +31,8 @@ export const settingsApi = {
setPermissionMode(mode: PermissionMode) {
return api.put<{ ok: true; mode: PermissionMode }>('/api/permissions/mode', { mode })
},
getCliLauncherStatus() {
return api.get<CliLauncherStatus>('/api/settings/cli-launcher')
},
}

View File

@ -4,7 +4,7 @@ import type { SkillMeta, SkillDetail } from '../types/skill'
export const skillsApi = {
list: (cwd?: string) => {
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
return api.get<{ skills: SkillMeta[] }>(`/api/skills${query}`)
return api.get<{ skills: SkillMeta[] }>(`/api/skills${query}`, { timeout: 120_000 })
},
detail: (source: string, name: string, cwd?: string) => {
@ -14,6 +14,9 @@ export const skillsApi = {
})
if (cwd) query.set('cwd', cwd)
return api.get<{ detail: SkillDetail }>(`/api/skills/detail?${query.toString()}`)
return api.get<{ detail: SkillDetail }>(
`/api/skills/detail?${query.toString()}`,
{ timeout: 120_000 },
)
},
}

View File

@ -0,0 +1,58 @@
import { isTauriRuntime } from '../lib/desktopRuntime'
export type TerminalSpawnResult = {
session_id: number
shell: string
cwd: string
}
export type TerminalOutputPayload = {
session_id: number
data: string
}
export type TerminalExitPayload = {
session_id: number
code: number
signal?: string | null
}
type Unlisten = () => void
async function invoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
if (!isTauriRuntime()) {
throw new Error('Terminal is available in the desktop app runtime.')
}
const api = await import(/* @vite-ignore */ '@tauri-apps/api/core')
return api.invoke<T>(command, args)
}
export const terminalApi = {
isAvailable: isTauriRuntime,
spawn(input: { cols: number; rows: number; cwd?: string }) {
return invoke<TerminalSpawnResult>('terminal_spawn', input)
},
write(sessionId: number, data: string) {
return invoke<void>('terminal_write', { sessionId, data })
},
resize(sessionId: number, cols: number, rows: number) {
return invoke<void>('terminal_resize', { sessionId, cols, rows })
},
kill(sessionId: number) {
return invoke<void>('terminal_kill', { sessionId })
},
async onOutput(handler: (payload: TerminalOutputPayload) => void): Promise<Unlisten> {
const events = await import(/* @vite-ignore */ '@tauri-apps/api/event')
return events.listen<TerminalOutputPayload>('terminal-output', (event) => handler(event.payload))
},
async onExit(handler: (payload: TerminalExitPayload) => void): Promise<Unlisten> {
const events = await import(/* @vite-ignore */ '@tauri-apps/api/event')
return events.listen<TerminalExitPayload>('terminal-exit', (event) => handler(event.payload))
},
}

View File

@ -135,7 +135,7 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
if (!activeQuestion) return null
return (
<div className={`mb-4 ml-10 rounded-[var(--radius-lg)] border overflow-hidden ${
<div className={`mb-4 rounded-[var(--radius-lg)] border overflow-hidden ${
submitted
? 'border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-low)] opacity-70'
: 'border-[var(--color-secondary)] bg-[var(--color-surface-container-lowest)]'

View File

@ -8,22 +8,50 @@ type Props = {
}
export function AssistantMessage({ content, isStreaming }: Props) {
const documentLayout = shouldUseDocumentLayout(content)
return (
<div className="group mb-5 ml-10 flex items-end gap-1.5">
<div className="min-w-0">
<div className="rounded-[20px] rounded-tl-[8px] border border-[var(--color-border)]/60 bg-[var(--color-surface)] px-4 py-3 text-sm text-[var(--color-text-primary)] shadow-sm">
<MarkdownRenderer content={content} />
<div className="group mb-5 flex justify-start">
<div
data-message-shell="assistant"
data-layout={documentLayout ? 'document' : 'bubble'}
className={`flex min-w-0 flex-col items-start gap-2 ${
documentLayout
? 'w-full max-w-full'
: 'w-full max-w-[88%] sm:max-w-[80%] lg:max-w-[72%]'
}`}
>
<div className={`rounded-[20px] rounded-tl-[8px] border border-[var(--color-border)]/60 bg-[var(--color-surface)] px-4 py-3 text-sm text-[var(--color-text-primary)] shadow-sm ${
documentLayout ? 'w-full' : 'max-w-full'
}`}>
<MarkdownRenderer content={content} variant={documentLayout ? 'document' : 'default'} />
{!isStreaming && <InlineImageGallery text={content} />}
{isStreaming && (
<span className="ml-0.5 inline-block h-4 w-0.5 animate-shimmer bg-[var(--color-brand)] align-text-bottom" />
)}
</div>
</div>
<MessageActionBar
copyText={isStreaming ? undefined : content}
copyLabel="Copy reply"
/>
<MessageActionBar
copyText={isStreaming ? undefined : content}
copyLabel="Copy reply"
align="start"
/>
</div>
</div>
)
}
function shouldUseDocumentLayout(content: string) {
const normalized = content.trim()
if (!normalized) return false
if (/```/.test(normalized)) return true
if (/^\s{0,3}(#{1,6}\s|[-*+]\s|\d+\.\s|>\s|\|.+\|)/m.test(normalized)) return true
const paragraphs = normalized
.split(/\n\s*\n/)
.map((chunk) => chunk.trim())
.filter(Boolean)
return paragraphs.length >= 2 || normalized.split('\n').filter((line) => line.trim()).length >= 8
}

View File

@ -4,6 +4,7 @@ import { useChatStore } from '../../stores/chatStore'
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
import { useUIStore } from '../../stores/uiStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore'
import { useTeamStore } from '../../stores/teamStore'
import { sessionsApi } from '../../api/sessions'
import { PermissionModeSelector } from '../controls/PermissionModeSelector'
@ -669,7 +670,9 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
</div>
<div className="flex items-center gap-2">
{!isMemberSession && <ModelSelector />}
{!isMemberSession && activeTabId && (
<ModelSelector runtimeKey={activeTabId} disabled={isActive} />
)}
<button
onClick={!isMemberSession && isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
disabled={!isMemberSession && isActive ? false : !canSubmit}
@ -709,6 +712,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
const { replaceTabSession } = useTabStore.getState()
const { disconnectSession, connectToSession } = useChatStore.getState()
const newId = await createSession(newWorkDir)
useSessionRuntimeStore.getState().moveSelection(oldId, newId)
disconnectSession(oldId)
replaceTabSession(oldId, newId)
connectToSession(newId)

View File

@ -118,9 +118,27 @@ function McpPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) {
useEffect(() => {
let cancelled = false
mcpApi.list(cwd)
.then((response) => {
.then(async (response) => {
if (cancelled) return
setServers(response.servers.filter((server) => server.scope === 'user' || server.scope === 'local' || server.scope === 'project'))
const visibleServers = response.servers.filter((server) => server.scope === 'user' || server.scope === 'local' || server.scope === 'project')
setServers(visibleServers)
const statusResults = await Promise.allSettled(
visibleServers.map((server) => mcpApi.status(server.name, cwd)),
)
if (cancelled) return
const liveServers = new Map<string, McpServerRecord>()
for (const result of statusResults) {
if (result.status === 'fulfilled') {
liveServers.set(result.value.server.name, result.value.server)
}
}
if (liveServers.size > 0) {
setServers((current) =>
current?.map((server) => liveServers.get(server.name) ?? server) ?? current,
)
}
})
.catch((err) => {
if (cancelled) return

View File

@ -5,6 +5,7 @@ type Props = {
copyLabel: string
onRewind?: () => void
rewindLabel?: string
align?: 'start' | 'end'
}
export function MessageActionBar({
@ -12,6 +13,7 @@ export function MessageActionBar({
copyLabel,
onRewind,
rewindLabel = 'Rewind to here',
align = 'start',
}: Props) {
const hasCopy = Boolean(copyText?.trim())
const hasRewind = Boolean(onRewind)
@ -19,7 +21,13 @@ export function MessageActionBar({
if (!hasCopy && !hasRewind) return null
return (
<div className="shrink-0 pb-2 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<div
data-message-actions
data-align={align}
className={`flex w-full opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100 ${
align === 'end' ? 'justify-end' : 'justify-start'
}`}
>
<div className="flex items-center gap-1.5">
{hasRewind && (
<button
@ -27,7 +35,7 @@ export function MessageActionBar({
onClick={onRewind}
aria-label={rewindLabel}
title={rewindLabel}
className="inline-flex min-h-7 items-center gap-1 rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface)] px-2.5 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
className="inline-flex min-h-7 items-center gap-1 rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] px-2.5 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
>
<span className="material-symbols-outlined text-[14px]">undo</span>
<span className="hidden min-[920px]:inline">Rewind</span>
@ -39,7 +47,7 @@ export function MessageActionBar({
label={copyLabel}
displayLabel="Copy"
displayCopiedLabel="Copied"
className="inline-flex min-h-7 items-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface)] px-2.5 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
className="inline-flex min-h-7 items-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] px-2.5 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
/>
)}
</div>

View File

@ -338,6 +338,79 @@ describe('MessageList nested tool calls', () => {
)
})
it('keeps user actions anchored to the right bubble and assistant actions to the left bubble', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'user-1',
type: 'user_text',
content: '请把这条 prompt 放在右侧',
timestamp: 1,
},
{
id: 'assistant-1',
type: 'assistant_text',
content: '这条回复应该停在左侧。',
timestamp: 2,
},
],
}),
},
})
render(<MessageList />)
const userShell = screen.getByText('请把这条 prompt 放在右侧').closest('[data-message-shell="user"]')
const assistantShell = screen.getByText('这条回复应该停在左侧。').closest('[data-message-shell="assistant"]')
const userActions = screen.getByRole('button', { name: 'Copy prompt' }).closest('[data-message-actions]')
const assistantActions = screen.getByRole('button', { name: 'Copy reply' }).closest('[data-message-actions]')
expect(userShell).toBeTruthy()
expect(userShell?.className).toContain('items-end')
expect(assistantShell).toBeTruthy()
expect(assistantShell?.className).toContain('items-start')
expect(assistantShell?.className).not.toContain('ml-10')
expect(userActions?.getAttribute('data-align')).toBe('end')
expect(assistantActions?.getAttribute('data-align')).toBe('start')
})
it('uses the document column for markdown-heavy assistant replies', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'assistant-doc',
type: 'assistant_text',
content: [
'## 交付结果',
'',
'已完成以下内容:',
'',
'- 添加任务',
'- 删除任务',
'',
'```bash',
'npm run build',
'```',
].join('\n'),
timestamp: 1,
},
],
}),
},
})
render(<MessageList />)
const assistantShell = screen.getByText('交付结果').closest('[data-message-shell="assistant"]')
expect(assistantShell?.getAttribute('data-layout')).toBe('document')
expect(assistantShell?.className).toContain('w-full')
expect(assistantShell?.className).not.toContain('ml-10')
})
it('opens a rewind preview modal for user messages', async () => {
vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({
target: {

View File

@ -127,7 +127,7 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr
const allowRawToggle = !preview
return (
<div className={`mb-4 ml-10 overflow-hidden rounded-[var(--radius-lg)] border ${
<div className={`mb-4 overflow-hidden rounded-[var(--radius-lg)] border ${
isPending
? 'border-[var(--color-warning)] bg-[var(--color-surface-container-lowest)]'
: 'border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-low)] opacity-70'

View File

@ -23,7 +23,7 @@ export function StreamingIndicator() {
}
return (
<div className="mb-2 ml-10 flex w-fit items-center gap-2 rounded-full border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1">
<div className="mb-2 flex w-fit items-center gap-2 rounded-full border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1">
<span className="text-[var(--color-brand)] animate-shimmer text-xs"></span>
<span className="text-xs font-medium text-[var(--color-text-secondary)]">{verb}...</span>
{elapsedSeconds > 0 && (

View File

@ -18,7 +18,7 @@ export function ThinkingBlock({ content, isActive = false }: { content: string;
const preview = firstLine.length > 80 ? firstLine.slice(0, 80) + '...' : firstLine
return (
<div className="mb-1 ml-10">
<div className="mb-1">
<style>{thinkingStyles}</style>
<button
onClick={() => setExpanded((v) => !v)}

View File

@ -51,7 +51,7 @@ export function ToolCallBlock({ toolName, input, result, compact = false }: Prop
return (
<div className={`overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-container-lowest)] ${
compact ? 'mb-0' : 'mb-2 ml-10'
compact ? 'mb-0' : 'mb-2'
}`}>
<button
type="button"

View File

@ -128,7 +128,7 @@ function AgentToolGroup({
}, [isStreaming])
return (
<div className="mb-2 ml-10">
<div className="mb-2">
<button
type="button"
onClick={() => setExpanded((value) => !value)}
@ -201,7 +201,7 @@ function ToolCallGroupMulti({ toolCalls, resultMap, childToolCallsByParent, isSt
}, [hasNestedToolCalls, isStreaming])
return (
<div className="mb-2 ml-10">
<div className="mb-2">
<button
type="button"
onClick={() => setExpanded((v) => !v)}

View File

@ -27,7 +27,7 @@ export function ToolResultBlock({ content, isError, toolName, standalone = true
const hasMore = text.length > 200
return (
<div className={`mb-2 ml-10 overflow-hidden rounded-xl border ${
<div className={`mb-2 overflow-hidden rounded-xl border ${
isError
? 'border-[var(--color-error)]/20'
: 'border-[var(--color-outline-variant)]/20'

View File

@ -13,8 +13,11 @@ export function UserMessage({ content, attachments, onRewind, rewindLabel }: Pro
const hasText = content.trim().length > 0
return (
<div className="group mb-5 flex items-end justify-end gap-1.5">
<div className="min-w-0 max-w-[82%] space-y-2">
<div className="group mb-5 flex justify-end">
<div
data-message-shell="user"
className="flex min-w-0 w-full max-w-[82%] flex-col items-end gap-2 sm:max-w-[78%] lg:max-w-[72%]"
>
{attachments && attachments.length > 0 && (
<AttachmentGallery attachments={attachments} variant="message" />
)}
@ -27,16 +30,17 @@ export function UserMessage({ content, attachments, onRewind, rewindLabel }: Pro
{content}
</div>
)}
</div>
{hasText && (
<MessageActionBar
copyText={content}
copyLabel="Copy prompt"
onRewind={onRewind}
rewindLabel={rewindLabel}
/>
)}
{hasText && (
<MessageActionBar
copyText={content}
copyLabel="Copy prompt"
onRewind={onRewind}
rewindLabel={rewindLabel}
align="end"
/>
)}
</div>
</div>
)
}

View File

@ -1,26 +1,132 @@
import { useState, useRef, useEffect } from 'react'
import { useSettingsStore } from '../../stores/settingsStore'
import { useEffect, useMemo, useRef, useState } from 'react'
import { OFFICIAL_DEFAULT_MODEL_ID, OFFICIAL_MODELS } from '../../constants/modelCatalog'
import { useTranslation } from '../../i18n'
import type { EffortLevel } from '../../types/settings'
import { useChatStore } from '../../stores/chatStore'
import { useProviderStore } from '../../stores/providerStore'
import { DRAFT_RUNTIME_SELECTION_KEY, useSessionRuntimeStore } from '../../stores/sessionRuntimeStore'
import { useSettingsStore } from '../../stores/settingsStore'
import type { SavedProvider } from '../../types/provider'
import type { RuntimeSelection } from '../../types/runtime'
import type { EffortLevel, ModelInfo } from '../../types/settings'
const MODEL_ICONS = {
opus: 'diamond',
sonnet: 'auto_awesome',
haiku: 'bolt',
} as const
type Props = {
/** Controlled mode: model ID override */
value?: string
/** Controlled mode: called on change instead of updating global store */
onChange?: (modelId: string) => void
type ProviderChoice = {
providerId: string | null
providerName: string
isDefault: boolean
models: ModelInfo[]
}
export function ModelSelector({ value, onChange }: Props = {}) {
type Props = {
value?: string
onChange?: (modelId: string) => void
runtimeKey?: string
disabled?: boolean
}
function officialChoices(availableModels: ModelInfo[], isDefault: boolean, officialName: string): ProviderChoice {
return {
providerId: null,
providerName: officialName,
isDefault,
models: availableModels.length > 0 ? availableModels : OFFICIAL_MODELS,
}
}
function buildProviderModels(
provider: SavedProvider,
labels: Record<'main' | 'haiku' | 'sonnet' | 'opus', string>,
): ModelInfo[] {
const entries: Array<{ id: string; label: string }> = [
{ id: provider.models.main.trim(), label: labels.main },
{ id: provider.models.haiku.trim(), label: labels.haiku },
{ id: provider.models.sonnet.trim(), label: labels.sonnet },
{ id: provider.models.opus.trim(), label: labels.opus },
]
const byId = new Map<string, { id: string; labels: string[] }>()
for (const entry of entries) {
if (!entry.id) continue
const existing = byId.get(entry.id)
if (existing) {
if (!existing.labels.includes(entry.label)) {
existing.labels.push(entry.label)
}
continue
}
byId.set(entry.id, { id: entry.id, labels: [entry.label] })
}
return [...byId.values()].map((entry) => ({
id: entry.id,
name: entry.id,
description: entry.labels.join(' · '),
context: '',
}))
}
function buildProviderChoices(
providers: SavedProvider[],
activeId: string | null,
availableModels: ModelInfo[],
officialName: string,
labels: Record<'main' | 'haiku' | 'sonnet' | 'opus', string>,
): ProviderChoice[] {
return [
officialChoices(availableModels, activeId === null, officialName),
...providers.map((provider) => ({
providerId: provider.id,
providerName: provider.name,
isDefault: activeId === provider.id,
models: buildProviderModels(provider, labels),
})),
]
}
function resolveDefaultRuntimeSelection(
activeId: string | null,
activeProviderName: string | null,
providers: SavedProvider[],
currentModelId: string | undefined,
): RuntimeSelection {
const inferredProviderId = activeId ?? (
activeProviderName
? providers.find((provider) => provider.name === activeProviderName)?.id ?? null
: null
)
return {
providerId: inferredProviderId,
modelId: currentModelId ?? OFFICIAL_DEFAULT_MODEL_ID,
}
}
export function ModelSelector({
value,
onChange,
runtimeKey,
disabled = false,
}: Props = {}) {
const t = useTranslation()
const { currentModel: storeModel, availableModels, effortLevel, setModel, setEffort } = useSettingsStore()
const {
currentModel: storeModel,
availableModels,
effortLevel,
activeProviderName,
setModel,
setEffort,
} = useSettingsStore()
const {
providers,
activeId,
isLoading: providersLoading,
fetchProviders,
} = useProviderStore()
const runtimeSelection = useSessionRuntimeStore((state) =>
runtimeKey ? state.selections[runtimeKey] : undefined,
)
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const requestedProvidersRef = useRef(false)
const EFFORT_OPTIONS: { value: EffortLevel; label: string }[] = [
{ value: 'low', label: t('settings.general.effort.low') },
@ -30,7 +136,13 @@ export function ModelSelector({ value, onChange }: Props = {}) {
]
const isControlled = value !== undefined
const selectedModel = isControlled ? availableModels.find((m) => m.id === value) || null : storeModel
const isRuntimeScoped = !isControlled && runtimeKey !== undefined
useEffect(() => {
if (!isRuntimeScoped || providersLoading || requestedProvidersRef.current) return
requestedProvidersRef.current = true
void fetchProviders()
}, [fetchProviders, isRuntimeScoped, providersLoading])
useEffect(() => {
if (!open) return
@ -48,107 +160,234 @@ export function ModelSelector({ value, onChange }: Props = {}) {
}
}, [open])
const getModelIcon = (id: string): string => {
const lower = id.toLowerCase()
if (lower.includes('opus')) return MODEL_ICONS.opus
if (lower.includes('sonnet')) return MODEL_ICONS.sonnet
if (lower.includes('haiku')) return MODEL_ICONS.haiku
return 'smart_toy'
const roleLabels = useMemo(
() => ({
main: t('settings.providers.mainModel'),
haiku: t('settings.providers.haikuModel'),
sonnet: t('settings.providers.sonnetModel'),
opus: t('settings.providers.opusModel'),
}),
[t],
)
const providerChoices = useMemo(
() => buildProviderChoices(
providers,
activeId,
activeId === null ? availableModels : OFFICIAL_MODELS,
t('settings.providers.officialName'),
roleLabels,
),
[activeId, availableModels, providers, roleLabels, t],
)
const selectedModel = isControlled
? availableModels.find((model) => model.id === value) || null
: storeModel
const activeRuntimeSelection = isRuntimeScoped
? runtimeSelection ?? resolveDefaultRuntimeSelection(
activeId,
activeProviderName,
providers,
storeModel?.id,
)
: null
const selectedProviderChoice = activeRuntimeSelection
? providerChoices.find((choice) => choice.providerId === activeRuntimeSelection.providerId) ?? null
: null
const selectedRuntimeModel = activeRuntimeSelection
? selectedProviderChoice?.models.find((model) => model.id === activeRuntimeSelection.modelId)
?? {
id: activeRuntimeSelection.modelId,
name: activeRuntimeSelection.modelId,
description: '',
context: '',
}
: null
const buttonModelLabel = isRuntimeScoped
? selectedRuntimeModel?.name ?? storeModel?.name ?? t('model.selectModel')
: selectedModel?.name ?? t('model.selectModel')
const buttonProviderLabel = isRuntimeScoped
? selectedProviderChoice?.providerName ?? activeProviderName ?? t('settings.providers.officialName')
: null
const handleRuntimeSelect = (selection: RuntimeSelection) => {
if (!runtimeKey) return
useSessionRuntimeStore.getState().setSelection(runtimeKey, selection)
if (runtimeKey !== DRAFT_RUNTIME_SELECTION_KEY) {
useChatStore.getState().setSessionRuntime(runtimeKey, selection)
}
setOpen(false)
}
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-hover)] rounded-full text-xs font-medium text-[var(--color-text-secondary)] transition-colors"
onClick={() => !disabled && setOpen(!open)}
disabled={disabled}
className="flex max-w-[280px] items-center gap-2 rounded-full bg-[var(--color-surface-container-low)] px-3 py-1.5 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="material-symbols-outlined text-[14px] text-[var(--color-brand)]">auto_awesome</span>
<span>{selectedModel?.name ?? t('model.selectModel')}</span>
<span className="material-symbols-outlined text-[12px]">expand_more</span>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--color-text-primary)]">
{buttonModelLabel}
</span>
{buttonProviderLabel && (
<span className="max-w-[108px] flex-shrink-0 truncate text-[11px] text-[var(--color-text-tertiary)]">
{buttonProviderLabel}
</span>
)}
</div>
<span className="material-symbols-outlined flex-shrink-0 text-[12px]">expand_more</span>
</button>
{open && (
<div className="absolute right-0 bottom-full mb-2 w-[340px] rounded-xl bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] shadow-[var(--shadow-dropdown)] z-50">
{/* Models */}
<div className="p-3">
<div className="text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)] mb-2 px-1">
<div className="absolute right-0 bottom-full z-50 mb-2 w-[360px] rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]">
<div className="max-h-[420px] overflow-y-auto p-3">
<div className="mb-2 px-1 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
{t('model.configuration')}
</div>
<div className="space-y-1">
{availableModels.map((model) => {
const isSelected = model.id === selectedModel?.id
return (
<button
key={model.id}
onClick={() => {
if (isControlled) {
onChange?.(model.id)
} else {
setModel(model.id)
}
setOpen(false)
}}
className={`
w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors
${isSelected
? 'bg-[var(--color-primary-fixed)] border border-[var(--color-brand)]/20'
: 'hover:bg-[var(--color-surface-hover)]'
}
`}
>
{/* Radio button */}
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
isSelected
? 'border-[var(--color-brand)]'
: 'border-[var(--color-outline)]'
}`}>
{isSelected && (
<div className="w-2 h-2 rounded-full bg-[var(--color-brand)]" />
{isRuntimeScoped ? (
<div className="space-y-3">
{providerChoices.map((choice) => (
<div key={choice.providerId ?? 'official'} className="space-y-1.5">
<div className="flex items-center justify-between px-2 pt-1">
<span className="truncate text-[11px] font-semibold tracking-[0.01em] text-[var(--color-text-secondary)]">
{choice.providerName}
</span>
{choice.isDefault && (
<span className="flex-shrink-0 text-[10px] font-medium text-[var(--color-text-tertiary)]">
{t('settings.providers.default')}
</span>
)}
</div>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">
{getModelIcon(model.id)}
</span>
<div className="space-y-1">
{choice.models.map((model) => {
const isSelected =
activeRuntimeSelection?.providerId === choice.providerId &&
activeRuntimeSelection.modelId === model.id
return (
<button
key={`${choice.providerId ?? 'official'}:${model.id}`}
onClick={() => handleRuntimeSelect({ providerId: choice.providerId, modelId: model.id })}
className={`
w-full rounded-lg border px-3 py-2.5 text-left transition-colors
${isSelected
? 'border-[var(--color-brand)]/20 bg-[var(--color-primary-fixed)]'
: 'border-transparent hover:bg-[var(--color-surface-hover)]'
}
`}
>
<div className="flex items-start gap-3">
<div className={`mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2 ${
isSelected ? 'border-[var(--color-brand)]' : 'border-[var(--color-outline)]'
}`}>
{isSelected && (
<div className="h-2 w-2 rounded-full bg-[var(--color-brand)]" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{model.name}</div>
{model.description && (
<div className="text-[10px] text-[var(--color-text-tertiary)] mt-0.5 truncate">{model.description}</div>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
{model.name}
</div>
{model.description && (
<div className="mt-0.5 truncate pr-[6px] text-[10px] text-[var(--color-text-tertiary)]">
{model.description}
</div>
)}
</div>
</div>
</button>
)
})}
</div>
</button>
)
})}
</div>
</div>
))}
</div>
) : (
<div className="space-y-1">
{availableModels.map((model) => {
const isSelected = model.id === selectedModel?.id
return (
<button
key={model.id}
onClick={() => {
if (isControlled) {
onChange?.(model.id)
} else {
void setModel(model.id)
}
setOpen(false)
}}
className={`
w-full rounded-lg px-3 py-2.5 text-left transition-colors
${isSelected
? 'bg-[var(--color-primary-fixed)] border border-[var(--color-brand)]/20'
: 'hover:bg-[var(--color-surface-hover)]'
}
`}
>
<div className="flex items-center gap-3">
<div className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2 ${
isSelected ? 'border-[var(--color-brand)]' : 'border-[var(--color-outline)]'
}`}>
{isSelected && (
<div className="h-2 w-2 rounded-full bg-[var(--color-brand)]" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{model.name}</div>
{model.description && (
<div className="mt-0.5 truncate text-[10px] text-[var(--color-text-tertiary)]">
{model.description}
</div>
)}
</div>
</div>
</button>
)
})}
</div>
)}
</div>
{/* Effort — hidden in controlled mode (not relevant for task creation) */}
{!isControlled && <div className="border-t border-[var(--color-border)] p-3">
<div className="text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)] mb-2 px-1">
{t('model.effort')}
{!isControlled && !isRuntimeScoped && (
<div className="border-t border-[var(--color-border)] p-3">
<div className="mb-2 px-1 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
{t('model.effort')}
</div>
<div className="grid grid-cols-4 gap-1.5">
{EFFORT_OPTIONS.map((opt) => {
const isSelected = opt.value === effortLevel
return (
<button
key={opt.value}
onClick={() => {
void setEffort(opt.value)
setOpen(false)
}}
className={`
rounded-lg py-2 text-center text-xs font-semibold transition-colors
${isSelected
? 'bg-[var(--color-brand)] text-white'
: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}
`}
>
{opt.label}
</button>
)
})}
</div>
</div>
<div className="grid grid-cols-4 gap-1.5">
{EFFORT_OPTIONS.map((opt) => {
const isSelected = opt.value === effortLevel
return (
<button
key={opt.value}
onClick={() => { setEffort(opt.value); setOpen(false) }}
className={`
py-2 rounded-lg text-xs font-semibold transition-colors text-center
${isSelected
? 'bg-[var(--color-brand)] text-white'
: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}
`}
>
{opt.label}
</button>
)
})}
</div>
</div>}
)}
</div>
)}
</div>

View File

@ -29,8 +29,9 @@ export function AppShell() {
// Restore tabs from localStorage
await useTabStore.getState().restoreTabs()
const activeId = useTabStore.getState().activeTabId
if (activeId) {
const { activeTabId: activeId, tabs } = useTabStore.getState()
const activeTab = tabs.find((tab) => tab.sessionId === activeId)
if (activeId && activeTab?.type === 'session') {
useChatStore.getState().connectToSession(activeId)
}
if (!cancelled) {

View File

@ -23,7 +23,7 @@ let cachedProjects: RecentProject[] | null = null
let cacheTimestamp = 0
const CACHE_TTL = 30_000
export function ProjectFilter() {
export function ProjectFilter({ variant = 'default' }: { variant?: 'default' | 'embedded' }) {
const t = useTranslation()
const { availableProjects, selectedProjects, setSelectedProjects } = useSessionStore()
const [open, setOpen] = useState(false)
@ -134,6 +134,7 @@ export function ProjectFilter() {
: selectedProjects.length === 1
? optionByPath.get(selectedProjects[0]!)?.title || fallbackProjectTitle(selectedProjects[0]!, t('sidebar.other'))
: `${selectedProjects.length} projects`
const triggerLabel = isAllSelected ? t('sidebar.allProjects') : label
const toggleProject = (projectPath: string) => {
if (isAllSelected) {
@ -160,19 +161,45 @@ export function ProjectFilter() {
type="button"
onClick={() => setOpen((current) => !current)}
aria-expanded={open}
className="flex items-center gap-1.5 rounded-[var(--radius-lg)] bg-[var(--color-surface-container-low)] px-3 py-2 text-sm font-medium text-[var(--color-text-primary)] shadow-[0_1px_0_rgba(255,255,255,0.6)] transition-colors hover:bg-[var(--color-surface-hover)]"
aria-label={triggerLabel}
title={triggerLabel}
className={
variant === 'embedded'
? `inline-flex h-7 w-7 items-center justify-center rounded-[8px] border transition-colors duration-200 ${
isAllSelected
? 'border-transparent text-[var(--color-text-tertiary)] hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-secondary)]'
: 'border-[var(--color-sidebar-item-active-border)] bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)] hover:bg-[var(--color-sidebar-item-hover)]'
}`
: 'inline-flex h-8 max-w-full items-center gap-1.5 rounded-[10px] border border-[var(--color-sidebar-filter-border)] bg-[var(--color-sidebar-filter-bg)] px-2 text-left text-[14px] text-[var(--color-text-primary)] transition-colors duration-200 hover:bg-[var(--color-sidebar-item-hover)]'
}
>
<span className="truncate max-w-[180px]">{label}</span>
<ChevronIcon open={open} />
{variant === 'embedded' ? (
<span className="relative flex items-center justify-center">
<FolderIcon className="h-[14px] w-[14px]" />
{!isAllSelected && (
<span className="absolute -right-1 -top-1 h-1.5 w-1.5 rounded-full bg-[var(--color-brand)]" />
)}
</span>
) : (
<>
<FolderIcon className="h-[14px] w-[14px] text-[var(--color-text-secondary)]" />
<span className="min-w-0">
<span className="block truncate text-[14px] font-semibold tracking-tight">{label}</span>
</span>
<span className="flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center text-[var(--color-text-tertiary)] transition-colors">
<ChevronIcon open={open} />
</span>
</>
)}
</button>
{open && dropdownPos && createPortal(
<div
ref={dropdownRef}
className="w-[380px] max-w-[calc(100vw-32px)] overflow-hidden rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]"
className="w-[360px] max-w-[calc(100vw-32px)] overflow-hidden rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]"
style={{
position: 'fixed',
left: Math.min(dropdownPos.left, window.innerWidth - Math.min(380, window.innerWidth - 32) - 16),
left: Math.min(dropdownPos.left, window.innerWidth - Math.min(360, window.innerWidth - 32) - 16),
...(dropdownPos.direction === 'down'
? { top: dropdownPos.top }
: { bottom: window.innerHeight - dropdownPos.top }),
@ -184,9 +211,13 @@ export function ProjectFilter() {
<button
type="button"
onClick={selectAll}
className="flex w-full items-center gap-3 rounded-[16px] px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
className={`flex w-full items-center gap-3 rounded-[12px] px-3 py-2.5 text-left transition-colors ${
isAllSelected
? 'bg-[var(--color-sidebar-item-active)]'
: 'hover:bg-[var(--color-sidebar-item-hover)]'
}`}
>
<FolderIcon />
<FolderIcon className="text-[var(--color-text-secondary)]" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--color-text-primary)]">{t('sidebar.allProjects')}</div>
</div>
@ -207,11 +238,13 @@ export function ProjectFilter() {
key={option.projectPath}
type="button"
onClick={() => toggleProject(option.projectPath)}
className={`flex w-full items-center gap-3 rounded-[16px] px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)] ${
checked ? 'bg-[var(--color-surface-selected)]' : ''
className={`flex w-full items-center gap-3 rounded-[12px] px-3 py-2.5 text-left transition-colors ${
checked
? 'bg-[var(--color-sidebar-item-active)]'
: 'hover:bg-[var(--color-sidebar-item-hover)]'
}`}
>
{option.isGit ? <GitBranchIcon /> : <FolderIcon />}
{option.isGit ? <GitBranchIcon className="text-[var(--color-text-secondary)]" /> : <FolderIcon />}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--color-text-primary)]">{option.title}</div>
{option.subtitle && (
@ -277,7 +310,7 @@ function ChevronIcon({ open }: { open: boolean }) {
)
}
function FolderIcon() {
function FolderIcon({ className = 'text-[var(--color-text-secondary)]' }: { className?: string }) {
return (
<svg
width="20"
@ -288,14 +321,14 @@ function FolderIcon() {
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0 text-[var(--color-text-secondary)]"
className={`flex-shrink-0 ${className}`}
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
)
}
function GitBranchIcon() {
function GitBranchIcon({ className = 'text-[var(--color-text-secondary)]' }: { className?: string }) {
return (
<svg
width="20"
@ -306,7 +339,7 @@ function GitBranchIcon() {
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0 text-[var(--color-text-secondary)]"
className={`flex-shrink-0 ${className}`}
>
<circle cx="18" cy="18" r="3" />
<circle cx="6" cy="6" r="3" />

View File

@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import '@testing-library/jest-dom'
vi.mock('./ProjectFilter', () => ({
@ -17,6 +17,7 @@ vi.mock('../../i18n', () => ({
'sidebar.noMatching': 'No matching sessions',
'sidebar.sessionListFailed': 'Session list failed',
'common.retry': 'Retry',
'common.cancel': 'Cancel',
'common.delete': 'Delete',
'common.rename': 'Rename',
'sidebar.timeGroup.today': 'Today',
@ -25,6 +26,7 @@ vi.mock('../../i18n', () => ({
'sidebar.timeGroup.last30days': 'Last 30 Days',
'sidebar.timeGroup.older': 'Older',
'sidebar.missingDir': 'Missing',
'sidebar.confirmDelete': 'Delete this session? This cannot be undone.',
'sidebar.collapse': 'Collapse sidebar',
'sidebar.expand': 'Expand sidebar',
}
@ -121,7 +123,7 @@ describe('Sidebar', () => {
expect(useTabStore.getState().tabs).toEqual([])
})
it('removes the matching tab when deleting a session from the sidebar', async () => {
it('requires confirmation before deleting a session from the sidebar', async () => {
deleteSession.mockResolvedValue(undefined)
useSessionStore.setState({
sessions: [
@ -146,8 +148,15 @@ describe('Sidebar', () => {
fireEvent.contextMenu(screen.getByRole('button', { name: /Open Session/ }))
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(deleteSession).not.toHaveBeenCalled()
const dialog = screen.getByRole('dialog')
expect(dialog).toBeInTheDocument()
expect(screen.getByText('Delete this session? This cannot be undone.')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' }))
})
await waitFor(() => {

View File

@ -3,6 +3,7 @@ import { useSessionStore } from '../../stores/sessionStore'
import { useUIStore } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
import { ProjectFilter } from './ProjectFilter'
import { ConfirmDialog } from '../shared/ConfirmDialog'
import type { SessionListItem } from '../../types/session'
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore'
import { useChatStore } from '../../stores/chatStore'
@ -29,6 +30,7 @@ export function Sidebar() {
const disconnectSession = useChatStore((s) => s.disconnectSession)
const [searchQuery, setSearchQuery] = useState('')
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
const [pendingDeleteSessionId, setPendingDeleteSessionId] = useState<string | null>(null)
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
@ -67,12 +69,18 @@ export function Sidebar() {
setContextMenu({ id, x: e.clientX, y: e.clientY })
}, [])
const handleDelete = useCallback(async (id: string) => {
const handleDelete = useCallback((id: string) => {
setContextMenu(null)
await deleteSession(id)
disconnectSession(id)
closeTab(id)
}, [closeTab, deleteSession, disconnectSession])
setPendingDeleteSessionId(id)
}, [])
const confirmDelete = useCallback(async () => {
if (!pendingDeleteSessionId) return
await deleteSession(pendingDeleteSessionId)
disconnectSession(pendingDeleteSessionId)
closeTab(pendingDeleteSessionId)
setPendingDeleteSessionId(null)
}, [closeTab, deleteSession, disconnectSession, pendingDeleteSessionId])
const handleStartRename = useCallback((id: string, currentTitle: string) => {
setContextMenu(null)
@ -125,7 +133,7 @@ export function Sidebar() {
<div className={`px-3 pb-2 ${isTauri && !isWindows ? 'pt-[44px]' : 'pt-3'}`}>
<div className={`flex ${sidebarOpen ? 'items-center justify-between gap-3' : 'flex-col items-center gap-2'}`}>
<div className={`flex min-w-0 items-center ${sidebarOpen ? 'gap-2.5' : 'justify-center'}`}>
<img src="/app-icon.png" alt="" className="h-8 w-8 rounded-lg flex-shrink-0" />
<img src="/app-icon.png" alt="" className="h-8 w-8 flex-shrink-0" />
<span
className={`sidebar-copy ${sidebarOpen ? 'sidebar-copy--visible' : 'sidebar-copy--hidden'} text-[13px] font-semibold tracking-tight text-[var(--color-text-primary)]`}
style={{ fontFamily: 'var(--font-headline)' }}
@ -200,30 +208,31 @@ export function Sidebar() {
<>
<div
data-testid="sidebar-project-filter-section"
className="sidebar-section sidebar-section--visible relative z-20 flex-none px-3 pb-1"
className="sidebar-section sidebar-section--visible relative z-20 flex-none px-3 pb-2"
style={{ overflow: 'visible' }}
>
<div className="flex items-center justify-between">
<ProjectFilter />
<div className="flex h-9 items-center rounded-[14px] border border-[var(--color-sidebar-search-border)] bg-[var(--color-sidebar-search-bg)] pl-1.5 pr-3 transition-colors focus-within:border-[var(--color-border-focus)]">
<ProjectFilter variant="embedded" />
<span className="mx-2 h-4 w-px bg-[var(--color-border)]/80" aria-hidden="true" />
<span className="pointer-events-none flex shrink-0 items-center text-[var(--color-text-tertiary)]">
<SearchIcon />
</span>
<input
id="sidebar-search"
type="text"
placeholder={t('sidebar.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="min-w-0 flex-1 bg-transparent pl-2 pr-0 text-[13px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none"
/>
</div>
</div>
<div className="sidebar-section sidebar-section--visible flex-none px-3 pb-2">
<input
id="sidebar-search"
type="text"
placeholder={t('sidebar.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full h-8 px-2.5 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none transition-colors focus:border-[var(--color-border-focus)]"
/>
</div>
<div
data-testid="sidebar-session-list-section"
className="sidebar-section sidebar-section--visible flex flex-1 min-h-0 flex-col"
>
<div className="min-h-0 flex-1 overflow-y-auto px-3">
<div className="sidebar-scroll-area min-h-0 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)]">{t('sidebar.sessionListFailed')}</div>
@ -246,7 +255,7 @@ export function Sidebar() {
if (!items || items.length === 0) return null
return (
<div key={group} className="mb-1">
<div className="px-2 pb-1 pt-3 text-[11px] font-semibold tracking-wide text-[var(--color-text-tertiary)]">
<div className="px-2 pb-1 pt-4 text-[11px] font-semibold tracking-wide text-[var(--color-text-tertiary)]">
{timeGroupLabels[group]}
</div>
{items.map((session) => (
@ -274,22 +283,22 @@ export function Sidebar() {
}}
onContextMenu={(e) => handleContextMenu(e, session.id)}
className={`
group w-full rounded-[var(--radius-md)] py-1.5 pl-4 pr-3 text-left text-sm transition-colors duration-200
group w-full rounded-[12px] px-3 py-2 text-left text-sm transition-colors duration-200
${session.id === activeTabId
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
? 'bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)]'
}
`}
>
<span className="flex items-center gap-2">
<span className="flex items-center gap-2.5">
<span
className="h-1 w-1 flex-shrink-0 rounded-full"
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: session.id === activeTabId ? 'var(--color-brand)' : 'var(--color-text-tertiary)',
opacity: session.id === activeTabId ? 1 : 0.5,
}}
/>
<span className="flex-1 truncate">{session.title || 'Untitled'}</span>
<span className="flex-1 truncate font-medium tracking-[-0.01em]">{session.title || 'Untitled'}</span>
{!session.workDirExists && (
<span
className="flex-shrink-0 text-[10px] text-[var(--color-warning)]"
@ -350,6 +359,17 @@ export function Sidebar() {
</button>
</div>
)}
<ConfirmDialog
open={pendingDeleteSessionId !== null}
onClose={() => setPendingDeleteSessionId(null)}
onConfirm={confirmDelete}
title={t('common.delete')}
body={pendingDeleteSessionId ? t('sidebar.confirmDelete') : ''}
confirmLabel={t('common.delete')}
cancelLabel={t('common.cancel')}
confirmVariant="danger"
/>
</aside>
)
}
@ -399,11 +419,11 @@ function NavItem({
aria-label={label}
title={collapsed ? label : undefined}
className={`
flex items-center rounded-[var(--radius-md)] transition-all duration-200
${collapsed ? 'h-10 w-10 justify-center px-0 py-0' : 'w-full gap-2.5 px-3 py-2 text-sm'}
flex items-center transition-colors duration-200
${collapsed ? 'h-10 w-10 justify-center rounded-[var(--radius-md)] px-0 py-0' : 'w-full gap-2.5 rounded-[12px] px-3 py-2.5 text-sm'}
${active
? 'bg-[var(--color-surface-selected)] font-medium text-[var(--color-text-primary)] shadow-[0_8px_24px_rgba(15,23,42,0.08)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
? 'bg-[var(--color-sidebar-item-active)] font-medium text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)]'
}
`}
>
@ -455,6 +475,15 @@ function ClockIcon() {
)
}
function SearchIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
)
}
function SidebarToggleIcon({ collapsed }: { collapsed: boolean }) {
return (
<svg

View File

@ -1,15 +1,20 @@
import { useSettingsStore } from '../../stores/settingsStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore'
import { useTabStore } from '../../stores/tabStore'
export function StatusBar() {
const { currentModel } = useSettingsStore()
const activeTabId = useTabStore((s) => s.activeTabId)
const runtimeSelection = useSessionRuntimeStore((s) =>
activeTabId ? s.selections[activeTabId] : undefined,
)
const projectPath = useSessionStore((s) => s.sessions.find((session) => session.id === activeTabId)?.projectPath)
const projectName = projectPath
? projectPath.split('-').filter(Boolean).pop() || ''
: ''
const modelLabel = runtimeSelection?.modelId ?? currentModel?.name ?? null
return (
<div className="h-[var(--statusbar-height)] flex items-center justify-between px-4 border-t border-[var(--color-border)] bg-[var(--color-surface-sidebar)] select-none text-[11px]">
@ -20,9 +25,9 @@ export function StatusBar() {
</div>
<div className="flex items-center gap-4">
{currentModel && (
{modelLabel && (
<span className="text-[var(--color-text-tertiary)] font-[var(--font-mono)]">
{currentModel.name}
{modelLabel}
</span>
)}
</div>

View File

@ -4,6 +4,7 @@ import { useSessionStore } from '../../stores/sessionStore'
import { useTranslation } from '../../i18n'
import { useUIStore } from '../../stores/uiStore'
import { Button } from '../shared/Button'
import { ConfirmDialog } from '../shared/ConfirmDialog'
import type { PluginCapabilityKey } from '../../types/plugin'
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
import { useSkillStore } from '../../stores/skillStore'
@ -36,10 +37,20 @@ export function PluginDetail() {
const selectServer = useMcpStore((s) => s.selectServer)
const t = useTranslation()
const [actionKey, setActionKey] = useState<string | null>(null)
const [showUninstallDialog, setShowUninstallDialog] = useState(false)
const activeSession = sessions.find((session) => session.id === activeSessionId)
const currentWorkDir = activeSession?.workDir || undefined
const otherCapabilityItems = useMemo(
() =>
CAPABILITY_ORDER.map((key) => ({
key,
items: selectedPlugin?.capabilities[key] ?? [],
})),
[selectedPlugin],
)
if (isDetailLoading) {
return (
<div className="flex justify-center py-12">
@ -52,14 +63,6 @@ export function PluginDetail() {
const canMutate = selectedPlugin.scope !== 'managed' && selectedPlugin.scope !== 'builtin'
const canNavigateSharedCapabilities = selectedPlugin.enabled
const otherCapabilityItems = useMemo(
() =>
CAPABILITY_ORDER.map((key) => ({
key,
items: selectedPlugin.capabilities[key],
})),
[selectedPlugin],
)
const runAction = async (key: string, fn: () => Promise<string>) => {
setActionKey(key)
@ -98,11 +101,6 @@ export function PluginDetail() {
}
}
const confirmUninstall = () => {
const label = t('settings.plugins.confirmUninstall', { name: selectedPlugin.name })
return window.confirm(label)
}
const openSettingsTab = (tab: 'skills' | 'agents' | 'mcp') => {
useUIStore.getState().setPendingSettingsTab(tab)
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
@ -290,8 +288,7 @@ export function PluginDetail() {
size="sm"
loading={isApplying && actionKey === 'uninstall'}
onClick={() => {
if (!confirmUninstall()) return
void runAction('uninstall', () => uninstallPlugin(selectedPlugin.id, selectedPlugin.scope, false, currentWorkDir))
setShowUninstallDialog(true)
}}
>
{t('settings.plugins.uninstall')}
@ -483,6 +480,24 @@ export function PluginDetail() {
</div>
</div>
</section>
<ConfirmDialog
open={showUninstallDialog}
onClose={() => {
if (isApplying && actionKey === 'uninstall') return
setShowUninstallDialog(false)
}}
onConfirm={async () => {
setShowUninstallDialog(false)
await runAction('uninstall', () => uninstallPlugin(selectedPlugin.id, selectedPlugin.scope, false, currentWorkDir))
}}
title={t('settings.plugins.uninstall')}
body={t('settings.plugins.confirmUninstall', { name: selectedPlugin.name })}
confirmLabel={t('settings.plugins.uninstall')}
cancelLabel={t('common.cancel')}
confirmVariant="danger"
loading={isApplying && actionKey === 'uninstall'}
/>
</div>
)
}

View File

@ -102,7 +102,7 @@ export function PluginList() {
return (
<div className="flex flex-col gap-6 min-w-0">
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
<div className="grid gap-4 px-5 py-5 min-w-0 xl:grid-cols-[minmax(0,1.5fr)_minmax(340px,1fr)] xl:items-end">
<div className="grid gap-4 px-5 py-5 min-w-0 2xl:grid-cols-[minmax(0,1.45fr)_minmax(420px,0.95fr)] 2xl:items-end">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
{t('settings.plugins.browserEyebrow')}
@ -129,8 +129,8 @@ export function PluginList() {
)}
</div>
<div className="flex flex-col gap-3 min-w-0">
<div className="grid grid-cols-2 gap-3 min-w-0 sm:grid-cols-4">
<div className="flex flex-col gap-3 min-w-0 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<div className="grid min-w-0 grid-cols-[repeat(auto-fit,minmax(120px,1fr))] gap-3">
<SummaryCard
label={t('settings.plugins.summary.total')}
value={String(summary?.total ?? plugins.length)}
@ -152,12 +152,22 @@ export function PluginList() {
icon="storefront"
/>
</div>
<div className="flex flex-wrap gap-2 justify-end">
<Button variant="secondary" size="sm" onClick={() => void fetchPlugins(currentWorkDir)}>
<div className="flex flex-wrap gap-2 sm:justify-end">
<Button
variant="secondary"
size="sm"
className="min-h-9 flex-1 sm:flex-none"
onClick={() => void fetchPlugins(currentWorkDir)}
>
<span className="material-symbols-outlined text-[16px]">refresh</span>
{t('settings.plugins.refresh')}
</Button>
<Button size="sm" onClick={handleReload} loading={isApplying}>
<Button
size="sm"
className="min-h-9 flex-1 sm:flex-none"
onClick={handleReload}
loading={isApplying}
>
<span className="material-symbols-outlined text-[16px]">sync</span>
{t('settings.plugins.apply')}
</Button>
@ -316,12 +326,14 @@ function SummaryCard({
icon: string
}) {
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 min-w-0">
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] min-w-0">
<div className="min-w-0 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-3">
<div className="flex min-w-0 items-start gap-1.5 text-[11px] uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-[14px] flex-shrink-0">{icon}</span>
<span className="truncate">{label}</span>
<span className="min-w-0 break-words text-[10px] leading-4 whitespace-normal">
{label}
</span>
</div>
<div className="mt-2 text-lg font-semibold text-[var(--color-text-primary)] truncate">
<div className="mt-2 truncate text-xl font-semibold text-[var(--color-text-primary)]">
{value}
</div>
</div>

View File

@ -1,433 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { sessionsApi } from '../../api/sessions'
import { useChatStore } from '../../stores/chatStore'
import { useMcpStore } from '../../stores/mcpStore'
import { usePluginStore } from '../../stores/pluginStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useSkillStore } from '../../stores/skillStore'
import { useTranslation } from '../../i18n'
import { MessageList } from '../chat/MessageList'
import { ComputerUsePermissionModal } from '../chat/ComputerUsePermissionModal'
import { Button } from '../shared/Button'
import { Textarea } from '../shared/Textarea'
import { DirectoryPicker } from '../shared/DirectoryPicker'
import { useUIStore } from '../../stores/uiStore'
import { buildInstallerPrompt } from '../../lib/installAssistantPrompt'
const INSTALLER_SESSION_KEY = 'cc-haha-installer-session-id'
const INSTALLER_CONTEXT_KEY = 'cc-haha-installer-context-dir'
const EXAMPLE_PROMPTS = [
'安装 pluginskill-creator@claude-plugins-official并应用到当前桌面端',
'添加一个 MCPname=linearurl=https://example.com/mcp优先用户级',
'帮我把一个 GitHub 仓库里的 skill 装到 ~/.claude/skills并告诉我装到了哪里',
]
function readStoredValue(key: string) {
try {
return localStorage.getItem(key) || ''
} catch {
return ''
}
}
function writeStoredValue(key: string, value: string) {
try {
if (value) {
localStorage.setItem(key, value)
} else {
localStorage.removeItem(key)
}
} catch {
// noop
}
}
export function InstallCenter() {
const t = useTranslation()
const sessions = useSessionStore((s) => s.sessions)
const fetchSessions = useSessionStore((s) => s.fetchSessions)
const connectToSession = useChatStore((s) => s.connectToSession)
const disconnectSession = useChatStore((s) => s.disconnectSession)
const sendMessage = useChatStore((s) => s.sendMessage)
const stopGeneration = useChatStore((s) => s.stopGeneration)
const fetchPlugins = usePluginStore((s) => s.fetchPlugins)
const reloadPlugins = usePluginStore((s) => s.reloadPlugins)
const fetchSkills = useSkillStore((s) => s.fetchSkills)
const fetchServers = useMcpStore((s) => s.fetchServers)
const addToast = useUIStore((s) => s.addToast)
const setPendingSettingsTab = useUIStore((s) => s.setPendingSettingsTab)
const [sessionId, setSessionId] = useState<string | null>(() => {
const stored = readStoredValue(INSTALLER_SESSION_KEY)
return stored || null
})
const [contextDir, setContextDir] = useState(() => readStoredValue(INSTALLER_CONTEXT_KEY))
const [draft, setDraft] = useState('')
const [isCreating, setIsCreating] = useState(false)
const createPromiseRef = useRef<Promise<string> | null>(null)
const previousChatStateRef = useRef<'idle' | 'thinking' | 'tool_executing' | 'streaming' | 'permission_pending'>('idle')
const installerSession = useMemo(
() => sessions.find((session) => session.id === sessionId) || null,
[sessionId, sessions],
)
const sessionState = useChatStore((s) =>
sessionId ? s.sessions[sessionId] : undefined,
)
const chatState = sessionState?.chatState ?? 'idle'
const pendingComputerUsePermission =
sessionState?.pendingComputerUsePermission?.request ?? null
const isBusy = isCreating || chatState !== 'idle'
useEffect(() => {
if (!sessionId) return
connectToSession(sessionId)
return () => {
disconnectSession(sessionId)
}
}, [connectToSession, disconnectSession, sessionId])
useEffect(() => {
writeStoredValue(INSTALLER_CONTEXT_KEY, contextDir.trim())
}, [contextDir])
useEffect(() => {
if (!sessionId) return
const previousState = previousChatStateRef.current
previousChatStateRef.current = chatState
if (previousState === 'idle' || chatState !== 'idle') return
const cwd = installerSession?.workDir || undefined
void (async () => {
await reloadPlugins(cwd).catch(() => null)
await Promise.allSettled([
fetchPlugins(cwd),
fetchSkills(cwd),
fetchServers(cwd ? [cwd] : undefined, cwd),
])
})()
}, [
chatState,
fetchPlugins,
fetchServers,
fetchSkills,
installerSession?.workDir,
reloadPlugins,
sessionId,
])
const ensureInstallerSession = async () => {
if (sessionId && installerSession) {
return sessionId
}
if (createPromiseRef.current) {
return createPromiseRef.current
}
setIsCreating(true)
createPromiseRef.current = (async () => {
const { sessionId: createdSessionId } = await sessionsApi.create(
contextDir.trim() || undefined,
)
await sessionsApi.rename(
createdSessionId,
t('settings.install.sessionTitle'),
)
writeStoredValue(INSTALLER_SESSION_KEY, createdSessionId)
setSessionId(createdSessionId)
await fetchSessions()
connectToSession(createdSessionId)
return createdSessionId
})()
try {
return await createPromiseRef.current
} finally {
createPromiseRef.current = null
setIsCreating(false)
}
}
const handleSubmit = async () => {
const request = draft.trim()
if (!request || isBusy) return
try {
const ensuredSessionId = await ensureInstallerSession()
sendMessage(
ensuredSessionId,
buildInstallerPrompt(request),
undefined,
{ displayContent: request },
)
setDraft('')
} catch (error) {
addToast({
type: 'error',
message:
error instanceof Error
? error.message
: t('settings.install.createFailed'),
})
}
}
const handleRefresh = async () => {
const cwd = installerSession?.workDir || undefined
await Promise.all([
fetchPlugins(cwd),
fetchSkills(cwd),
fetchServers(cwd ? [cwd] : undefined, cwd),
])
addToast({
type: 'success',
message: t('settings.install.refreshDone'),
})
}
const startFreshConversation = async () => {
if (sessionId) {
disconnectSession(sessionId)
}
writeStoredValue(INSTALLER_SESSION_KEY, '')
setSessionId(null)
previousChatStateRef.current = 'idle'
addToast({
type: 'info',
message: t('settings.install.newConversationReady'),
})
}
return (
<div className="w-full min-w-0">
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
<div className="grid gap-4 px-5 py-5 xl:grid-cols-[minmax(0,1.5fr)_minmax(280px,1fr)] xl:items-start">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
{t('settings.install.eyebrow')}
</div>
<div className="flex items-center gap-3 mb-2">
<span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">
download
</span>
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
{t('settings.install.title')}
</h2>
</div>
<p className="text-sm leading-6 text-[var(--color-text-secondary)] max-w-3xl">
{t('settings.install.description')}
</p>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 xl:grid-cols-2">
<SummaryPill
label={t('settings.install.targets.plugins')}
icon="extension"
/>
<SummaryPill
label={t('settings.install.targets.mcp')}
icon="hub"
/>
<SummaryPill
label={t('settings.install.targets.skills')}
icon="auto_awesome"
/>
<SummaryPill
label={installerSession?.workDir || t('settings.install.contextAuto')}
icon="folder"
/>
</div>
</div>
</section>
<section className="mt-6 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('settings.install.composeTitle')}
</h3>
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
{t('settings.install.composeHint')}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="secondary"
onClick={() => void handleRefresh()}
>
{t('settings.install.refresh')}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => void startFreshConversation()}
>
{t('settings.install.newConversation')}
</Button>
</div>
</div>
<div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="min-w-0">
<Textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault()
void handleSubmit()
}
}}
placeholder={t('settings.install.placeholder')}
className="min-h-[140px]"
/>
<div className="mt-3 flex flex-wrap items-center gap-2">
{EXAMPLE_PROMPTS.map((example) => (
<button
key={example}
type="button"
onClick={() => setDraft(example)}
className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
{example}
</button>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
{chatState !== 'idle' && sessionId ? (
<Button
size="sm"
variant="danger"
onClick={() => stopGeneration(sessionId)}
>
{t('settings.install.stop')}
</Button>
) : null}
<Button
size="sm"
onClick={() => void handleSubmit()}
loading={isCreating}
disabled={!draft.trim() || isBusy}
icon={
!isCreating ? (
<span className="material-symbols-outlined text-[16px]">send</span>
) : undefined
}
>
{t('settings.install.send')}
</Button>
<span className="text-xs text-[var(--color-text-tertiary)]">
{installerSession?.workDir
? t('settings.install.contextUsing', {
path: installerSession.workDir,
})
: t('settings.install.contextDefault')}
</span>
</div>
</div>
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-4">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
{t('settings.install.contextTitle')}
</div>
<p className="mt-2 text-xs leading-5 text-[var(--color-text-tertiary)]">
{t('settings.install.contextHint')}
</p>
<div className="mt-3">
<DirectoryPicker value={contextDir} onChange={setContextDir} />
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => setPendingSettingsTab('plugins')}
>
{t('settings.install.goPlugins')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setPendingSettingsTab('mcp')}
>
{t('settings.install.goMcp')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setPendingSettingsTab('skills')}
>
{t('settings.install.goSkills')}
</Button>
</div>
</div>
</div>
</section>
<section className="mt-6 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
<div className="flex items-center justify-between gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-5 py-4">
<div>
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('settings.install.sessionTitle')}
</h3>
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
{sessionId
? t('settings.install.sessionHint')
: t('settings.install.sessionEmpty')}
</p>
</div>
{sessionId ? (
<span className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
{chatState}
</span>
) : null}
</div>
<div className="min-h-[420px] bg-[var(--color-surface-container-lowest)]">
{sessionId ? (
<>
<MessageList sessionId={sessionId} />
<ComputerUsePermissionModal
sessionId={sessionId}
request={pendingComputerUsePermission}
/>
</>
) : (
<div className="flex h-[420px] flex-col items-center justify-center px-6 text-center">
<span className="material-symbols-outlined text-[36px] text-[var(--color-text-tertiary)] mb-3">
forum
</span>
<p className="text-sm text-[var(--color-text-secondary)]">
{t('settings.install.sessionEmpty')}
</p>
<p className="mt-2 max-w-md text-xs leading-6 text-[var(--color-text-tertiary)]">
{t('settings.install.sessionEmptyHint')}
</p>
</div>
)}
</div>
</section>
</div>
)
}
function SummaryPill({
label,
icon,
}: {
label: string
icon: string
}) {
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 min-w-0">
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] min-w-0">
<span className="material-symbols-outlined text-[14px] flex-shrink-0">{icon}</span>
<span className="truncate">{label}</span>
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More