Merge branch 'feat/v0.1.5-post-release-test'
@ -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">
|
||||
|
||||
@ -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=="],
|
||||
|
||||
@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 351 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 26 KiB |
17
desktop/public/app-icon.svg
Normal 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 |
@ -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}`)
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
54
desktop/sidecars/launcherRouting.test.ts
Normal 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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
64
desktop/sidecars/launcherRouting.ts
Normal 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 }
|
||||
}
|
||||
102
desktop/src-tauri/Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
42
desktop/src-tauri/app-icon-macos.svg
Normal 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 |
@ -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 |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 783 B After Width: | Height: | Size: 809 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 8.2 KiB |
@ -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()
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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: {} },
|
||||
},
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 } : {})
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@ -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)
|
||||
},
|
||||
|
||||
@ -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')
|
||||
},
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
58
desktop/src/api/terminal.ts
Normal 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))
|
||||
},
|
||||
}
|
||||
@ -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)]'
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 = [
|
||||
'安装 plugin:skill-creator@claude-plugins-official,并应用到当前桌面端',
|
||||
'添加一个 MCP:name=linear,url=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>
|
||||
)
|
||||
}
|
||||