mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -2,6 +2,18 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import { WsBridge } from '../ws-bridge.js'
|
||||
import { WebSocketServer, type WebSocket as WsServerSocket } from 'ws'
|
||||
|
||||
async function waitFor(
|
||||
predicate: () => boolean,
|
||||
timeoutMs = 500,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return true
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
}
|
||||
return predicate()
|
||||
}
|
||||
|
||||
describe('WsBridge', () => {
|
||||
let bridge: WsBridge
|
||||
|
||||
@@ -58,8 +70,16 @@ describe('WsBridge', () => {
|
||||
expect(bridge.hasSession('a')).toBe(false)
|
||||
expect(bridge.hasSession('b')).toBe(false)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
for (const ws of sockets) {
|
||||
const settled = await waitFor(() => (
|
||||
ws.readyState === ws.CLOSED
|
||||
&& ws.listenerCount('open') === 0
|
||||
&& ws.listenerCount('error') === 0
|
||||
&& ws.listenerCount('close') === 0
|
||||
))
|
||||
expect(settled).toBe(true)
|
||||
expect(ws.readyState).toBe(ws.CLOSED)
|
||||
expect(ws.listenerCount('open')).toBe(0)
|
||||
expect(ws.listenerCount('error')).toBe(0)
|
||||
expect(ws.listenerCount('close')).toBe(0)
|
||||
}
|
||||
|
||||
@@ -242,9 +242,31 @@ export class WsBridge {
|
||||
ws.removeAllListeners()
|
||||
if (ws.readyState === WebSocket.CLOSED) return
|
||||
|
||||
// `ws.close()` aborts an in-flight handshake by emitting an asynchronous
|
||||
// error before close. Keep a temporary sink after detaching the session
|
||||
// listeners so teardown cannot surface an unhandled EventEmitter error.
|
||||
if (ws.readyState === WebSocket.CONNECTING) {
|
||||
// Bun's `ws` compatibility layer can remain stuck in CLOSING when a
|
||||
// handshake is aborted. Let the handshake settle, consuming its natural
|
||||
// error, and close normally if the connection opens first.
|
||||
const cleanup = () => {
|
||||
ws.removeListener('open', onOpen)
|
||||
ws.removeListener('error', onError)
|
||||
ws.removeListener('close', onClose)
|
||||
}
|
||||
const onOpen = () => {
|
||||
ws.removeListener('open', onOpen)
|
||||
ws.close(code, reason)
|
||||
}
|
||||
const onError = () => cleanup()
|
||||
const onClose = () => cleanup()
|
||||
|
||||
ws.once('open', onOpen)
|
||||
ws.once('error', onError)
|
||||
ws.once('close', onClose)
|
||||
return
|
||||
}
|
||||
|
||||
// Keep a temporary error sink after detaching the session listeners so a
|
||||
// close-time transport error cannot surface as an unhandled EventEmitter
|
||||
// error.
|
||||
const swallowTeardownError = () => {}
|
||||
ws.on('error', swallowTeardownError)
|
||||
ws.once('close', () => {
|
||||
|
||||
@@ -44,7 +44,15 @@ describe('Electron IPC capabilities', () => {
|
||||
slug: 'moon-cat',
|
||||
displayName: 'Moon Cat',
|
||||
description: 'A quiet companion.',
|
||||
dialogTitle: '选择透明背景的宠物图片',
|
||||
dialogFilterName: '宠物图片',
|
||||
})).toBe(true)
|
||||
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.petsCreateFromImage, {
|
||||
slug: 'moon-cat',
|
||||
displayName: 'Moon Cat',
|
||||
description: 'A quiet companion.',
|
||||
dialogTitle: 'Bad\nTitle',
|
||||
})).toBe(false)
|
||||
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.petsCreateFromAtlas, {
|
||||
slug: 'moon-cat',
|
||||
displayName: 'Moon Cat',
|
||||
|
||||
@@ -18,8 +18,20 @@ const sessionIdPayload: Validator = value =>
|
||||
&& value.length <= 200
|
||||
&& /^[A-Za-z0-9._:-]+$/.test(value)
|
||||
|
||||
const isSafeUiLabel = (value: unknown) =>
|
||||
typeof value === 'string'
|
||||
&& value.trim().length > 0
|
||||
&& value.length <= 120
|
||||
&& !/[\u0000-\u001f\u007f-\u009f]/.test(value)
|
||||
|
||||
const petCreateFromAtlas: Validator = value => {
|
||||
if (!isRecord(value) || !hasOnlyKeys(value, ['slug', 'displayName', 'description'])) return false
|
||||
if (!isRecord(value) || !hasOnlyKeys(value, [
|
||||
'slug',
|
||||
'displayName',
|
||||
'description',
|
||||
'dialogTitle',
|
||||
'dialogFilterName',
|
||||
])) return false
|
||||
if (
|
||||
typeof value.slug !== 'string'
|
||||
|| value.slug.length === 0
|
||||
@@ -32,6 +44,8 @@ const petCreateFromAtlas: Validator = value => {
|
||||
&& typeof value.description === 'string'
|
||||
&& value.description.trim().length > 0
|
||||
&& value.description.length <= 500
|
||||
&& (value.dialogTitle === undefined || isSafeUiLabel(value.dialogTitle))
|
||||
&& (value.dialogFilterName === undefined || isSafeUiLabel(value.dialogFilterName))
|
||||
}
|
||||
|
||||
const petContextMenu: Validator = value => {
|
||||
|
||||
+47
-16
@@ -55,6 +55,7 @@ import {
|
||||
createCustomPetFromAtlas,
|
||||
createCustomPetFromImage,
|
||||
ensureCustomPetsRoot,
|
||||
getPetPackageErrorCode,
|
||||
loadCustomPets,
|
||||
} from './services/pets'
|
||||
import {
|
||||
@@ -370,30 +371,60 @@ function registerIpcHandlers() {
|
||||
registerHandler(ELECTRON_IPC_CHANNELS.traceOpenWindow, (_event, payload) => openTraceWindow(String(payload)))
|
||||
registerHandler(ELECTRON_IPC_CHANNELS.petsList, () => listCustomPets())
|
||||
registerHandler(ELECTRON_IPC_CHANNELS.petsCreateFromImage, async (event, payload) => {
|
||||
const input = payload as {
|
||||
slug: string
|
||||
displayName: string
|
||||
description: string
|
||||
dialogTitle?: string
|
||||
dialogFilterName?: string
|
||||
}
|
||||
const imagePath = await openDialog(currentWindow(event), {
|
||||
title: 'Choose a transparent pet image',
|
||||
filters: [{ name: 'Pet image', extensions: ['png', 'webp'] }],
|
||||
title: input.dialogTitle || 'Choose a transparent pet image',
|
||||
filters: [{ name: input.dialogFilterName || 'Pet image', extensions: ['png', 'webp'] }],
|
||||
})
|
||||
if (typeof imagePath !== 'string') return null
|
||||
const input = payload as { slug: string, displayName: string, description: string }
|
||||
const pet = await loadCustomPetCatalog.invalidateAfter(() =>
|
||||
createCustomPetFromImage({ ...input, imagePath }, {
|
||||
inspectImageSize: ({ data }) => nativeImage.createFromBuffer(data).getSize(),
|
||||
}))
|
||||
return { id: pet.id }
|
||||
try {
|
||||
const pet = await loadCustomPetCatalog.invalidateAfter(() =>
|
||||
createCustomPetFromImage({
|
||||
slug: input.slug,
|
||||
displayName: input.displayName,
|
||||
description: input.description,
|
||||
imagePath,
|
||||
}, {
|
||||
inspectImageSize: ({ data }) => nativeImage.createFromBuffer(data).getSize(),
|
||||
}))
|
||||
return { id: pet.id }
|
||||
} catch (error) {
|
||||
return { errorCode: getPetPackageErrorCode(error) }
|
||||
}
|
||||
})
|
||||
registerHandler(ELECTRON_IPC_CHANNELS.petsCreateFromAtlas, async (event, payload) => {
|
||||
const input = payload as {
|
||||
slug: string
|
||||
displayName: string
|
||||
description: string
|
||||
dialogTitle?: string
|
||||
dialogFilterName?: string
|
||||
}
|
||||
const atlasPath = await openDialog(currentWindow(event), {
|
||||
title: 'Choose a v2 pet animation atlas',
|
||||
filters: [{ name: 'Pet animation atlas', extensions: ['png', 'webp'] }],
|
||||
title: input.dialogTitle || 'Choose a v2 pet animation atlas',
|
||||
filters: [{ name: input.dialogFilterName || 'Pet animation atlas', extensions: ['png', 'webp'] }],
|
||||
})
|
||||
if (typeof atlasPath !== 'string') return null
|
||||
const input = payload as { slug: string, displayName: string, description: string }
|
||||
const pet = await loadCustomPetCatalog.invalidateAfter(() =>
|
||||
createCustomPetFromAtlas({ ...input, atlasPath }, {
|
||||
inspectImageSize: ({ data }) => nativeImage.createFromBuffer(data).getSize(),
|
||||
}))
|
||||
return { id: pet.id }
|
||||
try {
|
||||
const pet = await loadCustomPetCatalog.invalidateAfter(() =>
|
||||
createCustomPetFromAtlas({
|
||||
slug: input.slug,
|
||||
displayName: input.displayName,
|
||||
description: input.description,
|
||||
atlasPath,
|
||||
}, {
|
||||
inspectImageSize: ({ data }) => nativeImage.createFromBuffer(data).getSize(),
|
||||
}))
|
||||
return { id: pet.id }
|
||||
} catch (error) {
|
||||
return { errorCode: getPetPackageErrorCode(error) }
|
||||
}
|
||||
})
|
||||
registerHandler(ELECTRON_IPC_CHANNELS.petsOpenFolder, async () => {
|
||||
const root = await ensureCustomPetsRoot()
|
||||
|
||||
@@ -18,6 +18,7 @@ const desktopRoot = existsSync(path.resolve(process.cwd(), 'electron', 'main.ts'
|
||||
? process.cwd()
|
||||
: path.resolve(process.cwd(), 'desktop')
|
||||
const mainSource = readFileSync(path.join(desktopRoot, 'electron', 'main.ts'), 'utf8')
|
||||
.replace(/\r\n/g, '\n')
|
||||
|
||||
function createFakeWindow(initialBounds = {
|
||||
x: 100,
|
||||
@@ -430,14 +431,14 @@ describe('Electron pet window service', () => {
|
||||
expect(secondWindow.showInactive).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses native shapes off macOS and rejects IPC from another window', async () => {
|
||||
it('keeps the shaped Windows pet topmost and rejects IPC from another window', async () => {
|
||||
const petWindow = createFakeWindow()
|
||||
const otherWindow = createFakeWindow()
|
||||
const controller = new PetWindowController({
|
||||
createWindow: vi.fn(() => petWindow) as never,
|
||||
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
platform: 'linux',
|
||||
platform: 'win32',
|
||||
preloadPath: '/app/electron-dist/preload.cjs',
|
||||
})
|
||||
|
||||
@@ -448,8 +449,10 @@ describe('Electron pet window service', () => {
|
||||
controller.setIgnoreMouseEvents(petWindow as never, true)
|
||||
|
||||
expect(petWindow.setShape).toHaveBeenLastCalledWith([
|
||||
{ x: 100, y: 220, width: 144, height: 170 },
|
||||
{ x: 88, y: 208, width: 168, height: 192 },
|
||||
])
|
||||
expect(petWindow.setAlwaysOnTop).toHaveBeenCalledWith(true)
|
||||
expect(petWindow.setAlwaysOnTop).toHaveBeenLastCalledWith(true)
|
||||
expect(petWindow.setIgnoreMouseEvents).toHaveBeenCalledTimes(1)
|
||||
expect(() => controller.setInteractiveRegions(otherWindow as never, [
|
||||
{ x: 0, y: 0, width: 10, height: 10 },
|
||||
|
||||
@@ -25,6 +25,7 @@ export const PET_WINDOW_STATE_FILE = 'pet-window.json'
|
||||
|
||||
const MAX_ABSOLUTE_SCREEN_COORDINATE = 1_000_000
|
||||
const PET_WINDOW_DRAG_INTERVAL_MS = 16
|
||||
const PET_WINDOW_SHAPE_PADDING = 12
|
||||
const failedPetWindowStateWritePaths = new Set<string>()
|
||||
|
||||
type PetWindow = BrowserWindow
|
||||
@@ -278,6 +279,11 @@ export class PetWindowController {
|
||||
window.setIgnoreMouseEvents(true, { forward: true })
|
||||
}
|
||||
window.showInactive()
|
||||
if ((this.options.platform ?? process.platform) === 'darwin') {
|
||||
window.setAlwaysOnTop(true, 'floating')
|
||||
} else {
|
||||
window.setAlwaysOnTop(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,11 +317,15 @@ export class PetWindowController {
|
||||
if ((this.options.platform ?? process.platform) === 'darwin') return
|
||||
|
||||
const shape = regions.flatMap((region) => {
|
||||
const x = Math.max(0, Math.min(PET_WINDOW_WIDTH - 1, Math.round(region.x)))
|
||||
const y = Math.max(0, Math.min(PET_WINDOW_HEIGHT - 1, Math.round(region.y)))
|
||||
const width = Math.min(PET_WINDOW_WIDTH - x, Math.max(1, Math.round(region.width)))
|
||||
const height = Math.min(PET_WINDOW_HEIGHT - y, Math.max(1, Math.round(region.height)))
|
||||
return width > 0 && height > 0 ? [{ x, y, width, height }] : []
|
||||
const requestedLeft = Math.round(region.x) - PET_WINDOW_SHAPE_PADDING
|
||||
const requestedTop = Math.round(region.y) - PET_WINDOW_SHAPE_PADDING
|
||||
const requestedRight = Math.round(region.x + region.width) + PET_WINDOW_SHAPE_PADDING
|
||||
const requestedBottom = Math.round(region.y + region.height) + PET_WINDOW_SHAPE_PADDING
|
||||
const x = Math.max(0, Math.min(PET_WINDOW_WIDTH - 1, requestedLeft))
|
||||
const y = Math.max(0, Math.min(PET_WINDOW_HEIGHT - 1, requestedTop))
|
||||
const right = Math.max(x + 1, Math.min(PET_WINDOW_WIDTH, requestedRight))
|
||||
const bottom = Math.max(y + 1, Math.min(PET_WINDOW_HEIGHT, requestedBottom))
|
||||
return [{ x, y, width: right - x, height: bottom - y }]
|
||||
})
|
||||
if (shape.length > 0) window.setShape(shape)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
createCustomPetCatalogLoader,
|
||||
createCustomPetFromImage,
|
||||
ensureCustomPetsRoot,
|
||||
getPetPackageErrorCode,
|
||||
loadCustomPets,
|
||||
resolveCustomPetsRoot,
|
||||
type CustomPetLoadResult,
|
||||
@@ -448,12 +449,16 @@ describe('createCustomPetFromImage', () => {
|
||||
webpHeader(CUSTOM_PET_SINGLE_IMAGE_MAX_DIMENSION + 1, 64),
|
||||
)
|
||||
|
||||
await expect(createCustomPetFromImage({
|
||||
const geometryError = await createCustomPetFromImage({
|
||||
slug: 'huge-image',
|
||||
displayName: 'Huge Image',
|
||||
description: 'Too large.',
|
||||
imagePath: hugeImagePath,
|
||||
}, { root })).rejects.toThrow('between 32 and 4096 pixels')
|
||||
}, { root }).then(() => null, error => error)
|
||||
expect(geometryError).toBeInstanceOf(Error)
|
||||
expect((geometryError as Error).message).toContain('between 32 and 4096 pixels')
|
||||
expect(getPetPackageErrorCode(geometryError)).toBe('invalid-image-dimensions')
|
||||
expect(getPetPackageErrorCode(new Error('unexpected'))).toBe('io-error')
|
||||
expect(fs.existsSync(path.join(root, 'huge-image'))).toBe(false)
|
||||
|
||||
await expect(createCustomPetFromImage({
|
||||
@@ -646,7 +651,9 @@ describe('loadCustomPets', () => {
|
||||
['unsupported image format', { spritesheetPath: 'spritesheet.gif' }, 'unsupported-image-format'],
|
||||
])('rejects %s', async (_label, overrides, code) => {
|
||||
const root = makeTempDir()
|
||||
writePet(root, 'bad-pet', validManifest(overrides))
|
||||
const petDir = path.join(root, 'bad-pet')
|
||||
fs.mkdirSync(petDir)
|
||||
fs.writeFileSync(path.join(petDir, 'pet.json'), JSON.stringify(validManifest(overrides)))
|
||||
|
||||
const result = await loadCustomPets({ root, inspectImageSize: validSizeInspector })
|
||||
|
||||
|
||||
@@ -211,6 +211,10 @@ class PetPackageError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function getPetPackageErrorCode(error: unknown): CustomPetLoadErrorCode {
|
||||
return error instanceof PetPackageError ? error.code : 'io-error'
|
||||
}
|
||||
|
||||
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && 'code' in error
|
||||
}
|
||||
|
||||
@@ -109,17 +109,21 @@ describe('Electron sidecar manager', () => {
|
||||
})
|
||||
|
||||
it('can keep sidecar binaries and H5 assets unpacked while pointing app-root at app.asar', () => {
|
||||
const resourcesRoot = path.resolve(path.sep, 'Applications', 'App.app', 'Contents', 'Resources')
|
||||
const desktopRoot = path.join(resourcesRoot, 'app.asar.unpacked')
|
||||
const appRoot = path.join(resourcesRoot, 'app.asar')
|
||||
const h5DistDir = path.join(desktopRoot, 'dist')
|
||||
const plan = createServerPlan({
|
||||
desktopRoot: '/Applications/App.app/Contents/Resources/app.asar.unpacked',
|
||||
appRoot: '/Applications/App.app/Contents/Resources/app.asar',
|
||||
h5DistDir: '/Applications/App.app/Contents/Resources/app.asar.unpacked/dist',
|
||||
desktopRoot,
|
||||
appRoot,
|
||||
h5DistDir,
|
||||
port: 49321,
|
||||
env: {},
|
||||
})
|
||||
|
||||
expect(plan.command).toContain('/Applications/App.app/Contents/Resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-')
|
||||
expect(plan.args).toContain('/Applications/App.app/Contents/Resources/app.asar')
|
||||
expect(plan.env.CLAUDE_H5_DIST_DIR).toBe('/Applications/App.app/Contents/Resources/app.asar.unpacked/dist')
|
||||
expect(plan.command).toContain(path.join(desktopRoot, 'src-tauri', 'binaries', 'claude-sidecar-'))
|
||||
expect(plan.args).toContain(appRoot)
|
||||
expect(plan.env.CLAUDE_H5_DIST_DIR).toBe(h5DistDir)
|
||||
})
|
||||
|
||||
it('passes the packaged ripgrep path to the server and its CLI children', () => {
|
||||
@@ -137,7 +141,9 @@ describe('Electron sidecar manager', () => {
|
||||
})
|
||||
|
||||
expect(plan.env[RIPGREP_PATH_ENV]).toBe(bundledRipgrep)
|
||||
expect(plan.env.PATH?.split(path.delimiter)).toContain(
|
||||
const pathValue = Object.entries(plan.env)
|
||||
.find(([key]) => key.toLowerCase() === 'path')?.[1]
|
||||
expect(pathValue?.split(path.delimiter)).toContain(
|
||||
path.dirname(bundledRipgrep),
|
||||
)
|
||||
|
||||
@@ -555,4 +561,25 @@ describe('Electron sidecar manager', () => {
|
||||
await close(server)
|
||||
}
|
||||
})
|
||||
|
||||
it('isolates health probes from the shared HTTP connection pool', async () => {
|
||||
const connections = new Set<number>()
|
||||
const connectionHeaders: string[] = []
|
||||
const server = http.createServer((request, response) => {
|
||||
connections.add(request.socket.remotePort!)
|
||||
connectionHeaders.push(request.headers.connection ?? '')
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ status: 'ok' }))
|
||||
})
|
||||
const port = await listen(server)
|
||||
|
||||
try {
|
||||
await waitForServer('127.0.0.1', port, 1_000)
|
||||
await waitForServer('127.0.0.1', port, 1_000)
|
||||
expect(connections.size).toBe(2)
|
||||
expect(connectionHeaders).toEqual(['close', 'close'])
|
||||
} finally {
|
||||
await close(server)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import type { Readable } from 'node:stream'
|
||||
import http from 'node:http'
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
@@ -277,27 +278,46 @@ export async function waitForServer(host: string, port: number, timeoutMs = SERV
|
||||
}
|
||||
|
||||
async function assertServerHealth(healthUrl: string, timeoutMs: number): Promise<void> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const response = await fetch(healthUrl, {
|
||||
cache: 'no-store',
|
||||
signal: controller.signal,
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = http.get(healthUrl, {
|
||||
agent: false,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Connection: 'close',
|
||||
},
|
||||
}, response => {
|
||||
const chunks: Buffer[] = []
|
||||
response.on('data', chunk => chunks.push(Buffer.from(chunk)))
|
||||
response.on('error', reject)
|
||||
response.on('end', () => {
|
||||
if (response.statusCode === undefined || response.statusCode < 200 || response.statusCode >= 300) {
|
||||
reject(new Error(`healthcheck returned ${response.statusCode ?? 'no status'}`))
|
||||
return
|
||||
}
|
||||
|
||||
const contentType = response.headers['content-type'] ?? ''
|
||||
if (!contentType.toLowerCase().includes('application/json')) {
|
||||
reject(new Error(`healthcheck returned non-JSON response from ${healthUrl}`))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown
|
||||
if (!body || typeof body !== 'object' || !('status' in body) || body.status !== 'ok') {
|
||||
reject(new Error(`healthcheck returned invalid response from ${healthUrl}`))
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
} catch {
|
||||
reject(new Error(`healthcheck returned invalid response from ${healthUrl}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
if (!response.ok) throw new Error(`healthcheck returned ${response.status}`)
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (!contentType.toLowerCase().includes('application/json')) {
|
||||
throw new Error(`healthcheck returned non-JSON response from ${healthUrl}`)
|
||||
}
|
||||
|
||||
const body = await response.json().catch(() => null)
|
||||
if (!body || typeof body !== 'object' || !('status' in body) || body.status !== 'ok') {
|
||||
throw new Error(`healthcheck returned invalid response from ${healthUrl}`)
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
request.setTimeout(timeoutMs, () => {
|
||||
request.destroy(new Error(`healthcheck timed out after ${timeoutMs}ms`))
|
||||
})
|
||||
request.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
|
||||
@@ -79,11 +79,15 @@ export class SystemProxyBridge implements SystemProxyBridgeLike {
|
||||
this.startPromise = null
|
||||
const server = this.server
|
||||
this.server = null
|
||||
for (const socket of this.clientSockets) socket.destroy()
|
||||
for (const socket of this.outboundSockets) socket.destroy()
|
||||
// Bun needs the HTTP connection registry drained before close() will
|
||||
// finish after an upgraded CONNECT socket. Calling this before listen
|
||||
// completes breaks the separate startup/stop race, so guard on listening.
|
||||
if (server?.listening) server.closeAllConnections()
|
||||
const closing = server?.listening
|
||||
? new Promise<void>(resolve => server.close(() => resolve()))
|
||||
: Promise.resolve()
|
||||
for (const socket of this.clientSockets) socket.destroy()
|
||||
for (const socket of this.outboundSockets) socket.destroy()
|
||||
await closing
|
||||
await startPromise?.catch(() => {})
|
||||
}
|
||||
|
||||
@@ -74,8 +74,10 @@ describe('Electron terminal service', () => {
|
||||
it('uses the custom terminal config path before the standard ~/.claude path', () => {
|
||||
const app = { getPath: vi.fn(() => '/Users/test') }
|
||||
|
||||
expect(terminalConfigPath(app, { CLAUDE_CONFIG_DIR: '/portable' })).toBe('/portable/terminal-config.json')
|
||||
expect(terminalConfigPath(app, {})).toBe('/Users/test/.claude/terminal-config.json')
|
||||
expect(terminalConfigPath(app, { CLAUDE_CONFIG_DIR: '/portable' }))
|
||||
.toBe(path.join('/portable', 'terminal-config.json'))
|
||||
expect(terminalConfigPath(app, {}))
|
||||
.toBe(path.join('/Users/test', '.claude', 'terminal-config.json'))
|
||||
})
|
||||
|
||||
it('reads an old userData terminal config but writes future changes to ~/.claude', () => {
|
||||
@@ -181,8 +183,10 @@ describe('Electron terminal service', () => {
|
||||
|
||||
expect(prepareNodePtyRuntime(source, cache)).toBe(cache)
|
||||
expect(fs.existsSync(path.join(cache, 'index.js'))).toBe(true)
|
||||
expect(fs.statSync(cache).mode & 0o077).toBe(0)
|
||||
expect(fs.statSync(path.join(cache, 'prebuilds', 'darwin-arm64', 'spawn-helper')).mode & 0o777).toBe(0o500)
|
||||
if (process.platform !== 'win32') {
|
||||
expect(fs.statSync(cache).mode & 0o077).toBe(0)
|
||||
expect(fs.statSync(path.join(cache, 'prebuilds', 'darwin-arm64', 'spawn-helper')).mode & 0o777).toBe(0o500)
|
||||
}
|
||||
expect(fs.existsSync(path.join(cache, '.cc-haha-node-pty-manifest.json'))).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,28 +1,9 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { installTray, resolveTrayIconPath, shouldInstallTray } from './tray'
|
||||
|
||||
const trayMocksKey = '__electronTrayMocks'
|
||||
|
||||
vi.mock('electron', () => {
|
||||
const mocks = (globalThis as Record<string, unknown>)[trayMocksKey] as ReturnType<typeof createElectronTrayMocks> | undefined
|
||||
if (!mocks) {
|
||||
throw new Error('Electron tray mocks were not initialized for this test')
|
||||
}
|
||||
|
||||
return {
|
||||
Menu: {
|
||||
buildFromTemplate: mocks.buildFromTemplate,
|
||||
},
|
||||
Tray: mocks.Tray.mockImplementation(() => mocks.tray),
|
||||
nativeImage: {
|
||||
createFromPath: mocks.createFromPath,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
function createElectronTrayMocks() {
|
||||
const handlers = new Map<string, () => void>()
|
||||
return {
|
||||
@@ -42,10 +23,6 @@ function createElectronTrayMocks() {
|
||||
}
|
||||
|
||||
describe('Electron tray service', () => {
|
||||
afterEach(() => {
|
||||
delete (globalThis as Record<string, unknown>)[trayMocksKey]
|
||||
})
|
||||
|
||||
it('uses the existing desktop icon assets for the tray icon', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'electron-tray-'))
|
||||
try {
|
||||
@@ -78,7 +55,6 @@ describe('Electron tray service', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'electron-tray-install-'))
|
||||
try {
|
||||
const trayMocks = createElectronTrayMocks()
|
||||
;(globalThis as Record<string, unknown>)[trayMocksKey] = trayMocks
|
||||
const iconPath = path.join(root, 'src-tauri', 'icons', 'icon.png')
|
||||
mkdirSync(path.dirname(iconPath), { recursive: true })
|
||||
writeFileSync(iconPath, 'png')
|
||||
@@ -90,6 +66,15 @@ describe('Electron tray service', () => {
|
||||
desktopRoot: root,
|
||||
show,
|
||||
quit,
|
||||
electronRuntime: {
|
||||
Menu: {
|
||||
buildFromTemplate: trayMocks.buildFromTemplate,
|
||||
},
|
||||
Tray: trayMocks.Tray.mockImplementation(() => trayMocks.tray),
|
||||
nativeImage: {
|
||||
createFromPath: trayMocks.createFromPath,
|
||||
},
|
||||
} as never,
|
||||
})
|
||||
|
||||
expect(trayMocks.createFromPath).toHaveBeenCalledWith(iconPath)
|
||||
|
||||
@@ -2,6 +2,8 @@ import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { App, Tray } from 'electron'
|
||||
|
||||
type ElectronTrayRuntime = Pick<typeof import('electron'), 'Menu' | 'Tray' | 'nativeImage'>
|
||||
|
||||
export type TrayController = {
|
||||
tray: Tray
|
||||
dispose(): void
|
||||
@@ -29,13 +31,15 @@ export async function installTray({
|
||||
desktopRoot,
|
||||
show,
|
||||
quit,
|
||||
electronRuntime,
|
||||
}: {
|
||||
app: App
|
||||
desktopRoot: string
|
||||
show: () => void
|
||||
quit: () => void
|
||||
electronRuntime?: ElectronTrayRuntime
|
||||
}): Promise<TrayController> {
|
||||
const { Menu, Tray, nativeImage } = await import('electron')
|
||||
const { Menu, Tray, nativeImage } = electronRuntime ?? await import('electron')
|
||||
const icon = nativeImage.createFromPath(resolveTrayIconPath(desktopRoot))
|
||||
const tray = new Tray(icon)
|
||||
tray.setToolTip(app.name || 'Claude Code Haha')
|
||||
|
||||
@@ -9,7 +9,26 @@ import {
|
||||
parseProcessSnapshots,
|
||||
} from './electron-output-guard'
|
||||
|
||||
const outputDir = '/workspace/desktop/build-artifacts/electron'
|
||||
const outputDir = path.resolve('build-artifacts', 'electron')
|
||||
const bundledMainCommand = path.join(
|
||||
outputDir,
|
||||
'mac-arm64',
|
||||
'Claude Code Haha.app',
|
||||
'Contents',
|
||||
'MacOS',
|
||||
'Claude Code Haha',
|
||||
)
|
||||
const bundledSidecarCommand = path.join(
|
||||
outputDir,
|
||||
'mac-arm64',
|
||||
'Claude Code Haha.app',
|
||||
'Contents',
|
||||
'Resources',
|
||||
'app.asar.unpacked',
|
||||
'src-tauri',
|
||||
'binaries',
|
||||
'claude-sidecar server',
|
||||
)
|
||||
const scriptsDir = import.meta.dirname
|
||||
|
||||
describe('Electron output guard', () => {
|
||||
@@ -31,11 +50,11 @@ describe('Electron output guard', () => {
|
||||
const processes = findRunningElectronOutputProcesses(outputDir, [
|
||||
{
|
||||
pid: 20641,
|
||||
command: '/workspace/desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/MacOS/Claude Code Haha',
|
||||
command: bundledMainCommand,
|
||||
},
|
||||
{
|
||||
pid: 20656,
|
||||
command: '/workspace/desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar server',
|
||||
command: bundledSidecarCommand,
|
||||
},
|
||||
{
|
||||
pid: 99,
|
||||
@@ -46,11 +65,11 @@ describe('Electron output guard', () => {
|
||||
expect(processes).toEqual([
|
||||
{
|
||||
pid: 20641,
|
||||
command: '/workspace/desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/MacOS/Claude Code Haha',
|
||||
command: bundledMainCommand,
|
||||
},
|
||||
{
|
||||
pid: 20656,
|
||||
command: '/workspace/desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar server',
|
||||
command: bundledSidecarCommand,
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -58,7 +77,7 @@ describe('Electron output guard', () => {
|
||||
it('fails before cleanup with an actionable message', () => {
|
||||
expect(() => assertElectronOutputIdle(outputDir, [{
|
||||
pid: 20641,
|
||||
command: '/workspace/desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/MacOS/Claude Code Haha',
|
||||
command: bundledMainCommand,
|
||||
}])).toThrow(/Quit the packaged app before rebuilding.*PID 20641/s)
|
||||
})
|
||||
|
||||
|
||||
@@ -228,7 +228,8 @@ describe('Settings > Agents tab', () => {
|
||||
render(<Settings />)
|
||||
switchToAgentsTab()
|
||||
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument()
|
||||
expect(screen.getByText('Failed to load agents')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Network error')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Retry')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -773,7 +773,7 @@ describe('Content-only pages render without errors', () => {
|
||||
expect(vi.mocked(sessionsApi.getInspection)).toHaveBeenCalledWith(SESSION_ID, {
|
||||
includeContext: true,
|
||||
contextOnly: true,
|
||||
timeout: 20_000,
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
resetPageStores()
|
||||
|
||||
@@ -44,6 +44,7 @@ export type AgentMutationInput = {
|
||||
export type AgentListResponse = {
|
||||
activeAgents: AgentDefinition[]
|
||||
allAgents: AgentDefinition[]
|
||||
availableTools?: string[]
|
||||
}
|
||||
|
||||
export type AgentMutationResponse = {
|
||||
|
||||
@@ -145,10 +145,9 @@ describe('api diagnostics reporting', () => {
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
return Promise.resolve({
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
||||
} as Response)
|
||||
})
|
||||
|
||||
const request = expect(api.get('/api/slow')).rejects.toThrow('Request timed out after 120s')
|
||||
@@ -163,6 +162,36 @@ describe('api diagnostics reporting', () => {
|
||||
expect(body.details.message).toBe('Request timed out after 120s')
|
||||
})
|
||||
|
||||
it('keeps the timeout active until the response body is consumed', async () => {
|
||||
let requestSignal: AbortSignal | undefined
|
||||
let bodyReadStarted = false
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockImplementation((url: string | URL | Request, init?: RequestInit) => {
|
||||
if (String(url).endsWith('/api/slow-body')) {
|
||||
requestSignal = init?.signal ?? undefined
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => new Promise((_resolve, reject) => {
|
||||
bodyReadStarted = true
|
||||
requestSignal?.addEventListener('abort', () => {
|
||||
reject(new DOMException('The operation was aborted.', 'AbortError'))
|
||||
})
|
||||
}),
|
||||
} as Response)
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
||||
} as Response)
|
||||
})
|
||||
|
||||
await expect(api.get('/api/slow-body', { timeout: 20 })).rejects.toThrow('Request timed out after 0s')
|
||||
|
||||
expect(bodyReadStarted).toBe(true)
|
||||
expect(requestSignal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('can report raw client exceptions', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), {
|
||||
@@ -185,6 +214,36 @@ describe('api diagnostics reporting', () => {
|
||||
expect(body.type).toBe('client_window_error')
|
||||
})
|
||||
|
||||
it('drains diagnostics response bodies before releasing the request', async () => {
|
||||
let resolveBody!: () => void
|
||||
const bodyConsumed = new Promise<void>(resolve => {
|
||||
resolveBody = resolve
|
||||
})
|
||||
const arrayBuffer = vi.fn(async () => {
|
||||
await bodyConsumed
|
||||
return new ArrayBuffer(0)
|
||||
})
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockResolvedValueOnce({ arrayBuffer } as unknown as Response)
|
||||
|
||||
let settled = false
|
||||
const request = rawRecordDiagnosticEvent({
|
||||
type: 'client_window_error',
|
||||
severity: 'error',
|
||||
summary: 'drain me',
|
||||
}).then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(arrayBuffer).toHaveBeenCalledTimes(1)
|
||||
expect(settled).toBe(false)
|
||||
|
||||
resolveBody()
|
||||
await request
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('bounds raw diagnostics requests when the local server is unresponsive', async () => {
|
||||
vi.useFakeTimers()
|
||||
let signal: AbortSignal | undefined
|
||||
|
||||
@@ -101,7 +101,7 @@ async function request<T>(method: string, path: string, body?: unknown, options?
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T
|
||||
return res.json() as Promise<T>
|
||||
return await res.json() as T
|
||||
} catch (err) {
|
||||
if (timedOut) {
|
||||
const timeoutError = new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)
|
||||
@@ -158,6 +158,8 @@ export function rawRecordDiagnosticEvent(event: {
|
||||
headers: buildHeaders(),
|
||||
body: JSON.stringify(event),
|
||||
signal: controller.signal,
|
||||
}).then(async response => {
|
||||
await response.arrayBuffer()
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => clearTimeout(timeout))
|
||||
|
||||
@@ -14,6 +14,7 @@ const preferences = {
|
||||
enabled: false,
|
||||
selectedPetId: 'dada-code',
|
||||
size: 144,
|
||||
showTaskPanel: false,
|
||||
collapsed: false,
|
||||
motionEnabled: true,
|
||||
lastSessionId: null,
|
||||
|
||||
@@ -19,6 +19,7 @@ export type DesktopPetPreferences = {
|
||||
enabled: boolean
|
||||
selectedPetId: string
|
||||
size: number
|
||||
showTaskPanel: boolean
|
||||
collapsed: boolean
|
||||
motionEnabled: boolean
|
||||
lastSessionId: string | null
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('ContextUsageIndicator request behavior', () => {
|
||||
expect(sessionsApiMock.getInspection).toHaveBeenCalledWith('session-1', {
|
||||
includeContext: true,
|
||||
contextOnly: true,
|
||||
timeout: 20_000,
|
||||
timeout: 30_000,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@ type Props = {
|
||||
}
|
||||
|
||||
const ACTIVE_REFRESH_MS = 30_000
|
||||
const CONTEXT_REQUEST_TIMEOUT_MS = 20_000
|
||||
// The server bounds the CLI control request at 20s. Keep the HTTP deadline
|
||||
// comfortably later so the server can return a transcript estimate instead of
|
||||
// racing a client abort that can strand loopback sockets on Windows.
|
||||
const CONTEXT_REQUEST_TIMEOUT_MS = 30_000
|
||||
const AUTO_REFRESH_MIN_INTERVAL_MS = 10_000
|
||||
// Right after a compaction the CLI may still be busy finishing the turn, so
|
||||
// the forced refresh can time out — retry once instead of keeping the stale
|
||||
|
||||
@@ -99,6 +99,7 @@ vi.mock('../../i18n', () => ({
|
||||
'tabs.hideWorkspace': 'Hide Workspace',
|
||||
'tabs.showBrowser': 'Show Browser',
|
||||
'tabs.hideBrowser': 'Hide Browser',
|
||||
'settings.title': 'Localized Settings',
|
||||
'openProject.openProject': 'Open project',
|
||||
'openProject.openIn': 'Open in {target}',
|
||||
'openProject.openFailed': 'Could not open project',
|
||||
@@ -537,6 +538,23 @@ describe('TabBar', () => {
|
||||
expect(screen.queryByRole('button', { name: /activity/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the settings tab title from the current locale instead of its persisted title', async () => {
|
||||
const { TabBar } = await import('./TabBar')
|
||||
const { SETTINGS_TAB_ID, useTabStore } = await import('../../stores/tabStore')
|
||||
|
||||
useTabStore.setState({
|
||||
tabs: [{ sessionId: SETTINGS_TAB_ID, title: '设置', type: 'settings', status: 'idle' }],
|
||||
activeTabId: SETTINGS_TAB_ID,
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
render(<TabBar />)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Localized Settings')).toBeInTheDocument()
|
||||
expect(screen.queryByText('设置')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows current-session CLI tasks without a numeric activity badge', async () => {
|
||||
const { TabBar } = await import('./TabBar')
|
||||
const { useTabStore } = await import('../../stores/tabStore')
|
||||
|
||||
@@ -418,6 +418,7 @@ export function TabBar() {
|
||||
key={tab.sessionId}
|
||||
ref={(node) => { tabRefs.current.set(tab.sessionId, node) }}
|
||||
tab={tab}
|
||||
displayTitle={tab.type === 'settings' ? t('settings.title') : tab.title}
|
||||
isRunning={runningSessionIds.has(tab.sessionId)}
|
||||
isActive={tab.sessionId === activeTabId}
|
||||
isDragOver={dragOverIndex === index}
|
||||
@@ -568,6 +569,7 @@ export function TabBar() {
|
||||
|
||||
const TabItem = forwardRef<HTMLDivElement, {
|
||||
tab: Tab
|
||||
displayTitle: string
|
||||
isRunning: boolean
|
||||
isActive: boolean
|
||||
isDragOver: boolean
|
||||
@@ -578,7 +580,7 @@ const TabItem = forwardRef<HTMLDivElement, {
|
||||
onClose: () => void
|
||||
onContextMenu: (e: React.MouseEvent) => void
|
||||
onMouseDown: (event: React.MouseEvent) => void
|
||||
}>(({ tab, isRunning, isActive, isDragOver, isDragging, dragOffsetX, runningLabel, onClick, onClose, onContextMenu, onMouseDown }, ref) => {
|
||||
}>(({ tab, displayTitle, isRunning, isActive, isDragOver, isDragging, dragOffsetX, runningLabel, onClick, onClose, onContextMenu, onMouseDown }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -627,12 +629,12 @@ const TabItem = forwardRef<HTMLDivElement, {
|
||||
)}
|
||||
|
||||
<span className={`flex-1 truncate text-xs ${isActive ? 'text-[var(--color-text-primary)] font-medium' : 'text-[var(--color-text-secondary)]'}`}>
|
||||
{tab.title || 'Untitled'}
|
||||
{displayTitle || 'Untitled'}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Close ${tab.title || 'Untitled'}`}
|
||||
aria-label={`Close ${displayTitle || 'Untitled'}`}
|
||||
onMouseDown={(e) => { e.stopPropagation() }}
|
||||
onClick={(e) => { e.stopPropagation(); onClose() }}
|
||||
className="flex-shrink-0 -mr-1 inline-flex h-6 w-6 items-center justify-center rounded-md bg-transparent p-0 opacity-0 transition-[background-color,opacity,color] text-[var(--color-text-tertiary)] group-hover:opacity-100 hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-secondary)] focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
||||
|
||||
@@ -7,6 +7,7 @@ const apiCreateMock = vi.hoisted(() => vi.fn())
|
||||
const apiUpdateMock = vi.hoisted(() => vi.fn())
|
||||
const apiDeleteMock = vi.hoisted(() => vi.fn())
|
||||
const apiReloadMock = vi.hoisted(() => vi.fn())
|
||||
const recentProjectsMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../../api/agents', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../api/agents')>()
|
||||
@@ -22,6 +23,17 @@ vi.mock('../../api/agents', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../api/sessions', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../api/sessions')>()
|
||||
return {
|
||||
...actual,
|
||||
sessionsApi: {
|
||||
...actual.sessionsApi,
|
||||
getRecentProjects: recentProjectsMock,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../markdown/MarkdownRenderer', () => ({
|
||||
MarkdownRenderer: ({ content }: { content: string }) => <div>{content}</div>,
|
||||
}))
|
||||
@@ -75,9 +87,14 @@ async function renderManager(response: AgentListResponse = EMPTY_RESPONSE) {
|
||||
await waitFor(() => expect(apiListMock).toHaveBeenCalled())
|
||||
}
|
||||
|
||||
function chooseAgentSelect(label: string, option: string) {
|
||||
fireEvent.click(screen.getByRole('button', { name: label }))
|
||||
fireEvent.click(screen.getByRole('button', { name: option }))
|
||||
}
|
||||
|
||||
describe('AgentManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetAllMocks()
|
||||
apiReloadMock.mockResolvedValue({
|
||||
ok: true,
|
||||
session: {
|
||||
@@ -89,11 +106,13 @@ describe('AgentManager', () => {
|
||||
errors: 0,
|
||||
},
|
||||
})
|
||||
recentProjectsMock.mockResolvedValue({ projects: [] })
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
setProjectSession('/workspace/project')
|
||||
useAgentStore.setState({
|
||||
activeAgents: [],
|
||||
allAgents: [],
|
||||
availableTools: [],
|
||||
isLoading: false,
|
||||
isMutating: false,
|
||||
error: null,
|
||||
@@ -104,47 +123,146 @@ describe('AgentManager', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps project scope disabled when there is no active project', async () => {
|
||||
it('uses the shared project picker even when there is no active project', async () => {
|
||||
setProjectSession()
|
||||
recentProjectsMock.mockResolvedValue({
|
||||
projects: [{
|
||||
projectPath: '/workspace/selected',
|
||||
realPath: '/workspace/selected',
|
||||
projectName: 'Selected Project',
|
||||
repoName: 'Selected Project',
|
||||
isGit: true,
|
||||
sessionCount: 1,
|
||||
lastModified: '',
|
||||
}],
|
||||
})
|
||||
await renderManager()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create Agent' }))
|
||||
|
||||
expect(screen.getByRole('option', { name: 'Project' })).toBeDisabled()
|
||||
expect(screen.getByRole('option', { name: 'fable' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Open a project session to create a project-scoped agent.')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Project' }))
|
||||
expect(screen.getByRole('button', { name: 'Select a project...' })).toBeInTheDocument()
|
||||
expect(document.querySelector('select')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select a project...' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Selected Project/ }))
|
||||
expect(screen.getByText('Target project: /workspace/selected')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Model' }))
|
||||
const modelMenuOption = screen.getByRole('button', { name: 'fable' })
|
||||
expect(modelMenuOption).toBeInTheDocument()
|
||||
expect(modelMenuOption.parentElement).toHaveClass('bottom-full')
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.getByRole('heading', { name: 'Create Agent' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'fable' })).not.toBeInTheDocument()
|
||||
expect(screen.getByLabelText('System prompt').parentElement).toHaveTextContent('System prompt*')
|
||||
})
|
||||
|
||||
it('keeps the selected project context when creating and editing across projects', async () => {
|
||||
const created = makeAgent({
|
||||
source: 'projectSettings',
|
||||
baseDir: '/workspace/b/.claude/agents',
|
||||
target: '/workspace/b/.claude/agents/code_reviewer.md',
|
||||
})
|
||||
apiListMock
|
||||
.mockResolvedValueOnce({ ...EMPTY_RESPONSE, availableTools: ['Read', 'Grep', 'Bash'] })
|
||||
.mockResolvedValueOnce({ activeAgents: [created], allAgents: [created], availableTools: ['Read', 'Grep', 'Bash'] })
|
||||
.mockResolvedValueOnce({ activeAgents: [created], allAgents: [created] })
|
||||
apiCreateMock.mockResolvedValue({ agent: created })
|
||||
apiUpdateMock.mockResolvedValue({ agent: created })
|
||||
recentProjectsMock.mockResolvedValue({
|
||||
projects: [{
|
||||
projectPath: '/workspace/b',
|
||||
realPath: '/workspace/b',
|
||||
projectName: 'Project B',
|
||||
repoName: 'Project B',
|
||||
isGit: true,
|
||||
sessionCount: 1,
|
||||
lastModified: '',
|
||||
}],
|
||||
})
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
{
|
||||
id: 'session-a',
|
||||
title: 'Project A',
|
||||
createdAt: '',
|
||||
modifiedAt: '',
|
||||
messageCount: 0,
|
||||
projectPath: '/workspace/a',
|
||||
workDir: '/workspace/a',
|
||||
workDirExists: true,
|
||||
},
|
||||
{
|
||||
id: 'session-b',
|
||||
title: 'Project B',
|
||||
createdAt: '',
|
||||
modifiedAt: '',
|
||||
messageCount: 0,
|
||||
projectPath: '/workspace/b',
|
||||
workDir: '/workspace/b',
|
||||
workDirExists: true,
|
||||
},
|
||||
],
|
||||
activeSessionId: 'session-a',
|
||||
})
|
||||
|
||||
await renderManager()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create Agent' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Project' }))
|
||||
fireEvent.click(screen.getByTitle('/workspace/a'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Project B/ }))
|
||||
fireEvent.change(screen.getByLabelText(/^Name/), { target: { value: 'code_reviewer' } })
|
||||
fireEvent.change(screen.getByLabelText(/^Description/), { target: { value: 'Review code' } })
|
||||
fireEvent.change(screen.getByLabelText('System prompt'), { target: { value: 'Review carefully.' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(await screen.findByText('Agent Profile')).toBeInTheDocument()
|
||||
expect(apiListMock).toHaveBeenNthCalledWith(2, '/workspace/b')
|
||||
expect(apiReloadMock).toHaveBeenCalledWith('session-b')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.getByText('Target project: /workspace/b')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(apiUpdateMock).toHaveBeenCalledWith(
|
||||
'code_reviewer',
|
||||
expect.objectContaining({ cwd: '/workspace/b' }),
|
||||
))
|
||||
expect(apiListMock).toHaveBeenNthCalledWith(3, '/workspace/b')
|
||||
})
|
||||
|
||||
it('creates an underscore slug with custom model and effort, then selects the refreshed agent', async () => {
|
||||
const created = makeAgent({
|
||||
source: 'projectSettings',
|
||||
model: 'provider/custom-model',
|
||||
modelDisplay: 'provider/custom-model',
|
||||
tools: ['Agent(worker, researcher)', 'Read'],
|
||||
tools: ['Read', 'Agent(worker, researcher)'],
|
||||
color: 'purple',
|
||||
})
|
||||
apiListMock
|
||||
.mockResolvedValueOnce(EMPTY_RESPONSE)
|
||||
.mockResolvedValueOnce({ activeAgents: [created], allAgents: [created] })
|
||||
.mockResolvedValueOnce({ ...EMPTY_RESPONSE, availableTools: ['Read', 'Grep', 'Bash'] })
|
||||
.mockResolvedValueOnce({
|
||||
activeAgents: [created],
|
||||
allAgents: [created],
|
||||
availableTools: ['Read', 'Grep', 'Bash'],
|
||||
})
|
||||
apiCreateMock.mockResolvedValue({ agent: created })
|
||||
|
||||
render(<AgentManager />)
|
||||
await waitFor(() => expect(apiListMock).toHaveBeenCalledTimes(1))
|
||||
await waitFor(() => expect(useAgentStore.getState().availableTools).toContain('Read'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create Agent' }))
|
||||
fireEvent.change(screen.getByLabelText('Scope'), { target: { value: 'project' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Project' }))
|
||||
expect(screen.getByText('Target project: /workspace/project')).toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText(/^Name/), { target: { value: 'code_reviewer' } })
|
||||
fireEvent.change(screen.getByLabelText(/^Description/), { target: { value: 'Review code' } })
|
||||
fireEvent.change(screen.getByLabelText('System prompt'), { target: { value: 'Review carefully.' } })
|
||||
fireEvent.change(screen.getByLabelText('Model'), { target: { value: 'custom' } })
|
||||
chooseAgentSelect('Model', 'Custom model ID')
|
||||
fireEvent.change(screen.getByLabelText(/^Custom model ID/), { target: { value: 'provider/custom-model' } })
|
||||
fireEvent.change(screen.getByLabelText('Reasoning effort'), { target: { value: 'xhigh' } })
|
||||
fireEvent.change(screen.getByLabelText('Tools'), { target: { value: 'custom' } })
|
||||
fireEvent.change(screen.getByLabelText(/^Allowed tools/), {
|
||||
target: { value: 'Agent(worker, researcher), Read, Agent(worker, researcher)' },
|
||||
chooseAgentSelect('Reasoning effort', 'xhigh')
|
||||
chooseAgentSelect('Tools', 'Custom list')
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /Read/ }))
|
||||
fireEvent.change(screen.getByLabelText('Other tool names or permission patterns'), {
|
||||
target: { value: 'Agent(worker, researcher), Agent(worker, researcher)' },
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('Color'), { target: { value: 'purple' } })
|
||||
chooseAgentSelect('Color', 'purple')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(apiCreateMock).toHaveBeenCalledWith({
|
||||
@@ -155,7 +273,7 @@ describe('AgentManager', () => {
|
||||
systemPrompt: 'Review carefully.',
|
||||
model: 'provider/custom-model',
|
||||
effort: 'xhigh',
|
||||
tools: ['Agent(worker, researcher)', 'Read'],
|
||||
tools: ['Read', 'Agent(worker, researcher)'],
|
||||
color: 'purple',
|
||||
}))
|
||||
expect(apiReloadMock).toHaveBeenCalledWith('session-1')
|
||||
@@ -163,6 +281,50 @@ describe('AgentManager', () => {
|
||||
expect(useAgentStore.getState().selectedAgent).toEqual(created)
|
||||
})
|
||||
|
||||
it('lets users discover and select built-in tools without memorizing their names', async () => {
|
||||
const created = makeAgent({
|
||||
tools: ['Read', 'Grep', 'mcp__docs__search', 'Bash(git:*)'],
|
||||
})
|
||||
apiListMock
|
||||
.mockResolvedValueOnce({
|
||||
...EMPTY_RESPONSE,
|
||||
availableTools: ['Read', 'Grep', 'Bash', 'Edit'],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
activeAgents: [created],
|
||||
allAgents: [created],
|
||||
availableTools: ['Read', 'Grep', 'Bash', 'Edit'],
|
||||
})
|
||||
apiCreateMock.mockResolvedValue({ agent: created })
|
||||
|
||||
render(<AgentManager />)
|
||||
await waitFor(() => expect(apiListMock).toHaveBeenCalledTimes(1))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create Agent' }))
|
||||
fireEvent.change(screen.getByLabelText(/^Name/), { target: { value: 'code_reviewer' } })
|
||||
fireEvent.change(screen.getByLabelText(/^Description/), { target: { value: 'Review code' } })
|
||||
fireEvent.change(screen.getByLabelText('System prompt'), { target: { value: 'Review carefully.' } })
|
||||
chooseAgentSelect('Tools', 'Custom list')
|
||||
|
||||
expect(screen.getByText('Built-in tools')).toBeInTheDocument()
|
||||
expect(screen.getByText('Read files and directories')).toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('Search tools'), { target: { value: 'search files' } })
|
||||
expect(screen.getByRole('checkbox', { name: /Grep/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('checkbox', { name: /Edit/ })).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('Search tools'), { target: { value: '' } })
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /Read/ }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /Grep/ }))
|
||||
fireEvent.change(screen.getByLabelText('Other tool names or permission patterns'), {
|
||||
target: { value: 'mcp__docs__search, Bash(git:*)' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(apiCreateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: ['Read', 'Grep', 'mcp__docs__search', 'Bash(git:*)'],
|
||||
}),
|
||||
))
|
||||
})
|
||||
|
||||
it('uses the source project when the active worktree is no longer available', async () => {
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
@@ -182,7 +344,7 @@ describe('AgentManager', () => {
|
||||
await renderManager()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create Agent' }))
|
||||
fireEvent.change(screen.getByLabelText('Scope'), { target: { value: 'project' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Project' }))
|
||||
|
||||
expect(screen.getByText('Target project: /workspace/source-project')).toBeInTheDocument()
|
||||
})
|
||||
@@ -259,10 +421,10 @@ describe('AgentManager', () => {
|
||||
render(<AgentManager />)
|
||||
await waitFor(() => expect(apiListMock).toHaveBeenCalledTimes(1))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
fireEvent.change(screen.getByLabelText('Model'), { target: { value: 'inherit' } })
|
||||
fireEvent.change(screen.getByLabelText('Reasoning effort'), { target: { value: 'inherit' } })
|
||||
fireEvent.change(screen.getByLabelText('Tools'), { target: { value: 'inherit' } })
|
||||
fireEvent.change(screen.getByLabelText('Color'), { target: { value: '' } })
|
||||
chooseAgentSelect('Model', 'Inherit from parent')
|
||||
chooseAgentSelect('Reasoning effort', 'Inherit from parent')
|
||||
chooseAgentSelect('Tools', 'All tools')
|
||||
chooseAgentSelect('Color', 'Default')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(apiUpdateMock).toHaveBeenCalledWith('code_reviewer', {
|
||||
@@ -283,14 +445,18 @@ describe('AgentManager', () => {
|
||||
it('preserves an explicit empty tools list when editing only the description', async () => {
|
||||
const agent = makeAgent({ tools: [] })
|
||||
const updated = makeAgent({ tools: [], description: 'Updated review' })
|
||||
apiListMock.mockResolvedValue({ activeAgents: [updated], allAgents: [updated] })
|
||||
apiListMock.mockResolvedValue({
|
||||
activeAgents: [updated],
|
||||
allAgents: [updated],
|
||||
availableTools: ['Read'],
|
||||
})
|
||||
apiUpdateMock.mockResolvedValue({ agent: updated })
|
||||
useAgentStore.setState({ selectedAgent: agent, activeAgents: [agent], allAgents: [agent] })
|
||||
|
||||
render(<AgentManager />)
|
||||
await waitFor(() => expect(apiListMock).toHaveBeenCalledTimes(1))
|
||||
await waitFor(() => expect(useAgentStore.getState().availableTools).toContain('Read'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.getByLabelText('Tools')).toHaveValue('none')
|
||||
expect(screen.getByRole('button', { name: 'Tools' })).toHaveTextContent('No tools')
|
||||
fireEvent.change(screen.getByLabelText(/^Description/), { target: { value: 'Updated review' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
@@ -305,15 +471,20 @@ describe('AgentManager', () => {
|
||||
const originalTools = ['Agent(worker, researcher)', 'Read']
|
||||
const agent = makeAgent({ tools: originalTools })
|
||||
const updated = makeAgent({ tools: originalTools, description: 'Updated review' })
|
||||
apiListMock.mockResolvedValue({ activeAgents: [updated], allAgents: [updated] })
|
||||
apiListMock.mockResolvedValue({
|
||||
activeAgents: [updated],
|
||||
allAgents: [updated],
|
||||
availableTools: ['Read'],
|
||||
})
|
||||
apiUpdateMock.mockResolvedValue({ agent: updated })
|
||||
useAgentStore.setState({ selectedAgent: agent, activeAgents: [agent], allAgents: [agent] })
|
||||
|
||||
render(<AgentManager />)
|
||||
await waitFor(() => expect(apiListMock).toHaveBeenCalledTimes(1))
|
||||
await waitFor(() => expect(useAgentStore.getState().availableTools).toContain('Read'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.getByLabelText(/^Allowed tools/)).toHaveValue(
|
||||
'Agent(worker, researcher), Read',
|
||||
expect(screen.getByRole('checkbox', { name: /Read/ })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByLabelText('Other tool names or permission patterns')).toHaveValue(
|
||||
'Agent(worker, researcher)',
|
||||
)
|
||||
fireEvent.change(screen.getByLabelText(/^Description/), { target: { value: 'Updated review' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
@@ -334,7 +505,7 @@ describe('AgentManager', () => {
|
||||
render(<AgentManager />)
|
||||
await waitFor(() => expect(apiListMock).toHaveBeenCalledTimes(1))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.getByRole('option', { name: '7' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Reasoning effort' })).toHaveTextContent('7')
|
||||
fireEvent.change(screen.getByLabelText(/^Description/), { target: { value: 'Updated review' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
@@ -355,7 +526,7 @@ describe('AgentManager', () => {
|
||||
const prompt = screen.getByLabelText('System prompt')
|
||||
expect(prompt).toHaveValue('')
|
||||
expect(prompt.parentElement).not.toHaveTextContent('System prompt*')
|
||||
fireEvent.change(screen.getByLabelText('Reasoning effort'), { target: { value: 'high' } })
|
||||
chooseAgentSelect('Reasoning effort', 'high')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(apiUpdateMock).toHaveBeenCalled())
|
||||
@@ -417,7 +588,7 @@ describe('AgentManager', () => {
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'The change was saved, but the latest agent configuration could not be fully applied.',
|
||||
)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Refresh unavailable')
|
||||
expect(screen.getByRole('status')).not.toHaveTextContent('Refresh unavailable')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(apiListMock).toHaveBeenCalledTimes(2))
|
||||
expect(apiReloadMock).toHaveBeenCalledWith('session-1')
|
||||
@@ -432,8 +603,19 @@ describe('AgentManager', () => {
|
||||
fireEvent.change(screen.getByLabelText('System prompt'), { target: { value: 'Review carefully.' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Agent already exists')
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Failed to save agent')
|
||||
expect(screen.getByRole('alert')).not.toHaveTextContent('Agent already exists')
|
||||
expect(apiCreateMock.mock.calls[0]?.[0]).not.toHaveProperty('tools')
|
||||
expect(screen.getByRole('dialog', { name: 'Create Agent' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('localizes load failures without exposing raw server errors', async () => {
|
||||
useSettingsStore.setState({ locale: 'zh' })
|
||||
apiListMock.mockRejectedValue(new Error('HTTP 500: internal agent path leaked'))
|
||||
|
||||
render(<AgentManager />)
|
||||
|
||||
expect(await screen.findByText('加载 Agent 失败')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/internal agent path leaked/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
Boxes,
|
||||
Bolt,
|
||||
Braces,
|
||||
Check,
|
||||
ChevronDown,
|
||||
CircleAlert,
|
||||
Folder,
|
||||
Hammer,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Shield,
|
||||
Terminal,
|
||||
Trash2,
|
||||
@@ -21,6 +24,7 @@ import {
|
||||
Wrench,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { TranslationKey } from '../../i18n'
|
||||
import type {
|
||||
AgentDefinition,
|
||||
AgentMutationInput,
|
||||
@@ -34,6 +38,8 @@ import { useUIStore } from '../../stores/uiStore'
|
||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||
import { Button } from '../shared/Button'
|
||||
import { ConfirmDialog } from '../shared/ConfirmDialog'
|
||||
import { DirectoryPicker } from '../shared/DirectoryPicker'
|
||||
import { Dropdown } from '../shared/Dropdown'
|
||||
import { Input } from '../shared/Input'
|
||||
import { Modal } from '../shared/Modal'
|
||||
|
||||
@@ -62,6 +68,37 @@ const BUILT_IN_MODELS = ['haiku', 'sonnet', 'opus', 'fable'] as const
|
||||
const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
|
||||
const NAME_PATTERN = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/
|
||||
type ToolAccessMode = 'inherit' | 'none' | 'custom'
|
||||
type ToolCategory = 'readSearch' | 'modify' | 'execute' | 'workflow' | 'other'
|
||||
|
||||
const TOOL_CATEGORY_ORDER: ToolCategory[] = ['readSearch', 'modify', 'execute', 'workflow', 'other']
|
||||
const TOOL_METADATA: Record<string, { category: ToolCategory; description: TranslationKey }> = {
|
||||
Read: { category: 'readSearch', description: 'settings.agents.form.toolDescription.Read' },
|
||||
Glob: { category: 'readSearch', description: 'settings.agents.form.toolDescription.Glob' },
|
||||
Grep: { category: 'readSearch', description: 'settings.agents.form.toolDescription.Grep' },
|
||||
WebFetch: { category: 'readSearch', description: 'settings.agents.form.toolDescription.WebFetch' },
|
||||
WebSearch: { category: 'readSearch', description: 'settings.agents.form.toolDescription.WebSearch' },
|
||||
Edit: { category: 'modify', description: 'settings.agents.form.toolDescription.Edit' },
|
||||
Write: { category: 'modify', description: 'settings.agents.form.toolDescription.Write' },
|
||||
NotebookEdit: { category: 'modify', description: 'settings.agents.form.toolDescription.NotebookEdit' },
|
||||
Bash: { category: 'execute', description: 'settings.agents.form.toolDescription.Bash' },
|
||||
PowerShell: { category: 'execute', description: 'settings.agents.form.toolDescription.PowerShell' },
|
||||
TodoWrite: { category: 'workflow', description: 'settings.agents.form.toolDescription.TodoWrite' },
|
||||
Skill: { category: 'workflow', description: 'settings.agents.form.toolDescription.Skill' },
|
||||
ToolSearch: { category: 'workflow', description: 'settings.agents.form.toolDescription.ToolSearch' },
|
||||
EnterWorktree: { category: 'workflow', description: 'settings.agents.form.toolDescription.EnterWorktree' },
|
||||
ExitWorktree: { category: 'workflow', description: 'settings.agents.form.toolDescription.ExitWorktree' },
|
||||
StructuredOutput: { category: 'workflow', description: 'settings.agents.form.toolDescription.StructuredOutput' },
|
||||
}
|
||||
|
||||
function getAgentProjectPath(agent?: AgentDefinition): string | undefined {
|
||||
if (agent?.source !== 'projectSettings' || !agent.baseDir) return undefined
|
||||
const normalized = agent.baseDir.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
const suffix = '/.claude/agents'
|
||||
if (!normalized.toLowerCase().endsWith(suffix)) return undefined
|
||||
const projectPath = normalized.slice(0, -suffix.length)
|
||||
if (!projectPath) return '/'
|
||||
return /^[A-Za-z]:$/.test(projectPath) ? `${projectPath}/` : projectPath
|
||||
}
|
||||
|
||||
export function AgentManager() {
|
||||
const {
|
||||
@@ -84,8 +121,13 @@ export function AgentManager() {
|
||||
|
||||
const activeSession = sessions.find((session) => session.id === activeSessionId)
|
||||
const currentWorkDir = getSessionBrowsablePath(activeSession)
|
||||
const [agentContextPath, setAgentContextPath] = useState<string | undefined>(currentWorkDir)
|
||||
const contextSessionId = sessions.find(
|
||||
(session) => getSessionBrowsablePath(session) === agentContextPath,
|
||||
)?.id
|
||||
|
||||
useEffect(() => {
|
||||
setAgentContextPath(currentWorkDir)
|
||||
void fetchAgents(currentWorkDir)
|
||||
}, [fetchAgents, currentWorkDir])
|
||||
|
||||
@@ -117,9 +159,6 @@ export function AgentManager() {
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{t('settings.agents.refreshWarning')}
|
||||
</p>
|
||||
<p className="mt-0.5 break-words text-xs text-[var(--color-text-secondary)]">
|
||||
{mutationWarning}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -127,8 +166,8 @@ export function AgentManager() {
|
||||
size="sm"
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={() => void retryMutationRefresh(
|
||||
currentWorkDir,
|
||||
activeSessionId || undefined,
|
||||
agentContextPath,
|
||||
contextSessionId,
|
||||
)}
|
||||
>
|
||||
{t('common.retry')}
|
||||
@@ -164,12 +203,12 @@ export function AgentManager() {
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-2xl border border-[var(--color-error)]/30 bg-[var(--color-error)]/5 px-5 py-10 text-center">
|
||||
<p className="mb-3 text-sm text-[var(--color-error)]">{error}</p>
|
||||
<p className="mb-3 text-sm text-[var(--color-error)]">{t('settings.agents.loadError')}</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={() => void fetchAgents(currentWorkDir)}
|
||||
onClick={() => void fetchAgents(agentContextPath)}
|
||||
>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
@@ -271,15 +310,16 @@ export function AgentManager() {
|
||||
<AgentFormModal
|
||||
mode={formState.mode}
|
||||
agent={formState.agent}
|
||||
cwd={currentWorkDir}
|
||||
sessionId={activeSessionId || undefined}
|
||||
cwd={agentContextPath}
|
||||
sessionId={contextSessionId}
|
||||
onProjectContextChange={setAgentContextPath}
|
||||
onClose={() => setFormState(null)}
|
||||
/>
|
||||
)}
|
||||
<AgentDeleteDialog
|
||||
agent={deleteTarget}
|
||||
cwd={currentWorkDir}
|
||||
sessionId={activeSessionId || undefined}
|
||||
cwd={agentContextPath}
|
||||
sessionId={contextSessionId}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
@@ -397,21 +437,28 @@ function AgentFormModal({
|
||||
agent,
|
||||
cwd,
|
||||
sessionId,
|
||||
onProjectContextChange,
|
||||
onClose,
|
||||
}: {
|
||||
mode: 'create' | 'edit'
|
||||
agent?: AgentDefinition
|
||||
cwd?: string
|
||||
sessionId?: string
|
||||
onProjectContextChange: (path: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const createAgent = useAgentStore((state) => state.createAgent)
|
||||
const updateAgent = useAgentStore((state) => state.updateAgent)
|
||||
const isMutating = useAgentStore((state) => state.isMutating)
|
||||
const availableTools = useAgentStore((state) => state.availableTools)
|
||||
const sessions = useSessionStore((state) => state.sessions)
|
||||
const initialScope = agent?.source === 'projectSettings' ? 'project' : 'user'
|
||||
const initialModel = agent?.model || 'inherit'
|
||||
const [scope, setScope] = useState<AgentScope>(initialScope)
|
||||
const [projectPath, setProjectPath] = useState(
|
||||
initialScope === 'project' ? getAgentProjectPath(agent) || cwd || '' : cwd || '',
|
||||
)
|
||||
const [name, setName] = useState(agent?.agentType || '')
|
||||
const [description, setDescription] = useState(agent?.description || '')
|
||||
const [systemPrompt, setSystemPrompt] = useState(agent?.systemPrompt || '')
|
||||
@@ -430,8 +477,18 @@ function AgentFormModal({
|
||||
? 'none'
|
||||
: 'custom'
|
||||
const [toolAccess, setToolAccess] = useState<ToolAccessMode>(initialToolAccess)
|
||||
const initialToolsText = agent?.tools?.join(', ') || ''
|
||||
const [tools, setTools] = useState(initialToolsText)
|
||||
const initialTools = agent?.tools ?? []
|
||||
const [selectedBuiltInTools, setSelectedBuiltInTools] = useState(
|
||||
initialTools.filter(tool => availableTools.includes(tool)),
|
||||
)
|
||||
const [customTools, setCustomTools] = useState(
|
||||
initialTools.filter(tool => !availableTools.includes(tool)).join(', '),
|
||||
)
|
||||
const [toolsDirty, setToolsDirty] = useState(false)
|
||||
const parsedTools = useMemo(
|
||||
() => [...new Set([...selectedBuiltInTools, ...parseTools(customTools)])],
|
||||
[customTools, selectedBuiltInTools],
|
||||
)
|
||||
const [color, setColor] = useState(agent?.color || '')
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
@@ -439,22 +496,22 @@ function AgentFormModal({
|
||||
const handleSubmit = async () => {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
const trimmedName = name.trim()
|
||||
const parsedTools = parseTools(tools)
|
||||
if (!NAME_PATTERN.test(trimmedName)) nextErrors.name = t('settings.agents.form.nameError')
|
||||
if (!description.trim()) nextErrors.description = t('settings.agents.form.descriptionRequired')
|
||||
if (mode === 'create' && !systemPrompt.trim()) nextErrors.systemPrompt = t('settings.agents.form.systemPromptRequired')
|
||||
if (modelChoice === 'custom' && !customModel.trim()) nextErrors.customModel = t('settings.agents.form.customModelRequired')
|
||||
if (toolAccess === 'custom' && parsedTools.length === 0) nextErrors.tools = t('settings.agents.form.toolsCustomRequired')
|
||||
if (scope === 'project' && !cwd) nextErrors.scope = t('settings.agents.form.projectUnavailable')
|
||||
if (scope === 'project' && !projectPath) nextErrors.scope = t('settings.agents.form.projectUnavailable')
|
||||
setFieldErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
const toolSelectionIsUnchanged = mode === 'edit' &&
|
||||
toolAccess === initialToolAccess &&
|
||||
(toolAccess !== 'custom' || tools === initialToolsText)
|
||||
(toolAccess !== 'custom' || !toolsDirty)
|
||||
const targetCwd = scope === 'project' ? projectPath : cwd
|
||||
const input: AgentMutationInput = {
|
||||
scope,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(targetCwd ? { cwd: targetCwd } : {}),
|
||||
...(mode === 'edit' && agent?.target ? { target: agent.target } : {}),
|
||||
name: trimmedName,
|
||||
description: description.trim(),
|
||||
@@ -481,14 +538,18 @@ function AgentFormModal({
|
||||
|
||||
setSubmitError(null)
|
||||
try {
|
||||
const targetSessionId = scope === 'project'
|
||||
? sessions.find((session) => getSessionBrowsablePath(session) === projectPath)?.id
|
||||
: sessionId
|
||||
if (mode === 'edit' && agent) {
|
||||
await updateAgent(agent.agentType, input, sessionId)
|
||||
await updateAgent(agent.agentType, input, targetSessionId)
|
||||
} else {
|
||||
await createAgent(input, sessionId)
|
||||
await createAgent(input, targetSessionId)
|
||||
}
|
||||
if (scope === 'project' && targetCwd) onProjectContextChange(targetCwd)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : t('settings.agents.form.saveError'))
|
||||
} catch {
|
||||
setSubmitError(t('settings.agents.form.saveError'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,21 +568,50 @@ function AgentFormModal({
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<Field label={t('settings.agents.form.scope')} error={fieldErrors.scope} required>
|
||||
<select
|
||||
aria-label={t('settings.agents.form.scope')}
|
||||
value={scope}
|
||||
disabled={mode === 'edit'}
|
||||
onChange={(event) => setScope(event.target.value as AgentScope)}
|
||||
className={selectClassName}
|
||||
>
|
||||
<option value="user">{t('settings.agents.form.scopeUser')}</option>
|
||||
<option value="project" disabled={!cwd}>{t('settings.agents.form.scopeProject')}</option>
|
||||
</select>
|
||||
{!cwd && <p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.agents.form.projectUnavailable')}</p>}
|
||||
{scope === 'project' && cwd && (
|
||||
<p className="break-all text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('settings.agents.form.projectTarget', { path: cwd })}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2" role="group" aria-label={t('settings.agents.form.scope')}>
|
||||
{([
|
||||
{ value: 'user' as const, label: t('settings.agents.form.scopeUser'), icon: <User size={16} /> },
|
||||
{ value: 'project' as const, label: t('settings.agents.form.scopeProject'), icon: <Folder size={16} /> },
|
||||
]).map((option) => {
|
||||
const selected = scope === option.value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
disabled={mode === 'edit'}
|
||||
onClick={() => setScope(option.value)}
|
||||
className={`flex min-h-16 items-center gap-3 rounded-[var(--radius-lg)] border px-4 py-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||
selected
|
||||
? 'border-[var(--color-border-focus)] bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${
|
||||
selected ? 'bg-[var(--color-primary-fixed)] text-[var(--color-brand)]' : 'bg-[var(--color-surface-container-high)]'
|
||||
}`}>
|
||||
{option.icon}
|
||||
</span>
|
||||
<span className="text-sm font-semibold">{option.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{scope === 'project' && (
|
||||
<div className="mt-2 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{projectPath
|
||||
? t('settings.agents.form.projectTarget', { path: projectPath })
|
||||
: t('settings.agents.form.projectUnavailable')}
|
||||
</p>
|
||||
</div>
|
||||
{mode === 'create' && (
|
||||
<DirectoryPicker value={projectPath} onChange={setProjectPath} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
@@ -560,18 +650,28 @@ function AgentFormModal({
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label={t('settings.agents.form.model')}>
|
||||
<select aria-label={t('settings.agents.form.model')} value={modelChoice} onChange={(event) => setModelChoice(event.target.value)} className={selectClassName}>
|
||||
<option value="inherit">{t('settings.agents.form.inherit')}</option>
|
||||
{BUILT_IN_MODELS.map((model) => <option key={model} value={model}>{model}</option>)}
|
||||
<option value="custom">{t('settings.agents.form.customModel')}</option>
|
||||
</select>
|
||||
<AgentSelect
|
||||
label={t('settings.agents.form.model')}
|
||||
value={modelChoice}
|
||||
onChange={setModelChoice}
|
||||
items={[
|
||||
{ value: 'inherit', label: t('settings.agents.form.inherit') },
|
||||
...BUILT_IN_MODELS.map((model) => ({ value: model, label: model })),
|
||||
{ value: 'custom', label: t('settings.agents.form.customModel') },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('settings.agents.form.effort')}>
|
||||
<select aria-label={t('settings.agents.form.effort')} value={effort} onChange={(event) => setEffort(event.target.value)} className={selectClassName}>
|
||||
<option value="inherit">{t('settings.agents.form.inherit')}</option>
|
||||
{hasLegacyEffort && <option value={initialEffort}>{initialEffort}</option>}
|
||||
{EFFORTS.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
<AgentSelect
|
||||
label={t('settings.agents.form.effort')}
|
||||
value={effort}
|
||||
onChange={setEffort}
|
||||
items={[
|
||||
{ value: 'inherit', label: t('settings.agents.form.inherit') },
|
||||
...(hasLegacyEffort ? [{ value: initialEffort, label: initialEffort }] : []),
|
||||
...EFFORTS.map((value) => ({ value, label: value })),
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -586,16 +686,16 @@ function AgentFormModal({
|
||||
)}
|
||||
|
||||
<Field label={t('settings.agents.form.tools')}>
|
||||
<select
|
||||
aria-label={t('settings.agents.form.tools')}
|
||||
<AgentSelect<ToolAccessMode>
|
||||
label={t('settings.agents.form.tools')}
|
||||
value={toolAccess}
|
||||
onChange={(event) => setToolAccess(event.target.value as ToolAccessMode)}
|
||||
className={selectClassName}
|
||||
>
|
||||
<option value="inherit">{t('settings.agents.form.toolsInherit')}</option>
|
||||
<option value="none">{t('settings.agents.form.toolsNone')}</option>
|
||||
<option value="custom">{t('settings.agents.form.toolsCustom')}</option>
|
||||
</select>
|
||||
onChange={setToolAccess}
|
||||
items={[
|
||||
{ value: 'inherit', label: t('settings.agents.form.toolsInherit') },
|
||||
{ value: 'none', label: t('settings.agents.form.toolsNone') },
|
||||
{ value: 'custom', label: t('settings.agents.form.toolsCustom') },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<p className="-mt-3 text-xs text-[var(--color-text-tertiary)]">
|
||||
{toolAccess === 'inherit'
|
||||
@@ -605,21 +705,36 @@ function AgentFormModal({
|
||||
: t('settings.agents.form.toolsHint')}
|
||||
</p>
|
||||
{toolAccess === 'custom' && (
|
||||
<Input
|
||||
label={t('settings.agents.form.toolsCustomLabel')}
|
||||
required
|
||||
value={tools}
|
||||
<ToolPicker
|
||||
availableTools={availableTools}
|
||||
selectedTools={selectedBuiltInTools}
|
||||
customTools={customTools}
|
||||
error={fieldErrors.tools}
|
||||
placeholder={t('settings.agents.form.toolsPlaceholder')}
|
||||
onChange={(event) => setTools(event.target.value)}
|
||||
onSelectedToolsChange={(nextTools) => {
|
||||
setSelectedBuiltInTools(nextTools)
|
||||
setToolsDirty(true)
|
||||
}}
|
||||
onCustomToolsChange={(value) => {
|
||||
setCustomTools(value)
|
||||
setToolsDirty(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Field label={t('settings.agents.form.color')}>
|
||||
<select aria-label={t('settings.agents.form.color')} value={color} onChange={(event) => setColor(event.target.value)} className={selectClassName}>
|
||||
<option value="">{t('settings.agents.form.noColor')}</option>
|
||||
{Object.keys(AGENT_COLORS).map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
<AgentSelect
|
||||
label={t('settings.agents.form.color')}
|
||||
value={color}
|
||||
onChange={setColor}
|
||||
items={[
|
||||
{ value: '', label: t('settings.agents.form.noColor') },
|
||||
...Object.keys(AGENT_COLORS).map((value) => ({
|
||||
value,
|
||||
label: value,
|
||||
icon: <span className="h-3 w-3 rounded-full" style={{ backgroundColor: AGENT_COLORS[value] }} />,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{submitError && (
|
||||
@@ -632,6 +747,143 @@ function AgentFormModal({
|
||||
)
|
||||
}
|
||||
|
||||
function ToolPicker({
|
||||
availableTools,
|
||||
selectedTools,
|
||||
customTools,
|
||||
error,
|
||||
onSelectedToolsChange,
|
||||
onCustomToolsChange,
|
||||
}: {
|
||||
availableTools: string[]
|
||||
selectedTools: string[]
|
||||
customTools: string
|
||||
error?: string
|
||||
onSelectedToolsChange: (tools: string[]) => void
|
||||
onCustomToolsChange: (value: string) => void
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const [query, setQuery] = useState('')
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
const visibleTools = availableTools.filter((tool) => {
|
||||
if (!normalizedQuery) return true
|
||||
const metadata = TOOL_METADATA[tool]
|
||||
const description = t(metadata?.description ?? 'settings.agents.form.toolDescription.generic')
|
||||
const category = t(`settings.agents.form.toolCategory.${metadata?.category ?? 'other'}`)
|
||||
return `${tool} ${description} ${category}`.toLowerCase().includes(normalizedQuery)
|
||||
})
|
||||
const groupedTools = TOOL_CATEGORY_ORDER.map((category) => ({
|
||||
category,
|
||||
tools: visibleTools.filter(tool => (TOOL_METADATA[tool]?.category ?? 'other') === category),
|
||||
})).filter(group => group.tools.length > 0)
|
||||
|
||||
const toggleTool = (tool: string) => {
|
||||
onSelectedToolsChange(
|
||||
selectedTools.includes(tool)
|
||||
? selectedTools.filter(selectedTool => selectedTool !== tool)
|
||||
: [...selectedTools, tool],
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{t('settings.agents.form.builtInTools')}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('settings.agents.form.builtInToolsHint')}
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-[var(--color-primary-fixed)] px-2.5 py-1 text-xs font-medium text-[var(--color-brand)]">
|
||||
{t('settings.agents.form.toolsSelectedCount', { count: selectedTools.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{availableTools.length > 0 ? (
|
||||
<>
|
||||
<label className="relative mb-3 block">
|
||||
<Search size={15} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]" />
|
||||
<input
|
||||
type="search"
|
||||
aria-label={t('settings.agents.form.toolsSearch')}
|
||||
value={query}
|
||||
placeholder={t('settings.agents.form.toolsSearchPlaceholder')}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="h-9 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] pl-9 pr-3 text-sm text-[var(--color-text-primary)] outline-none transition-colors placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)] focus:ring-2 focus:ring-[var(--color-brand)]/15"
|
||||
/>
|
||||
</label>
|
||||
<div className="max-h-64 space-y-3 overflow-y-auto pr-1">
|
||||
{groupedTools.map(({ category, tools }) => (
|
||||
<section key={category} aria-label={t(`settings.agents.form.toolCategory.${category}`)}>
|
||||
<h4 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-tertiary)]">
|
||||
{t(`settings.agents.form.toolCategory.${category}`)}
|
||||
</h4>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{tools.map((tool) => {
|
||||
const selected = selectedTools.includes(tool)
|
||||
const description = t(TOOL_METADATA[tool]?.description ?? 'settings.agents.form.toolDescription.generic')
|
||||
return (
|
||||
<button
|
||||
key={tool}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={selected}
|
||||
aria-label={`${tool} — ${description}`}
|
||||
onClick={() => toggleTool(tool)}
|
||||
className={`flex min-h-14 items-start gap-2.5 rounded-[var(--radius-md)] border px-3 py-2.5 text-left transition-colors ${
|
||||
selected
|
||||
? 'border-[var(--color-border-focus)] bg-[var(--color-surface-selected)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
|
||||
selected
|
||||
? 'border-[var(--color-brand)] bg-[var(--color-brand)] text-white'
|
||||
: 'border-[var(--color-border-strong)] bg-[var(--color-surface)]'
|
||||
}`}>
|
||||
{selected && <Check size={12} strokeWidth={3} />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-mono text-xs font-semibold text-[var(--color-text-primary)]">{tool}</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-4 text-[var(--color-text-tertiary)]">{description}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{groupedTools.length === 0 && (
|
||||
<p className="py-5 text-center text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('settings.agents.form.toolsNoResults')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('settings.agents.form.toolsUnavailable')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 border-t border-[var(--color-border)] pt-3">
|
||||
<Input
|
||||
label={t('settings.agents.form.toolsCustomLabel')}
|
||||
value={customTools}
|
||||
error={error}
|
||||
placeholder={t('settings.agents.form.toolsPlaceholder')}
|
||||
onChange={(event) => onCustomToolsChange(event.target.value)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('settings.agents.form.toolsCustomHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentDeleteDialog({
|
||||
agent,
|
||||
cwd,
|
||||
@@ -659,8 +911,8 @@ function AgentDeleteDialog({
|
||||
try {
|
||||
await deleteAgent(agent.agentType, scope, cwd, agent.target, sessionId)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
setDeleteError(error instanceof Error ? error.message : t('settings.agents.deleteError'))
|
||||
} catch {
|
||||
setDeleteError(t('settings.agents.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -756,6 +1008,42 @@ function getAgentSourceAccentClass(source: AgentSource) {
|
||||
}
|
||||
}
|
||||
|
||||
function AgentSelect<T extends string>({
|
||||
label,
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
items: Array<{ value: T; label: string; icon?: ReactNode }>
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
}) {
|
||||
const selected = items.find((item) => item.value === value) ?? items[0]
|
||||
return (
|
||||
<Dropdown<T>
|
||||
items={items}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
width="100%"
|
||||
maxHeight={280}
|
||||
placement="top"
|
||||
className="block w-full"
|
||||
trigger={(
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
className="flex h-10 w-full items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-left text-sm text-[var(--color-text-primary)] outline-none transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)] focus-visible:border-[var(--color-border-focus)] focus-visible:shadow-[var(--shadow-focus-ring)]"
|
||||
>
|
||||
{selected?.icon && <span className="shrink-0">{selected.icon}</span>}
|
||||
<span className="min-w-0 flex-1 truncate">{selected?.label ?? value}</span>
|
||||
<ChevronDown size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, error, required, children }: { label: string; error?: string; required?: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -794,5 +1082,4 @@ function DetailStat({ label, value, icon }: { label: string; value: string; icon
|
||||
)
|
||||
}
|
||||
|
||||
const selectClassName = 'h-10 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-sm text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
const textAreaClassName = 'min-h-32 resize-y rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)]'
|
||||
|
||||
@@ -316,7 +316,7 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
) : (
|
||||
<span className={`material-symbols-outlined shrink-0 ${isWorkbar ? 'text-[17px]' : 'text-[14px]'} text-[var(--color-text-secondary)]`}>folder</span>
|
||||
<span aria-hidden="true" className={`material-symbols-outlined shrink-0 ${isWorkbar ? 'text-[17px]' : 'text-[14px]'} text-[var(--color-text-secondary)]`}>folder</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-[var(--color-text-primary)]">
|
||||
{selectedLabel}
|
||||
@@ -330,7 +330,7 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
|
||||
className={emptyTriggerClassName}
|
||||
title={t('dirPicker.selectProject')}
|
||||
>
|
||||
<span className={`material-symbols-outlined shrink-0 ${isWorkbar ? 'text-[17px]' : 'text-[14px]'}`}>folder_open</span>
|
||||
<span aria-hidden="true" className={`material-symbols-outlined shrink-0 ${isWorkbar ? 'text-[17px]' : 'text-[14px]'}`}>folder_open</span>
|
||||
<span className="min-w-0 truncate">{t('dirPicker.selectProject')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -15,6 +15,7 @@ type DropdownProps<T extends string> = {
|
||||
width?: CSSProperties['width']
|
||||
maxHeight?: CSSProperties['maxHeight']
|
||||
align?: 'left' | 'right'
|
||||
placement?: 'bottom' | 'top'
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -26,6 +27,7 @@ export function Dropdown<T extends string>({
|
||||
width = 320,
|
||||
maxHeight,
|
||||
align = 'left',
|
||||
placement = 'bottom',
|
||||
className = '',
|
||||
}: DropdownProps<T>) {
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -39,13 +41,16 @@ export function Dropdown<T extends string>({
|
||||
}
|
||||
}
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
if (e.key !== 'Escape') return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
document.addEventListener('keydown', handleEsc, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('keydown', handleEsc)
|
||||
document.removeEventListener('keydown', handleEsc, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
@@ -58,12 +63,13 @@ export function Dropdown<T extends string>({
|
||||
{open && (
|
||||
<div
|
||||
className={`
|
||||
absolute z-50 mt-1 rounded-[var(--radius-lg)]
|
||||
absolute z-50 rounded-[var(--radius-lg)]
|
||||
bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)]
|
||||
shadow-[var(--shadow-dropdown)]
|
||||
animate-in fade-in slide-in-from-top-1
|
||||
animate-in fade-in
|
||||
${maxHeight ? 'overflow-y-auto' : 'overflow-hidden'}
|
||||
${align === 'right' ? 'right-0' : 'left-0'}
|
||||
${placement === 'top' ? 'bottom-full mb-1 slide-in-from-bottom-1' : 'top-full mt-1 slide-in-from-top-1'}
|
||||
`}
|
||||
style={{ width, maxHeight }}
|
||||
>
|
||||
@@ -86,7 +92,7 @@ export function Dropdown<T extends string>({
|
||||
)}
|
||||
</div>
|
||||
{item.value === value && (
|
||||
<span className="material-symbols-outlined flex-shrink-0 text-[16px] text-[var(--color-brand)]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
|
||||
<span aria-hidden="true" className="material-symbols-outlined flex-shrink-0 text-[16px] text-[var(--color-brand)]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { WorkspaceDiffSurface } from './WorkspaceDiffSurface'
|
||||
import {
|
||||
WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD,
|
||||
WORKSPACE_PREVIEW_LINE_LIMIT,
|
||||
WorkspaceDiffSurface as ExportedWorkspaceDiffSurface,
|
||||
} from './WorkspaceCodeSurface'
|
||||
|
||||
@@ -349,25 +348,23 @@ describe('WorkspaceDiffSurface', () => {
|
||||
expect(screen.getByRole('textbox', { name: 'Review comment' })).toHaveValue('Keep this collapsed draft')
|
||||
})
|
||||
|
||||
it('uses plain text instead of Shiki after expanding a diff beyond the large preview threshold', () => {
|
||||
const additions = Array.from(
|
||||
it('uses plain text instead of Shiki beyond the large preview threshold', () => {
|
||||
const structuralMetadata = Array.from(
|
||||
{ length: WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD + 1 },
|
||||
(_, index) => `+const value${index} = ${index}`,
|
||||
() => '--- a/src/large.ts',
|
||||
)
|
||||
const largeDiff = [
|
||||
'diff --git a/src/large.ts b/src/large.ts',
|
||||
'--- a/src/large.ts',
|
||||
'+++ b/src/large.ts',
|
||||
`@@ -0,0 +1,${additions.length} @@`,
|
||||
...additions,
|
||||
...structuralMetadata,
|
||||
'@@ -0,0 +1 @@',
|
||||
'+const value = 1',
|
||||
].join('\n')
|
||||
render(<WorkspaceDiffSurface value={largeDiff} path="src/large.ts" lineLimit={1} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show all loaded lines' }))
|
||||
render(<WorkspaceDiffSurface value={largeDiff} path="src/large.ts" />)
|
||||
|
||||
expect(screen.getByTestId('workspace-code')).toHaveAttribute('data-highlight-engine', 'plain')
|
||||
expect(highlightRequestSpy).not.toHaveBeenCalled()
|
||||
expect(getCodeRow('const value5000 = 5000')).toHaveTextContent('const value5000 = 5000')
|
||||
expect(getCodeRow('const value = 1')).toHaveTextContent('const value = 1')
|
||||
})
|
||||
|
||||
it('renders parsed file headers and keeps multiple files visually separated', () => {
|
||||
@@ -478,7 +475,7 @@ describe('WorkspaceDiffSurface', () => {
|
||||
|
||||
it('does not request Shiki highlighting again for each controlled draft change', () => {
|
||||
const additions = Array.from(
|
||||
{ length: WORKSPACE_PREVIEW_LINE_LIMIT - 4 },
|
||||
{ length: 20 },
|
||||
(_, index) => `+const value${index + 1} = ${index + 1}`,
|
||||
)
|
||||
const nearLimitDiff = [
|
||||
|
||||
@@ -37,6 +37,7 @@ type WorkspaceApiMocks = {
|
||||
}
|
||||
|
||||
var mocks: WorkspaceApiMocks | undefined
|
||||
const workspacePreviewLineLimitForTests = vi.hoisted(() => 20)
|
||||
|
||||
function getMocks() {
|
||||
if (!mocks) {
|
||||
@@ -284,6 +285,11 @@ vi.mock('../../api/openTargets', () => ({
|
||||
|
||||
vi.mock('@tauri-apps/plugin-shell', () => ({ open: vi.fn().mockResolvedValue(undefined) }))
|
||||
|
||||
vi.mock('./WorkspaceCodeSurface', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('./WorkspaceCodeSurface')>(),
|
||||
WORKSPACE_PREVIEW_LINE_LIMIT: workspacePreviewLineLimitForTests,
|
||||
}))
|
||||
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||
@@ -1689,7 +1695,10 @@ describe('WorkspacePanel', () => {
|
||||
})
|
||||
|
||||
it('can expand long file previews beyond the default rendered line cap', async () => {
|
||||
const longFile = Array.from({ length: 2300 }, (_, index) => `const line${index + 1} = ${index + 1}`).join('\n')
|
||||
const longFile = Array.from(
|
||||
{ length: workspacePreviewLineLimitForTests + 3 },
|
||||
(_, index) => `const line${index + 1} = ${index + 1}`,
|
||||
).join('\n')
|
||||
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
@@ -1723,15 +1732,15 @@ describe('WorkspacePanel', () => {
|
||||
const highlightedCode = view.getByTestId('workspace-code').textContent ?? ''
|
||||
|
||||
expect(highlightedCode).toContain('const line1 = 1')
|
||||
expect(highlightedCode).toContain('const line2000 = 2000')
|
||||
expect(highlightedCode).not.toContain('const line2001 = 2001')
|
||||
expect(highlightedCode).toContain('const line20 = 20')
|
||||
expect(highlightedCode).not.toContain('const line21 = 21')
|
||||
await clickElement(view.getByRole('button', { name: 'Show all loaded lines' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(view.getByTestId('workspace-code').textContent).toContain('const line2300 = 2300')
|
||||
expect(view.getByTestId('workspace-code').textContent).toContain('const line23 = 23')
|
||||
})
|
||||
expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy()
|
||||
}, 20_000)
|
||||
})
|
||||
|
||||
it('renders image previews from workspace files', async () => {
|
||||
await setWorkspaceState((state) => ({
|
||||
|
||||
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
|
||||
enabled: true,
|
||||
selectedPetId: 'dada-code',
|
||||
size: 112,
|
||||
showTaskPanel: true,
|
||||
collapsed: false,
|
||||
motionEnabled: true,
|
||||
lastSessionId: 'session-running' as string | null,
|
||||
@@ -126,6 +127,7 @@ describe('PetApp', () => {
|
||||
mocks.preferences.enabled = true
|
||||
mocks.preferences.selectedPetId = 'dada-code'
|
||||
mocks.preferences.size = 112
|
||||
mocks.preferences.showTaskPanel = true
|
||||
mocks.preferences.collapsed = false
|
||||
mocks.preferences.motionEnabled = true
|
||||
mocks.preferences.lastSessionId = 'session-running'
|
||||
@@ -153,6 +155,65 @@ describe('PetApp', () => {
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
it('hides the task panel by preference even while work is active', async () => {
|
||||
mocks.preferences.showTaskPanel = false
|
||||
render(<PetApp />)
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'pet.window.interact' })).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(mocks.getChatStatus).toHaveBeenCalledWith('session-running', expect.any(AbortSignal))
|
||||
})
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('pet.window.sessionCount:1')).not.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(mocks.setInteractiveRegions.mock.calls.at(-1)?.[0]).toHaveLength(2)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'pet.window.expandTasks:1' }))
|
||||
expect(mocks.updatePetPreferences).toHaveBeenCalledWith({ showTaskPanel: true })
|
||||
expect(await screen.findByRole('list')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the task panel when no task is active even if the preference is enabled', async () => {
|
||||
mocks.chats = {
|
||||
'session-running': chat('idle', 'The build is done.'),
|
||||
'session-idle': chat('idle', 'The atlas is ready.'),
|
||||
}
|
||||
mocks.getChatStatus.mockResolvedValue({ state: 'idle', activityState: 'idle' })
|
||||
render(<PetApp />)
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'pet.window.interact' })).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(mocks.getChatStatus).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
expect(screen.queryByText('pet.window.noSessions')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('pet.window.sessionCount:0')).not.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(mocks.setInteractiveRegions.mock.calls.at(-1)?.[0]).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('hides the whole task surface when an older server reports only completed review markers', async () => {
|
||||
mocks.chats = {
|
||||
'session-running': chat('idle', 'The build is done.'),
|
||||
'session-idle': chat('idle', 'The atlas is ready.'),
|
||||
}
|
||||
mocks.getChatStatus.mockResolvedValue({ state: 'idle', activityState: 'review' })
|
||||
render(<PetApp />)
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'pet.window.interact' })).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(mocks.getChatStatus).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Build pet window')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Review animation')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('pet.window.expandTasks:2')).not.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(mocks.setInteractiveRegions.mock.calls.at(-1)?.[0]).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows only active session work and opens the task from the whole row', async () => {
|
||||
render(<PetApp />)
|
||||
|
||||
@@ -177,24 +238,21 @@ describe('PetApp', () => {
|
||||
expect(mocks.focusSession).toHaveBeenCalledWith('session-running')
|
||||
})
|
||||
|
||||
it('shows a vertical list for every non-idle session when expanded', async () => {
|
||||
it('hides legacy review sessions while keeping real active work visible', async () => {
|
||||
mocks.getChatStatus.mockImplementation(async (sessionId: string) => ({
|
||||
state: 'thinking',
|
||||
activityState: sessionId === 'session-idle' ? 'review' : 'running',
|
||||
}))
|
||||
render(<PetApp />)
|
||||
|
||||
const reviewRow = await screen.findByRole('button', {
|
||||
name: 'Review animation, pet.window.status.review',
|
||||
await screen.findByRole('button', {
|
||||
name: 'Build pet window, pet.window.status.running',
|
||||
})
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(2)
|
||||
|
||||
fireEvent.click(reviewRow)
|
||||
expect(mocks.focusSession).toHaveBeenCalledWith('session-idle')
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(1)
|
||||
expect(screen.queryByText('Review animation')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps one compact session row collapsed and expands from the chevron without navigating', async () => {
|
||||
mocks.preferences.collapsed = true
|
||||
it('shows every active task and returns the card to its badge from the chevron', async () => {
|
||||
mocks.sessions = [
|
||||
session('session-running', 'Build pet window', '2026-07-19T12:01:00Z'),
|
||||
session('session-second', 'Polish animation', '2026-07-19T12:00:00Z'),
|
||||
@@ -209,39 +267,38 @@ describe('PetApp', () => {
|
||||
expect(await screen.findByRole('button', {
|
||||
name: 'Build pet window, pet.window.status.running',
|
||||
})).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', {
|
||||
expect(screen.getByRole('button', {
|
||||
name: 'Polish animation, pet.window.status.running',
|
||||
})).not.toBeInTheDocument()
|
||||
})).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {
|
||||
name: 'pet.window.expand pet.window.sessionCount:2',
|
||||
name: 'pet.window.hideTasks:2',
|
||||
}))
|
||||
|
||||
expect(mocks.focusSession).not.toHaveBeenCalled()
|
||||
expect(mocks.updatePetPreferences).toHaveBeenCalledWith({ collapsed: false })
|
||||
expect(await screen.findByRole('button', {
|
||||
name: 'Polish animation, pet.window.status.running',
|
||||
})).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(2)
|
||||
expect(mocks.updatePetPreferences).toHaveBeenCalledWith({ showTaskPanel: false })
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'pet.window.expandTasks:2' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses to the compact row while keeping both pet regions interactive', async () => {
|
||||
it('hides the card while keeping the mascot and task badge interactive', async () => {
|
||||
render(<PetApp />)
|
||||
await screen.findByRole('button', {
|
||||
name: 'Build pet window, pet.window.status.running',
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(mocks.setInteractiveRegions).toHaveBeenCalledWith(expect.any(Array))
|
||||
expect(mocks.setInteractiveRegions.mock.calls.at(-1)?.[0]).toHaveLength(2)
|
||||
expect(mocks.setInteractiveRegions.mock.calls.at(-1)?.[0]).toHaveLength(3)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {
|
||||
name: 'pet.window.collapse pet.window.sessionCount:1',
|
||||
name: 'pet.window.hideTasks:1',
|
||||
}))
|
||||
|
||||
expect(mocks.updatePetPreferences).toHaveBeenCalledWith({ collapsed: true })
|
||||
expect(mocks.updatePetPreferences).toHaveBeenCalledWith({ showTaskPanel: false })
|
||||
expect(mocks.hidePet).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('textbox', { name: 'pet.window.followUp' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'pet.window.expandTasks:1' })).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(mocks.setInteractiveRegions.mock.calls.at(-1)?.[0]).toHaveLength(2)
|
||||
})
|
||||
@@ -408,7 +465,7 @@ describe('PetApp', () => {
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('pet.window.saveError')
|
||||
expect(mocks.hidePet).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', {
|
||||
name: 'pet.window.collapse pet.window.sessionCount:1',
|
||||
name: 'pet.window.hideTasks:1',
|
||||
})).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from 'react'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import {
|
||||
desktopUiPreferencesApi,
|
||||
type DesktopPetPreferences,
|
||||
@@ -83,7 +83,9 @@ export function PetApp() {
|
||||
const transientTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const stackRef = useRef<HTMLDivElement | null>(null)
|
||||
const mascotRef = useRef<HTMLButtonElement | null>(null)
|
||||
const taskBadgeRef = useRef<HTMLButtonElement | null>(null)
|
||||
const activityCardRef = useRef<HTMLElement | null>(null)
|
||||
const panelToggleRef = useRef<HTMLButtonElement | null>(null)
|
||||
const dragGestureRef = useRef<PetDragGesture | null>(null)
|
||||
const suppressNextMascotClickRef = useRef(false)
|
||||
const suppressClickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
@@ -323,11 +325,18 @@ export function PetApp() {
|
||||
const allPets: readonly PetDescriptor[] = [...BUILTIN_PETS, ...customPets]
|
||||
const selectedPet = allPets.find((pet) => pet.id === preferences?.selectedPetId)
|
||||
?? findBuiltinPet(preferences?.selectedPetId ?? '')
|
||||
const expanded = preferences ? !preferences.collapsed || Boolean(actionError) : false
|
||||
const showActivityCard = Boolean(actionError)
|
||||
|| Boolean(preferences?.showTaskPanel && activities.length > 0)
|
||||
const expanded = showActivityCard
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!preferences) return
|
||||
const elements = [mascotRef.current, activityCardRef.current]
|
||||
const elements = [
|
||||
mascotRef.current,
|
||||
taskBadgeRef.current,
|
||||
activityCardRef.current,
|
||||
panelToggleRef.current,
|
||||
]
|
||||
const targets = elements.filter((element) => element !== null) as HTMLElement[]
|
||||
const updateRegions = () => {
|
||||
const regions = targets.map((element) => {
|
||||
@@ -352,11 +361,13 @@ export function PetApp() {
|
||||
observer?.disconnect()
|
||||
window.removeEventListener('resize', updateRegions)
|
||||
}
|
||||
}, [expanded, preferences?.size, selectedPet])
|
||||
}, [activities.length, expanded, preferences?.size, selectedPet, showActivityCard])
|
||||
|
||||
const isInteractivePoint = useCallback((x: number, y: number) => [
|
||||
mascotRef.current,
|
||||
taskBadgeRef.current,
|
||||
activityCardRef.current,
|
||||
panelToggleRef.current,
|
||||
].some((element) => {
|
||||
if (!element) return false
|
||||
const rect = element.getBoundingClientRect()
|
||||
@@ -412,7 +423,9 @@ export function PetApp() {
|
||||
: null
|
||||
const remainsInteractive = nextNode !== null && [
|
||||
mascotRef.current,
|
||||
taskBadgeRef.current,
|
||||
activityCardRef.current,
|
||||
panelToggleRef.current,
|
||||
].some((element) => element?.contains(nextNode))
|
||||
if (remainsInteractive) return
|
||||
|
||||
@@ -458,121 +471,136 @@ export function PetApp() {
|
||||
void getDesktopHost().pets.setIgnoreMouseEvents(true)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
ref={mascotRef}
|
||||
type="button"
|
||||
className="pet-mascot-button"
|
||||
data-dragging={isMascotDragging ? 'true' : 'false'}
|
||||
data-drag-direction={dragDirection ?? undefined}
|
||||
aria-label={t('pet.window.interact')}
|
||||
onMouseEnter={() => {
|
||||
void getDesktopHost().pets.setIgnoreMouseEvents(false)
|
||||
if (!dragGestureRef.current && animationState === 'idle') {
|
||||
playTransient('jumping')
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
if (suppressNextMascotClickRef.current) {
|
||||
<div className="pet-mascot-wrap">
|
||||
<button
|
||||
ref={mascotRef}
|
||||
type="button"
|
||||
className="pet-mascot-button"
|
||||
data-dragging={isMascotDragging ? 'true' : 'false'}
|
||||
data-drag-direction={dragDirection ?? undefined}
|
||||
aria-label={t('pet.window.interact')}
|
||||
onMouseEnter={() => {
|
||||
void getDesktopHost().pets.setIgnoreMouseEvents(false)
|
||||
if (!dragGestureRef.current && animationState === 'idle') {
|
||||
playTransient('jumping')
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
if (suppressNextMascotClickRef.current) {
|
||||
suppressNextMascotClickRef.current = false
|
||||
if (suppressClickTimerRef.current) {
|
||||
clearTimeout(suppressClickTimerRef.current)
|
||||
suppressClickTimerRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
playTransient('waving')
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void getDesktopHost().pets.showContextMenu(t('pet.window.close'))
|
||||
.then((shouldClose) => {
|
||||
if (shouldClose) void closePet()
|
||||
})
|
||||
.catch(() => setActionError(t('pet.window.closeError')))
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0 || event.isPrimary === false || dragGestureRef.current) return
|
||||
suppressNextMascotClickRef.current = false
|
||||
if (suppressClickTimerRef.current) {
|
||||
clearTimeout(suppressClickTimerRef.current)
|
||||
suppressClickTimerRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
playTransient('waving')
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void getDesktopHost().pets.showContextMenu(t('pet.window.close'))
|
||||
.then((shouldClose) => {
|
||||
if (shouldClose) void closePet()
|
||||
})
|
||||
.catch(() => setActionError(t('pet.window.closeError')))
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0 || event.isPrimary === false || dragGestureRef.current) return
|
||||
suppressNextMascotClickRef.current = false
|
||||
if (suppressClickTimerRef.current) {
|
||||
clearTimeout(suppressClickTimerRef.current)
|
||||
suppressClickTimerRef.current = null
|
||||
}
|
||||
dragGestureRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startScreenX: event.screenX,
|
||||
startScreenY: event.screenY,
|
||||
directionScreenX: event.screenX,
|
||||
lastScreenX: event.screenX,
|
||||
lastScreenY: event.screenY,
|
||||
startPromise: null,
|
||||
}
|
||||
void getDesktopHost().pets.setIgnoreMouseEvents(false)
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
} catch {
|
||||
// Pointer capture can fail when the pointer has already been cancelled.
|
||||
}
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const gesture = dragGestureRef.current
|
||||
if (!gesture || gesture.pointerId !== event.pointerId || (event.buttons & 1) === 0) return
|
||||
gesture.lastScreenX = event.screenX
|
||||
gesture.lastScreenY = event.screenY
|
||||
if (!gesture.startPromise) {
|
||||
const distance = Math.hypot(
|
||||
event.screenX - gesture.startScreenX,
|
||||
event.screenY - gesture.startScreenY,
|
||||
)
|
||||
if (distance < PET_DRAG_THRESHOLD_PX) return
|
||||
suppressNextMascotClickRef.current = true
|
||||
setIsMascotDragging(true)
|
||||
gesture.startPromise = Promise.resolve().then(() =>
|
||||
getDesktopHost().pets.dragWindow({
|
||||
phase: 'start',
|
||||
x: gesture.startScreenX,
|
||||
y: gesture.startScreenY,
|
||||
}))
|
||||
void gesture.startPromise.catch(() => undefined)
|
||||
}
|
||||
const directionDelta = event.screenX - gesture.directionScreenX
|
||||
if (Math.abs(directionDelta) >= PET_DRAG_THRESHOLD_PX) {
|
||||
setDragDirection(directionDelta < 0 ? 'left' : 'right')
|
||||
gesture.directionScreenX = event.screenX
|
||||
}
|
||||
event.preventDefault()
|
||||
}}
|
||||
onPointerUp={(event) => finishMascotDrag(event, true)}
|
||||
onPointerCancel={(event) => finishMascotDrag(event, true)}
|
||||
onLostPointerCapture={(event) => finishMascotDrag(event, false)}
|
||||
onMouseLeave={(event) => releasePointerPassthrough(event.relatedTarget)}
|
||||
>
|
||||
<PetRenderer
|
||||
pet={selectedPet}
|
||||
state={animationState}
|
||||
size={preferences.size}
|
||||
motionEnabled={preferences.motionEnabled}
|
||||
lookDirection={animationState === 'idle'
|
||||
? lookDirection
|
||||
: undefined}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<section
|
||||
ref={activityCardRef}
|
||||
className="pet-activity-card"
|
||||
data-expanded={expanded ? 'true' : 'false'}
|
||||
aria-label={t('pet.window.sessionCount', { count: activities.length })}
|
||||
onMouseEnter={() => void getDesktopHost().pets.setIgnoreMouseEvents(false)}
|
||||
onMouseLeave={(event) => releasePointerPassthrough(event.relatedTarget)}
|
||||
>
|
||||
{actionError && (
|
||||
<p role="alert" className="mx-3 mt-2 rounded-lg bg-rose-500/10 px-3 py-2 text-xs text-rose-700 dark:text-rose-200">
|
||||
{actionError}
|
||||
</p>
|
||||
dragGestureRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startScreenX: event.screenX,
|
||||
startScreenY: event.screenY,
|
||||
directionScreenX: event.screenX,
|
||||
lastScreenX: event.screenX,
|
||||
lastScreenY: event.screenY,
|
||||
startPromise: null,
|
||||
}
|
||||
void getDesktopHost().pets.setIgnoreMouseEvents(false)
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
} catch {
|
||||
// Pointer capture can fail when the pointer has already been cancelled.
|
||||
}
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const gesture = dragGestureRef.current
|
||||
if (!gesture || gesture.pointerId !== event.pointerId || (event.buttons & 1) === 0) return
|
||||
gesture.lastScreenX = event.screenX
|
||||
gesture.lastScreenY = event.screenY
|
||||
if (!gesture.startPromise) {
|
||||
const distance = Math.hypot(
|
||||
event.screenX - gesture.startScreenX,
|
||||
event.screenY - gesture.startScreenY,
|
||||
)
|
||||
if (distance < PET_DRAG_THRESHOLD_PX) return
|
||||
suppressNextMascotClickRef.current = true
|
||||
setIsMascotDragging(true)
|
||||
gesture.startPromise = Promise.resolve().then(() =>
|
||||
getDesktopHost().pets.dragWindow({
|
||||
phase: 'start',
|
||||
x: gesture.startScreenX,
|
||||
y: gesture.startScreenY,
|
||||
}))
|
||||
void gesture.startPromise.catch(() => undefined)
|
||||
}
|
||||
const directionDelta = event.screenX - gesture.directionScreenX
|
||||
if (Math.abs(directionDelta) >= PET_DRAG_THRESHOLD_PX) {
|
||||
setDragDirection(directionDelta < 0 ? 'left' : 'right')
|
||||
gesture.directionScreenX = event.screenX
|
||||
}
|
||||
event.preventDefault()
|
||||
}}
|
||||
onPointerUp={(event) => finishMascotDrag(event, true)}
|
||||
onPointerCancel={(event) => finishMascotDrag(event, true)}
|
||||
onLostPointerCapture={(event) => finishMascotDrag(event, false)}
|
||||
onMouseLeave={(event) => releasePointerPassthrough(event.relatedTarget)}
|
||||
>
|
||||
<PetRenderer
|
||||
pet={selectedPet}
|
||||
state={animationState}
|
||||
size={preferences.size}
|
||||
motionEnabled={preferences.motionEnabled}
|
||||
lookDirection={animationState === 'idle'
|
||||
? lookDirection
|
||||
: undefined}
|
||||
/>
|
||||
</button>
|
||||
{activities.length > 0 && !showActivityCard && (
|
||||
<button
|
||||
ref={taskBadgeRef}
|
||||
type="button"
|
||||
className="pet-task-badge"
|
||||
aria-label={t('pet.window.expandTasks', { count: activities.length })}
|
||||
onMouseEnter={() => void getDesktopHost().pets.setIgnoreMouseEvents(false)}
|
||||
onMouseLeave={(event) => releasePointerPassthrough(event.relatedTarget)}
|
||||
onClick={() => void persistPreferences({ showTaskPanel: true })}
|
||||
>
|
||||
{activities.length}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showActivityCard && (
|
||||
<section
|
||||
ref={activityCardRef}
|
||||
className="pet-activity-card"
|
||||
data-expanded={expanded ? 'true' : 'false'}
|
||||
aria-label={t('pet.window.sessionCount', { count: activities.length })}
|
||||
onMouseEnter={() => void getDesktopHost().pets.setIgnoreMouseEvents(false)}
|
||||
onMouseLeave={(event) => releasePointerPassthrough(event.relatedTarget)}
|
||||
>
|
||||
{actionError && (
|
||||
<p role="alert" className="mx-3 mt-2 rounded-lg bg-rose-500/10 px-3 py-2 text-xs text-rose-700 dark:text-rose-200">
|
||||
{actionError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{activities.length > 0 ? (
|
||||
<div
|
||||
className="pet-session-list"
|
||||
data-expanded={expanded ? 'true' : 'false'}
|
||||
@@ -580,55 +608,46 @@ export function PetApp() {
|
||||
aria-live="polite"
|
||||
aria-label={t('pet.window.sessionCount', { count: activities.length })}
|
||||
>
|
||||
{activities.map((activity, index) => {
|
||||
{activities.map((activity) => {
|
||||
const title = activity.session.title || t('pet.window.untitledSession')
|
||||
const status = t(`pet.window.status.${activity.status}` as Parameters<typeof t>[0])
|
||||
const hiddenWhileCollapsed = !expanded && index > 0
|
||||
return (
|
||||
<div
|
||||
role="listitem"
|
||||
key={activity.session.id}
|
||||
aria-hidden={hiddenWhileCollapsed || undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="pet-session-row"
|
||||
aria-label={`${title}, ${status}`}
|
||||
tabIndex={hiddenWhileCollapsed ? -1 : 0}
|
||||
onClick={() => void getDesktopHost().pets.focusSession(activity.session.id)}
|
||||
>
|
||||
<span className={`pet-session-indicator ${statusDotClass(activity.status)}`} />
|
||||
<span className="pet-session-copy">
|
||||
<span className="pet-session-title">{title}</span>
|
||||
<span className="pet-session-status">{status}</span>
|
||||
</span>
|
||||
<span className={`pet-session-indicator ${statusDotClass(activity.status)}`} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="pet-empty-state" aria-live="polite">
|
||||
<span>{t('pet.window.noSessions')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="pet-panel-toggle"
|
||||
data-expanded={expanded ? 'true' : 'false'}
|
||||
aria-label={`${expanded ? t('pet.window.collapse') : t('pet.window.expand')} ${t(
|
||||
'pet.window.sessionCount',
|
||||
{ count: activities.length },
|
||||
)}`}
|
||||
onClick={() => {
|
||||
setActionError(null)
|
||||
void persistPreferences({ collapsed: expanded })
|
||||
}}
|
||||
>
|
||||
<ChevronUp className={expanded ? 'rotate-180' : ''} size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</section>
|
||||
<button
|
||||
ref={panelToggleRef}
|
||||
type="button"
|
||||
className="pet-panel-toggle"
|
||||
data-expanded={expanded ? 'true' : 'false'}
|
||||
aria-label={t('pet.window.hideTasks', { count: activities.length })}
|
||||
onClick={() => {
|
||||
setActionError(null)
|
||||
void persistPreferences({ showTaskPanel: false })
|
||||
}}
|
||||
>
|
||||
<ChevronDown size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -58,6 +58,7 @@ const defaultPetPreferences: DesktopPetPreferences = {
|
||||
enabled: false,
|
||||
selectedPetId: 'dada-code',
|
||||
size: 144,
|
||||
showTaskPanel: false,
|
||||
collapsed: false,
|
||||
motionEnabled: true,
|
||||
lastSessionId: null,
|
||||
@@ -67,7 +68,7 @@ function preferencesResponse(pet: DesktopPetPreferences) {
|
||||
return {
|
||||
exists: true,
|
||||
preferences: {
|
||||
schemaVersion: 3,
|
||||
schemaVersion: 4,
|
||||
sidebar: {
|
||||
projectOrder: [],
|
||||
pinnedProjects: [],
|
||||
@@ -156,6 +157,20 @@ describe('PetSettings', () => {
|
||||
expect(hidePetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the task panel disabled by default and persists enabling it', async () => {
|
||||
render(<PetSettings />)
|
||||
|
||||
const panelToggle = await screen.findByRole('checkbox', { name: 'Show active task panel' })
|
||||
expect(panelToggle).not.toBeChecked()
|
||||
expect(screen.queryByRole('checkbox', { name: 'Start collapsed' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(panelToggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updatePetPreferencesMock).toHaveBeenCalledWith({ showTaskPanel: true })
|
||||
})
|
||||
})
|
||||
|
||||
it('rolls back optimistic preferences when persistence fails', async () => {
|
||||
updatePetPreferencesMock.mockRejectedValueOnce(new Error('disk full'))
|
||||
render(<PetSettings />)
|
||||
@@ -232,12 +247,12 @@ describe('PetSettings', () => {
|
||||
|
||||
fireEvent.change(screen.getByRole('slider', { name: 'Pet size' }), { target: { value: '176' } })
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Play animations' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Start collapsed' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Show active task panel' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updatePetPreferencesMock).toHaveBeenCalledWith({ size: 176 })
|
||||
expect(updatePetPreferencesMock).toHaveBeenCalledWith({ motionEnabled: false })
|
||||
expect(updatePetPreferencesMock).toHaveBeenCalledWith({ collapsed: true })
|
||||
expect(updatePetPreferencesMock).toHaveBeenCalledWith({ showTaskPanel: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -319,6 +334,8 @@ describe('PetSettings', () => {
|
||||
slug: 'orbit-fox',
|
||||
displayName: 'Orbit Fox',
|
||||
description: 'A bright local companion.',
|
||||
dialogTitle: 'Choose a transparent pet image',
|
||||
dialogFilterName: 'Pet image',
|
||||
}))
|
||||
expect(createFromAtlasMock).not.toHaveBeenCalled()
|
||||
expect(updatePetPreferencesMock).toHaveBeenCalledWith({ selectedPetId: 'custom:orbit-fox' })
|
||||
@@ -365,13 +382,15 @@ describe('PetSettings', () => {
|
||||
slug: 'orbit-fox',
|
||||
displayName: 'Orbit Fox',
|
||||
description: 'A bright local companion.',
|
||||
dialogTitle: 'Choose a v2 pet animation atlas',
|
||||
dialogFilterName: 'Pet animation atlas',
|
||||
}))
|
||||
expect(updatePetPreferencesMock).toHaveBeenCalledWith({ selectedPetId: 'custom:orbit-fox' })
|
||||
expect(await screen.findByText('Orbit Fox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the create dialog open and shows validation failures', async () => {
|
||||
createFromAtlasMock.mockRejectedValueOnce(new Error('The spritesheet image must be 1536x2288.'))
|
||||
it('keeps the create dialog open and localizes validation failures', async () => {
|
||||
createFromAtlasMock.mockResolvedValueOnce({ errorCode: 'invalid-image-dimensions' })
|
||||
render(<PetSettings />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Add pet' }))
|
||||
@@ -381,7 +400,7 @@ describe('PetSettings', () => {
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Description' }), { target: { value: 'Needs repair.' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose atlas and create' }))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('The spritesheet image must be 1536x2288.')
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('The animation atlas must be exactly 1536×2288 pixels.')
|
||||
expect(screen.getByRole('dialog', { name: 'Create a custom pet' })).toBeInTheDocument()
|
||||
expect(updatePetPreferencesMock).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||
selectedPetId: 'custom:bad-atlas',
|
||||
@@ -429,4 +448,25 @@ describe('PetSettings', () => {
|
||||
expect(await screen.findByRole('heading', { name: 'Built-in pets' })).toBeInTheDocument()
|
||||
expect(getPreferencesMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('uses the active locale for native picker labels and image validation', async () => {
|
||||
useSettingsStore.setState({ locale: 'zh' })
|
||||
createFromImageMock.mockResolvedValueOnce({ errorCode: 'invalid-image-dimensions' })
|
||||
render(<PetSettings />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '添加宠物' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /用一张图片制作轻动画/ }))
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '宠物 ID' }), { target: { value: 'bad-image' } })
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '显示名称' }), { target: { value: 'Bad Image' } })
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '宠物描述' }), { target: { value: 'Needs repair.' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择图片并创建' }))
|
||||
|
||||
await waitFor(() => expect(createFromImageMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
dialogTitle: '选择透明背景的宠物图片',
|
||||
dialogFilterName: '宠物图片',
|
||||
})))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'宠物图片每边必须为 32–4096 像素,且总像素数不能超过 16,777,216。',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '../../api/desktopUiPreferences'
|
||||
import { Button } from '../../components/shared/Button'
|
||||
import { Modal } from '../../components/shared/Modal'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useTranslation, type TranslationKey } from '../../i18n'
|
||||
import { getDesktopHost } from '../../lib/desktopHost'
|
||||
import { BUILTIN_PETS } from './builtinPets'
|
||||
import { PetRenderer } from './PetRenderer'
|
||||
@@ -17,6 +17,33 @@ const PET_SIZE_MAX = 192
|
||||
const PET_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
type PetCreationMethod = 'image' | 'atlas'
|
||||
|
||||
const PET_CREATE_ERROR_KEYS: Record<string, TranslationKey> = {
|
||||
'invalid-id': 'settings.pets.createError.invalidId',
|
||||
'duplicate-id': 'settings.pets.createError.duplicateId',
|
||||
'unsupported-image-format': 'settings.pets.createError.unsupportedFormat',
|
||||
'image-too-large': 'settings.pets.createError.imageTooLarge',
|
||||
'total-image-bytes-exceeded': 'settings.pets.createError.imageTooLarge',
|
||||
'decode-budget-exceeded': 'settings.pets.createError.imageTooLarge',
|
||||
'invalid-image': 'settings.pets.createError.invalidImage',
|
||||
'missing-image': 'settings.pets.createError.invalidImage',
|
||||
'symlink-image': 'settings.pets.createError.invalidImage',
|
||||
'invalid-renderer': 'settings.pets.createError.invalidImage',
|
||||
'invalid-sprite-version': 'settings.pets.createError.invalidImage',
|
||||
'invalid-manifest-version': 'settings.pets.createError.invalidImage',
|
||||
'root-invalid': 'settings.pets.createError.storage',
|
||||
'directory-changed': 'settings.pets.createError.storage',
|
||||
'io-error': 'settings.pets.createError.storage',
|
||||
}
|
||||
|
||||
function petCreateErrorKey(method: PetCreationMethod, code: string): TranslationKey {
|
||||
if (code === 'invalid-image-dimensions') {
|
||||
return method === 'image'
|
||||
? 'settings.pets.createError.imageDimensions'
|
||||
: 'settings.pets.createError.atlasDimensions'
|
||||
}
|
||||
return PET_CREATE_ERROR_KEYS[code] ?? 'settings.pets.createError'
|
||||
}
|
||||
|
||||
export function PetSettings() {
|
||||
const t = useTranslation()
|
||||
const desktopAvailable = getDesktopHost().isDesktop
|
||||
@@ -184,20 +211,24 @@ export function PetSettings() {
|
||||
setSaveError(null)
|
||||
|
||||
const host = getDesktopHost()
|
||||
let created: { id: string } | null
|
||||
let created: { id: string } | { errorCode: string } | null
|
||||
try {
|
||||
const input = {
|
||||
slug: createForm.slug,
|
||||
displayName: createForm.displayName.trim(),
|
||||
description: createForm.description.trim(),
|
||||
dialogTitle: createMethod === 'image'
|
||||
? t('settings.pets.dialog.imageTitle')
|
||||
: t('settings.pets.dialog.atlasTitle'),
|
||||
dialogFilterName: createMethod === 'image'
|
||||
? t('settings.pets.dialog.imageFilter')
|
||||
: t('settings.pets.dialog.atlasFilter'),
|
||||
}
|
||||
created = createMethod === 'image'
|
||||
? await host.pets.createFromImage(input)
|
||||
: await host.pets.createFromAtlas(input)
|
||||
} catch (error) {
|
||||
setCreateError(error instanceof Error && error.message
|
||||
? error.message
|
||||
: t('settings.pets.createError'))
|
||||
} catch {
|
||||
setCreateError(t('settings.pets.createError'))
|
||||
setCreateBusy(false)
|
||||
return
|
||||
}
|
||||
@@ -207,6 +238,12 @@ export function PetSettings() {
|
||||
return
|
||||
}
|
||||
|
||||
if ('errorCode' in created) {
|
||||
setCreateError(t(petCreateErrorKey(createMethod, created.errorCode)))
|
||||
setCreateBusy(false)
|
||||
return
|
||||
}
|
||||
|
||||
resetCreateDialog()
|
||||
|
||||
try {
|
||||
@@ -347,10 +384,10 @@ export function PetSettings() {
|
||||
</div>
|
||||
<div className="border-t border-[var(--color-border)]/70 pt-4">
|
||||
<ToggleRow
|
||||
label={t('settings.pets.collapsed')}
|
||||
description={t('settings.pets.collapsedDescription')}
|
||||
checked={preferences.collapsed}
|
||||
onChange={(checked) => void updatePreferences({ collapsed: checked })}
|
||||
label={t('settings.pets.showTaskPanel')}
|
||||
description={t('settings.pets.showTaskPanelDescription')}
|
||||
checked={preferences.showTaskPanel}
|
||||
onChange={(checked) => void updatePreferences({ showTaskPanel: checked })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -47,7 +47,7 @@ function chat(overrides: Partial<PerSessionState>): PerSessionState {
|
||||
}
|
||||
|
||||
describe('pet session activity model', () => {
|
||||
it('maps permission, failure, running, review, and idle in priority order', () => {
|
||||
it('maps actionable states in priority order and treats completed review markers as idle', () => {
|
||||
const sessions = [
|
||||
session('idle', '2026-07-19T12:04:00Z'),
|
||||
session('running', '2026-07-19T12:03:00Z'),
|
||||
@@ -70,9 +70,9 @@ describe('pet session activity model', () => {
|
||||
expect(activities.map(({ session: item, status }) => [item.id, status])).toEqual([
|
||||
['waiting', 'waiting'],
|
||||
['failed', 'failed'],
|
||||
['review', 'review'],
|
||||
['running', 'running'],
|
||||
['idle', 'idle'],
|
||||
['review', 'idle'],
|
||||
])
|
||||
})
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('pet session activity model', () => {
|
||||
)).toBe('waiting')
|
||||
})
|
||||
|
||||
it('counts running background agents and read-only observed sessions', () => {
|
||||
it('counts running background agents and ignores legacy review markers', () => {
|
||||
expect(resolvePetSessionStatus(chat({
|
||||
backgroundAgentTasks: {
|
||||
agent: {
|
||||
@@ -124,7 +124,7 @@ describe('pet session activity model', () => {
|
||||
chats: {},
|
||||
observedStatuses: { remote: 'review' },
|
||||
})
|
||||
expect(activity?.status).toBe('review')
|
||||
expect(activity?.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('shows current streaming progress before the previous assistant reply', () => {
|
||||
|
||||
@@ -32,7 +32,9 @@ export function resolvePetSessionStatus(
|
||||
if (chat?.messages.at(-1)?.type === 'error') return 'failed'
|
||||
if (observed === 'failed') return 'failed'
|
||||
if (chat?.historyStatus === 'error') return 'failed'
|
||||
if (review || observed === 'review') return 'review'
|
||||
// Older servers reported every successful turn as review while its tab was
|
||||
// still open. Completion is idle unless a real actionable state is present.
|
||||
if (review || observed === 'review') return 'idle'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,15 @@ describe('useTranslation', () => {
|
||||
expect(translate('kr', 'session.timeMinutes', { n: 5 })).toBe('5분 전')
|
||||
})
|
||||
|
||||
it('localizes pet validation and agent failures in every registered locale', () => {
|
||||
for (const locale of ['en', 'zh', 'zh-TW', 'jp', 'kr'] as const) {
|
||||
expect(translate(locale, 'settings.pets.createError.imageDimensions')).not.toBe(
|
||||
'settings.pets.createError.imageDimensions',
|
||||
)
|
||||
expect(translate(locale, 'settings.agents.loadError')).not.toBe('settings.agents.loadError')
|
||||
}
|
||||
})
|
||||
|
||||
it('describes exactly the standard ~/.claude mode and an external custom mode', () => {
|
||||
expect(translate('en', 'settings.general.storageSystemDescription')).toContain('~/.claude')
|
||||
expect(translate('zh', 'settings.general.storageSystemDescription')).toContain('~/.claude')
|
||||
|
||||
@@ -277,6 +277,18 @@ export const en = {
|
||||
'settings.pets.createCancel': 'Cancel',
|
||||
'settings.pets.createSubmit': 'Create',
|
||||
'settings.pets.createError': 'The custom pet could not be created.',
|
||||
'settings.pets.createError.invalidId': 'Use a lowercase pet ID containing only letters, numbers, and single hyphens.',
|
||||
'settings.pets.createError.duplicateId': 'A custom pet with this ID already exists.',
|
||||
'settings.pets.createError.unsupportedFormat': 'Choose a PNG or WebP image.',
|
||||
'settings.pets.createError.imageTooLarge': 'The selected image must be no larger than 8 MB.',
|
||||
'settings.pets.createError.imageDimensions': 'The pet image must be 32–4096 pixels per side and contain no more than 16,777,216 pixels.',
|
||||
'settings.pets.createError.atlasDimensions': 'The animation atlas must be exactly 1536×2288 pixels.',
|
||||
'settings.pets.createError.invalidImage': 'The selected image could not be read. Choose a static PNG or WebP image.',
|
||||
'settings.pets.createError.storage': 'The pet could not be saved in the custom pet folder.',
|
||||
'settings.pets.dialog.imageTitle': 'Choose a transparent pet image',
|
||||
'settings.pets.dialog.imageFilter': 'Pet image',
|
||||
'settings.pets.dialog.atlasTitle': 'Choose a v2 pet animation atlas',
|
||||
'settings.pets.dialog.atlasFilter': 'Pet animation atlas',
|
||||
'settings.pets.builtInTitle': 'Built-in pets',
|
||||
'settings.pets.customTitle': 'Your pets',
|
||||
'settings.pets.customEmpty': 'No custom pets yet.',
|
||||
@@ -287,6 +299,8 @@ export const en = {
|
||||
'settings.pets.sizeDescription': 'Adjust the pet between 96 and 192 pixels.',
|
||||
'settings.pets.motion': 'Play animations',
|
||||
'settings.pets.motionDescription': 'Turn off movement while keeping the pet available.',
|
||||
'settings.pets.showTaskPanel': 'Show active task panel',
|
||||
'settings.pets.showTaskPanelDescription': 'Show the rectangular task panel while tasks are running. It stays hidden when no task is active.',
|
||||
'settings.pets.collapsed': 'Start collapsed',
|
||||
'settings.pets.collapsedDescription': 'Show only the pet until you open its task panel.',
|
||||
'settings.pets.folderTitle': 'Custom pet folder',
|
||||
@@ -313,6 +327,8 @@ export const en = {
|
||||
'pet.window.status.running': 'Working',
|
||||
'pet.window.status.idle': 'Idle',
|
||||
'pet.window.sessionCount': '{count} tasks',
|
||||
'pet.window.expandTasks': 'Show {count} active tasks',
|
||||
'pet.window.hideTasks': 'Hide {count} active tasks',
|
||||
'pet.window.untitledSession': 'Untitled task',
|
||||
|
||||
// Settings > Usage
|
||||
@@ -860,6 +876,7 @@ export const en = {
|
||||
// Settings > Agents
|
||||
'settings.tab.agents': 'Agents',
|
||||
'settings.agents.title': 'Installed Agents',
|
||||
'settings.agents.loadError': 'Failed to load agents',
|
||||
'settings.agents.description': 'Browse agents available to Claude across built-in, project, and user sources. Use /agents in TUI to inspect active sources and overrides.',
|
||||
'settings.agents.browserTitle': 'Browse installed agents',
|
||||
'settings.agents.browserEyebrow': 'Agent Browser',
|
||||
@@ -936,12 +953,42 @@ export const en = {
|
||||
'settings.agents.form.toolsInherit': 'All tools',
|
||||
'settings.agents.form.toolsNone': 'No tools',
|
||||
'settings.agents.form.toolsCustom': 'Custom list',
|
||||
'settings.agents.form.toolsCustomLabel': 'Allowed tools',
|
||||
'settings.agents.form.toolsPlaceholder': 'Read, Grep, Bash',
|
||||
'settings.agents.form.toolsHint': 'Enter a comma-separated list of allowed tools.',
|
||||
'settings.agents.form.toolsCustomLabel': 'Other tool names or permission patterns',
|
||||
'settings.agents.form.toolsPlaceholder': 'mcp__docs__search, Bash(git:*)',
|
||||
'settings.agents.form.toolsHint': 'Select the built-in tools this agent needs. Add custom or MCP tools below.',
|
||||
'settings.agents.form.builtInTools': 'Built-in tools',
|
||||
'settings.agents.form.builtInToolsHint': 'Available in the current runtime. Grant only what this Agent needs.',
|
||||
'settings.agents.form.toolsSelectedCount': '{count} selected',
|
||||
'settings.agents.form.toolsSearch': 'Search tools',
|
||||
'settings.agents.form.toolsSearchPlaceholder': 'Search by name or purpose',
|
||||
'settings.agents.form.toolsNoResults': 'No matching tools',
|
||||
'settings.agents.form.toolsUnavailable': 'The built-in tool list is unavailable. You can still enter exact tool names below.',
|
||||
'settings.agents.form.toolsCustomHint': 'Optional. Use this for MCP tools, custom tools, or permission patterns such as Bash(git:*).',
|
||||
'settings.agents.form.toolCategory.readSearch': 'Read and search',
|
||||
'settings.agents.form.toolCategory.modify': 'Modify files',
|
||||
'settings.agents.form.toolCategory.execute': 'Run commands',
|
||||
'settings.agents.form.toolCategory.workflow': 'Workflow',
|
||||
'settings.agents.form.toolCategory.other': 'Other',
|
||||
'settings.agents.form.toolDescription.Read': 'Read files and directories',
|
||||
'settings.agents.form.toolDescription.Glob': 'Find files by name or path pattern',
|
||||
'settings.agents.form.toolDescription.Grep': 'Search files by text or pattern',
|
||||
'settings.agents.form.toolDescription.WebFetch': 'Read content from a web page',
|
||||
'settings.agents.form.toolDescription.WebSearch': 'Search the web',
|
||||
'settings.agents.form.toolDescription.Edit': 'Apply targeted edits to existing files',
|
||||
'settings.agents.form.toolDescription.Write': 'Create or replace files',
|
||||
'settings.agents.form.toolDescription.NotebookEdit': 'Edit Jupyter notebook cells',
|
||||
'settings.agents.form.toolDescription.Bash': 'Run terminal commands in a Unix shell',
|
||||
'settings.agents.form.toolDescription.PowerShell': 'Run Windows PowerShell commands',
|
||||
'settings.agents.form.toolDescription.TodoWrite': 'Maintain a task checklist',
|
||||
'settings.agents.form.toolDescription.Skill': 'Load an installed skill workflow',
|
||||
'settings.agents.form.toolDescription.ToolSearch': 'Discover deferred tools by capability',
|
||||
'settings.agents.form.toolDescription.EnterWorktree': 'Create and enter an isolated Git worktree',
|
||||
'settings.agents.form.toolDescription.ExitWorktree': 'Leave and clean up an Agent worktree',
|
||||
'settings.agents.form.toolDescription.StructuredOutput': 'Return schema-validated structured data',
|
||||
'settings.agents.form.toolDescription.generic': 'Use this built-in runtime capability',
|
||||
'settings.agents.form.toolsInheritHint': 'The tools field is omitted, which gives this agent access to all tools.',
|
||||
'settings.agents.form.toolsNoneHint': 'An empty tools list is saved, so this agent cannot use tools.',
|
||||
'settings.agents.form.toolsCustomRequired': 'Enter at least one allowed tool.',
|
||||
'settings.agents.form.toolsCustomRequired': 'Select or enter at least one allowed tool.',
|
||||
'settings.agents.form.color': 'Color',
|
||||
'settings.agents.form.noColor': 'Default',
|
||||
'settings.agents.form.saveError': 'Failed to save agent',
|
||||
|
||||
@@ -279,6 +279,18 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'settings.pets.createCancel': 'キャンセル',
|
||||
'settings.pets.createSubmit': '作成',
|
||||
'settings.pets.createError': 'カスタムペットを作成できませんでした。',
|
||||
'settings.pets.createError.invalidId': 'ペット ID には小文字、数字、単一のハイフンのみ使用できます。',
|
||||
'settings.pets.createError.duplicateId': 'この ID のカスタムペットはすでに存在します。',
|
||||
'settings.pets.createError.unsupportedFormat': 'PNG または WebP 画像を選択してください。',
|
||||
'settings.pets.createError.imageTooLarge': '選択する画像は 8 MB 以下にしてください。',
|
||||
'settings.pets.createError.imageDimensions': 'ペット画像は各辺 32~4096 ピクセル、総画素数 16,777,216 以下である必要があります。',
|
||||
'settings.pets.createError.atlasDimensions': 'アニメーションアトラスは正確に 1536×2288 ピクセルである必要があります。',
|
||||
'settings.pets.createError.invalidImage': '選択した画像を読み込めません。静止 PNG または WebP 画像を選択してください。',
|
||||
'settings.pets.createError.storage': 'カスタムペットフォルダーにペットを保存できませんでした。',
|
||||
'settings.pets.dialog.imageTitle': '透明背景のペット画像を選択',
|
||||
'settings.pets.dialog.imageFilter': 'ペット画像',
|
||||
'settings.pets.dialog.atlasTitle': 'v2 ペットアニメーションアトラスを選択',
|
||||
'settings.pets.dialog.atlasFilter': 'ペットアニメーションアトラス',
|
||||
'settings.pets.builtInTitle': '内蔵ペット',
|
||||
'settings.pets.customTitle': 'あなたのペット',
|
||||
'settings.pets.customEmpty': 'カスタムペットはまだありません。',
|
||||
@@ -289,6 +301,8 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'settings.pets.sizeDescription': '96〜192ピクセルの範囲で調整します。',
|
||||
'settings.pets.motion': 'アニメーションを再生',
|
||||
'settings.pets.motionDescription': 'オフにしてもペットは利用できますが、動きません。',
|
||||
'settings.pets.showTaskPanel': '実行中のタスクパネルを表示',
|
||||
'settings.pets.showTaskPanelDescription': 'タスクの実行中だけ長方形のパネルを表示します。実行中のタスクがなければ常に非表示です。',
|
||||
'settings.pets.collapsed': '最初は折りたたむ',
|
||||
'settings.pets.collapsedDescription': 'タスクパネルを開くまでペットだけを表示します。',
|
||||
'settings.pets.folderTitle': 'カスタムペットフォルダー',
|
||||
@@ -315,6 +329,8 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'pet.window.status.running': '作業中',
|
||||
'pet.window.status.idle': '待機中',
|
||||
'pet.window.sessionCount': '{count} 件のタスク',
|
||||
'pet.window.expandTasks': '実行中のタスク {count} 件を表示',
|
||||
'pet.window.hideTasks': '実行中のタスク {count} 件を隠す',
|
||||
'pet.window.untitledSession': '無題のタスク',
|
||||
|
||||
// Settings > Usage
|
||||
@@ -862,6 +878,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
// Settings > Agents
|
||||
'settings.tab.agents': 'エージェント',
|
||||
'settings.agents.title': 'インストール済みエージェント',
|
||||
'settings.agents.loadError': 'エージェントを読み込めませんでした',
|
||||
'settings.agents.description': '組み込み、プロジェクト、ユーザーの各ソースにわたって Claude が利用できるエージェントを閲覧します。TUI で /agents を使うと、アクティブなソースとオーバーライドを確認できます。',
|
||||
'settings.agents.browserTitle': 'インストール済みエージェントを閲覧',
|
||||
'settings.agents.browserEyebrow': 'エージェントブラウザ',
|
||||
@@ -938,12 +955,42 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'settings.agents.form.toolsInherit': 'すべてのツール',
|
||||
'settings.agents.form.toolsNone': 'ツールを許可しない',
|
||||
'settings.agents.form.toolsCustom': 'カスタムリスト',
|
||||
'settings.agents.form.toolsCustomLabel': '許可するツール',
|
||||
'settings.agents.form.toolsPlaceholder': 'Read, Grep, Bash',
|
||||
'settings.agents.form.toolsHint': '許可するツールをカンマ区切りで入力します。',
|
||||
'settings.agents.form.toolsCustomLabel': 'その他のツール名または権限パターン',
|
||||
'settings.agents.form.toolsPlaceholder': 'mcp__docs__search, Bash(git:*)',
|
||||
'settings.agents.form.toolsHint': 'この Agent に必要な組み込みツールを選択し、カスタムや MCP ツールは下に追加します。',
|
||||
'settings.agents.form.builtInTools': '組み込みツール',
|
||||
'settings.agents.form.builtInToolsHint': '現在の実行環境で利用可能です。Agent に必要な機能だけを許可してください。',
|
||||
'settings.agents.form.toolsSelectedCount': '{count} 件選択',
|
||||
'settings.agents.form.toolsSearch': 'ツールを検索',
|
||||
'settings.agents.form.toolsSearchPlaceholder': '名前または用途で検索',
|
||||
'settings.agents.form.toolsNoResults': '一致するツールはありません',
|
||||
'settings.agents.form.toolsUnavailable': '組み込みツール一覧を取得できません。下に正確なツール名を入力できます。',
|
||||
'settings.agents.form.toolsCustomHint': '任意。MCP、カスタムツール、Bash(git:*) のような権限パターンに使用します。',
|
||||
'settings.agents.form.toolCategory.readSearch': '読み取りと検索',
|
||||
'settings.agents.form.toolCategory.modify': 'ファイル変更',
|
||||
'settings.agents.form.toolCategory.execute': 'コマンド実行',
|
||||
'settings.agents.form.toolCategory.workflow': 'ワークフロー',
|
||||
'settings.agents.form.toolCategory.other': 'その他',
|
||||
'settings.agents.form.toolDescription.Read': 'ファイルとディレクトリを読み取る',
|
||||
'settings.agents.form.toolDescription.Glob': '名前やパスのパターンでファイルを探す',
|
||||
'settings.agents.form.toolDescription.Grep': 'テキストやパターンでファイルを検索する',
|
||||
'settings.agents.form.toolDescription.WebFetch': 'Web ページの内容を読み取る',
|
||||
'settings.agents.form.toolDescription.WebSearch': 'Web を検索する',
|
||||
'settings.agents.form.toolDescription.Edit': '既存ファイルを部分的に編集する',
|
||||
'settings.agents.form.toolDescription.Write': 'ファイルを作成または上書きする',
|
||||
'settings.agents.form.toolDescription.NotebookEdit': 'Jupyter Notebook のセルを編集する',
|
||||
'settings.agents.form.toolDescription.Bash': 'Unix Shell で端末コマンドを実行する',
|
||||
'settings.agents.form.toolDescription.PowerShell': 'Windows PowerShell コマンドを実行する',
|
||||
'settings.agents.form.toolDescription.TodoWrite': 'タスクリストを管理する',
|
||||
'settings.agents.form.toolDescription.Skill': 'インストール済み Skill のワークフローを読み込む',
|
||||
'settings.agents.form.toolDescription.ToolSearch': '機能から遅延読み込みツールを探す',
|
||||
'settings.agents.form.toolDescription.EnterWorktree': '分離された Git Worktree を作成して入る',
|
||||
'settings.agents.form.toolDescription.ExitWorktree': 'Agent Worktree を終了して整理する',
|
||||
'settings.agents.form.toolDescription.StructuredOutput': 'Schema 検証済みの構造化データを返す',
|
||||
'settings.agents.form.toolDescription.generic': 'この組み込み実行機能を使用する',
|
||||
'settings.agents.form.toolsInheritHint': 'tools フィールドを省略し、このエージェントにすべてのツールを許可します。',
|
||||
'settings.agents.form.toolsNoneHint': '空の tools リストを保存し、このエージェントによるツール使用を禁止します。',
|
||||
'settings.agents.form.toolsCustomRequired': '許可するツールを1つ以上入力してください。',
|
||||
'settings.agents.form.toolsCustomRequired': '許可するツールを1つ以上選択または入力してください。',
|
||||
'settings.agents.form.color': '色',
|
||||
'settings.agents.form.noColor': 'デフォルト',
|
||||
'settings.agents.form.saveError': 'エージェントの保存に失敗しました',
|
||||
|
||||
@@ -279,6 +279,18 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'settings.pets.createCancel': '취소',
|
||||
'settings.pets.createSubmit': '만들기',
|
||||
'settings.pets.createError': '사용자 지정 펫을 만들 수 없습니다.',
|
||||
'settings.pets.createError.invalidId': '펫 ID에는 소문자, 숫자, 단일 하이픈만 사용할 수 있습니다.',
|
||||
'settings.pets.createError.duplicateId': '이 ID를 사용하는 사용자 지정 펫이 이미 있습니다.',
|
||||
'settings.pets.createError.unsupportedFormat': 'PNG 또는 WebP 이미지를 선택하세요.',
|
||||
'settings.pets.createError.imageTooLarge': '선택한 이미지는 8MB 이하여야 합니다.',
|
||||
'settings.pets.createError.imageDimensions': '펫 이미지는 각 변이 32~4096픽셀이고 전체 픽셀 수가 16,777,216 이하여야 합니다.',
|
||||
'settings.pets.createError.atlasDimensions': '애니메이션 아틀라스는 정확히 1536×2288픽셀이어야 합니다.',
|
||||
'settings.pets.createError.invalidImage': '선택한 이미지를 읽을 수 없습니다. 정적 PNG 또는 WebP 이미지를 선택하세요.',
|
||||
'settings.pets.createError.storage': '사용자 지정 펫 폴더에 펫을 저장할 수 없습니다.',
|
||||
'settings.pets.dialog.imageTitle': '투명 배경 펫 이미지 선택',
|
||||
'settings.pets.dialog.imageFilter': '펫 이미지',
|
||||
'settings.pets.dialog.atlasTitle': 'v2 펫 애니메이션 아틀라스 선택',
|
||||
'settings.pets.dialog.atlasFilter': '펫 애니메이션 아틀라스',
|
||||
'settings.pets.builtInTitle': '기본 펫',
|
||||
'settings.pets.customTitle': '내 펫',
|
||||
'settings.pets.customEmpty': '아직 사용자 지정 펫이 없습니다.',
|
||||
@@ -289,6 +301,8 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'settings.pets.sizeDescription': '96~192픽셀 사이에서 조절합니다.',
|
||||
'settings.pets.motion': '애니메이션 재생',
|
||||
'settings.pets.motionDescription': '꺼도 펫은 사용할 수 있지만 움직이지 않습니다.',
|
||||
'settings.pets.showTaskPanel': '진행 중인 작업 패널 표시',
|
||||
'settings.pets.showTaskPanelDescription': '작업이 실행 중일 때만 사각형 작업 패널을 표시합니다. 진행 중인 작업이 없으면 항상 숨깁니다.',
|
||||
'settings.pets.collapsed': '접힌 상태로 시작',
|
||||
'settings.pets.collapsedDescription': '작업 패널을 열기 전에는 펫만 표시합니다.',
|
||||
'settings.pets.folderTitle': '사용자 지정 펫 폴더',
|
||||
@@ -315,6 +329,8 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'pet.window.status.running': '작업 중',
|
||||
'pet.window.status.idle': '대기 중',
|
||||
'pet.window.sessionCount': '작업 {count}개',
|
||||
'pet.window.expandTasks': '진행 중인 작업 {count}개 표시',
|
||||
'pet.window.hideTasks': '진행 중인 작업 {count}개 숨기기',
|
||||
'pet.window.untitledSession': '제목 없는 작업',
|
||||
|
||||
// Settings > Usage
|
||||
@@ -862,6 +878,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
// Settings > Agents
|
||||
'settings.tab.agents': '에이전트',
|
||||
'settings.agents.title': '설치된 에이전트',
|
||||
'settings.agents.loadError': '에이전트를 불러오지 못했습니다',
|
||||
'settings.agents.description': '기본 제공, 프로젝트, 사용자 소스 전반에서 Claude가 사용할 수 있는 에이전트를 살펴봅니다. TUI에서 /agents를 사용하면 활성 소스와 재정의를 확인할 수 있습니다.',
|
||||
'settings.agents.browserTitle': '설치된 에이전트 살펴보기',
|
||||
'settings.agents.browserEyebrow': '에이전트 브라우저',
|
||||
@@ -938,12 +955,42 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'settings.agents.form.toolsInherit': '모든 도구',
|
||||
'settings.agents.form.toolsNone': '도구 사용 안 함',
|
||||
'settings.agents.form.toolsCustom': '사용자 지정 목록',
|
||||
'settings.agents.form.toolsCustomLabel': '허용할 도구',
|
||||
'settings.agents.form.toolsPlaceholder': 'Read, Grep, Bash',
|
||||
'settings.agents.form.toolsHint': '허용할 도구를 쉼표로 구분해 입력합니다.',
|
||||
'settings.agents.form.toolsCustomLabel': '기타 도구 이름 또는 권한 패턴',
|
||||
'settings.agents.form.toolsPlaceholder': 'mcp__docs__search, Bash(git:*)',
|
||||
'settings.agents.form.toolsHint': '이 Agent에 필요한 기본 제공 도구를 선택하고 사용자 지정 및 MCP 도구는 아래에 추가하세요.',
|
||||
'settings.agents.form.builtInTools': '기본 제공 도구',
|
||||
'settings.agents.form.builtInToolsHint': '현재 실행 환경에서 사용할 수 있습니다. Agent에 필요한 기능만 허용하세요.',
|
||||
'settings.agents.form.toolsSelectedCount': '{count}개 선택',
|
||||
'settings.agents.form.toolsSearch': '도구 검색',
|
||||
'settings.agents.form.toolsSearchPlaceholder': '이름 또는 용도로 검색',
|
||||
'settings.agents.form.toolsNoResults': '일치하는 도구가 없습니다',
|
||||
'settings.agents.form.toolsUnavailable': '기본 제공 도구 목록을 가져올 수 없습니다. 아래에 정확한 도구 이름을 입력할 수 있습니다.',
|
||||
'settings.agents.form.toolsCustomHint': '선택 사항입니다. MCP, 사용자 지정 도구 또는 Bash(git:*) 같은 권한 패턴에 사용합니다.',
|
||||
'settings.agents.form.toolCategory.readSearch': '읽기 및 검색',
|
||||
'settings.agents.form.toolCategory.modify': '파일 수정',
|
||||
'settings.agents.form.toolCategory.execute': '명령 실행',
|
||||
'settings.agents.form.toolCategory.workflow': '워크플로',
|
||||
'settings.agents.form.toolCategory.other': '기타',
|
||||
'settings.agents.form.toolDescription.Read': '파일과 디렉터리 읽기',
|
||||
'settings.agents.form.toolDescription.Glob': '이름 또는 경로 패턴으로 파일 찾기',
|
||||
'settings.agents.form.toolDescription.Grep': '텍스트 또는 패턴으로 파일 검색',
|
||||
'settings.agents.form.toolDescription.WebFetch': '웹 페이지 내용 읽기',
|
||||
'settings.agents.form.toolDescription.WebSearch': '웹 검색',
|
||||
'settings.agents.form.toolDescription.Edit': '기존 파일의 일부를 정확히 수정',
|
||||
'settings.agents.form.toolDescription.Write': '파일 생성 또는 덮어쓰기',
|
||||
'settings.agents.form.toolDescription.NotebookEdit': 'Jupyter Notebook 셀 편집',
|
||||
'settings.agents.form.toolDescription.Bash': 'Unix Shell에서 터미널 명령 실행',
|
||||
'settings.agents.form.toolDescription.PowerShell': 'Windows PowerShell 명령 실행',
|
||||
'settings.agents.form.toolDescription.TodoWrite': '작업 체크리스트 관리',
|
||||
'settings.agents.form.toolDescription.Skill': '설치된 Skill 워크플로 불러오기',
|
||||
'settings.agents.form.toolDescription.ToolSearch': '기능으로 지연 로드 도구 찾기',
|
||||
'settings.agents.form.toolDescription.EnterWorktree': '격리된 Git Worktree 생성 및 진입',
|
||||
'settings.agents.form.toolDescription.ExitWorktree': 'Agent Worktree 종료 및 정리',
|
||||
'settings.agents.form.toolDescription.StructuredOutput': 'Schema 검증된 구조화 데이터 반환',
|
||||
'settings.agents.form.toolDescription.generic': '이 기본 제공 실행 기능 사용',
|
||||
'settings.agents.form.toolsInheritHint': 'tools 필드를 생략하여 이 에이전트가 모든 도구를 사용할 수 있게 합니다.',
|
||||
'settings.agents.form.toolsNoneHint': '빈 tools 목록을 저장하여 이 에이전트가 도구를 사용하지 못하게 합니다.',
|
||||
'settings.agents.form.toolsCustomRequired': '허용할 도구를 하나 이상 입력하세요.',
|
||||
'settings.agents.form.toolsCustomRequired': '허용할 도구를 하나 이상 선택하거나 입력하세요.',
|
||||
'settings.agents.form.color': '색상',
|
||||
'settings.agents.form.noColor': '기본값',
|
||||
'settings.agents.form.saveError': '에이전트를 저장하지 못했습니다',
|
||||
|
||||
@@ -279,6 +279,18 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.pets.createCancel': '取消',
|
||||
'settings.pets.createSubmit': '建立',
|
||||
'settings.pets.createError': '無法建立自訂寵物。',
|
||||
'settings.pets.createError.invalidId': '寵物 ID 只能使用小寫字母、數字和單一連字號。',
|
||||
'settings.pets.createError.duplicateId': '已有使用此 ID 的自訂寵物。',
|
||||
'settings.pets.createError.unsupportedFormat': '請選擇 PNG 或 WebP 圖片。',
|
||||
'settings.pets.createError.imageTooLarge': '所選圖片不得超過 8 MB。',
|
||||
'settings.pets.createError.imageDimensions': '寵物圖片每邊必須為 32–4096 像素,且總像素數不得超過 16,777,216。',
|
||||
'settings.pets.createError.atlasDimensions': '動畫圖集尺寸必須恰好為 1536×2288 像素。',
|
||||
'settings.pets.createError.invalidImage': '無法讀取所選圖片,請選擇靜態 PNG 或 WebP 圖片。',
|
||||
'settings.pets.createError.storage': '無法將寵物儲存到自訂寵物資料夾。',
|
||||
'settings.pets.dialog.imageTitle': '選擇透明背景的寵物圖片',
|
||||
'settings.pets.dialog.imageFilter': '寵物圖片',
|
||||
'settings.pets.dialog.atlasTitle': '選擇 v2 寵物動畫圖集',
|
||||
'settings.pets.dialog.atlasFilter': '寵物動畫圖集',
|
||||
'settings.pets.builtInTitle': '內建寵物',
|
||||
'settings.pets.customTitle': '你的寵物',
|
||||
'settings.pets.customEmpty': '還沒有自訂寵物。',
|
||||
@@ -289,6 +301,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.pets.sizeDescription': '可在 96 到 192 像素之間調整。',
|
||||
'settings.pets.motion': '播放動畫',
|
||||
'settings.pets.motionDescription': '關閉後寵物仍可使用,但不會移動。',
|
||||
'settings.pets.showTaskPanel': '顯示進行中的工作區域',
|
||||
'settings.pets.showTaskPanelDescription': '有工作執行時顯示矩形工作區域;沒有進行中的工作時一律隱藏。',
|
||||
'settings.pets.collapsed': '預設收合',
|
||||
'settings.pets.collapsedDescription': '預設只顯示寵物,需要時再展開任務面板。',
|
||||
'settings.pets.folderTitle': '自訂寵物資料夾',
|
||||
@@ -315,6 +329,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'pet.window.status.running': '工作中',
|
||||
'pet.window.status.idle': '閒置',
|
||||
'pet.window.sessionCount': '{count} 個任務',
|
||||
'pet.window.expandTasks': '顯示 {count} 個進行中的任務',
|
||||
'pet.window.hideTasks': '隱藏 {count} 個進行中的任務',
|
||||
'pet.window.untitledSession': '未命名任務',
|
||||
|
||||
// Settings > Usage
|
||||
@@ -862,6 +878,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
// Settings > Agents
|
||||
'settings.tab.agents': 'Agents',
|
||||
'settings.agents.title': '已安裝的 Agents',
|
||||
'settings.agents.loadError': '載入 Agent 失敗',
|
||||
'settings.agents.description': '瀏覽 Claude 當前可用的 Agents,包括內建、專案和使用者來源。可在 TUI 中使用 /agents 檢視當前生效項與覆蓋關係。',
|
||||
'settings.agents.browserTitle': '瀏覽已安裝 Agents',
|
||||
'settings.agents.browserEyebrow': 'Agent 瀏覽器',
|
||||
@@ -938,12 +955,42 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.agents.form.toolsInherit': '全部工具',
|
||||
'settings.agents.form.toolsNone': '不允許使用工具',
|
||||
'settings.agents.form.toolsCustom': '自訂清單',
|
||||
'settings.agents.form.toolsCustomLabel': '允許使用的工具',
|
||||
'settings.agents.form.toolsPlaceholder': 'Read, Grep, Bash',
|
||||
'settings.agents.form.toolsHint': '請輸入允許使用的工具,並以英文逗號分隔。',
|
||||
'settings.agents.form.toolsCustomLabel': '其他工具名稱或權限規則',
|
||||
'settings.agents.form.toolsPlaceholder': 'mcp__docs__search, Bash(git:*)',
|
||||
'settings.agents.form.toolsHint': '選擇這個 Agent 需要的內建工具;自訂工具和 MCP 工具可在下方補充。',
|
||||
'settings.agents.form.builtInTools': '內建工具',
|
||||
'settings.agents.form.builtInToolsHint': '以下工具在目前執行環境可用,請只授予 Agent 必要的能力。',
|
||||
'settings.agents.form.toolsSelectedCount': '已選 {count} 項',
|
||||
'settings.agents.form.toolsSearch': '搜尋工具',
|
||||
'settings.agents.form.toolsSearchPlaceholder': '依名稱或用途搜尋',
|
||||
'settings.agents.form.toolsNoResults': '沒有相符的工具',
|
||||
'settings.agents.form.toolsUnavailable': '暫時無法取得內建工具清單,你仍可在下方輸入正確的工具名稱。',
|
||||
'settings.agents.form.toolsCustomHint': '選填。用於 MCP 工具、自訂工具或 Bash(git:*) 這類權限規則。',
|
||||
'settings.agents.form.toolCategory.readSearch': '讀取與搜尋',
|
||||
'settings.agents.form.toolCategory.modify': '修改檔案',
|
||||
'settings.agents.form.toolCategory.execute': '執行命令',
|
||||
'settings.agents.form.toolCategory.workflow': '工作流程',
|
||||
'settings.agents.form.toolCategory.other': '其他',
|
||||
'settings.agents.form.toolDescription.Read': '讀取檔案和目錄',
|
||||
'settings.agents.form.toolDescription.Glob': '依名稱或路徑規則尋找檔案',
|
||||
'settings.agents.form.toolDescription.Grep': '依文字或規則搜尋檔案內容',
|
||||
'settings.agents.form.toolDescription.WebFetch': '讀取網頁內容',
|
||||
'settings.agents.form.toolDescription.WebSearch': '搜尋網際網路',
|
||||
'settings.agents.form.toolDescription.Edit': '精確修改現有檔案',
|
||||
'settings.agents.form.toolDescription.Write': '建立或覆寫檔案',
|
||||
'settings.agents.form.toolDescription.NotebookEdit': '編輯 Jupyter Notebook 儲存格',
|
||||
'settings.agents.form.toolDescription.Bash': '在 Unix Shell 中執行終端命令',
|
||||
'settings.agents.form.toolDescription.PowerShell': '執行 Windows PowerShell 命令',
|
||||
'settings.agents.form.toolDescription.TodoWrite': '維護工作清單',
|
||||
'settings.agents.form.toolDescription.Skill': '載入已安裝的 Skill 工作流程',
|
||||
'settings.agents.form.toolDescription.ToolSearch': '依能力尋找延遲載入的工具',
|
||||
'settings.agents.form.toolDescription.EnterWorktree': '建立並進入隔離的 Git Worktree',
|
||||
'settings.agents.form.toolDescription.ExitWorktree': '離開並清理 Agent Worktree',
|
||||
'settings.agents.form.toolDescription.StructuredOutput': '回傳經過 Schema 驗證的結構化資料',
|
||||
'settings.agents.form.toolDescription.generic': '使用這項內建執行能力',
|
||||
'settings.agents.form.toolsInheritHint': '不寫入 tools 欄位,因此這個 Agent 可以使用全部工具。',
|
||||
'settings.agents.form.toolsNoneHint': '寫入空的 tools 清單,因此這個 Agent 無法使用任何工具。',
|
||||
'settings.agents.form.toolsCustomRequired': '請至少輸入一個允許使用的工具。',
|
||||
'settings.agents.form.toolsCustomRequired': '請至少選擇或輸入一個允許使用的工具。',
|
||||
'settings.agents.form.color': '顏色',
|
||||
'settings.agents.form.noColor': '預設',
|
||||
'settings.agents.form.saveError': '儲存 Agent 失敗',
|
||||
|
||||
@@ -279,6 +279,18 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.pets.createCancel': '取消',
|
||||
'settings.pets.createSubmit': '创建',
|
||||
'settings.pets.createError': '无法创建自定义宠物。',
|
||||
'settings.pets.createError.invalidId': '宠物 ID 只能使用小写字母、数字和单个连字符。',
|
||||
'settings.pets.createError.duplicateId': '已存在使用此 ID 的自定义宠物。',
|
||||
'settings.pets.createError.unsupportedFormat': '请选择 PNG 或 WebP 图片。',
|
||||
'settings.pets.createError.imageTooLarge': '所选图片不能超过 8 MB。',
|
||||
'settings.pets.createError.imageDimensions': '宠物图片每边必须为 32–4096 像素,且总像素数不能超过 16,777,216。',
|
||||
'settings.pets.createError.atlasDimensions': '动画图集尺寸必须恰好为 1536×2288 像素。',
|
||||
'settings.pets.createError.invalidImage': '无法读取所选图片,请选择静态 PNG 或 WebP 图片。',
|
||||
'settings.pets.createError.storage': '无法将宠物保存到自定义宠物目录。',
|
||||
'settings.pets.dialog.imageTitle': '选择透明背景的宠物图片',
|
||||
'settings.pets.dialog.imageFilter': '宠物图片',
|
||||
'settings.pets.dialog.atlasTitle': '选择 v2 宠物动画图集',
|
||||
'settings.pets.dialog.atlasFilter': '宠物动画图集',
|
||||
'settings.pets.builtInTitle': '内置宠物',
|
||||
'settings.pets.customTitle': '你的宠物',
|
||||
'settings.pets.customEmpty': '还没有自定义宠物。',
|
||||
@@ -289,6 +301,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.pets.sizeDescription': '可在 96 到 192 像素之间调整。',
|
||||
'settings.pets.motion': '播放动画',
|
||||
'settings.pets.motionDescription': '关闭后宠物仍可使用,但不会移动。',
|
||||
'settings.pets.showTaskPanel': '显示进行中的任务区域',
|
||||
'settings.pets.showTaskPanelDescription': '有任务运行时显示矩形任务区域;没有进行中的任务时始终隐藏。',
|
||||
'settings.pets.collapsed': '默认收起',
|
||||
'settings.pets.collapsedDescription': '默认只显示宠物,需要时再展开任务面板。',
|
||||
'settings.pets.folderTitle': '自定义宠物文件夹',
|
||||
@@ -315,6 +329,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'pet.window.status.running': '工作中',
|
||||
'pet.window.status.idle': '空闲',
|
||||
'pet.window.sessionCount': '{count} 个任务',
|
||||
'pet.window.expandTasks': '显示 {count} 个进行中的任务',
|
||||
'pet.window.hideTasks': '隐藏 {count} 个进行中的任务',
|
||||
'pet.window.untitledSession': '未命名任务',
|
||||
|
||||
// Settings > Usage
|
||||
@@ -862,6 +878,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
// Settings > Agents
|
||||
'settings.tab.agents': 'Agents',
|
||||
'settings.agents.title': '已安装的 Agents',
|
||||
'settings.agents.loadError': '加载 Agent 失败',
|
||||
'settings.agents.description': '浏览 Claude 当前可用的 Agents,包括内置、项目和用户来源。可在 TUI 中使用 /agents 查看当前生效项与覆盖关系。',
|
||||
'settings.agents.browserTitle': '浏览已安装 Agents',
|
||||
'settings.agents.browserEyebrow': 'Agent 浏览器',
|
||||
@@ -938,12 +955,42 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.agents.form.toolsInherit': '全部工具',
|
||||
'settings.agents.form.toolsNone': '不允许使用工具',
|
||||
'settings.agents.form.toolsCustom': '自定义列表',
|
||||
'settings.agents.form.toolsCustomLabel': '允许使用的工具',
|
||||
'settings.agents.form.toolsPlaceholder': 'Read, Grep, Bash',
|
||||
'settings.agents.form.toolsHint': '请输入允许使用的工具,并用英文逗号分隔。',
|
||||
'settings.agents.form.toolsCustomLabel': '其他工具名称或权限规则',
|
||||
'settings.agents.form.toolsPlaceholder': 'mcp__docs__search, Bash(git:*)',
|
||||
'settings.agents.form.toolsHint': '选择这个 Agent 需要的内置工具;自定义工具和 MCP 工具可在下方补充。',
|
||||
'settings.agents.form.builtInTools': '内置工具',
|
||||
'settings.agents.form.builtInToolsHint': '以下工具在当前运行环境可用,请只授予 Agent 必需的能力。',
|
||||
'settings.agents.form.toolsSelectedCount': '已选 {count} 项',
|
||||
'settings.agents.form.toolsSearch': '搜索工具',
|
||||
'settings.agents.form.toolsSearchPlaceholder': '按名称或用途搜索',
|
||||
'settings.agents.form.toolsNoResults': '没有匹配的工具',
|
||||
'settings.agents.form.toolsUnavailable': '暂时无法获取内置工具列表,你仍可在下方输入准确的工具名称。',
|
||||
'settings.agents.form.toolsCustomHint': '可选。用于 MCP 工具、自定义工具或 Bash(git:*) 这样的权限规则。',
|
||||
'settings.agents.form.toolCategory.readSearch': '读取与搜索',
|
||||
'settings.agents.form.toolCategory.modify': '修改文件',
|
||||
'settings.agents.form.toolCategory.execute': '执行命令',
|
||||
'settings.agents.form.toolCategory.workflow': '工作流',
|
||||
'settings.agents.form.toolCategory.other': '其他',
|
||||
'settings.agents.form.toolDescription.Read': '读取文件和目录',
|
||||
'settings.agents.form.toolDescription.Glob': '按名称或路径规则查找文件',
|
||||
'settings.agents.form.toolDescription.Grep': '按文本或规则搜索文件内容',
|
||||
'settings.agents.form.toolDescription.WebFetch': '读取网页内容',
|
||||
'settings.agents.form.toolDescription.WebSearch': '搜索互联网',
|
||||
'settings.agents.form.toolDescription.Edit': '精确修改已有文件',
|
||||
'settings.agents.form.toolDescription.Write': '创建或覆盖文件',
|
||||
'settings.agents.form.toolDescription.NotebookEdit': '编辑 Jupyter Notebook 单元格',
|
||||
'settings.agents.form.toolDescription.Bash': '在 Unix Shell 中执行终端命令',
|
||||
'settings.agents.form.toolDescription.PowerShell': '执行 Windows PowerShell 命令',
|
||||
'settings.agents.form.toolDescription.TodoWrite': '维护任务清单',
|
||||
'settings.agents.form.toolDescription.Skill': '加载已安装的 Skill 工作流',
|
||||
'settings.agents.form.toolDescription.ToolSearch': '按能力查找延迟加载的工具',
|
||||
'settings.agents.form.toolDescription.EnterWorktree': '创建并进入隔离的 Git Worktree',
|
||||
'settings.agents.form.toolDescription.ExitWorktree': '退出并清理 Agent Worktree',
|
||||
'settings.agents.form.toolDescription.StructuredOutput': '返回经过 Schema 校验的结构化数据',
|
||||
'settings.agents.form.toolDescription.generic': '使用这项内置运行能力',
|
||||
'settings.agents.form.toolsInheritHint': '不写入 tools 字段,因此这个 Agent 可以使用全部工具。',
|
||||
'settings.agents.form.toolsNoneHint': '写入空的 tools 列表,因此这个 Agent 不能使用任何工具。',
|
||||
'settings.agents.form.toolsCustomRequired': '请至少输入一个允许使用的工具。',
|
||||
'settings.agents.form.toolsCustomRequired': '请至少选择或输入一个允许使用的工具。',
|
||||
'settings.agents.form.color': '颜色',
|
||||
'settings.agents.form.noColor': '默认',
|
||||
'settings.agents.form.saveError': '保存 Agent 失败',
|
||||
|
||||
@@ -173,11 +173,13 @@ export type DesktopPetCreateInput = {
|
||||
slug: string
|
||||
displayName: string
|
||||
description: string
|
||||
dialogTitle?: string
|
||||
dialogFilterName?: string
|
||||
}
|
||||
|
||||
export type DesktopPetCreateResult = {
|
||||
id: string
|
||||
}
|
||||
export type DesktopPetCreateResult =
|
||||
| { id: string }
|
||||
| { errorCode: string }
|
||||
|
||||
export type DesktopPetWindowDrag = {
|
||||
phase: 'start' | 'move' | 'end'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -141,6 +142,21 @@ describe('TerminalSettings', () => {
|
||||
expect(terminalMocks.fitInstance.fit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the terminal runtime current across the StrictMode effect replay', async () => {
|
||||
terminalMocks.available = true
|
||||
|
||||
render(
|
||||
<StrictMode>
|
||||
<TerminalSettings runtimeId="strict-mode-terminal" />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalledTimes(1))
|
||||
expect(screen.getByText('Running')).toBeInTheDocument()
|
||||
|
||||
destroyTerminalRuntime('strict-mode-terminal')
|
||||
})
|
||||
|
||||
it('does not start duplicate xterm surfaces for one runtime', async () => {
|
||||
terminalMocks.available = true
|
||||
terminalMocks.terminalInstance.open.mockImplementation((host: HTMLElement) => {
|
||||
|
||||
@@ -75,6 +75,7 @@ export function TerminalSettings({
|
||||
const desktopTerminal = useSettingsStore((state) => state.desktopTerminal)
|
||||
const setDesktopTerminal = useSettingsStore((state) => state.setDesktopTerminal)
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
const lifecycleVersionRef = useRef(0)
|
||||
const localRuntimeIdRef = useRef<string | null>(null)
|
||||
if (!localRuntimeIdRef.current) {
|
||||
localRuntimeIdRef.current = runtimeId ?? createLocalTerminalRuntimeId()
|
||||
@@ -334,6 +335,8 @@ export function TerminalSettings({
|
||||
}, [cwd, resizeSession, runtime])
|
||||
|
||||
useEffect(() => {
|
||||
lifecycleVersionRef.current += 1
|
||||
const lifecycleVersion = lifecycleVersionRef.current
|
||||
if (!terminalApi.isAvailable()) return
|
||||
if (runtime.terminal) {
|
||||
if (hostRef.current) {
|
||||
@@ -356,7 +359,13 @@ export function TerminalSettings({
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
if (!preserveOnUnmount) {
|
||||
destroyTerminalRuntime(runtime.id)
|
||||
// StrictMode replays effects once during initial mount. Let the replay
|
||||
// retain this runtime instead of leaving the component with a stale
|
||||
// object that can never start or restart.
|
||||
queueMicrotask(() => {
|
||||
if (lifecycleVersionRef.current !== lifecycleVersion) return
|
||||
destroyTerminalRuntime(runtime.id)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [preserveOnUnmount, resizeSession, runtime, startTerminal])
|
||||
|
||||
@@ -68,6 +68,7 @@ describe('agentStore', () => {
|
||||
useAgentStore.setState({
|
||||
activeAgents: [],
|
||||
allAgents: [],
|
||||
availableTools: [],
|
||||
isLoading: false,
|
||||
isMutating: false,
|
||||
error: null,
|
||||
@@ -187,6 +188,55 @@ describe('agentStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('finishes a create while the active session reload is still pending', async () => {
|
||||
const reload = deferred<never>()
|
||||
const createdAgent = makeAgent({ target: 'reviewer.md' })
|
||||
apiCreateMock.mockResolvedValue({ agent: createdAgent })
|
||||
apiListMock.mockResolvedValue({
|
||||
activeAgents: [createdAgent],
|
||||
allAgents: [createdAgent],
|
||||
})
|
||||
apiReloadMock.mockReturnValue(reload.promise)
|
||||
|
||||
await expect(
|
||||
useAgentStore.getState().createAgent(makeInput(), 'session-1'),
|
||||
).resolves.toBe(createdAgent)
|
||||
|
||||
expect(apiReloadMock).toHaveBeenCalledWith('session-1')
|
||||
expect(useAgentStore.getState()).toMatchObject({
|
||||
selectedAgent: createdAgent,
|
||||
isMutating: false,
|
||||
mutationWarning: null,
|
||||
})
|
||||
|
||||
reload.reject(new Error('Session reload unavailable'))
|
||||
await vi.waitFor(() => expect(useAgentStore.getState().mutationWarning).toBe(
|
||||
'Session reload unavailable',
|
||||
))
|
||||
})
|
||||
|
||||
it('ignores a late reload warning after switching projects', async () => {
|
||||
const reload = deferred<never>()
|
||||
const createdAgent = makeAgent({ target: 'reviewer.md' })
|
||||
const currentAgent = makeAgent({ agentType: 'current-project', source: 'projectSettings' })
|
||||
apiCreateMock.mockResolvedValue({ agent: createdAgent })
|
||||
apiListMock
|
||||
.mockResolvedValueOnce({ activeAgents: [createdAgent], allAgents: [createdAgent] })
|
||||
.mockResolvedValueOnce({ activeAgents: [currentAgent], allAgents: [currentAgent] })
|
||||
apiReloadMock.mockReturnValue(reload.promise)
|
||||
|
||||
await useAgentStore.getState().createAgent(makeInput(), 'session-1')
|
||||
await useAgentStore.getState().fetchAgents('/workspace/current-project')
|
||||
reload.reject(new Error('Old session reload failed'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(useAgentStore.getState()).toMatchObject({
|
||||
allAgents: [currentAgent],
|
||||
mutationWarning: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let a mutation refresh overwrite a later project switch', async () => {
|
||||
const mutationRefresh = deferred<{ activeAgents: AgentDefinition[]; allAgents: AgentDefinition[] }>()
|
||||
const currentRefresh = deferred<{ activeAgents: AgentDefinition[]; allAgents: AgentDefinition[] }>()
|
||||
@@ -311,6 +361,34 @@ describe('agentStore', () => {
|
||||
expect(useAgentStore.getState().selectedAgent).toBe(updatedAgent)
|
||||
})
|
||||
|
||||
it('finishes an update while the active session reload is still pending', async () => {
|
||||
const reload = deferred<never>()
|
||||
const updatedAgent = makeAgent({ description: 'Updated', target: 'reviewer.md' })
|
||||
apiUpdateMock.mockResolvedValue({ agent: updatedAgent })
|
||||
apiListMock.mockResolvedValue({
|
||||
activeAgents: [updatedAgent],
|
||||
allAgents: [updatedAgent],
|
||||
})
|
||||
apiReloadMock.mockReturnValue(reload.promise)
|
||||
|
||||
await expect(useAgentStore.getState().updateAgent(
|
||||
'reviewer',
|
||||
makeInput({ description: 'Updated' }),
|
||||
'session-1',
|
||||
)).resolves.toBe(updatedAgent)
|
||||
|
||||
expect(useAgentStore.getState()).toMatchObject({
|
||||
selectedAgent: updatedAgent,
|
||||
isMutating: false,
|
||||
mutationWarning: null,
|
||||
})
|
||||
|
||||
reload.reject(new Error('Session reload unavailable'))
|
||||
await vi.waitFor(() => expect(useAgentStore.getState().mutationWarning).toBe(
|
||||
'Session reload unavailable',
|
||||
))
|
||||
})
|
||||
|
||||
it('does not expose an old mutation failure after a new project fetch succeeds', async () => {
|
||||
const mutationRequest = deferred<void>()
|
||||
const currentAgent = makeAgent({ agentType: 'current-project', source: 'projectSettings' })
|
||||
@@ -427,6 +505,32 @@ describe('agentStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('finishes a delete while the active session reload is still pending', async () => {
|
||||
const reload = deferred<never>()
|
||||
apiDeleteMock.mockResolvedValue(undefined)
|
||||
apiListMock.mockResolvedValue({ activeAgents: [], allAgents: [] })
|
||||
apiReloadMock.mockReturnValue(reload.promise)
|
||||
|
||||
await expect(useAgentStore.getState().deleteAgent(
|
||||
'reviewer',
|
||||
'project',
|
||||
'/workspace/current',
|
||||
'reviewer.md',
|
||||
'session-1',
|
||||
)).resolves.toBeUndefined()
|
||||
|
||||
expect(useAgentStore.getState()).toMatchObject({
|
||||
selectedAgent: null,
|
||||
isMutating: false,
|
||||
mutationWarning: null,
|
||||
})
|
||||
|
||||
reload.reject(new Error('Session reload unavailable'))
|
||||
await vi.waitFor(() => expect(useAgentStore.getState().mutationWarning).toBe(
|
||||
'Session reload unavailable',
|
||||
))
|
||||
})
|
||||
|
||||
it('does not let a delete refresh clear the selection from a later project switch', async () => {
|
||||
const mutationRefresh = deferred<{ activeAgents: AgentDefinition[]; allAgents: AgentDefinition[] }>()
|
||||
const currentAgent = makeAgent({ agentType: 'current-project', source: 'projectSettings' })
|
||||
|
||||
@@ -12,6 +12,7 @@ export type AgentDetailReturnTab = 'agents' | 'plugins'
|
||||
type AgentStore = {
|
||||
activeAgents: AgentDefinition[]
|
||||
allAgents: AgentDefinition[]
|
||||
availableTools: string[]
|
||||
isLoading: boolean
|
||||
isMutating: boolean
|
||||
error: string | null
|
||||
@@ -46,6 +47,7 @@ let latestFetchRequestId = 0
|
||||
export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
activeAgents: [],
|
||||
allAgents: [],
|
||||
availableTools: [],
|
||||
isLoading: false,
|
||||
isMutating: false,
|
||||
error: null,
|
||||
@@ -64,7 +66,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
mutationWarning: null,
|
||||
})
|
||||
try {
|
||||
const { activeAgents, allAgents } = await agentsApi.list(cwd)
|
||||
const { activeAgents, allAgents, availableTools = [] } = await agentsApi.list(cwd)
|
||||
if (requestId !== latestFetchRequestId) return
|
||||
set((state) => {
|
||||
const selectedAgent = state.selectedAgent
|
||||
@@ -73,6 +75,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
return {
|
||||
activeAgents,
|
||||
allAgents,
|
||||
availableTools,
|
||||
isLoading: false,
|
||||
selectedAgent,
|
||||
selectedAgentReturnTab: selectedAgent ? state.selectedAgentReturnTab : 'agents',
|
||||
@@ -114,7 +117,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
throw error
|
||||
}
|
||||
|
||||
const reloadWarningPromise = getSessionReloadWarning(sessionId)
|
||||
startSessionReloadWarning(sessionId, requestId, set)
|
||||
try {
|
||||
const response = await agentsApi.list(input.cwd)
|
||||
const refreshedAgent = findEditableAgent(
|
||||
@@ -126,18 +129,15 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
if (!refreshedAgent) {
|
||||
throw new Error('Created agent was not returned by the refreshed list')
|
||||
}
|
||||
const reloadWarning = await reloadWarningPromise
|
||||
if (requestId !== latestFetchRequestId) return refreshedAgent
|
||||
set({
|
||||
...response,
|
||||
selectedAgent: refreshedAgent,
|
||||
selectedAgentReturnTab: 'agents',
|
||||
isMutating: false,
|
||||
mutationWarning: reloadWarning,
|
||||
})
|
||||
return refreshedAgent
|
||||
} catch (refreshError) {
|
||||
const reloadWarning = await reloadWarningPromise
|
||||
if (requestId === latestFetchRequestId) {
|
||||
set((state) => ({
|
||||
...upsertMutationAgent(state, createdAgent),
|
||||
@@ -149,7 +149,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
refreshError,
|
||||
'Failed to refresh agents after creating the agent',
|
||||
),
|
||||
reloadWarning,
|
||||
state.mutationWarning,
|
||||
),
|
||||
}))
|
||||
}
|
||||
@@ -177,7 +177,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
throw error
|
||||
}
|
||||
|
||||
const reloadWarningPromise = getSessionReloadWarning(sessionId)
|
||||
startSessionReloadWarning(sessionId, requestId, set)
|
||||
try {
|
||||
const response = await agentsApi.list(input.cwd)
|
||||
const refreshedAgent = findEditableAgent(
|
||||
@@ -189,18 +189,15 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
if (!refreshedAgent) {
|
||||
throw new Error('Updated agent was not returned by the refreshed list')
|
||||
}
|
||||
const reloadWarning = await reloadWarningPromise
|
||||
if (requestId !== latestFetchRequestId) return refreshedAgent
|
||||
set({
|
||||
...response,
|
||||
selectedAgent: refreshedAgent,
|
||||
selectedAgentReturnTab: 'agents',
|
||||
isMutating: false,
|
||||
mutationWarning: reloadWarning,
|
||||
})
|
||||
return refreshedAgent
|
||||
} catch (refreshError) {
|
||||
const reloadWarning = await reloadWarningPromise
|
||||
if (requestId === latestFetchRequestId) {
|
||||
set((state) => ({
|
||||
...upsertMutationAgent(state, updatedAgent),
|
||||
@@ -212,7 +209,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
refreshError,
|
||||
'Failed to refresh agents after updating the agent',
|
||||
),
|
||||
reloadWarning,
|
||||
state.mutationWarning,
|
||||
),
|
||||
}))
|
||||
}
|
||||
@@ -238,20 +235,17 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
throw error
|
||||
}
|
||||
|
||||
const reloadWarningPromise = getSessionReloadWarning(sessionId)
|
||||
startSessionReloadWarning(sessionId, requestId, set)
|
||||
try {
|
||||
const response = await agentsApi.list(cwd)
|
||||
const reloadWarning = await reloadWarningPromise
|
||||
if (requestId !== latestFetchRequestId) return
|
||||
set({
|
||||
...response,
|
||||
selectedAgent: null,
|
||||
selectedAgentReturnTab: 'agents',
|
||||
isMutating: false,
|
||||
mutationWarning: reloadWarning,
|
||||
})
|
||||
} catch (refreshError) {
|
||||
const reloadWarning = await reloadWarningPromise
|
||||
if (requestId === latestFetchRequestId) {
|
||||
set((state) => ({
|
||||
activeAgents: removeMutationAgent(
|
||||
@@ -274,7 +268,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
refreshError,
|
||||
'Failed to refresh agents after deleting the agent',
|
||||
),
|
||||
reloadWarning,
|
||||
state.mutationWarning,
|
||||
),
|
||||
}))
|
||||
}
|
||||
@@ -360,6 +354,19 @@ function getErrorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback
|
||||
}
|
||||
|
||||
function startSessionReloadWarning(
|
||||
sessionId: string | undefined,
|
||||
requestId: number,
|
||||
setState: typeof useAgentStore.setState,
|
||||
) {
|
||||
void getSessionReloadWarning(sessionId).then((reloadWarning) => {
|
||||
if (requestId !== latestFetchRequestId) return
|
||||
setState((state) => ({
|
||||
mutationWarning: combineWarnings(state.mutationWarning, reloadWarning),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
async function getSessionReloadWarning(
|
||||
sessionId?: string,
|
||||
): Promise<string | null> {
|
||||
|
||||
@@ -156,6 +156,24 @@ describe('cliTaskStore', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('coalesces overlapping task polls for the same session', async () => {
|
||||
let resolvePoll: ((value: { tasks: CLITask[] }) => void) | null = null
|
||||
vi.mocked(cliTasksApi.getTasksForList).mockImplementation(
|
||||
() => new Promise((resolve) => {
|
||||
resolvePoll = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const first = useCLITaskStore.getState().fetchSessionTasks('session-1')
|
||||
const second = useCLITaskStore.getState().fetchSessionTasks('session-1')
|
||||
|
||||
expect(cliTasksApi.getTasksForList).toHaveBeenCalledTimes(1)
|
||||
resolvePoll!({ tasks: [makeTask('session-1')] })
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(useCLITaskStore.getState().tasks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ignores an older task response that finishes after a newer refresh', async () => {
|
||||
let resolveOlder: ((value: { tasks: CLITask[] }) => void) | null = null
|
||||
let resolveNewer: ((value: { tasks: CLITask[] }) => void) | null = null
|
||||
|
||||
@@ -42,6 +42,7 @@ type CLITaskStore = {
|
||||
let taskRequestSequence = 0
|
||||
let taskRequestGeneration = 0
|
||||
const latestAppliedTaskRequestBySession = new Map<string, number>()
|
||||
const activeTaskPollBySession = new Map<string, Promise<void>>()
|
||||
|
||||
type TaskRequest = {
|
||||
requestId: number
|
||||
@@ -130,22 +131,36 @@ export const useCLITaskStore = create<CLITaskStore>((set, get) => ({
|
||||
})
|
||||
}
|
||||
|
||||
const request = beginTaskRequest()
|
||||
try {
|
||||
const { tasks } = await cliTasksApi.getTasksForList(sessionId)
|
||||
if (
|
||||
canApplyTaskResponse(sessionId, request)
|
||||
&& get().sessionId === sessionId
|
||||
&& !get().resetting
|
||||
) {
|
||||
markTaskResponseApplied(sessionId, request)
|
||||
set((state) => ({
|
||||
tasks,
|
||||
...resolveDismissState(tasks, state.dismissedCompletionKey),
|
||||
}))
|
||||
const activePoll = activeTaskPollBySession.get(sessionId)
|
||||
if (activePoll) return activePoll
|
||||
|
||||
const poll = (async () => {
|
||||
const request = beginTaskRequest()
|
||||
try {
|
||||
const { tasks } = await cliTasksApi.getTasksForList(sessionId)
|
||||
if (
|
||||
canApplyTaskResponse(sessionId, request)
|
||||
&& get().sessionId === sessionId
|
||||
&& !get().resetting
|
||||
) {
|
||||
markTaskResponseApplied(sessionId, request)
|
||||
set((state) => ({
|
||||
tasks,
|
||||
...resolveDismissState(tasks, state.dismissedCompletionKey),
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
// Preserve the last known task state across transient polling failures.
|
||||
}
|
||||
})()
|
||||
|
||||
activeTaskPollBySession.set(sessionId, poll)
|
||||
try {
|
||||
await poll
|
||||
} finally {
|
||||
if (activeTaskPollBySession.get(sessionId) === poll) {
|
||||
activeTaskPollBySession.delete(sessionId)
|
||||
}
|
||||
} catch {
|
||||
// Preserve the last known task state across transient polling failures.
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1699,6 +1699,46 @@ html[data-window-kind='pet'] #root {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.pet-mascot-wrap {
|
||||
position: relative;
|
||||
order: 2;
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pet-task-badge {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
top: 8px;
|
||||
right: 2px;
|
||||
display: grid;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||
border-radius: 999px;
|
||||
padding: 0 6px;
|
||||
background: rgba(14, 58, 91, 0.94);
|
||||
box-shadow: 0 5px 14px rgba(13, 31, 48, 0.26);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font: 650 12px/1 var(--font-body);
|
||||
pointer-events: auto;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.pet-task-badge:hover {
|
||||
background: rgba(20, 76, 116, 0.98);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.pet-task-badge:focus-visible {
|
||||
outline: 2px solid rgba(95, 179, 255, 0.78);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.pet-mascot-button[data-dragging='true'] {
|
||||
cursor: grabbing;
|
||||
transform: scale(0.96);
|
||||
@@ -1787,14 +1827,18 @@ html[data-window-kind='pet'] #root {
|
||||
|
||||
.pet-activity-card {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
z-index: 15;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
order: 1;
|
||||
width: calc(100% - 32px);
|
||||
max-width: 360px;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
/* Keep the card, collapse control, and visible mascot as one compact stack. */
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.76);
|
||||
border-radius: 24px;
|
||||
padding: 6px 50px 6px 7px;
|
||||
padding: 6px 7px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.68),
|
||||
@@ -1807,7 +1851,7 @@ html[data-window-kind='pet'] #root {
|
||||
|
||||
.pet-activity-card[data-expanded='true'] {
|
||||
border-radius: 22px;
|
||||
padding-right: 48px;
|
||||
padding-right: 7px;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .pet-activity-card {
|
||||
@@ -1860,7 +1904,7 @@ html[data-theme='dark'] .pet-session-list > [role='listitem'] + [role='listitem'
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
border: 0;
|
||||
border-radius: 17px;
|
||||
padding: 7px 10px;
|
||||
@@ -1953,41 +1997,44 @@ html[data-theme='dark'] .pet-session-status {
|
||||
|
||||
.pet-panel-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
z-index: 13;
|
||||
right: 50%;
|
||||
bottom: -31px;
|
||||
display: grid;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: rgba(66, 62, 74, 0.62);
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
background: rgba(52, 55, 61, 0.72);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
cursor: pointer;
|
||||
transform: translateY(-50%);
|
||||
transform: translateX(50%);
|
||||
transition: background 140ms ease, color 140ms ease, transform 140ms ease;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.pet-activity-card[data-expanded='true'] .pet-panel-toggle {
|
||||
top: 8px;
|
||||
transform: none;
|
||||
top: auto;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
|
||||
.pet-panel-toggle:hover {
|
||||
background: rgba(77, 71, 89, 0.09);
|
||||
color: rgba(38, 35, 44, 0.9);
|
||||
background: rgba(43, 47, 54, 0.88);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pet-panel-toggle:active {
|
||||
transform: translateY(-50%) scale(0.94);
|
||||
transform: translateX(50%) scale(0.94);
|
||||
}
|
||||
|
||||
.pet-activity-card[data-expanded='true'] .pet-panel-toggle:active {
|
||||
transform: scale(0.94);
|
||||
transform: translateX(50%) scale(0.94);
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .pet-panel-toggle {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
background: rgba(36, 39, 45, 0.82);
|
||||
color: rgba(239, 235, 246, 0.62);
|
||||
}
|
||||
|
||||
|
||||
@@ -156,4 +156,12 @@ describe('desktop theme tokens', () => {
|
||||
expect(css).toContain('left: -9999em;')
|
||||
expect(css).toContain('overflow: hidden;')
|
||||
})
|
||||
|
||||
it('keeps the pet task card controls above the mascot hit target', () => {
|
||||
const mascotCss = getCssBetween('.pet-mascot-button {', '.pet-mascot-wrap {')
|
||||
const cardCss = getCssBetween('.pet-activity-card {', '.pet-activity-card[data-expanded=')
|
||||
|
||||
expect(mascotCss).toContain('z-index: 10;')
|
||||
expect(cardCss).toContain('z-index: 15;')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as path from 'node:path'
|
||||
import type { AppState } from '../../state/AppStateStore.js'
|
||||
import { handleAgentsApi } from '../api/agents.js'
|
||||
import { clearAgentDefinitionsCache } from '../../tools/AgentTool/loadAgentsDir.js'
|
||||
import { findGitRoot } from '../../utils/git.js'
|
||||
import { refreshActivePlugins } from '../../utils/plugins/refresh.js'
|
||||
import { AgentService } from '../services/agentService.js'
|
||||
import { conversationService } from '../services/conversationService.js'
|
||||
@@ -253,6 +254,10 @@ describe('Agents API Markdown CRUD', () => {
|
||||
`/api/agents?cwd=${encodeURIComponent(projectCwd)}`,
|
||||
)
|
||||
expect(userList.status).toBe(200)
|
||||
expect(userList.data.availableTools).toEqual(
|
||||
expect.arrayContaining(['Read', 'Grep', 'Bash']),
|
||||
)
|
||||
expect(userList.data.availableTools).not.toContain('Agent')
|
||||
expect(userList.data.activeAgents).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -460,6 +465,47 @@ describe('Agents API Markdown CRUD', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('creates a project agent in a nested repository initialized after its root was cached', async () => {
|
||||
const nestedProjectRoot = path.join(projectRoot, 'manual-workspace')
|
||||
await fs.mkdir(nestedProjectRoot, { recursive: true })
|
||||
|
||||
findGitRoot.cache.delete(nestedProjectRoot)
|
||||
expect(findGitRoot(nestedProjectRoot)).toBe(projectRoot)
|
||||
await fs.mkdir(path.join(nestedProjectRoot, '.git'))
|
||||
|
||||
try {
|
||||
const creation = await api('POST', '/api/agents', {
|
||||
scope: 'project',
|
||||
cwd: nestedProjectRoot,
|
||||
name: 'nested-repository-reviewer',
|
||||
description: 'Reviews the independently initialized nested repository',
|
||||
systemPrompt: 'Keep project agent changes inside this repository.',
|
||||
})
|
||||
|
||||
const nestedAgentFile = path.join(
|
||||
nestedProjectRoot,
|
||||
'.claude',
|
||||
'agents',
|
||||
'nested-repository-reviewer.md',
|
||||
)
|
||||
expect(creation.status).toBe(201)
|
||||
expect(creation.data.agent.target).toBe(await fs.realpath(nestedAgentFile))
|
||||
expect(await fileExists(nestedAgentFile)).toBe(true)
|
||||
expect(
|
||||
await fileExists(
|
||||
path.join(
|
||||
projectRoot,
|
||||
'.claude',
|
||||
'agents',
|
||||
'nested-repository-reviewer.md',
|
||||
),
|
||||
),
|
||||
).toBe(false)
|
||||
} finally {
|
||||
findGitRoot.cache.delete(nestedProjectRoot)
|
||||
}
|
||||
})
|
||||
|
||||
it('updates and deletes the exact nested project file when its filename differs from its agent name', async () => {
|
||||
const nestedProjectDir = path.join(
|
||||
projectRoot,
|
||||
|
||||
@@ -310,36 +310,39 @@ describe('ConversationService', () => {
|
||||
expect(env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE).not.toContain('myself_code')
|
||||
})
|
||||
|
||||
test('buildChildEnv inherits exported terminal shell variables for desktop CLI sessions', async () => {
|
||||
const shellPath = path.join(tmpDir, 'zsh')
|
||||
const nodeBin = path.join(tmpDir, 'node-bin')
|
||||
const nvmDir = path.join(tmpDir, '.nvm')
|
||||
await fs.mkdir(nodeBin, { recursive: true })
|
||||
await fs.mkdir(nvmDir, { recursive: true })
|
||||
await writeFakeZsh(shellPath)
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, '.zshrc'),
|
||||
[
|
||||
`export NVM_DIR="${nvmDir}"`,
|
||||
`export PATH="${nodeBin}:$PATH"`,
|
||||
'',
|
||||
].join('\n'),
|
||||
)
|
||||
test.skipIf(process.platform === 'win32')(
|
||||
'buildChildEnv inherits exported terminal shell variables for desktop CLI sessions',
|
||||
async () => {
|
||||
const shellPath = path.join(tmpDir, 'zsh')
|
||||
const nodeBin = path.join(tmpDir, 'node-bin')
|
||||
const nvmDir = path.join(tmpDir, '.nvm')
|
||||
await fs.mkdir(nodeBin, { recursive: true })
|
||||
await fs.mkdir(nvmDir, { recursive: true })
|
||||
await writeFakeZsh(shellPath)
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, '.zshrc'),
|
||||
[
|
||||
`export NVM_DIR="${nvmDir}"`,
|
||||
`export PATH="${nodeBin}:$PATH"`,
|
||||
'',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
|
||||
process.env.HOME = tmpDir
|
||||
process.env.SHELL = shellPath
|
||||
process.env.PATH = '/usr/bin:/bin'
|
||||
delete process.env.ZDOTDIR
|
||||
resetTerminalShellEnvironmentCacheForTests()
|
||||
delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
|
||||
process.env.HOME = tmpDir
|
||||
process.env.SHELL = shellPath
|
||||
process.env.PATH = '/usr/bin:/bin'
|
||||
delete process.env.ZDOTDIR
|
||||
resetTerminalShellEnvironmentCacheForTests()
|
||||
|
||||
const service = new ConversationService() as any
|
||||
const env = (await service.buildChildEnv(tmpDir)) as Record<string, string>
|
||||
const service = new ConversationService() as any
|
||||
const env = (await service.buildChildEnv(tmpDir)) as Record<string, string>
|
||||
|
||||
expect(env.NVM_DIR).toBe(nvmDir)
|
||||
expect(env.PATH.split(path.delimiter)[0]).toBe(nodeBin)
|
||||
expect(env.PATH.split(path.delimiter)).toContain('/usr/bin')
|
||||
})
|
||||
expect(env.NVM_DIR).toBe(nvmDir)
|
||||
expect(env.PATH.split(path.delimiter)[0]).toBe(nodeBin)
|
||||
expect(env.PATH.split(path.delimiter)).toContain('/usr/bin')
|
||||
},
|
||||
)
|
||||
|
||||
test('strips inherited provider env when desktop provider config exists', async () => {
|
||||
const ccHahaDir = path.join(tmpDir, 'cc-haha')
|
||||
|
||||
@@ -27,10 +27,14 @@ async function getStatus(sessionId: string): Promise<string> {
|
||||
return body.activityState
|
||||
}
|
||||
|
||||
function makeClientSocket(sessionId: string): ServerWebSocket<WebSocketData> {
|
||||
function makeClientSocket(
|
||||
sessionId: string,
|
||||
clientKind: WebSocketData['clientKind'] = 'full',
|
||||
): ServerWebSocket<WebSocketData> {
|
||||
return {
|
||||
data: {
|
||||
sessionId,
|
||||
clientKind,
|
||||
connectedAt: Date.now(),
|
||||
channel: 'client',
|
||||
sdkToken: null,
|
||||
@@ -108,20 +112,70 @@ describe('read-only session chat activity status', () => {
|
||||
expect(await getStatus(sessionId)).toBe('failed')
|
||||
})
|
||||
|
||||
it('marks a successful CLI result for review and clears it when a new turn starts', async () => {
|
||||
it('returns idle after a successful CLI result even while the session tab remains open', async () => {
|
||||
const sessionId = `status-review-${crypto.randomUUID()}`
|
||||
const ws = makeClientSocket(sessionId)
|
||||
handleWebSocket.open(ws)
|
||||
__markActiveTurnForTests(sessionId)
|
||||
__settleActiveTurnForTests(sessionId, {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
usage: {},
|
||||
})
|
||||
expect(await getStatus(sessionId)).toBe('review')
|
||||
expect(await getStatus(sessionId)).toBe('idle')
|
||||
|
||||
__registerPendingUserTurnForTests(sessionId)
|
||||
expect(await getStatus(sessionId)).toBe('running')
|
||||
})
|
||||
|
||||
it('keeps a successful completed turn idle when the last full client closes', async () => {
|
||||
const sessionId = `status-review-close-${crypto.randomUUID()}`
|
||||
const pet = makeClientSocket(sessionId, 'pet')
|
||||
const ws = makeClientSocket(sessionId)
|
||||
handleWebSocket.open(pet)
|
||||
handleWebSocket.open(ws)
|
||||
__markActiveTurnForTests(sessionId)
|
||||
__settleActiveTurnForTests(sessionId, {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
usage: {},
|
||||
})
|
||||
expect(await getStatus(sessionId)).toBe('idle')
|
||||
|
||||
handleWebSocket.close(ws, 1000, 'tab closed')
|
||||
expect(await getStatus(sessionId)).toBe('idle')
|
||||
})
|
||||
|
||||
it('keeps a failed result visible when the last full client closes', async () => {
|
||||
const sessionId = `status-failed-close-${crypto.randomUUID()}`
|
||||
const ws = makeClientSocket(sessionId)
|
||||
handleWebSocket.open(ws)
|
||||
__markActiveTurnForTests(sessionId)
|
||||
__settleActiveTurnForTests(sessionId, {
|
||||
type: 'result',
|
||||
subtype: 'error_during_execution',
|
||||
is_error: true,
|
||||
result: 'Provider request failed',
|
||||
usage: {},
|
||||
})
|
||||
expect(await getStatus(sessionId)).toBe('failed')
|
||||
|
||||
handleWebSocket.close(ws, 1000, 'tab closed')
|
||||
expect(await getStatus(sessionId)).toBe('failed')
|
||||
})
|
||||
|
||||
it('does not create review state when a disconnected turn finishes', async () => {
|
||||
const sessionId = `status-review-disconnected-${crypto.randomUUID()}`
|
||||
__markActiveTurnForTests(sessionId)
|
||||
__settleActiveTurnForTests(sessionId, {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
usage: {},
|
||||
})
|
||||
|
||||
expect(await getStatus(sessionId)).toBe('idle')
|
||||
})
|
||||
|
||||
it('keeps an interrupted result idle instead of classifying it as failed', async () => {
|
||||
const sessionId = `status-stopped-${crypto.randomUUID()}`
|
||||
const ws = makeClientSocket(sessionId)
|
||||
@@ -148,13 +202,15 @@ describe('read-only session chat activity status', () => {
|
||||
|
||||
it('clears terminal activity through the shared test reset hook', () => {
|
||||
const sessionId = `status-reset-${crypto.randomUUID()}`
|
||||
handleWebSocket.open(makeClientSocket(sessionId))
|
||||
__markActiveTurnForTests(sessionId)
|
||||
__settleActiveTurnForTests(sessionId, {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
subtype: 'error_during_execution',
|
||||
is_error: true,
|
||||
usage: {},
|
||||
})
|
||||
expect(getSessionChatActivityState(sessionId)).toBe('review')
|
||||
expect(getSessionChatActivityState(sessionId)).toBe('failed')
|
||||
|
||||
__resetWebSocketHandlerStateForTests()
|
||||
expect(getSessionChatActivityState(sessionId)).toBe('idle')
|
||||
|
||||
@@ -138,6 +138,86 @@ describe('ConversationService', () => {
|
||||
await expect(request).resolves.toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('should remove a pending control callback when the HTTP request is aborted', async () => {
|
||||
const svc = new ConversationService()
|
||||
const sid = crypto.randomUUID()
|
||||
const sent: unknown[] = []
|
||||
const session: any = {
|
||||
proc: { kill() {}, exited: Promise.resolve(0) },
|
||||
outputCallbacks: [],
|
||||
workDir: process.cwd(),
|
||||
permissionMode: 'default',
|
||||
sdkToken: 'token',
|
||||
sdkSocket: {
|
||||
send(data: string) {
|
||||
sent.push(JSON.parse(data))
|
||||
},
|
||||
},
|
||||
pendingOutbound: [],
|
||||
startupPending: false,
|
||||
startupExitCode: null,
|
||||
stdoutLines: [],
|
||||
stderrLines: [],
|
||||
outputDrain: Promise.resolve(),
|
||||
sdkMessages: [],
|
||||
initMessage: null,
|
||||
pendingPermissionRequests: new Map(),
|
||||
}
|
||||
;(svc as any).sessions.set(sid, session)
|
||||
const controller = new AbortController()
|
||||
|
||||
const request = svc.requestControl(
|
||||
sid,
|
||||
{ subtype: 'get_context_usage' },
|
||||
10_000,
|
||||
controller.signal,
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(session.outputCallbacks).toHaveLength(1)
|
||||
|
||||
controller.abort(new Error('HTTP client disconnected'))
|
||||
|
||||
await expect(request).rejects.toThrow('HTTP client disconnected')
|
||||
expect(session.outputCallbacks).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should remove the abort listener when a control request times out', async () => {
|
||||
const svc = new ConversationService()
|
||||
const sid = crypto.randomUUID()
|
||||
const controller = new AbortController()
|
||||
const removeAbortListener = spyOn(controller.signal, 'removeEventListener')
|
||||
const session: any = {
|
||||
proc: { kill() {}, exited: Promise.resolve(0) },
|
||||
outputCallbacks: [],
|
||||
workDir: process.cwd(),
|
||||
permissionMode: 'default',
|
||||
sdkToken: 'token',
|
||||
sdkSocket: { send() {} },
|
||||
pendingOutbound: [],
|
||||
startupPending: false,
|
||||
startupExitCode: null,
|
||||
stdoutLines: [],
|
||||
stderrLines: [],
|
||||
outputDrain: Promise.resolve(),
|
||||
sdkMessages: [],
|
||||
initMessage: null,
|
||||
pendingPermissionRequests: new Map(),
|
||||
}
|
||||
;(svc as any).sessions.set(sid, session)
|
||||
|
||||
await expect(svc.requestControl(
|
||||
sid,
|
||||
{ subtype: 'get_context_usage' },
|
||||
20,
|
||||
controller.signal,
|
||||
)).rejects.toThrow('Timed out waiting for get_context_usage response')
|
||||
|
||||
expect(session.outputCallbacks).toHaveLength(0)
|
||||
expect(removeAbortListener).toHaveBeenCalledWith('abort', expect.any(Function))
|
||||
})
|
||||
|
||||
it('should ignore a stale SDK disconnect after a replacement socket attaches', () => {
|
||||
const svc = new ConversationService()
|
||||
const sessionId = crypto.randomUUID()
|
||||
@@ -413,7 +493,7 @@ describe('ConversationService', () => {
|
||||
const svc = new ConversationService()
|
||||
const sent: Array<{ request_id: string }> = []
|
||||
const sessionId = 'session-permission-rejected'
|
||||
;(svc as any).sessions.set(sessionId, {
|
||||
const session = {
|
||||
proc: null,
|
||||
outputCallbacks: [],
|
||||
workDir: process.cwd(),
|
||||
@@ -428,7 +508,8 @@ describe('ConversationService', () => {
|
||||
stderrLines: [],
|
||||
sdkMessages: [],
|
||||
pendingPermissionRequests: new Map(),
|
||||
})
|
||||
}
|
||||
;(svc as any).sessions.set(sessionId, session)
|
||||
|
||||
const change = svc.setPermissionMode(sessionId, 'auto')
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
@@ -443,6 +524,8 @@ describe('ConversationService', () => {
|
||||
|
||||
await expect(change).rejects.toThrow('auto mode unavailable')
|
||||
expect(svc.getSessionPermissionMode(sessionId)).toBe('default')
|
||||
expect(session.outputCallbacks).toHaveLength(0)
|
||||
expect((svc as any).pendingPermissionModeChanges.size).toBe(0)
|
||||
})
|
||||
|
||||
it('should time out without recording a mode when control succeeds without CLI confirmation', async () => {
|
||||
@@ -1779,6 +1862,8 @@ describe('WebSocket Chat Integration', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
server?.stop(true)
|
||||
const { stopServerRuntimeForShutdown } = await import('../index.js')
|
||||
await stopServerRuntimeForShutdown()
|
||||
if (tmpDir) {
|
||||
await rmWithRetry(tmpDir)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const DEFAULT_PET_PREFERENCES = {
|
||||
enabled: false,
|
||||
selectedPetId: 'dada-code',
|
||||
size: 144,
|
||||
showTaskPanel: false,
|
||||
collapsed: false,
|
||||
motionEnabled: true,
|
||||
lastSessionId: null,
|
||||
@@ -65,7 +66,7 @@ describe('DesktopUiPreferencesService', () => {
|
||||
|
||||
expect(result.exists).toBe(false)
|
||||
expect(result.preferences).toEqual({
|
||||
schemaVersion: 3,
|
||||
schemaVersion: 4,
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
subtitle: 'github.com/NanmiCoder/cc-haha',
|
||||
@@ -109,7 +110,7 @@ describe('DesktopUiPreferencesService', () => {
|
||||
|
||||
expect(before.exists).toBe(true)
|
||||
expect(before.preferences).toEqual({
|
||||
schemaVersion: 3,
|
||||
schemaVersion: 4,
|
||||
futureField: { keep: true },
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
@@ -127,7 +128,7 @@ describe('DesktopUiPreferencesService', () => {
|
||||
},
|
||||
})
|
||||
expect(after).toEqual({
|
||||
schemaVersion: 3,
|
||||
schemaVersion: 4,
|
||||
futureField: { keep: true },
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
@@ -220,7 +221,7 @@ describe('DesktopUiPreferencesService', () => {
|
||||
})
|
||||
|
||||
expect(after).toMatchObject({
|
||||
schemaVersion: 3,
|
||||
schemaVersion: 4,
|
||||
futureField: { keep: true },
|
||||
profile: {
|
||||
displayName: 'Local Operator',
|
||||
@@ -253,13 +254,14 @@ describe('DesktopUiPreferencesService', () => {
|
||||
enabled: false,
|
||||
selectedPetId: 'huhu-plan',
|
||||
size: 144,
|
||||
showTaskPanel: false,
|
||||
collapsed: false,
|
||||
motionEnabled: true,
|
||||
lastSessionId: 'session-before',
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
settingsRenderer.updatePetPreferences({ size: 176, motionEnabled: false }),
|
||||
settingsRenderer.updatePetPreferences({ size: 176, showTaskPanel: true, motionEnabled: false }),
|
||||
petRenderer.updatePetPreferences({ collapsed: true, lastSessionId: 'session-after' }),
|
||||
])
|
||||
|
||||
@@ -268,18 +270,19 @@ describe('DesktopUiPreferencesService', () => {
|
||||
enabled: false,
|
||||
selectedPetId: 'huhu-plan',
|
||||
size: 176,
|
||||
showTaskPanel: true,
|
||||
collapsed: true,
|
||||
motionEnabled: false,
|
||||
lastSessionId: 'session-after',
|
||||
})
|
||||
})
|
||||
|
||||
test('patches an old-schema pet while preserving its other fields and unknown root fields', async () => {
|
||||
test('migrates a schema-3 pet to a hidden task panel while preserving other and unknown fields', async () => {
|
||||
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'cc-haha', 'desktop-ui.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
schemaVersion: 3,
|
||||
futureField: { keep: true },
|
||||
pet: {
|
||||
futurePetField: { keep: 'pet-too' },
|
||||
@@ -297,13 +300,14 @@ describe('DesktopUiPreferencesService', () => {
|
||||
const after = await new DesktopUiPreferencesService().updatePetPreferences({ enabled: true })
|
||||
|
||||
expect(after).toMatchObject({
|
||||
schemaVersion: 3,
|
||||
schemaVersion: 4,
|
||||
futureField: { keep: true },
|
||||
pet: {
|
||||
futurePetField: { keep: 'pet-too' },
|
||||
enabled: true,
|
||||
selectedPetId: 'custom:rocky-bot',
|
||||
size: 168,
|
||||
showTaskPanel: false,
|
||||
collapsed: true,
|
||||
motionEnabled: false,
|
||||
lastSessionId: 'session-old',
|
||||
@@ -385,7 +389,7 @@ describe('DesktopUiPreferencesService', () => {
|
||||
})
|
||||
|
||||
expect(after).toEqual({
|
||||
schemaVersion: 3,
|
||||
schemaVersion: 4,
|
||||
profile: {
|
||||
displayName: 'Claude Captain',
|
||||
subtitle: 'local.example/profile',
|
||||
@@ -456,7 +460,7 @@ describe('desktop UI preferences API', () => {
|
||||
expect(putBody).toEqual({
|
||||
ok: true,
|
||||
preferences: {
|
||||
schemaVersion: 3,
|
||||
schemaVersion: 4,
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
subtitle: 'github.com/NanmiCoder/cc-haha',
|
||||
@@ -482,7 +486,7 @@ describe('desktop UI preferences API', () => {
|
||||
expect(getBody).toEqual({
|
||||
exists: true,
|
||||
preferences: {
|
||||
schemaVersion: 3,
|
||||
schemaVersion: 4,
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
subtitle: 'github.com/NanmiCoder/cc-haha',
|
||||
@@ -506,6 +510,7 @@ describe('desktop UI preferences API', () => {
|
||||
enabled: true,
|
||||
selectedPetId: ' seedy ',
|
||||
size: 40,
|
||||
showTaskPanel: true,
|
||||
collapsed: false,
|
||||
motionEnabled: true,
|
||||
lastSessionId: 'session-42',
|
||||
@@ -517,11 +522,12 @@ describe('desktop UI preferences API', () => {
|
||||
await expect(putRes.json()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
preferences: {
|
||||
schemaVersion: 3,
|
||||
schemaVersion: 4,
|
||||
pet: {
|
||||
enabled: true,
|
||||
selectedPetId: 'seedy',
|
||||
size: 96,
|
||||
showTaskPanel: true,
|
||||
collapsed: false,
|
||||
motionEnabled: true,
|
||||
lastSessionId: 'session-42',
|
||||
|
||||
@@ -75,6 +75,18 @@ async function startTestServer() {
|
||||
wsUrl = `ws://127.0.0.1:${server.port}`
|
||||
}
|
||||
|
||||
async function stopTestServer() {
|
||||
server?.stop(true)
|
||||
const { stopServerRuntimeForShutdown } = await import('../../index.js')
|
||||
await stopServerRuntimeForShutdown()
|
||||
await fs.rm(tmpDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: process.platform === 'win32' ? 5 : 0,
|
||||
retryDelay: 100,
|
||||
})
|
||||
}
|
||||
|
||||
async function api(method: string, urlPath: string, body?: unknown) {
|
||||
const res = await fetch(`${baseUrl}${urlPath}`, {
|
||||
method,
|
||||
@@ -87,10 +99,7 @@ async function api(method: string, urlPath: string, body?: unknown) {
|
||||
|
||||
describe('Business Flow: Scheduled Tasks', () => {
|
||||
beforeAll(startTestServer)
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
afterAll(stopTestServer)
|
||||
|
||||
// ==========================================================================
|
||||
// 定时任务完整生命周期
|
||||
@@ -207,10 +216,7 @@ describe('Business Flow: Scheduled Tasks', () => {
|
||||
|
||||
describe('Business Flow: Permission Modes', () => {
|
||||
beforeAll(startTestServer)
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
afterAll(stopTestServer)
|
||||
|
||||
const VALID_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions', 'dontAsk', 'auto']
|
||||
|
||||
@@ -253,10 +259,7 @@ describe('Business Flow: Permission Modes', () => {
|
||||
|
||||
describe('Business Flow: Agent Management', () => {
|
||||
beforeAll(startTestServer)
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
afterAll(stopTestServer)
|
||||
|
||||
it('should start with shared active/all agent payload', async () => {
|
||||
const { data } = await api('GET', '/api/agents')
|
||||
@@ -367,10 +370,7 @@ describe('Business Flow: Agent Management', () => {
|
||||
|
||||
describe('Business Flow: Models & Effort', () => {
|
||||
beforeAll(startTestServer)
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
afterAll(stopTestServer)
|
||||
|
||||
it('should return available fallback models', async () => {
|
||||
const { data } = await api('GET', '/api/models')
|
||||
@@ -455,10 +455,7 @@ describe('Business Flow: Models & Effort', () => {
|
||||
|
||||
describe('Business Flow: Sessions & CLI Interop', () => {
|
||||
beforeAll(startTestServer)
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
afterAll(stopTestServer)
|
||||
|
||||
let sessionId: string
|
||||
|
||||
@@ -581,10 +578,7 @@ describe('Business Flow: Search', () => {
|
||||
await fs.writeFile(path.join(testDir, 'utils.ts'), 'export function helper() { return 42 }\n')
|
||||
await fs.writeFile(path.join(testDir, 'config.json'), '{"port": 3456}\n')
|
||||
})
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
afterAll(stopTestServer)
|
||||
|
||||
it('should find matches in workspace files', async () => {
|
||||
const { status, data } = await api('POST', '/api/search', {
|
||||
@@ -624,10 +618,7 @@ describe('Business Flow: Search', () => {
|
||||
|
||||
describe('Business Flow: WebSocket Chat', () => {
|
||||
beforeAll(startTestServer)
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
afterAll(stopTestServer)
|
||||
|
||||
it('should establish WebSocket connection and receive connected event', async () => {
|
||||
const messages: any[] = []
|
||||
@@ -763,10 +754,7 @@ describe('Business Flow: WebSocket Chat', () => {
|
||||
|
||||
describe('Business Flow: Settings Persistence', () => {
|
||||
beforeAll(startTestServer)
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
afterAll(stopTestServer)
|
||||
|
||||
it('should write and read complex settings', async () => {
|
||||
const settings = {
|
||||
@@ -823,10 +811,7 @@ describe('Business Flow: Settings Persistence', () => {
|
||||
|
||||
describe('Business Flow: Status & Diagnostics', () => {
|
||||
beforeAll(startTestServer)
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
afterAll(stopTestServer)
|
||||
|
||||
it('should return health with uptime', async () => {
|
||||
const { data } = await api('GET', '/api/status')
|
||||
@@ -863,10 +848,7 @@ describe('Business Flow: Status & Diagnostics', () => {
|
||||
|
||||
describe('Business Flow: Error Handling', () => {
|
||||
beforeAll(startTestServer)
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
afterAll(stopTestServer)
|
||||
|
||||
it('should return 404 for unknown API resource', async () => {
|
||||
const { status, data } = await api('GET', '/api/unknown')
|
||||
|
||||
@@ -74,6 +74,18 @@ async function startTestServer() {
|
||||
baseUrl = `http://127.0.0.1:${server.port}`
|
||||
}
|
||||
|
||||
async function stopTestServer() {
|
||||
server?.stop(true)
|
||||
const { stopServerRuntimeForShutdown } = await import('../../index.js')
|
||||
await stopServerRuntimeForShutdown()
|
||||
await fs.rm(tmpDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: process.platform === 'win32' ? 5 : 0,
|
||||
retryDelay: 100,
|
||||
})
|
||||
}
|
||||
|
||||
async function api(method: string, path: string, body?: unknown): Promise<{ status: number; data: any }> {
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
method,
|
||||
@@ -89,10 +101,7 @@ describe('E2E: Full Flow', () => {
|
||||
await startTestServer()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
afterAll(stopTestServer)
|
||||
|
||||
// =============================================
|
||||
// 1. Health & Status
|
||||
@@ -100,19 +109,21 @@ describe('E2E: Full Flow', () => {
|
||||
|
||||
it('should return healthy status', async () => {
|
||||
const res = await fetch(`${baseUrl}/health`)
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe('*')
|
||||
const data = await res.json()
|
||||
expect(data.status).toBe('ok')
|
||||
})
|
||||
|
||||
it('should return server status', async () => {
|
||||
const { data } = await api('GET', '/api/status')
|
||||
const response = await fetch(`${baseUrl}/api/status`)
|
||||
const data = await response.json()
|
||||
expect(data.status).toBe('ok')
|
||||
expect(data.version).toBeDefined()
|
||||
})
|
||||
|
||||
it('should return diagnostics', async () => {
|
||||
const { data } = await api('GET', '/api/status/diagnostics')
|
||||
expect(data.platform).toBe('darwin')
|
||||
expect(data.platform).toBe(process.platform)
|
||||
expect(data.configDir).toBe(tmpDir)
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { startServer } from '../index.js'
|
||||
import { startServer, stopServerRuntimeForShutdown } from '../index.js'
|
||||
import {
|
||||
clearFilesystemAccessRootsForTests,
|
||||
registerFilesystemAccessRoot,
|
||||
@@ -26,6 +26,7 @@ let originalLocalAccessToken: string | undefined
|
||||
let originalPetAccessToken: string | undefined
|
||||
let originalServerPort = 3456
|
||||
const PHONE_ORIGIN = 'https://phone.example'
|
||||
const SERVER_STOP_WAIT_MS = 500
|
||||
|
||||
async function waitForServer(url: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
@@ -78,9 +79,24 @@ async function startRemoteServer(options: { authRequired?: boolean } = {}): Prom
|
||||
await waitForServer(`${baseUrl}/health`)
|
||||
}
|
||||
|
||||
async function restartRemoteServer(options: { authRequired?: boolean } = {}): Promise<void> {
|
||||
server?.stop(true)
|
||||
async function stopRemoteServer(): Promise<void> {
|
||||
const runningServer = server
|
||||
server = undefined
|
||||
await stopServerRuntimeForShutdown({ waitForCli: false })
|
||||
if (!runningServer) return
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
await Promise.race([
|
||||
runningServer.stop(true),
|
||||
new Promise<void>((resolve) => {
|
||||
timeout = setTimeout(resolve, SERVER_STOP_WAIT_MS)
|
||||
}),
|
||||
])
|
||||
if (timeout) clearTimeout(timeout)
|
||||
}
|
||||
|
||||
async function restartRemoteServer(options: { authRequired?: boolean } = {}): Promise<void> {
|
||||
await stopRemoteServer()
|
||||
await startRemoteServer(options)
|
||||
}
|
||||
|
||||
@@ -133,15 +149,24 @@ async function enableH5Access(options: {
|
||||
function expectWebSocketOpen(url: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url)
|
||||
let opened = false
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close()
|
||||
reject(new Error(`Timed out opening websocket: ${url}`))
|
||||
}, 5000)
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
clearTimeout(timeout)
|
||||
opened = true
|
||||
ws.close()
|
||||
resolve()
|
||||
})
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
clearTimeout(timeout)
|
||||
if (opened) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(`WebSocket closed before upgrade completed: ${url}`))
|
||||
}
|
||||
})
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
@@ -215,8 +240,7 @@ beforeEach(async () => {
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
server?.stop(true)
|
||||
server = undefined
|
||||
await stopRemoteServer()
|
||||
clearFilesystemAccessRootsForTests()
|
||||
ProviderService.setServerPort(originalServerPort)
|
||||
|
||||
@@ -236,7 +260,12 @@ afterEach(async () => {
|
||||
if (originalPetAccessToken === undefined) delete process.env.CC_HAHA_PET_ACCESS_TOKEN
|
||||
else process.env.CC_HAHA_PET_ACCESS_TOKEN = originalPetAccessToken
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
await fs.rm(tmpDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 50,
|
||||
})
|
||||
})
|
||||
|
||||
describe('remote H5 auth and CORS integration', () => {
|
||||
@@ -382,6 +411,7 @@ describe('remote H5 auth and CORS integration', () => {
|
||||
enabled: false,
|
||||
selectedPetId: 'dada-code',
|
||||
size: 144,
|
||||
showTaskPanel: false,
|
||||
collapsed: true,
|
||||
motionEnabled: true,
|
||||
lastSessionId: null,
|
||||
|
||||
@@ -77,7 +77,9 @@ describe('HahaGrokOAuthService', () => {
|
||||
accessToken: 'grok-access',
|
||||
refreshToken: 'grok-refresh',
|
||||
})
|
||||
expect((await fs.stat(getHahaGrokOAuthFilePath())).mode & 0o777).toBe(0o600)
|
||||
if (process.platform !== 'win32') {
|
||||
expect((await fs.stat(getHahaGrokOAuthFilePath())).mode & 0o777).toBe(0o600)
|
||||
}
|
||||
})
|
||||
|
||||
test('refreshes an expiring token and preserves an unrotated refresh token', async () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as os from 'os'
|
||||
import { createServer } from 'net'
|
||||
import { handleHahaOpenAIOAuthApi } from '../api/haha-openai-oauth.js'
|
||||
import { hahaOpenAIOAuthService } from '../services/hahaOpenAIOAuthService.js'
|
||||
import { startServer } from '../index.js'
|
||||
import { startServer, stopServerRuntimeForShutdown } from '../index.js'
|
||||
import { ProviderService } from '../services/providerService.js'
|
||||
|
||||
let tmpDir: string
|
||||
@@ -200,7 +200,8 @@ describe('GET /auth/callback', () => {
|
||||
expect(html).toContain('OpenAI Login Failed')
|
||||
expect(html).toContain('Missing code or state parameter')
|
||||
} finally {
|
||||
server.stop(true)
|
||||
await server.stop(true)
|
||||
await stopServerRuntimeForShutdown({ waitForCli: false })
|
||||
ProviderService.setServerPort(originalServerPort)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as mcpClient from '../../services/mcp/client.js'
|
||||
import * as mcpConfig from '../../services/mcp/config.js'
|
||||
import { _setGlobalConfigCacheForTesting, getProjectPathForConfig } from '../../utils/config.js'
|
||||
import { getGlobalClaudeFile } from '../../utils/env.js'
|
||||
import { normalizePathForConfigKey } from '../../utils/path.js'
|
||||
import * as mcpHostPreflight from '../services/mcpHostPreflight.js'
|
||||
import { handleMcpApi } from '../api/mcp.js'
|
||||
import { conversationService } from '../services/conversationService.js'
|
||||
@@ -110,6 +111,8 @@ describe('MCP API', () => {
|
||||
const previousOriginalCwd = getOriginalCwd()
|
||||
const projectA = path.join(tmpDir, 'project-a')
|
||||
const projectB = path.join(tmpDir, 'project-b')
|
||||
const projectAKey = normalizePathForConfigKey(projectA)
|
||||
const projectBKey = normalizePathForConfigKey(projectB)
|
||||
await fs.mkdir(projectA, { recursive: true })
|
||||
await fs.mkdir(projectB, { recursive: true })
|
||||
|
||||
@@ -144,13 +147,13 @@ describe('MCP API', () => {
|
||||
await fs.readFile(path.join(tmpDir, '.claude.json'), 'utf8'),
|
||||
)
|
||||
|
||||
expect(rawConfig.projects?.[projectA]?.mcpServers?.['scoped-server']).toBeUndefined()
|
||||
expect(rawConfig.projects?.[projectA]?.disabledMcpServers ?? []).not.toContain('scoped-server')
|
||||
expect(rawConfig.projects?.[projectB]?.mcpServers?.['scoped-server']).toMatchObject({
|
||||
expect(rawConfig.projects?.[projectAKey]?.mcpServers?.['scoped-server']).toBeUndefined()
|
||||
expect(rawConfig.projects?.[projectAKey]?.disabledMcpServers ?? []).not.toContain('scoped-server')
|
||||
expect(rawConfig.projects?.[projectBKey]?.mcpServers?.['scoped-server']).toMatchObject({
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
})
|
||||
expect(rawConfig.projects?.[projectB]?.disabledMcpServers).toContain('scoped-server')
|
||||
expect(rawConfig.projects?.[projectBKey]?.disabledMcpServers).toContain('scoped-server')
|
||||
} finally {
|
||||
if (previousNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV
|
||||
@@ -265,7 +268,7 @@ describe('MCP API', () => {
|
||||
expect(projectPathsRes.status).toBe(200)
|
||||
const body = await projectPathsRes.json()
|
||||
|
||||
expect(body.projectPaths).toEqual([projectB])
|
||||
expect(body.projectPaths).toEqual([normalizePathForConfigKey(projectB)])
|
||||
} finally {
|
||||
if (previousNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV
|
||||
|
||||
@@ -636,7 +636,8 @@ describe('SearchService.searchSessions', () => {
|
||||
|
||||
expect(directoryAfter.mtimeMs).toBe(directoryBefore.mtimeMs)
|
||||
expect(directoryAfter.ctimeMs).toBe(directoryBefore.ctimeMs)
|
||||
expect((await fs.stat(transcriptPath)).mtimeMs).toBe(sourceBefore.mtimeMs)
|
||||
const restoredMtimeMs = (await fs.stat(transcriptPath)).mtimeMs
|
||||
expect(Math.abs(restoredMtimeMs - sourceBefore.mtimeMs)).toBeLessThan(1)
|
||||
expect(filterCalls).toBe(2)
|
||||
expect(rgCalls).toBe(2)
|
||||
expect(results.map(result => result.sessionId)).toEqual(['changed-session'])
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { HTTP_CONNECTION_IDLE_TIMEOUT_SECONDS } from '../index.js'
|
||||
|
||||
describe('server connection lifetime', () => {
|
||||
it('lets HTTP clients own pooled connection lifetime', () => {
|
||||
expect(HTTP_CONNECTION_IDLE_TIMEOUT_SECONDS).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -151,10 +151,7 @@ function git(cwd: string, ...args: string[]): string {
|
||||
}
|
||||
|
||||
async function createWorkspaceApiGitRepo(baseDir: string): Promise<string> {
|
||||
const workDir = path.join(
|
||||
baseDir,
|
||||
`workspace-api-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
)
|
||||
const workDir = await fs.mkdtemp(path.join(baseDir, 'w-'))
|
||||
|
||||
const javaControllerDir = path.join(
|
||||
workDir,
|
||||
|
||||
@@ -18,6 +18,15 @@ const taskFixture = (overrides: Record<string, unknown>) => ({
|
||||
...overrides,
|
||||
})
|
||||
|
||||
async function rmWithRetry(targetPath: string): Promise<void> {
|
||||
await fs.rm(targetPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: process.platform === 'win32' ? 5 : 0,
|
||||
retryDelay: 100,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TaskService unit tests
|
||||
// ============================================================================
|
||||
@@ -135,8 +144,10 @@ describe('Tasks API', () => {
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
server?.stop(true)
|
||||
const { stopServerRuntimeForShutdown } = await import('../../server/index.js')
|
||||
await stopServerRuntimeForShutdown()
|
||||
await rmWithRetry(tmpDir)
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
})
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ import { getCwd } from '../../utils/cwd.js'
|
||||
import { AGENT_COLORS } from '../../tools/AgentTool/agentColorManager.js'
|
||||
import { parseEffortValue } from '../../utils/effort.js'
|
||||
import { reloadSessionComponents } from '../services/sessionComponentReloadService.js'
|
||||
import { getAllBaseTools } from '../../tools.js'
|
||||
import { filterToolsForAgent } from '../../tools/AgentTool/agentToolUtils.js'
|
||||
|
||||
const agentService = new AgentService()
|
||||
|
||||
@@ -89,6 +91,7 @@ async function handleAgents(
|
||||
const resolvedAgents = resolveAgentOverrides(allAgents, activeAgents)
|
||||
|
||||
return Response.json({
|
||||
availableTools: getAvailableCustomAgentTools(),
|
||||
activeAgents: await Promise.all(
|
||||
activeAgents.map(agent => serializeAgentForRequest(agent, true, cwd)),
|
||||
),
|
||||
@@ -162,6 +165,21 @@ async function handleAgents(
|
||||
)
|
||||
}
|
||||
|
||||
function getAvailableCustomAgentTools(): string[] {
|
||||
const enabledTools = getAllBaseTools().filter(tool => {
|
||||
try {
|
||||
return tool.isEnabled()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
return filterToolsForAgent({
|
||||
tools: enabledTools,
|
||||
isBuiltIn: false,
|
||||
isAsync: true,
|
||||
}).map(tool => tool.name).sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
// ─── Tasks API ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// GET /api/tasks → list all tasks (across all task lists)
|
||||
|
||||
@@ -195,7 +195,7 @@ export async function handleSessionsApi(
|
||||
{ status: 405 }
|
||||
)
|
||||
}
|
||||
return await getSessionInspection(sessionId, url)
|
||||
return await getSessionInspection(req, sessionId, url)
|
||||
}
|
||||
|
||||
if (subResource === 'workspace') {
|
||||
@@ -632,7 +632,7 @@ async function getSessionSlashCommands(sessionId: string): Promise<Response> {
|
||||
return Response.json({ commands: slashCommands })
|
||||
}
|
||||
|
||||
async function getSessionInspection(sessionId: string, url: URL): Promise<Response> {
|
||||
async function getSessionInspection(req: Request, sessionId: string, url: URL): Promise<Response> {
|
||||
const includeContext = url.searchParams.get('includeContext') !== '0'
|
||||
const contextOnly = includeContext && url.searchParams.get('contextOnly') === '1'
|
||||
let transcriptSnapshot: Awaited<ReturnType<typeof sessionService.getInspectionTranscriptSnapshot>> | undefined
|
||||
@@ -714,8 +714,10 @@ async function getSessionInspection(sessionId: string, url: URL): Promise<Respon
|
||||
sessionId,
|
||||
{ subtype: 'get_context_usage', estimateOnly: true },
|
||||
20_000,
|
||||
req.signal,
|
||||
)
|
||||
} catch (error) {
|
||||
throwIfRequestAborted(req)
|
||||
errors.context = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
if (!response.context) {
|
||||
@@ -727,16 +729,18 @@ async function getSessionInspection(sessionId: string, url: URL): Promise<Respon
|
||||
} else {
|
||||
const basicControlTimeoutMs = includeContext ? 10_000 : 4_000
|
||||
const [usageResult, contextResult, mcpResult] = await Promise.allSettled([
|
||||
conversationService.requestControl(sessionId, { subtype: 'get_session_usage' }, basicControlTimeoutMs),
|
||||
conversationService.requestControl(sessionId, { subtype: 'get_session_usage' }, basicControlTimeoutMs, req.signal),
|
||||
includeContext
|
||||
? conversationService.requestControl(
|
||||
sessionId,
|
||||
{ subtype: 'get_context_usage', estimateOnly: true },
|
||||
20_000,
|
||||
req.signal,
|
||||
)
|
||||
: Promise.resolve(null),
|
||||
conversationService.requestControl(sessionId, { subtype: 'mcp_status' }, basicControlTimeoutMs),
|
||||
conversationService.requestControl(sessionId, { subtype: 'mcp_status' }, basicControlTimeoutMs, req.signal),
|
||||
])
|
||||
throwIfRequestAborted(req)
|
||||
|
||||
if (usageResult.status === 'fulfilled') {
|
||||
const transcriptUsage = (await getTranscriptSnapshot())?.usage ?? null
|
||||
@@ -778,6 +782,12 @@ async function getSessionInspection(sessionId: string, url: URL): Promise<Respon
|
||||
return Response.json(response)
|
||||
}
|
||||
|
||||
function throwIfRequestAborted(req: Request): void {
|
||||
if (!req.signal.aborted) return
|
||||
if (req.signal.reason instanceof Error) throw req.signal.reason
|
||||
throw new DOMException('The operation was aborted', 'AbortError')
|
||||
}
|
||||
|
||||
function usageTokenTotal(usage: unknown): number {
|
||||
if (!usage || typeof usage !== 'object') return 0
|
||||
const record = usage as Record<string, unknown>
|
||||
|
||||
+28
-15
@@ -42,6 +42,7 @@ import {
|
||||
isPetSessionInProjection,
|
||||
PET_SESSION_LIMIT,
|
||||
} from './petAccessPolicy.js'
|
||||
import { settleResponseOnRequestAbort } from './requestLifecycle.js'
|
||||
|
||||
function readArgValue(flag: string): string | undefined {
|
||||
const args = process.argv.slice(2)
|
||||
@@ -73,6 +74,7 @@ const PORT = SERVER_OPTIONS.port
|
||||
const HOST = SERVER_OPTIONS.host
|
||||
const SEARCH_INDEX_PRIMARY_WAIT_MS = 30_000
|
||||
const SEARCH_INDEX_PRIMARY_POLL_MS = 50
|
||||
export const HTTP_CONNECTION_IDLE_TIMEOUT_SECONDS = 0
|
||||
|
||||
type BackgroundIndexStartupOptions = {
|
||||
startPrimary?: () => Promise<void>
|
||||
@@ -211,6 +213,10 @@ export function startServer(port = PORT, host = HOST) {
|
||||
? '127.0.0.1'
|
||||
: host
|
||||
|
||||
// Chromium can keep HTTP/1.1 sockets pooled longer than Bun's request idle
|
||||
// timeout. On Windows, reusing a socket after Bun timed it out can leave the
|
||||
// request waiting for response headers until the renderer's 120s deadline.
|
||||
// Let the client own the lifetime of these local pooled connections instead.
|
||||
/**
|
||||
* Explicit deployment auth remains a stronger override than H5-scoped
|
||||
* request gating.
|
||||
@@ -226,11 +232,27 @@ export function startServer(port = PORT, host = HOST) {
|
||||
server = Bun.serve<WebSocketData>({
|
||||
port,
|
||||
hostname: host,
|
||||
idleTimeout: 60,
|
||||
idleTimeout: HTTP_CONNECTION_IDLE_TIMEOUT_SECONDS,
|
||||
|
||||
async fetch(req, server) {
|
||||
await ensurePersistentStorageUpgraded()
|
||||
const url = new URL(req.url)
|
||||
|
||||
// Startup probes must not wait on migrations, config reads, or auth.
|
||||
// Electron deliberately uses this endpoint to decide when the sidecar
|
||||
// is ready, so keep it independent of every other runtime subsystem.
|
||||
if (url.pathname === '/health') {
|
||||
return Response.json(
|
||||
{ status: 'ok', timestamp: new Date().toISOString() },
|
||||
{
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
await ensurePersistentStorageUpgraded()
|
||||
const origin = req.headers.get('Origin')
|
||||
const clientAddress = server.requestIP(req)?.address ?? null
|
||||
const localTokenOverride = url.searchParams.get('localToken') ?? url.searchParams.get('token')
|
||||
@@ -479,7 +501,10 @@ export function startServer(port = PORT, host = HOST) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await handleApiRequest(req, url)
|
||||
const response = await settleResponseOnRequestAbort(
|
||||
req,
|
||||
handleApiRequest(req, url),
|
||||
)
|
||||
return withCors(response, cors)
|
||||
} catch (error) {
|
||||
void diagnosticsService.recordEvent({
|
||||
@@ -531,18 +556,6 @@ export function startServer(port = PORT, host = HOST) {
|
||||
}
|
||||
}
|
||||
|
||||
// Health check
|
||||
if (url.pathname === '/health') {
|
||||
if (cors.rejected) {
|
||||
return corsRejectedResponse(cors)
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{ status: 'ok', timestamp: new Date().toISOString() },
|
||||
{ headers: cors.headers },
|
||||
)
|
||||
}
|
||||
|
||||
// Static H5 shell/assets are non-secret bootstrap content and must load
|
||||
// before the browser can read the QR token; API/proxy/ws stay protected above.
|
||||
const staticResponse = await handleStaticH5Request(req, url)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { settleResponseOnRequestAbort } from './requestLifecycle'
|
||||
|
||||
describe('settleResponseOnRequestAbort', () => {
|
||||
it('releases the server request when the client disconnects', async () => {
|
||||
const controller = new AbortController()
|
||||
const request = new Request('http://127.0.0.1/api/status', {
|
||||
signal: controller.signal,
|
||||
})
|
||||
const pending = new Promise<Response>(() => undefined)
|
||||
|
||||
const responsePromise = settleResponseOnRequestAbort(request, pending)
|
||||
controller.abort()
|
||||
|
||||
const response = await responsePromise
|
||||
expect(response.status).toBe(499)
|
||||
})
|
||||
|
||||
it('returns completed responses and removes its abort listener', async () => {
|
||||
const controller = new AbortController()
|
||||
const request = new Request('http://127.0.0.1/api/status', {
|
||||
signal: controller.signal,
|
||||
})
|
||||
let added = 0
|
||||
let removed = 0
|
||||
const originalAdd = request.signal.addEventListener.bind(request.signal)
|
||||
const originalRemove = request.signal.removeEventListener.bind(request.signal)
|
||||
request.signal.addEventListener = ((...args: Parameters<AbortSignal['addEventListener']>) => {
|
||||
added += 1
|
||||
return originalAdd(...args)
|
||||
}) as AbortSignal['addEventListener']
|
||||
request.signal.removeEventListener = ((...args: Parameters<AbortSignal['removeEventListener']>) => {
|
||||
removed += 1
|
||||
return originalRemove(...args)
|
||||
}) as AbortSignal['removeEventListener']
|
||||
|
||||
const response = await settleResponseOnRequestAbort(
|
||||
request,
|
||||
Promise.resolve(Response.json({ ok: true })),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(added).toBe(1)
|
||||
expect(removed).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
const CLIENT_CLOSED_REQUEST_STATUS = 499
|
||||
|
||||
function clientClosedResponse(): Response {
|
||||
return new Response(null, { status: CLIENT_CLOSED_REQUEST_STATUS })
|
||||
}
|
||||
|
||||
export async function settleResponseOnRequestAbort(
|
||||
request: Request,
|
||||
operation: Promise<Response>,
|
||||
): Promise<Response> {
|
||||
if (request.signal.aborted) return clientClosedResponse()
|
||||
|
||||
let resolveAborted!: (response: Response) => void
|
||||
const aborted = new Promise<Response>(resolve => {
|
||||
resolveAborted = resolve
|
||||
})
|
||||
const onAbort = () => resolveAborted(clientClosedResponse())
|
||||
request.signal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
try {
|
||||
return await Promise.race([operation, aborted])
|
||||
} finally {
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
}
|
||||
@@ -281,6 +281,11 @@ export class AgentService {
|
||||
throw ApiError.badRequest('Agent project cwd must be an existing directory')
|
||||
}
|
||||
|
||||
// Agent CRUD must observe repository boundaries as they exist now. A
|
||||
// long-running desktop session may have resolved this directory before a
|
||||
// nested repository was initialized, leaving findGitRoot's LRU entry
|
||||
// pointing at an ancestor repository.
|
||||
findGitRoot.cache.delete(realCwd)
|
||||
const projectRoot = findGitRoot(realCwd) ?? realCwd
|
||||
const realProjectRoot = await fs.realpath(projectRoot)
|
||||
const worktreeAgentsCandidate = path.join(
|
||||
|
||||
@@ -645,13 +645,21 @@ export class ConversationService {
|
||||
this.trackPendingPermissionModeChange(sessionId, mode, 1)
|
||||
|
||||
let confirmationSettled = false
|
||||
let confirmationTimeout: ReturnType<typeof setTimeout>
|
||||
let handleOutput: (msg: any) => void
|
||||
let confirmationTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let handleOutput: ((msg: any) => void) | undefined
|
||||
let rejectConfirmation: ((reason?: unknown) => void) | undefined
|
||||
const cleanupConfirmation = () => {
|
||||
clearTimeout(confirmationTimeout)
|
||||
this.removeOutputCallback(sessionId, handleOutput)
|
||||
if (confirmationTimeout !== undefined) clearTimeout(confirmationTimeout)
|
||||
if (handleOutput) this.removeOutputCallback(sessionId, handleOutput)
|
||||
}
|
||||
const cancelConfirmation = (reason: unknown) => {
|
||||
if (confirmationSettled) return
|
||||
confirmationSettled = true
|
||||
cleanupConfirmation()
|
||||
rejectConfirmation?.(reason)
|
||||
}
|
||||
const confirmation = new Promise<void>((resolve, reject) => {
|
||||
rejectConfirmation = reject
|
||||
handleOutput = (msg: any) => {
|
||||
if (
|
||||
msg?.type !== 'system' ||
|
||||
@@ -665,12 +673,6 @@ export class ConversationService {
|
||||
cleanupConfirmation()
|
||||
resolve()
|
||||
}
|
||||
|
||||
confirmationTimeout = setTimeout(() => {
|
||||
confirmationSettled = true
|
||||
cleanupConfirmation()
|
||||
reject(new Error(`Timed out waiting for permission mode confirmation: ${mode}`))
|
||||
}, timeoutMs)
|
||||
this.onOutput(sessionId, handleOutput)
|
||||
})
|
||||
// requestControl can reject before the confirmation promise is awaited.
|
||||
@@ -678,15 +680,22 @@ export class ConversationService {
|
||||
void confirmation.catch(() => undefined)
|
||||
|
||||
try {
|
||||
const startedAt = Date.now()
|
||||
await this.requestControl(sessionId, {
|
||||
subtype: 'set_permission_mode',
|
||||
mode,
|
||||
}, timeoutMs)
|
||||
if (!confirmationSettled) {
|
||||
const remainingMs = Math.max(1, timeoutMs - (Date.now() - startedAt))
|
||||
confirmationTimeout = setTimeout(() => {
|
||||
cancelConfirmation(new Error(`Timed out waiting for permission mode confirmation: ${mode}`))
|
||||
}, remainingMs)
|
||||
}
|
||||
await confirmation
|
||||
|
||||
return this.sessions.has(sessionId)
|
||||
} catch (err) {
|
||||
if (!confirmationSettled) cleanupConfirmation()
|
||||
cancelConfirmation(err)
|
||||
throw err
|
||||
} finally {
|
||||
this.trackPendingPermissionModeChange(sessionId, mode, -1)
|
||||
@@ -736,10 +745,12 @@ export class ConversationService {
|
||||
private async waitForControlChannelReady(
|
||||
sessionId: string,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const startedAt = Date.now()
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (signal?.aborted) throw controlRequestAbortReason(signal)
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error('CLI session is not running')
|
||||
@@ -747,7 +758,7 @@ export class ConversationService {
|
||||
if (this.isControlChannelReady(session)) {
|
||||
return
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, CONTROL_READY_POLL_MS))
|
||||
await waitForControlPoll(CONTROL_READY_POLL_MS, signal)
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for CLI control channel to become ready')
|
||||
@@ -757,27 +768,36 @@ export class ConversationService {
|
||||
sessionId: string,
|
||||
request: Record<string, unknown>,
|
||||
timeoutMs = 10_000,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(controlRequestAbortReason(signal))
|
||||
}
|
||||
if (!this.sessions.has(sessionId)) {
|
||||
return Promise.reject(new Error('CLI session is not running'))
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
await this.waitForControlChannelReady(sessionId, timeoutMs)
|
||||
await this.waitForControlChannelReady(sessionId, timeoutMs, signal)
|
||||
const responseTimeoutMs = Math.max(1, timeoutMs - (Date.now() - startedAt))
|
||||
const requestId = crypto.randomUUID()
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.removeOutputCallback(sessionId, handleOutput)
|
||||
reject(new Error(`Timed out waiting for ${String(request.subtype ?? 'control')} response`))
|
||||
}, responseTimeoutMs)
|
||||
let settled = false
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
signal?.removeEventListener('abort', handleAbort)
|
||||
this.removeOutputCallback(sessionId, handleOutput)
|
||||
fn()
|
||||
}
|
||||
|
||||
const handleAbort = () => {
|
||||
finish(() => reject(controlRequestAbortReason(signal!)))
|
||||
}
|
||||
|
||||
const handleOutput = (msg: any) => {
|
||||
if (
|
||||
msg?.type !== 'control_response' ||
|
||||
@@ -798,7 +818,17 @@ export class ConversationService {
|
||||
))
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
finish(() => reject(new Error(
|
||||
`Timed out waiting for ${String(request.subtype ?? 'control')} response`,
|
||||
)))
|
||||
}, responseTimeoutMs)
|
||||
this.onOutput(sessionId, handleOutput)
|
||||
signal?.addEventListener('abort', handleAbort, { once: true })
|
||||
if (signal?.aborted) {
|
||||
handleAbort()
|
||||
return
|
||||
}
|
||||
const sent = this.sendSdkMessage(sessionId, {
|
||||
type: 'control_request',
|
||||
request_id: requestId,
|
||||
@@ -2173,6 +2203,29 @@ export class ConversationService {
|
||||
}
|
||||
}
|
||||
|
||||
function controlRequestAbortReason(signal: AbortSignal): Error {
|
||||
if (signal.reason instanceof Error) return signal.reason
|
||||
return new DOMException('The operation was aborted', 'AbortError')
|
||||
}
|
||||
|
||||
function waitForControlPoll(timeoutMs: number, signal?: AbortSignal): Promise<void> {
|
||||
if (!signal) return new Promise((resolve) => setTimeout(resolve, timeoutMs))
|
||||
if (signal.aborted) return Promise.reject(controlRequestAbortReason(signal))
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
signal.removeEventListener('abort', handleAbort)
|
||||
resolve()
|
||||
}, timeoutMs)
|
||||
const handleAbort = () => {
|
||||
clearTimeout(timeout)
|
||||
reject(controlRequestAbortReason(signal))
|
||||
}
|
||||
signal.addEventListener('abort', handleAbort, { once: true })
|
||||
if (signal.aborted) handleAbort()
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeSessionPermissionUpdates(
|
||||
suggestions: unknown[] | undefined,
|
||||
toolName: string,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ApiError } from '../middleware/errorHandler.js'
|
||||
import { readRecoverableJsonFile } from './recoverableJsonFile.js'
|
||||
import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.js'
|
||||
|
||||
const CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION = 3
|
||||
const CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION = 4
|
||||
const MAX_PROJECT_PREFERENCE_ENTRIES = 2_000
|
||||
const MAX_PROFILE_DISPLAY_NAME_LENGTH = 80
|
||||
const MAX_PROFILE_SUBTITLE_LENGTH = 160
|
||||
@@ -44,6 +44,7 @@ export type DesktopPetPreferences = {
|
||||
enabled: boolean
|
||||
selectedPetId: string
|
||||
size: number
|
||||
showTaskPanel: boolean
|
||||
collapsed: boolean
|
||||
motionEnabled: boolean
|
||||
lastSessionId: string | null
|
||||
@@ -81,6 +82,7 @@ const DEFAULT_PET_PREFERENCES: DesktopPetPreferences = {
|
||||
enabled: false,
|
||||
selectedPetId: DEFAULT_PET_ID,
|
||||
size: DEFAULT_PET_SIZE,
|
||||
showTaskPanel: false,
|
||||
collapsed: false,
|
||||
motionEnabled: true,
|
||||
lastSessionId: null,
|
||||
@@ -196,6 +198,9 @@ export function normalizeDesktopPetPreferences(value: unknown): DesktopPetPrefer
|
||||
enabled: typeof record.enabled === 'boolean' ? record.enabled : DEFAULT_PET_PREFERENCES.enabled,
|
||||
selectedPetId: normalizePetId(record.selectedPetId),
|
||||
size: normalizePetSize(record.size),
|
||||
showTaskPanel: typeof record.showTaskPanel === 'boolean'
|
||||
? record.showTaskPanel
|
||||
: DEFAULT_PET_PREFERENCES.showTaskPanel,
|
||||
collapsed: typeof record.collapsed === 'boolean' ? record.collapsed : DEFAULT_PET_PREFERENCES.collapsed,
|
||||
motionEnabled: typeof record.motionEnabled === 'boolean'
|
||||
? record.motionEnabled
|
||||
|
||||
@@ -160,7 +160,7 @@ describe('readCompleteJsonlRange', () => {
|
||||
}
|
||||
return {
|
||||
size: payload.length,
|
||||
mtimeMs: 1,
|
||||
mtimeMs: race === 'replace' ? 2 : 1,
|
||||
dev: 2,
|
||||
ino: race === 'replace' ? 4 : 3,
|
||||
}
|
||||
|
||||
@@ -220,12 +220,6 @@ describe('local index reconciliation watcher', () => {
|
||||
const scope = await createTempDir()
|
||||
const projectDir = join(scope, 'projects', '-repo')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
const entered = { resolve: () => {}, promise: Promise.resolve() }
|
||||
let resolveEntered!: () => void
|
||||
entered.promise = new Promise<void>(resolve => {
|
||||
resolveEntered = resolve
|
||||
})
|
||||
entered.resolve = resolveEntered
|
||||
let releaseFirst!: () => void
|
||||
const firstRelease = new Promise<void>(resolve => {
|
||||
releaseFirst = resolve
|
||||
@@ -240,20 +234,24 @@ describe('local index reconciliation watcher', () => {
|
||||
onBatch: async batch => {
|
||||
batches.push(batch)
|
||||
if (batches.length === 1) {
|
||||
entered.resolve()
|
||||
await firstRelease
|
||||
}
|
||||
},
|
||||
})
|
||||
await watcher.start()
|
||||
await entered.promise
|
||||
watcher.queueTranscriptPath(join(projectDir, 'late.jsonl'))
|
||||
const stopping = watcher.stop()
|
||||
releaseFirst()
|
||||
await stopping
|
||||
await Bun.sleep(40)
|
||||
try {
|
||||
await waitFor(() => batches.length > 0)
|
||||
watcher.queueTranscriptPath(join(projectDir, 'late.jsonl'))
|
||||
const stopping = watcher.stop()
|
||||
releaseFirst()
|
||||
await stopping
|
||||
await Bun.sleep(40)
|
||||
|
||||
expect(batches).toEqual([{ paths: [], fullSweep: true }])
|
||||
expect(batches).toEqual([{ paths: [], fullSweep: true }])
|
||||
} finally {
|
||||
releaseFirst()
|
||||
await watcher.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('reports watch failure once per attempt and retries with bounded backoff', async () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { CronScheduler } from '../cronScheduler.js'
|
||||
let tmpDir: string | undefined
|
||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
const originalLocalIndexMode = process.env.CC_HAHA_LOCAL_INDEX
|
||||
const exactFileTime = new Date(1_700_000_000_000)
|
||||
|
||||
afterEach(async () => {
|
||||
await resetScheduledRunReadModelForTests()
|
||||
@@ -365,12 +366,13 @@ describe('scheduled run read model', () => {
|
||||
prompt: 'prompt',
|
||||
}] })
|
||||
await fs.writeFile(sourcePath, serialize('run-a'))
|
||||
await fs.utimes(sourcePath, exactFileTime, exactFileTime)
|
||||
const original = await fs.stat(sourcePath)
|
||||
|
||||
expect((await readScheduledRunPage(sourcePath, { summaryOnly: true }))?.runs[0]?.id).toBe('run-a')
|
||||
|
||||
await fs.writeFile(sourcePath, serialize('run-b'))
|
||||
await fs.utimes(sourcePath, original.atimeMs / 1000, original.mtimeMs / 1000)
|
||||
await fs.utimes(sourcePath, exactFileTime, exactFileTime)
|
||||
const restored = await fs.stat(sourcePath)
|
||||
expect(restored.size).toBe(original.size)
|
||||
expect(restored.mtimeMs).toBe(original.mtimeMs)
|
||||
@@ -397,11 +399,12 @@ describe('scheduled run read model', () => {
|
||||
output: `${'a'.repeat(half)}${middle}${'a'.repeat(half)}`,
|
||||
}] })
|
||||
await fs.writeFile(sourcePath, serialize('a'))
|
||||
await fs.utimes(sourcePath, exactFileTime, exactFileTime)
|
||||
const original = await fs.stat(sourcePath)
|
||||
await readScheduledRunPage(sourcePath, { summaryOnly: true })
|
||||
|
||||
await fs.writeFile(sourcePath, serialize('b'))
|
||||
await fs.utimes(sourcePath, original.atimeMs / 1000, original.mtimeMs / 1000)
|
||||
await fs.utimes(sourcePath, exactFileTime, exactFileTime)
|
||||
const restored = await fs.stat(sourcePath)
|
||||
expect(restored.size).toBe(original.size)
|
||||
expect(restored.mtimeMs).toBe(original.mtimeMs)
|
||||
@@ -426,13 +429,14 @@ describe('scheduled run read model', () => {
|
||||
output: `${'a'.repeat(half)}${middle}${'a'.repeat(half)}`,
|
||||
}] })
|
||||
await fs.writeFile(sourcePath, serialize('a'))
|
||||
await fs.utimes(sourcePath, exactFileTime, exactFileTime)
|
||||
const original = await fs.stat(sourcePath)
|
||||
await readScheduledRunPage(sourcePath, { summaryOnly: true })
|
||||
|
||||
setScheduledRunFingerprintAfterInitialStatHookForTests(async () => {
|
||||
setScheduledRunFingerprintAfterInitialStatHookForTests(null)
|
||||
await fs.writeFile(sourcePath, serialize('b'))
|
||||
await fs.utimes(sourcePath, original.atimeMs / 1000, original.mtimeMs / 1000)
|
||||
await fs.utimes(sourcePath, exactFileTime, exactFileTime)
|
||||
})
|
||||
|
||||
await readScheduledRunPage(sourcePath, { summaryOnly: true })
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { verifySourceFingerprint } from './sourceFingerprint.js'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
const exactFileTime = new Date(1_700_000_000_000)
|
||||
|
||||
async function createTempDir(label: string): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), `cc-haha-${label}-`))
|
||||
@@ -62,10 +63,12 @@ async function createCandidate(options: {
|
||||
projectPath: string
|
||||
sessionId: string
|
||||
content: string
|
||||
fileTime?: Date
|
||||
}): Promise<SessionSourceCandidate> {
|
||||
const path = join(options.root, 'projects', options.projectPath, `${options.sessionId}.jsonl`)
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, options.content)
|
||||
if (options.fileTime) await utimes(path, options.fileTime, options.fileTime)
|
||||
const snapshot = await stat(path)
|
||||
return {
|
||||
path,
|
||||
@@ -669,6 +672,7 @@ describe('session projector', () => {
|
||||
projectPath: '-repo-a',
|
||||
sessionId: 'commit-ctime-race',
|
||||
content: beforeContent,
|
||||
fileTime: exactFileTime,
|
||||
})
|
||||
const originalSnapshot = await stat(candidate.path)
|
||||
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
||||
@@ -684,8 +688,8 @@ describe('session projector', () => {
|
||||
writeFileSync(candidate.path, afterContent)
|
||||
utimesSync(
|
||||
candidate.path,
|
||||
originalSnapshot.atimeMs / 1_000,
|
||||
originalSnapshot.mtimeMs / 1_000,
|
||||
exactFileTime,
|
||||
exactFileTime,
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -719,6 +723,7 @@ describe('session projector', () => {
|
||||
projectPath: '-repo-a',
|
||||
sessionId: 'activity-commit-ctime-race',
|
||||
content: beforeContent,
|
||||
fileTime: exactFileTime,
|
||||
})
|
||||
const originalSnapshot = await stat(candidate.path)
|
||||
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
||||
@@ -734,8 +739,8 @@ describe('session projector', () => {
|
||||
writeFileSync(path, afterContent)
|
||||
utimesSync(
|
||||
path,
|
||||
originalSnapshot.atimeMs / 1_000,
|
||||
originalSnapshot.mtimeMs / 1_000,
|
||||
exactFileTime,
|
||||
exactFileTime,
|
||||
)
|
||||
}
|
||||
return statSync(path)
|
||||
|
||||
@@ -112,6 +112,7 @@ describe('source fingerprint transitions', () => {
|
||||
path,
|
||||
indexedBytes: Buffer.byteLength(original),
|
||||
parserVersion: 1,
|
||||
identityResolver: stats => `${stats.dev}:${stats.ino}`,
|
||||
})
|
||||
const replacedPath = `${path}.old`
|
||||
await rename(path, replacedPath)
|
||||
@@ -120,6 +121,7 @@ describe('source fingerprint transitions', () => {
|
||||
path,
|
||||
previous: beforeReplacement,
|
||||
parserVersion: 1,
|
||||
identityResolver: stats => `${stats.dev}:${stats.ino}`,
|
||||
})).toEqual({ kind: 'rebuild', reason: 'replace' })
|
||||
|
||||
const beforeRewrite = await captureSourceFingerprint({
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('MCP host preflight', () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('finds npx from user shell PATH when the desktop process PATH is minimal', async () => {
|
||||
it.skipIf(process.platform === 'win32')('finds npx from user shell PATH when the desktop process PATH is minimal', async () => {
|
||||
const shellPath = path.join(tmpDir, 'zsh')
|
||||
const nodeBin = path.join(tmpDir, 'node-bin')
|
||||
const npxPath = path.join(nodeBin, 'npx')
|
||||
|
||||
@@ -144,10 +144,10 @@ export type SessionChatActivityState =
|
||||
|
||||
/**
|
||||
* Pet/activity status deliberately reuses the authoritative WebSocket turn and
|
||||
* permission state above. Only terminal outcomes and the legacy REST queue
|
||||
* fallback need their own memory; waiting/running are derived live.
|
||||
* permission state above. Only failures and the legacy REST queue fallback
|
||||
* need their own memory; successful completion returns directly to idle.
|
||||
*/
|
||||
const terminalSessionChatStates = new Map<string, 'failed' | 'review'>()
|
||||
const terminalSessionChatStates = new Map<string, 'failed'>()
|
||||
const legacyQueuedSessionChats = new Set<string>()
|
||||
const interruptedSessionChats = new Set<string>()
|
||||
|
||||
@@ -171,7 +171,14 @@ function settleSessionChatActivity(sessionId: string, cliMsg: any): void {
|
||||
terminalSessionChatStates.delete(sessionId)
|
||||
return
|
||||
}
|
||||
terminalSessionChatStates.set(sessionId, cliMsg.is_error ? 'failed' : 'review')
|
||||
if (cliMsg.is_error) {
|
||||
terminalSessionChatStates.set(sessionId, 'failed')
|
||||
return
|
||||
}
|
||||
|
||||
// A successful result is complete. Keeping the tab open does not imply that
|
||||
// the user has an outstanding review action.
|
||||
terminalSessionChatStates.delete(sessionId)
|
||||
}
|
||||
|
||||
type CliBackgroundTaskLifecycle = {
|
||||
@@ -337,6 +344,7 @@ export type WebSocketData = {
|
||||
// legitimately watch the same running session at the same time.
|
||||
const activeSessions = new Map<string, Set<ServerWebSocket<WebSocketData>>>()
|
||||
let activePetClient: ServerWebSocket<WebSocketData> | null = null
|
||||
|
||||
const clientOutputCallbacks = new Map<
|
||||
ServerWebSocket<WebSocketData>,
|
||||
{
|
||||
|
||||
@@ -821,8 +821,9 @@ export async function captureResponseTraceSnapshot(
|
||||
if (completed) return
|
||||
interrupted = true
|
||||
void reader.cancel().catch(() => {})
|
||||
// This promise awaits the backstop; keep its timer referenced so Bun on
|
||||
// Windows can fire it even when the pending stream read never settles.
|
||||
graceTimer = setTimeout(() => resolve('forced'), options?.abortGraceMs ?? TRACE_ABORT_CAPTURE_GRACE_MS)
|
||||
graceTimer.unref?.()
|
||||
}
|
||||
if (signal.aborted) onAbort()
|
||||
else signal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('MCP stdio environment', () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('adds PATH entries sourced from the user zshrc when MCP env has no explicit PATH', async () => {
|
||||
it.skipIf(process.platform === 'win32')('adds PATH entries sourced from the user zshrc when MCP env has no explicit PATH', async () => {
|
||||
const shellPath = path.join(tmpDir, 'zsh')
|
||||
const nodeBin = path.join(tmpDir, 'node-bin')
|
||||
await mkdir(nodeBin, { recursive: true })
|
||||
|
||||
@@ -25,12 +25,12 @@ describe('proxy environment handling', () => {
|
||||
afterEach(restoreEnv)
|
||||
|
||||
test('bypasses proxy fetch options for loopback provider proxy targets', () => {
|
||||
process.env.HTTP_PROXY = 'http://127.0.0.1:1181'
|
||||
process.env.HTTPS_PROXY = 'http://127.0.0.1:1181'
|
||||
process.env.NO_PROXY = 'localhost,127.0.0.1,::1'
|
||||
delete process.env.http_proxy
|
||||
delete process.env.https_proxy
|
||||
delete process.env.no_proxy
|
||||
process.env.HTTP_PROXY = 'http://127.0.0.1:1181'
|
||||
process.env.HTTPS_PROXY = 'http://127.0.0.1:1181'
|
||||
process.env.NO_PROXY = 'localhost,127.0.0.1,::1'
|
||||
|
||||
expect(shouldBypassProxy('http://127.0.0.1:3456/proxy/providers/p1/v1/messages')).toBe(true)
|
||||
expect(getProxyFetchOptions({
|
||||
@@ -40,12 +40,12 @@ describe('proxy environment handling', () => {
|
||||
})
|
||||
|
||||
test('bypasses bracketed IPv6 loopback targets for plain ::1 NO_PROXY entries', () => {
|
||||
process.env.HTTP_PROXY = 'http://127.0.0.1:1181'
|
||||
process.env.HTTPS_PROXY = 'http://127.0.0.1:1181'
|
||||
process.env.NO_PROXY = '::1'
|
||||
delete process.env.http_proxy
|
||||
delete process.env.https_proxy
|
||||
delete process.env.no_proxy
|
||||
process.env.HTTP_PROXY = 'http://127.0.0.1:1181'
|
||||
process.env.HTTPS_PROXY = 'http://127.0.0.1:1181'
|
||||
process.env.NO_PROXY = '::1'
|
||||
|
||||
expect(shouldBypassProxy('http://[::1]:3456/api/status')).toBe(true)
|
||||
expect(getProxyFetchOptions({
|
||||
@@ -66,12 +66,12 @@ describe('proxy environment handling', () => {
|
||||
})
|
||||
|
||||
test('keeps proxy fetch options for external provider targets', () => {
|
||||
process.env.HTTP_PROXY = 'http://127.0.0.1:1181'
|
||||
process.env.HTTPS_PROXY = 'http://127.0.0.1:1181'
|
||||
process.env.NO_PROXY = 'localhost,127.0.0.1,::1'
|
||||
delete process.env.http_proxy
|
||||
delete process.env.https_proxy
|
||||
delete process.env.no_proxy
|
||||
process.env.HTTP_PROXY = 'http://127.0.0.1:1181'
|
||||
process.env.HTTPS_PROXY = 'http://127.0.0.1:1181'
|
||||
process.env.NO_PROXY = 'localhost,127.0.0.1,::1'
|
||||
|
||||
expect(shouldBypassProxy('https://api.example.com/v1/messages')).toBe(false)
|
||||
expect(getProxyFetchOptions({
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('terminal shell environment', () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('captures exported variables from an interactive user shell', async () => {
|
||||
it.skipIf(process.platform === 'win32')('captures exported variables from an interactive user shell', async () => {
|
||||
const shellPath = path.join(tmpDir, 'zsh')
|
||||
const nodeBin = path.join(tmpDir, 'node-bin')
|
||||
const nvmDir = path.join(tmpDir, '.nvm')
|
||||
@@ -72,20 +72,22 @@ describe('terminal shell environment', () => {
|
||||
})
|
||||
|
||||
it('merges shell PATH before base PATH while preserving app env overrides', () => {
|
||||
const basePath = ['/usr/bin', '/bin'].join(path.delimiter)
|
||||
const shellPath = ['/opt/homebrew/bin', '/usr/bin'].join(path.delimiter)
|
||||
const merged = mergeTerminalShellEnvironment(
|
||||
{
|
||||
PATH: '/usr/bin:/bin',
|
||||
PATH: basePath,
|
||||
CC_HAHA_DESKTOP_SERVER_URL: 'http://127.0.0.1:3456',
|
||||
TOOL_HOME: '/base/tool',
|
||||
},
|
||||
{
|
||||
PATH: '/opt/homebrew/bin:/usr/bin',
|
||||
PATH: shellPath,
|
||||
NVM_DIR: '/Users/test/.nvm',
|
||||
TOOL_HOME: '/shell/tool',
|
||||
},
|
||||
)
|
||||
|
||||
expect(merged.PATH).toBe('/opt/homebrew/bin:/usr/bin:/bin')
|
||||
expect(merged.PATH).toBe(['/opt/homebrew/bin', '/usr/bin', '/bin'].join(path.delimiter))
|
||||
expect(merged.NVM_DIR).toBe('/Users/test/.nvm')
|
||||
expect(merged.TOOL_HOME).toBe('/base/tool')
|
||||
expect(merged.CC_HAHA_DESKTOP_SERVER_URL).toBe('http://127.0.0.1:3456')
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
# v0.4.10 之后人工界面测试缺陷与修复记录
|
||||
|
||||
> 测试范围:`v0.4.10..91829e91`
|
||||
>
|
||||
> 平台:Windows 11 x64;隔离 `CLAUDE_CONFIG_DIR` 与 Electron `user-data-dir`
|
||||
>
|
||||
> 状态:本轮执行已收口。DeepSeek、MiniMax 真实编程任务、附件恢复、多模态、SubAgent、Task 并发与 Windows 长空闲恢复均有真实 UI 证据;BUG-001~BUG-011 已修复并验证。未执行或仅部分执行的硬件、故障注入、CLI/TUI 用例仍按限制保留,不冒充 passed。
|
||||
|
||||
## BUG-001|React StrictMode 下内置终端首次打开为空白
|
||||
|
||||
- 严重程度:P0
|
||||
- 状态:fixed / verified
|
||||
- 对应用例:WIN-03、终端基础冒烟
|
||||
- 测试场景:开发构建中打开顶部内置终端标签。
|
||||
- 实际结果:终端区域为空白且没有可用会话;刷新设置页不能恢复。
|
||||
- 期望结果:终端只创建一个 runtime,显示 `Running`、shell、cwd 和可交互提示符。
|
||||
- 根因:React StrictMode 首次挂载会重放 effect。第一次 cleanup 立即销毁共享 terminal runtime,第二次 effect 继续持有已经失效的对象,因而无法重新启动。
|
||||
- 修复:为组件 effect 增加生命周期版本;普通真实卸载仍在 microtask 中销毁 runtime,StrictMode replay 的过期 cleanup 则不再销毁当前 runtime。
|
||||
- 变更文件:
|
||||
- `desktop/src/pages/TerminalSettings.tsx`
|
||||
- `desktop/src/pages/TerminalSettings.test.tsx`
|
||||
- 验证证据:
|
||||
- 聚焦 Vitest:17/17 passed。
|
||||
- TypeScript `tsc --noEmit` passed。
|
||||
- Computer Use 复验:顶部终端显示 `Running`、`C:\WINDOWS\system32\cmd.exe` 和隔离配置目录提示符;Restart 后仍为 `Running`。
|
||||
- 完整 `bun run check:desktop`:2430 passed、2 skipped、0 failed,production build passed。
|
||||
|
||||
## BUG-002|切换语言后 Settings 顶部标签仍保留旧中文标题
|
||||
|
||||
- 严重程度:P1
|
||||
- 状态:fixed / verified
|
||||
- 对应用例:I18N-01
|
||||
- 测试场景:应用最初为简体中文,打开设置标签后切换到 English。
|
||||
- 实际结果:设置页正文已经变为英文,但顶部标签仍显示持久化的“设置”。
|
||||
- 期望结果:顶部标签与当前 locale 同步显示 `Settings`,关闭按钮的无障碍名称也同步更新。
|
||||
- 根因:设置标签直接渲染创建标签时保存的 `tab.title`,没有根据当前 locale 重新读取 `settings.title`。
|
||||
- 修复:设置类型标签动态渲染当前 `t('settings.title')`,同时用于 close button 的 aria-label;普通会话标签仍保留原标题。
|
||||
- 变更文件:
|
||||
- `desktop/src/components/layout/TabBar.tsx`
|
||||
- `desktop/src/components/layout/TabBar.test.tsx`
|
||||
- 验证证据:
|
||||
- 聚焦 Vitest:40/40 passed。
|
||||
- TypeScript `tsc --noEmit` passed。
|
||||
- Computer Use 复验:切换 English 后顶部显示 `Settings`,关闭按钮为 `Close Settings`。
|
||||
- 完整 `bun run check:desktop`:passed。
|
||||
|
||||
## QA-001|Windows 完整桌面门禁的跨平台测试夹具失败
|
||||
|
||||
- 严重程度:测试基础设施
|
||||
- 状态:fixed / verified
|
||||
- 首次结果:`bun run check:desktop` 为 12 failed、2418 passed,因测试失败未进入最终 build。
|
||||
- 分类:
|
||||
- 7 个稳定失败来自测试夹具把 POSIX/macOS 路径、权限位或 `PATH` 大小写写死到 Windows。
|
||||
- 2 个 `petWindow` 失败来自源码字符串断言写死 LF,不能处理 Windows CRLF。
|
||||
- 3 个 Workspace 测试在全量并发下渲染约 5001、1996、2300 个 DOM 行,触发 5/20 秒超时;聚焦运行均通过。
|
||||
- 修复原则:没有发现对应生产逻辑错误,因此只修正测试的跨平台构造和无必要的大规模 DOM 夹具,没有改生产代码,也没有简单扩大超时。
|
||||
- 变更文件:
|
||||
- `desktop/scripts/electron-output-guard.test.ts`
|
||||
- `desktop/electron/services/pets.test.ts`
|
||||
- `desktop/electron/services/sidecarManager.test.ts`
|
||||
- `desktop/electron/services/terminal.test.ts`
|
||||
- `desktop/electron/services/petWindow.test.ts`
|
||||
- `desktop/src/components/workspace/WorkspaceDiffSurface.test.tsx`
|
||||
- `desktop/src/components/workspace/WorkspacePanel.test.tsx`
|
||||
- 验证证据:
|
||||
- 路径/权限聚焦测试:98 passed、1 macOS-only skipped。
|
||||
- petWindow/Workspace 聚焦测试:95/95 passed;同组 coverage 运行 95/95 passed。
|
||||
- 修复后完整 `check:desktop`:2430 passed、2 skipped、0 failed;最终 `tsc -b` 与 Vite production build passed。
|
||||
- 紧接着的全量 JSON 复跑:524/524 suites passed,2430 passed、2 skipped。
|
||||
|
||||
## 排除的环境误报
|
||||
|
||||
### ENV-001|旧 Sidecar 导致 Pets 无法加载
|
||||
|
||||
- 初始现象:设置页显示“无法加载宠物”。
|
||||
- 结论:不是候选代码缺陷。Electron 主进程使用当前源码,但启动时加载的是 2026-07-18 的旧 `claude-sidecar` 二进制,早于测试范围内的服务端改动。
|
||||
- 处置:备份旧二进制后执行 `bun run build:sidecars`,仅在隔离测试实例中换入当前 Sidecar。
|
||||
- 复验:4 个内置宠物 Dada、Huhu、Bubu、Huihui 正常出现;桌面宠物窗口可打开。
|
||||
|
||||
### ENV-002|旧 Sidecar 导致 Agent 保存后前端读取 `agentType` 报错
|
||||
|
||||
- 初始现象:创建 Agent 后弹出 `Cannot read properties of undefined (reading 'agentType')`。
|
||||
- 结论:不是候选代码缺陷。旧 Sidecar 的 `POST /api/agents` 返回 `{ok:true}`,当前桌面端期望 `{agent: ...}`。
|
||||
- 处置:同 ENV-001,重建当前 Sidecar。
|
||||
- 复验:用户 Agent `manual_reviewer` 可创建、查看、编辑回继承;内置 Agent 保持只读。
|
||||
|
||||
## BUG-003|权限模式控制请求被拒绝后遗留 pending confirmation
|
||||
|
||||
- 严重程度:P1
|
||||
- 状态:fixed / verified
|
||||
- 对应用例:DENY-01、DENY-02、权限模式切换
|
||||
- 发现方式:Windows `check:coverage` 在连续执行“确认成功 → CLI 拒绝 → 确认超时”三个权限模式测试时稳定不退出。
|
||||
- 根因:`ConversationService.setPermissionMode()` 在创建 confirmation Promise 后调用 `requestControl()`;CLI 拒绝 control request 时,catch 只清理 timer/callback,没有 settle 已创建的 confirmation Promise。在 coverage 异步跟踪下,该 pending Promise 会让后续计时测试进入持续 CPU 状态。
|
||||
- 修复:保存 confirmation reject 句柄,引入 settled guard 和显式 `cancelConfirmation(reason)`;control request 失败时清理 callback/timer、拒绝 confirmation,并原样重新抛出原始 CLI 错误。确认超时计时器只在 control request 成功后按总预算剩余时间启动。
|
||||
- 变更文件:
|
||||
- `src/server/services/conversationService.ts`
|
||||
- `src/server/__tests__/conversations.test.ts`
|
||||
- 验证证据:
|
||||
- 三个权限模式顺序回归:3 passed、0 failed。
|
||||
- 同组三用例 coverage:3 passed、0 failed,不再挂起。
|
||||
- 完整 `conversations.test.ts` coverage:96 个具名测试全部完成并通过;Windows afterAll 临时目录清理另有一次 `EBUSY`,不属于行为断言失败。
|
||||
- 回归额外断言 CLI 拒绝后 `outputCallbacks` 为空、`pendingPermissionModeChanges` 为空。
|
||||
|
||||
## QA-002|Windows `check:coverage` 的 root-server 阶段异常慢
|
||||
|
||||
- 严重程度:测试基础设施
|
||||
- 状态:fixed / verified
|
||||
- 实际结果:单次 `bun run check:coverage` 在第一个 `root-server` 覆盖率套件持续 22 分钟,没有生成 `coverage.log` 或 lcov 产物;Bun 进程保持约单核满载、约 500 MB working set 且响应正常。
|
||||
- 历史对照:同机历史 coverage 报告中 root-server 阶段约为 135、151、190 秒。
|
||||
- runner 限制:`scripts/quality-gate/coverage.ts` 会把整个子进程 stdout/stderr 缓冲到退出后再写日志,且没有套件级 timeout,所以执行中没有可用于定位最后用例的进度产物。
|
||||
- 处置:确认 PID、父子关系和完整 command line 后,仅终止本次 `check:coverage` 的三个 Bun 进程;没有启动重复运行,也没有影响 Vite、Electron 或其他测试。
|
||||
- 根因定位:连续权限模式用例会触发 BUG-003 的 pending confirmation 泄漏;定向 coverage 修复后不再挂起。
|
||||
- 结论:首次覆盖率门禁记为 blocked/timeout;修复后完整重跑在 130.2 秒内完成,5/5 suites passed,changed lines 95.56%(43/45)。
|
||||
|
||||
## BUG-004|Bun 下销毁 CONNECTING WebSocket 会永久停在 CLOSING
|
||||
|
||||
- 严重程度:P1
|
||||
- 状态:fixed / verified
|
||||
- 对应用例:WIN-05
|
||||
- 发现方式:修复 BUG-003 后,完整 coverage 的 adapters 阶段为 405 passed、1 failed;`WsBridge > destroy cleans up all sessions without leaking connecting-socket errors` 在 20ms 后仍保留一个 error listener。
|
||||
- 根因:Bun 1.3.11 会把 bare `ws` 导入替换为 BunWebSocket 兼容层。对仍为 CONNECTING 的不可达连接立即调用 `close()` 或 `terminate()`,socket 会永久停在 CLOSING,且不再触发 error/close;原临时 error sink 和 close listener 因而永久保留。
|
||||
- 修复:CONNECTING 状态不再调用会毒化状态的 close/terminate。移除业务 listener 后只挂 teardown listener:自然 error 被消费并清理;若 open 先发生,则再正常 close,并在 close/error 时清理。OPEN/CLOSING/CLOSED 的原关闭路径保持不变;生产代码没有新增任意超时。
|
||||
- 变更文件:
|
||||
- `adapters/common/ws-bridge.ts`
|
||||
- `adapters/common/__tests__/ws-bridge.test.ts`
|
||||
- 验证证据:
|
||||
- 聚焦测试:13 passed、0 failed。
|
||||
- 聚焦 coverage:13 passed、0 failed;`ws-bridge.ts` lines 85.59%、functions 73.68%。
|
||||
- 回归按条件等待 CLOSED,并断言 open/error/close listeners 全部为 0。
|
||||
- `bun run check:adapters`:406 passed、0 failed。
|
||||
- 修复后完整 `bun run check:coverage`:5/5 suites passed。
|
||||
|
||||
## BUG-005|Trace abort backstop timer 被 unref 后在 Bun/Windows 单核自旋
|
||||
|
||||
- 严重程度:P1
|
||||
- 状态:fixed / verified
|
||||
- 对应用例:Trace/诊断稳定性、server 质量门禁
|
||||
- 发现方式:`check:server` 卡在 `trace-capture.test.ts`;拆分单文件后,在任何测试结果输出前持续占满一个 CPU 核心。
|
||||
- 根因:`captureResponseTraceSnapshot` 对无法被 cancel 唤醒的永久 pending `reader.read()` 使用 grace backstop timer,但该 timer 随即 `unref()`。Bun 1.3.11 Windows 在 `Promise.race` 只剩 pending read 与 unref timer 时会同步忙循环,timer 不触发。
|
||||
- 修复:保留 backstop timer 的 event-loop 引用,并注释说明它是当前函数正在等待的正确性兜底,不能 unref。
|
||||
- 变更文件:`src/services/api/traceCapture.ts`
|
||||
- 验证证据:
|
||||
- 原挂起用例:1/1 passed,63ms。
|
||||
- 完整 `trace-capture.test.ts`:61/61 passed、281 assertions,Bun 2.67 秒,自然退出。
|
||||
- `bun run check:server`:219 files、2248 passed、0 failed。
|
||||
- `bun run check:chat-contract`:5 files、299 passed、0 failed。
|
||||
|
||||
## QA-003|Reconciliation safety sweep 测试等待 unref interval 时忙循环
|
||||
|
||||
- 严重程度:测试基础设施
|
||||
- 状态:fixed / verified
|
||||
- 实际结果:前 5 个测试显示 passed 后进程持续单核满载,没有最终汇总;定位到第 6 个 low-frequency safety sweep 用例。
|
||||
- 根因:测试 `await` 的 Promise 只能由 production 中已 `unref()` 的 safety interval resolve;pending Promise 不保持事件循环活跃,在没有 referenced timer 时 Bun runner 忙等,测试也无法走到 `watcher.stop()`。
|
||||
- 修复:用已有带 referenced `Bun.sleep` 的 `waitFor` 等待首个 batch,并用 `try/finally` 始终释放被阻塞的首批次、停止 watcher。
|
||||
- 变更文件:`src/server/services/localIndex/reconciliationWatcher.test.ts`
|
||||
- 验证证据:单用例 1/1 passed、191ms;完整文件 7/7 passed、570ms,自然输出汇总并退出。
|
||||
|
||||
## BUG-006|Windows 下停止系统代理时 CONNECT 连接使 Electron 测试超时
|
||||
|
||||
- 严重程度:P1 / Windows 资源生命周期
|
||||
- 状态:fixed / verified
|
||||
- 触发用例:WIN-04;`check:native` 内的 `SystemProxyBridge > closes active CONNECT clients and outbound routes during stop`。
|
||||
- 实际结果:Vitest 单文件通过,但 native 使用的 Bun test runner 中 `bridge.stop()` 稳定超过 500ms。
|
||||
- 根因:Bun/Windows 对已升级的 CONNECT socket,仅销毁桥接层跟踪的 socket 不足以让 `http.Server.close()` callback 完成。
|
||||
- 修复:先销毁已跟踪的 client/outbound sockets;仅在 server 仍处于 listening 时调用 `closeAllConnections()`,然后调用并等待 `server.close()`。保留 listening guard,避免破坏 startup/stop 竞态。
|
||||
- 变更文件:`desktop/electron/services/systemProxyBridge.ts`
|
||||
- 验证证据:Bun 聚焦测试 18/18 passed;Electron TypeScript passed;完整 `check:native` 中 Electron 310 passed、1 skipped、0 failed,目录打包与 current package smoke passed。
|
||||
|
||||
## QA-004|Tray 测试与 Menu 测试共享 Electron 模块 mock
|
||||
|
||||
- 严重程度:测试基础设施
|
||||
- 状态:fixed / verified
|
||||
- 触发用例:`check:native` 内 `tray.test.ts` 的未处理错误 `Electron tray mocks were not initialized for this test`。
|
||||
- 实际结果:tray 单文件 4/4 passed;与 menu 测试联跑时稳定失败,证明是测试加载顺序相关污染。
|
||||
- 根因:Bun 整套 Electron 测试共享 `electron` 模块 mock 注册表,`menu.test.ts` 与 `tray.test.ts` 会互相覆盖模块级 mock。
|
||||
- 修复:`installTray` 接受可选且类型受限的 Electron runtime 注入,生产默认仍动态导入 Electron;tray 测试改为注入本地 mocks,不再注册全局 `vi.mock('electron')`。
|
||||
- 变更文件:`desktop/electron/services/tray.ts`、`desktop/electron/services/tray.test.ts`
|
||||
- 验证证据:tray 4/4 passed;menu + tray 15/15 passed;Electron TypeScript passed;完整 `check:native` passed。
|
||||
|
||||
## BUG-007|项目 Agent 保存被可选热重载阻塞 120 秒
|
||||
|
||||
- 严重程度:P1 / Agent 管理可用性
|
||||
- 状态:fixed / verified
|
||||
- 触发用例:AGT-03、AGT-08。
|
||||
- 实际结果:Project Agent 的文件和列表刷新已完成,但 Create Agent 对话框仍保持 Save/Cancel disabled 与 loading;120 秒后才关闭并显示 `The change was saved, but the latest agent configuration could not be fully applied. Request timed out after 120s`。
|
||||
- 现场证据:首次隔离工作区位于外层 Git 仓库内,服务端按既有 Git-root 规则将文件写到仓库 `.claude/agents/project_reviewer.md`;该测试文件及本轮创建的空 `agents` 目录已精确清理。随后在 `artifacts/manual-ui-91829e91/workspace` 内初始化独立 Git 根,后续复测不会写入产品仓库。
|
||||
- 根因:`desktop/src/stores/agentStore.ts` 在已成功 create/list 后仍等待 `agentsApi.reload()`;reload 请求使用 120 秒 timeout,并在结束前保持全局 `isMutating=true`。热重载本应是保存后的非阻塞增强,失败只需 warning + Retry。
|
||||
- 修复:create/update/delete 在持久化和列表刷新成功后立即结束 mutation;session reload 改为后台 warning,并用 request id 隔离项目切换后的迟到结果。
|
||||
- 验证证据:AgentManager + agentStore 44/44 passed,desktop lint passed;Computer Use 复测点击 Save 后约 1.2 秒进入 Agent 详情页,提示“active CLI session is not running”但 Save/Cancel 不再阻塞。
|
||||
|
||||
## BUG-008|Agent Git 根缓存未感知运行中新增的嵌套仓库
|
||||
|
||||
- 严重程度:P1 / 项目隔离与数据安全
|
||||
- 状态:fixed / verified
|
||||
- 触发用例:AGT-03。
|
||||
- 实际结果:Create Agent 表单显示目标为 `artifacts/manual-ui-91829e91/workspace`,且该目录已通过 `git init` 成为独立 Git 根;保存后文件仍被写入外层产品仓库 `.claude/agents/project_reviewer_fixed.md`。
|
||||
- 现场证据:`git -C artifacts/manual-ui-91829e91/workspace rev-parse --show-toplevel` 返回隔离工作区;隔离目录无 Agent 文件,外层文件的创建时间与本轮保存一致。已读取并核对内容后精确删除该测试文件。
|
||||
- 根因:Agent 服务首次解析隔离目录时它尚未初始化为仓库,`findGitRoot(realCwd)` 的全局缓存记录了外层仓库;运行中 `git init` 后 Agent CRUD 仍复用旧缓存,且 `clearAgentDefinitionsCache()` 不会清理 Git-root 缓存。
|
||||
- 修复:项目 Agent 路径解析前按 `realCwd` 刷新 Git-root 缓存;新增“先预热父仓库、再创建嵌套 `.git`”的 API 回归测试。
|
||||
- 验证证据:新增用例在临时撤掉修复时按预期失败,恢复后 `agents-api.test.ts` 17/17 passed、260 assertions。重建 Sidecar 并重启隔离 Electron 后,Computer Use 创建 `project_reviewer_fixed2`,约 1.5 秒进入详情页且目标显示隔离工作区;Shell 确认文件只存在于 `artifacts/manual-ui-91829e91/workspace/.claude/agents/`,外层产品仓库同名文件不存在。
|
||||
|
||||
## BUG-009|Windows 本地半关闭连接堆积并拖垮 Session 列表
|
||||
|
||||
- 严重程度:P0 / Windows 桌面核心可用性
|
||||
- 状态:fixed / focused + live Windows verified
|
||||
- 触发用例:WIN-06、CTX-01、会话列表与 Scheduled 冒烟。
|
||||
- 实际结果:左侧短暂显示 `Session list failed to load` / `Request timed out after 120s`,稍后刷新又自行消失。同期并非只有 Session:16:59:19 的 Task list 与 turn-checkpoints 同时 120 秒超时;17:04:01 的 `/api/sessions?limit=400` 与 `/api/scheduled-tasks` 同时 120 秒超时,之前分别有两次 context inspection 20 秒超时。
|
||||
- Windows 现场证据:Sidecar 和 CLI 进程始终存活,故障窗口无 Electron/Bun crash;端口 51529 残留 9 对异常连接,Electron 侧为 `FinWait2`、Sidecar 侧为 `CloseWait`。Session 目录只有 3 个小 JSONL,本地 SQLite 也很小,不支持“400 条 Session 扫描过慢”的解释。
|
||||
- 首轮根因假设:`ContextUsageIndicator` 的 HTTP deadline 与服务端 `get_context_usage` control budget 同为 20,000 ms,客户端 abort 与服务端 timeout 同刻竞速,使 localhost keep-alive socket 未及时收尾。该假设只能解释早期相关性,不是最终根因。
|
||||
- 最终根因:Electron/Chromium 会长期池化 HTTP/1.1 本地连接,而 `Bun.serve` 配置 `idleTimeout: 60`。Chromium netlog 证明失败的 source 4974 在 socket #1319 空闲约 104 秒后复用:此前该 socket 多次正常收发,复用时客户端成功写入 684 bytes,服务端却不再返回响应头,120,011 ms 后 Chromium 取消。Windows 上 Bun 超时后的 socket 没有被客户端识别为已关闭,形成 stale keep-alive;任务、turn-checkpoints、workspace-status 等任意复用该连接的 REST 都会受影响。
|
||||
- 修复:保留客户端/abort/health 的正确生命周期修复,并将本地 `Bun.serve` HTTP `idleTimeout` 设为 `0`,让 Chromium 客户端拥有池化连接生命周期,避免 server 先静默失效而 client 继续复用。曾尝试给所有 REST 响应加 `Connection: close`,但 12 次导航制造 403 个 `TIME_WAIT`,已撤回该全局策略。
|
||||
- 变更文件:
|
||||
- `desktop/src/components/chat/ContextUsageIndicator.tsx`
|
||||
- `desktop/src/components/chat/ContextUsageIndicator.test.tsx`
|
||||
- `desktop/src/api/client.ts`
|
||||
- `desktop/src/api/client.test.ts`
|
||||
- `desktop/src/stores/cliTaskStore.ts`
|
||||
- `desktop/src/stores/cliTaskStore.test.ts`
|
||||
- `desktop/electron/services/sidecarManager.ts`
|
||||
- `desktop/electron/services/sidecarManager.test.ts`
|
||||
- `src/server/index.ts`
|
||||
- `src/server/__tests__/server-options.test.ts`
|
||||
- `src/server/requestLifecycle.ts`
|
||||
- `src/server/api/sessions.ts`
|
||||
- `src/server/services/conversationService.ts`
|
||||
- `src/server/__tests__/conversations.test.ts`
|
||||
- 验证证据:
|
||||
- 服务端 abort callback 清理:1/1 passed;服务端 timeout listener 清理:1/1 passed。
|
||||
- ContextUsageIndicator 聚焦 Vitest:7/7 passed。
|
||||
- 重建 Sidecar、重启同一隔离 Electron 后,真实 DeepSeek 返回 `WINDOWS_TIMEOUT_FIX_ACTIVE`,会话列表与 context 2% 正常。
|
||||
- 修复版基线为 0 `CloseWait` / 0 `FinWait2`;Computer Use 连续手动刷新 context 10 次后仍为 0 / 0,仅有正常 `Established` 和短期 `TimeWait`。
|
||||
- 随后会话列表手动刷新约 1.3 秒,Scheduled 页面约 1.3 秒打开,均无 timeout;这只证明了短时场景。
|
||||
- 最终定位前的重启复现中,真实 MiniMax 多模态会话跨过 120 秒后再次出现 `CloseWait/FinWait2`,并在稍后明确写入 3 条新的 120 秒诊断:task-list、turn-checkpoints、workspace-status;这批证据用于否定“只是 context 计算慢”。
|
||||
- `server-options.test.ts`、request-lifecycle 与 tasks 聚焦回归合计 14 passed / 0 failed / 27 expects;Sidecar 重新编译成功。
|
||||
- 修复版真实 Electron 启动后先空闲 85 秒,随后 MiniMax 在同一轮并发执行 2 个 TaskCreate、TaskList + 2 个 TaskGet、2 个 TaskUpdate 和最终 TaskList,UI 两项均为绿色 completed,模型返回 `TASK_PARALLEL_IDLE0_OK`。
|
||||
- 修复版 netlog 中同一路径最慢 235 ms,所有 localhost API 最慢 578 ms;运行 137 秒后 sidecar 仍只有正常 `Established`,0 `CloseWait` / 0 `FinWait2`,诊断日志无新增 timeout。WIN-06 改为 passed。
|
||||
- 使用官方 `@oven/bun-windows-x64-baseline@1.3.14` binary 重编译 sidecar;真实 Electron 空闲 106 秒后 MiniMax 返回 `IDLE0_BUN1314_OK`,运行 131 秒仍为 0 `CloseWait` / 0 `FinWait2` 且无新增 timeout,排除仅在系统 Bun 1.3.11 上偶然通过。
|
||||
- `cliTaskStore` 新增同一 session 重叠轮询合并;聚焦 12/12 passed,避免 React/轮询重入放大本地连接压力,但它不是 stale socket 根因的替代修复。
|
||||
- 剩余风险:H5 开启时 sidecar 监听 `0.0.0.0`,`idleTimeout: 0` 也会允许尚未发送完整 HTTP headers 的裸 TCP 连接长期占用资源。当前 H5 token 鉴权只发生在形成 HTTP 请求之后;若要彻底收敛 slowloris/资源占用风险,应将桌面 loopback 与 LAN H5 listener 拆分,或增加 pre-header 连接级 deadline。此架构项不影响本轮 localhost timeout 修复结论,但发布时需要风险接受或后续整改。
|
||||
|
||||
## BUG-010|Windows 宠物窗口重启后失去置顶层级
|
||||
|
||||
- 严重程度:P1 / Windows 桌面宠物可见性
|
||||
- 状态:fixed / focused + live Windows verified
|
||||
- 触发用例:PET-01、PET-02。
|
||||
- 实际结果:完整重启隔离 Electron 后,宠物窗口存在且 shaped region 正确,但被 ChatGPT 等普通窗口遮住;用户只能偶尔看到很小的光标/透明边缘,容易误判为宠物图片资源缺失。
|
||||
- 排除项:当前配置选择的是仓库内置 `dada-code`,4 个内置宠物资源均存在且与 HEAD 一致。另一台电脑导入的猫/狗宠物位于 `${CLAUDE_CONFIG_DIR}/cc-haha/pets`,属于本地用户状态,不会通过 Git 自动同步;本次故障不是资源丢失或 fallback。
|
||||
- Windows 现场证据:修复前宠物 Win32 z-rank 285,ChatGPT 为 39,窗口样式 `topmost=false`;宠物 mascot region 为 `96,116,288,322`,任务卡 region 为 `12,322,372,388`,证明渲染区域存在但层级错误。
|
||||
- 根因:`BrowserWindow` 构造时的 `alwaysOnTop: true` 在 Windows `showInactive()` 后未保持;代码只在 macOS 分支重新调用 `setAlwaysOnTop`。
|
||||
- 修复:`showInactive()` 后,macOS 继续使用 `setAlwaysOnTop(true, 'floating')`,Windows 等其他平台显式调用 `setAlwaysOnTop(true)`。
|
||||
- 变更文件:
|
||||
- `desktop/electron/services/petWindow.ts`
|
||||
- `desktop/electron/services/petWindow.test.ts`
|
||||
- 验证证据:
|
||||
- `bun run test -- --run electron/services/petWindow.test.ts`:24/24 passed(连续两次)。
|
||||
- `bun run build:electron`:passed;重启隔离 Electron 两次。
|
||||
- 修复后宠物 `topmost=True`、z-rank 13,高于主窗口 30 与 ChatGPT 43;截图/可访问性树可见 192px Dada 和“暂无进行中的任务 / 收起 0 个任务”。
|
||||
- 后续 BUG-011 修复原生 shape 与前端层级后,Computer Use 已能真实鼠标命中任务圆点和关闭按钮;宠物本体拖动与多显示器位置恢复仍未完成,PET-02 继续保持 partial。
|
||||
|
||||
## BUG-011|宠物任务卡顶部被原生 shape 裁切,关闭按钮被宠物透明命中区截获
|
||||
|
||||
- 严重程度:P1 / Windows 宠物任务面板可用性
|
||||
- 状态:fixed / focused + live Windows verified
|
||||
- 触发用例:PET-02、PET-04;用户截图显示卡片顶部圆角/阴影缺失,点击卡片下方箭头没有反应。
|
||||
- 实际结果:任务卡 DOM 矩形紧贴 Windows `BrowserWindow.setShape()` 边界,圆角抗锯齿与阴影越界部分被原生窗口裁切;关闭按钮绝对定位在卡片 DOM 矩形外,最初既不在原生命中 shape 内,又处在 `z-index: 5` 的卡片 stacking context 中,低于 `z-index: 10` 的宠物透明按钮区域。鼠标点箭头实际聚焦宠物按钮,因此卡片不关闭。
|
||||
- 排查证据:
|
||||
- 修复前真实鼠标点击箭头后 UI 无变化,隔离配置 `showTaskPanel` 仍为 `true`。
|
||||
- 同一界面用 Tab 将焦点移到关闭按钮并按 Enter,卡片立即隐藏且 `showTaskPanel` 变为 `false`,排除 React handler 和偏好持久化故障。
|
||||
- 修复层级前鼠标点击箭头后,下一次 Tab 焦点从宠物按钮移到任务行,证明点击被宠物透明命中区截获。
|
||||
- 修复:
|
||||
- Windows/Linux 原生交互 shape 对每个渲染区域增加 12px 安全边距并钳制在 384×400 窗口内,保留圆角、边框抗锯齿和阴影。
|
||||
- 将独立关闭按钮注册为原生命中区域,并纳入 renderer 的交互点/透传判断。
|
||||
- 将任务卡 stacking context 提升到 `z-index: 15`,高于宠物按钮的 `z-index: 10`,让画在宠物上方的关闭按钮也实际接收鼠标事件。
|
||||
- 变更文件:
|
||||
- `desktop/electron/services/petWindow.ts`
|
||||
- `desktop/electron/services/petWindow.test.ts`
|
||||
- `desktop/src/features/pets/PetApp.tsx`
|
||||
- `desktop/src/features/pets/PetApp.test.tsx`
|
||||
- `desktop/src/theme/globals.css`
|
||||
- `desktop/src/theme/globals.test.ts`
|
||||
- 验证证据:
|
||||
- 原生 shape 回归 24/24 passed;PetApp + globals 聚焦回归 24/24 passed。
|
||||
- 真实 Windows Electron + DeepSeek 编程任务中,卡片四角、顶部边框和阴影完整可见。
|
||||
- Computer Use 真实鼠标完成“数字圆点展开 → 箭头关闭 → 数字圆点恢复”两轮;第二轮无键盘辅助或 DOM 注入。
|
||||
- 隔离配置最终为 `showTaskPanel: false`,证明关闭状态已真实落盘。
|
||||
|
||||
## 当前测试限制
|
||||
|
||||
- LIVE-001(closed):用户明确授权真实 Provider 额度并提供专用测试配置。隔离 Electron 中 DeepSeek 连接测试 179 ms 成功,`deepseek-v4-pro` 已设为默认;真实回复 `LIVE_DEEPSEEK_OK`、拒绝权限后的 `CHAT_CONTINUES` 和修复后重启的 `WINDOWS_TIMEOUT_FIX_ACTIVE` 均通过。配置与证据只保留在隔离测试目录,本文不记录密钥。
|
||||
- PROVIDER-001(closed / false negative):MiniMax-M3 的 Anthropic 兼容接口确实支持 image block。此前出现 `[Unsupported Image]` 的会话复用了由 DeepSeek 产生该文本的历史 assistant 消息,并把同一图片重复附加,不能作为 MiniMax 不支持图片的证据。使用首轮即选 MiniMax-M3 的全新会话、只附一张 `canary.png` 后,真实模型准确返回 `PDF_CANARY_91829E91`;trace 为 `/anthropic/v1/messages`、1 个 image block、HTTP 200,prompt 仅出现 1 次,请求和响应均无 `[Unsupported Image]`。离线附件序列化回归 `conversation-attachments.test.ts` 为 3 passed / 0 failed / 22 expects。MiniMax 连接测试为 1,349 ms,本文不记录密钥。
|
||||
- LIVE-002(closed):真实 MiniMax-M3 编程流程调用 TaskCreate/List/Get/Update、SubAgent、Read/Bash 并运行 `bun test`,3 个任务全部完成、测试 2 passed / 0 failed,最终返回 `REAL_TASK_AGENT_FLOW_OK`。真实 DeepSeek 流程同样完成 3 个 Task V2、真实编辑和 `bun test`,最终返回 `DEEPSEEK_TASK_V2_OK 2 pass, 0 fail`。这两条均为隔离 Git 工作区的实际 Provider 流程,不是 mock。
|
||||
- PLAT-001:项目声明的 Electron 42.4 本地二进制不可用,本轮交互暂用已缓存的 Electron 42.3。最终构建与源码门禁不受此回退影响,但 Electron 版本特异风险会在最终结论中单独保留。
|
||||
- NATIVE-001(closed):`check:native` 首次执行在替换 Windows sidecar 时因隔离 Electron 遗留进程持有 exe 而 `EPERM`。核实进程链为隔离测试的 `bun 39396 → electron 29132 → sidecar 44260` 后仅结束该实例;清理后门禁进入 Electron 套件并暴露 BUG-006 / QA-004,修复后完整重跑 passed。
|
||||
|
||||
## 最终门禁汇总
|
||||
|
||||
- `bun run check:impact`:policy blocked;正确识别 adapters、cli-core、desktop、server,并要求 desktop、server、provider-contract、chat-contract、adapters、native、coverage。唯一 blocker 是 CLI core 变更需要 PR 标签与维护者批准,不是测试失败。
|
||||
- `bun run check:desktop`:passed;完整 Vitest、lint 与 production Vite build 均成功。首轮仅因旧断言仍期待 20 秒 context timeout 而失败;更新为当前 30 秒契约后聚焦 30/30 passed,完整门禁重跑通过。
|
||||
- `bun run check:server`:passed;221 files、2254 passed、0 failed。首次与其他大门禁并行时 `conversations.test.ts` 单例触发 10 秒资源竞争超时;单例 203 ms 通过,随后完整串行重跑通过。
|
||||
- `bun run check:provider-contract`:passed;18 个 deterministic suites 全部通过。
|
||||
- `bun run check:chat-contract`:passed。
|
||||
- `bun run check:adapters`:passed;406 passed、0 failed、1091 assertions。
|
||||
- `bun run check:native`:passed;sidecar compiled smoke 8/8,Electron 311 passed、1 个 macOS-only skipped、0 failed;Electron production build、Windows x64 目录打包及 current package smoke 均通过。一次 1 秒命令超时是执行器主动中止,不是代码失败,完整重跑 84 秒成功。
|
||||
- `bun run check:coverage`:passed;5/5 groups、0 failed,报告位于 `artifacts/coverage/2026-07-22T11-11-42-208Z/coverage-report.md`。
|
||||
- 最终工作区核对:`git diff --check` passed;`git diff --stat` 为 53 个已跟踪文件、931 insertions / 301 deletions(另有本轮新增测试与两份人工文档);`git status --short` 已复核。未执行 `bun run verify`,因为本轮按 `check:impact` 选择的最小完整门禁交付,未声明 PR-ready/push-ready。
|
||||
@@ -0,0 +1,158 @@
|
||||
# v0.4.10 之后的人工界面功能测试 Checklist
|
||||
|
||||
> 分析范围:`v0.4.10..91829e91`(2026-07-20 至 2026-07-21)
|
||||
>
|
||||
> 基线:`v0.4.10`;候选:`91829e91`;共 11 个非合并提交
|
||||
>
|
||||
> 差异规模:233 files,+22,673 / -1,317
|
||||
>
|
||||
> 本清单只覆盖该范围新增或修复的用户可观察行为,不代替 v0.4.10 全量回归。
|
||||
|
||||
## 1. 测试记录
|
||||
|
||||
| 项目 | 填写内容 |
|
||||
|---|---|
|
||||
| 测试人 / 日期 | Codex + Computer Use / 2026-07-21 |
|
||||
| 安装包版本 / commit | `91829e91a41d0c4dcba2680e82f889ed7220e332` |
|
||||
| 操作系统 / 架构 | Windows 11 / x64 |
|
||||
| 安装方式 | 隔离开发构建;缓存 Electron 42.3(声明的 42.4 本地二进制不可用) |
|
||||
| 测试 Provider / 模型 | 真实 DeepSeek / `deepseek-v4-pro`(179 ms);真实 MiniMax / `MiniMax-M3`(1,349 ms);均已取得真实回复 |
|
||||
| 测试配置目录 | `artifacts/manual-ui-91829e91/config`;独立 Electron user-data-dir |
|
||||
| 总体结论 | executed with recorded limits:DeepSeek、MiniMax 真实编程任务、附件恢复、多模态、SubAgent、Task 并发和 Windows 长空闲恢复主链路已通过;BUG-001~BUG-011 均已修复并取得聚焦或真实 UI 证据。最新完整质量门禁全部通过,但仍有宠物本体拖动/多显示器、代理/PAC、IM/H5 故障注入、CLI/TUI 等未执行或仅部分执行项,不能表述为“所有功能无条件通过” |
|
||||
|
||||
结果标记约定:完成每项后在复选框后追加 `passed`、`failed`、`blocked` 或 `skipped`,并附截图、录屏、日志位置或缺陷编号。`blocked` 与 `skipped` 不计为通过。
|
||||
|
||||
## 2. 提交分析与覆盖映射
|
||||
|
||||
| 提交 | 更新类型 | 用户可观察变化 | 对应用例 |
|
||||
|---|---|---|---|
|
||||
| `36560c09` | 功能 | 新增可交互桌面宠物、任务状态面板、自定义宠物导入 | PET-01~PET-10 |
|
||||
| `71dc2fd4` | 修复 | Kimi Code 预设升级到 K3、API Key 鉴权和 262K 上下文 | PRO-02~PRO-03 |
|
||||
| `8995f56d` | 修复 | Provider 返回空/零 usage 时仍从会话记录估算上下文 | CTX-01 |
|
||||
| `bf128cb9` | 修复 | 服务端回放带附件的用户消息时不再重复显示 | ATT-01~ATT-02 |
|
||||
| `6b725a0b` | 修复 | 运行中的 SubAgent 可打开详情并持续显示新记录 | SUB-01~SUB-03 |
|
||||
| `7d272e0f` | 修复 | 拒绝工具权限后当前轮正确中断并回到可输入状态 | DENY-01~DENY-02 |
|
||||
| `ba8917f1` | 修复 | Provider 创建入口不再依赖运行时 presets 请求 | PRO-01、PRO-03 |
|
||||
| `727bcea0` | 修复 | Windows Renderer、终端、代理及 IM WebSocket 生命周期恢复 | WIN-01~WIN-05 |
|
||||
| `1270ced4` | 修复 | Task V2 工具常驻、任务读写串行、桌面任务状态抗乱序/抖动 | TASK-01~TASK-03 |
|
||||
| `7f6fdada` | 功能 | 桌面 Agent CRUD、作用域、逐 Agent 模型与 effort、热重载,以及 CLI/TUI Agent effort 与编辑字段保真 | AGT-01~AGT-08、CLI-01~CLI-03 |
|
||||
| `91829e91` | 修复 | 桌面会话恢复时保留文件/目录/PDF 内容,同时过滤终端反馈分享内容 | ATT-03~ATT-05 |
|
||||
|
||||
## 3. 测试准备与安全边界
|
||||
|
||||
- [x] 使用专用测试账号、临时工作区和独立 `CLAUDE_CONFIG_DIR`,不得读取或改写开发者真实的 `~/.claude`、凭据、会话及 Agent 定义。 — passed:配置、会话与 Electron user-data 均在 `artifacts/manual-ui-91829e91/`。
|
||||
- [x] 准备一个可安全写入的 Git 测试项目,包含一份带唯一 canary 文本的小文件、一个目录和一个小型 PDF。 — passed:文本、目录和 PDF canary 已创建;PDF 已渲染目检。
|
||||
- [ ] 准备透明背景 PNG/WebP(32~4096 px、8 MB 内)以及一份不合规图片;动画图集测试另准备 1536×2288、8 列×11 行的 v2 PNG/WebP。
|
||||
- [x] 准备至少两个测试 Provider;Kimi 实际请求和任何 live model 测试仅在维护者明确授权额度后执行。 — passed:隔离配置中 DeepSeek 与 MiniMax 均完成真实连接测试;维护者已明确授权额度。
|
||||
- [ ] Windows 故障注入只对专用测试构建执行;提前保存工作并记录主进程、Renderer、Sidecar 的 PID。
|
||||
- [ ] 若测试代理/PAC,使用受控代理和测试地址;测试结束后恢复系统代理设置。
|
||||
|
||||
## 4. P0 核心流程
|
||||
|
||||
### 桌面宠物
|
||||
|
||||
- [x] **PET-01|入口、开关与持久化** — passed:确认 4 个内置宠物和独立透明宠物窗口;完整结束隔离 Electron/Sidecar 并用新构建重启后,显示开关、当前内置宠物、192px 大小和动画偏好均保持。当前显示的 Dada 机器人来自仓库内置 `dada-code` 资源,不是缺图 fallback;另一台电脑自行导入的猫/狗包属于 `${CLAUDE_CONFIG_DIR}/cc-haha/pets` 本地用户状态,不会随 Git 自动同步。
|
||||
- [ ] **PET-02|窗口交互与位置恢复**:分别验证短按宠物触发挥手、悬停/移动鼠标触发视线或动作、拖动宠物移动窗口;拖动不能误触单击动作,透明非宠物区域应允许点击下层窗口。将宠物拖到屏幕边缘和第二显示器后重启,位置应恢复且保持在可见工作区;拔除显示器或改变分辨率后窗口被钳制回可见区域。 — partial:BUG-010 修复后 Win32 `topmost=True`;BUG-011 修复后 Computer Use 已能真实鼠标命中任务圆点和关闭按钮,并完成两轮展开/关闭。宠物本体短按、悬停、拖动、透明区透传和多显示器恢复仍未完成。
|
||||
- [ ] **PET-03|任务状态与导航**:同时制造“工作中、等待用户处理、失败/需关注、等待审阅”状态,确认宠物动作/提示匹配且优先级为“等待处理 → 失败 → 等待审阅 → 工作中”;有多个非空闲会话时展开面板最多列出最近 9 个任务,点击整行聚焦主窗口及对应会话。
|
||||
- [ ] **PET-04|任务卡隐藏与关闭宠物**:默认不展示任务卡;有活跃任务时只显示宠物右上角数字圆点,点击圆点展开任务卡,点击下箭头隐藏卡片且不能改变任务状态。右键菜单关闭宠物后设置页开关立即同步为关闭,正在执行的任务不能被停止。 — partial:真实 Windows Electron + DeepSeek 编程任务中,Computer Use 已完成两轮“数字圆点展开 → 下箭头隐藏 → 数字圆点恢复”,最终 `showTaskPanel: false` 已落盘;隐藏卡片后任务继续执行 Bun 测试并返回 `2 pass / 0 fail`、`PET_CLOSE_SMOKE_OK`。右键关闭整个宠物及设置页同步本轮未复测。
|
||||
|
||||
### 附件与会话连续性
|
||||
|
||||
- [x] **ATT-01|图片消息回放去重**:发送“检查这张截图”并附带本地图片,等待服务端回放/重连;界面始终只有一个用户消息气泡和一个附件预览,不显示 `[Image source: ...]` 或内部绝对路径文本。 — passed:在首轮即选用 MiniMax-M3 的全新会话中只附加一张 `canary.png`,UI 仅出现一个用户气泡和一个附件,真实模型准确返回第二行标识 `PDF_CANARY_91829E91`。trace 显示 Anthropic `/v1/messages` 请求只有 1 个 image block、HTTP 200,且请求/响应均无 `[Unsupported Image]`。此前失败来自复用曾由 DeepSeek 生成 `[Unsupported Image]` 的跨 Provider 历史且同图被重复附加,不是 MiniMax 多模态接口限制,详见 PROVIDER-001。
|
||||
- [x] **ATT-02|多文件物化路径去重**:一次发送两个仅含 data 的不同类型文件,确认上传后仍只有一个用户气泡、两个名称正确的附件 chip;UUID 前缀的服务端上传路径不能出现在正文。随后发送同提示但不同附件,不能被误判为重复消息。 — passed:在全新 MiniMax-M3 会话中通过系统文件选择器一次选择 `canary.txt` 与 `resume-secret.txt`;UI 只有一个用户气泡和两个名称正确的附件 chip,正文未暴露物化路径,真实模型不调用工具即准确返回两个文件各自的 canary。
|
||||
- [x] **ATT-03|完全退出后恢复附件上下文**:在桌面端发送带唯一 canary 的文件,等待回复和持久化后完全退出应用;重启恢复同一会话,不重新附加文件,追问 canary。模型应能依据原附件内容回答,而不只是看到 `@路径`。 — passed:发送 `resume-secret.txt` 后完整退出并重启 Electron/Sidecar,恢复同一会话且不重新附加;真实模型仍回答 `HARBOR-PLUM-7319`,附件 chip 与用户消息未重复。
|
||||
- [x] **ATT-04|目录与 PDF 恢复**:分别用目录和 PDF 重复 ATT-03;历史中附件引用应正常显示,重启后的追问仍能使用原内容,且用户消息不重复。 — passed:完整退出并重启后,真实模型从已恢复的 PDF 回答 `PDF_CANARY_91829E91`,从目录回答 `FOLDER_CANARY_91829E91`;历史附件结构保持且用户消息未重复。
|
||||
|
||||
### SubAgent、权限拒绝与 Task
|
||||
|
||||
- [x] **SUB-01|运行中详情**:启动一个持续至少 10 秒且会调用工具的 SubAgent,立即从活动面板点击运行项。详情标签应在任务完成前打开,显示“运行中”、Agent/来源/任务信息、提示词和已产生的运行记录。 — passed:真实 MiniMax 编程会话启动 SubAgent 后,在运行中从 Activity 打开详情;页面显示 prompt、source、agent、task、token 和已产生的 Read/Bash 记录。首次因隔离项目尚无 HEAD 导致 worktree 启动失败,用户选择不使用 worktree 后重新运行成功。
|
||||
- [x] **SUB-02|实时刷新与完成**:保持详情页打开,确认新增文本和工具调用约每 2 秒出现;task id 晚到时页面自动接入实时记录。完成后状态、结果、更新时间和可用的 token usage 正确,且不需要关闭重开。 — passed:详情保持打开直至完成,后续 Read/Bash 与结果可见;完成态显示完整 transcript、报告及 11,473 tokens,点击刷新后约 2 秒重新载入且无需关闭标签。
|
||||
- [x] **DENY-01|拒绝普通工具** — passed:真实 DeepSeek 请求 Write `denied.txt`,Computer Use 点击 Deny;工具卡显示 `User denied via UI error`,Shell 确认目标文件不存在,输入框恢复可用。
|
||||
- [x] **DENY-02|拒绝后继续聊天** — passed:拒绝后立即发送无工具追问,真实模型返回 `CHAT_CONTINUES`;没有残留 pending 权限、持续运行态或缺少 tool result 错误。计划确认拒绝仍待其他用例覆盖。
|
||||
- [x] **TASK-01|跨 Provider 完整生命周期与并发读写**:分别在至少两个 Provider 下让模型创建、读取、列出、更新并完成 Task V2;另让真实 Provider 在同一 assistant turn 并发发出两个 create/update,再紧随 get/list,确认写操作串行且读取不会看到中间态或丢失态。工具无需 ToolSearch 即可调用,任务栏状态与工具结果一致,消息结束时未完成的工具状态会再刷新一次。 — passed:真实 MiniMax-M3 与 DeepSeek 编程会话均调用 TaskCreate/List/Get/Update,分别将 3 个任务更新为 completed;任务栏同步显示 3 项绿色完成。两条流程均执行真实 `bun test`,各为 2 passed / 0 failed,最终分别返回 `REAL_TASK_AGENT_FLOW_OK` 与 `DEEPSEEK_TASK_V2_OK`。随后在修复版 Windows sidecar 上,真实 MiniMax 在同一轮并发执行 2 个 TaskCreate、TaskList + 2 个 TaskGet、并发 2 个 TaskUpdate 和最终 TaskList;两项均完整且为绿色 completed,返回 `TASK_PARALLEL_IDLE0_OK`。
|
||||
- [ ] **TASK-02|切换会话与完成后收起**:任务运行时切换到另一会话,上一会话任务应立即消失且不串入新会话;回到原会话状态恢复。关闭已完成任务列表后,后续轮询不能让它再次弹出。
|
||||
|
||||
### Agent 管理及逐 Agent 配置
|
||||
|
||||
- [ ] **AGT-01|浏览与来源优先级**:打开“设置 → Agents”,确认总数、生效数和来源分组正确;创建同名用户级与项目级 Agent,二者均可见,项目级显示为生效、用户级显示被项目来源覆盖。对可用来源抽查完整优先级:policy → CLI flag → project → user → plugin → built-in。
|
||||
- [x] **AGT-02|用户级创建**:创建名称含下划线的用户 Agent,填写描述、system prompt、模型、effort、工具模式和颜色;保存后自动选中刷新后的定义,详情值正确,文件写入隔离测试配置的 `agents` 目录。 — passed:在隔离配置创建 `configured_probe`,设置 fable、high、Read/Grep/Bash 与紫色;保存后自动进入详情且文件只写入隔离 `agents` 目录,随后从 UI 删除。
|
||||
- [ ] **AGT-03|项目级创建**:没有活动项目时项目范围必须禁用;打开项目会话后创建项目 Agent,目标路径显示当前项目,文件只写入该项目 `.claude/agents/`,其他项目不可见或不生效。
|
||||
- [ ] **AGT-04|模型、effort 与工具模式**:逐项确认模型包含继承、fable、opus、sonnet、haiku、自定义模型 ID;effort 包含继承、low、medium、high、xhigh、max;工具支持全部继承、禁用全部、自定义列表。缺少自定义模型 ID 或自定义工具时不能保存。 — partial:已真实保存并回读 fable、high、自定义 Read/Grep/Bash;其余模型/effort 枚举和缺失自定义值拦截尚未逐项执行。
|
||||
- [ ] **AGT-05|热重载并执行**:在活动会话中保存 Agent 后立即 spawn,不重启应用;新定义应可被发现并完成任务。修改 system prompt、模型或 effort 后再次 spawn,下一次运行使用新定义;从真实 Provider trace/运行详情核验 definition 的 model 与 effort 已实际应用,而不只核对表单值。
|
||||
- [ ] **AGT-06|编辑回继承与字段保真**:把模型、effort、颜色和工具改回继承/默认;重新打开详情确认对应配置显示为继承,frontmatter 中相应字段被删除。编辑描述时不得破坏未知高级 frontmatter、空 tools、带括号/逗号的工具名或原 system prompt。
|
||||
- [ ] **AGT-07|只读与删除**:内置、插件、托管策略、CLI 参数等来源只允许查看且没有可执行的编辑/删除;用户级和项目级可删除,确认弹窗展示准确目标,删除后列表刷新且文件消失。 — partial:内置 Agent 详情确认只读;用户级 `configured_probe` 已通过 UI 删除并从列表消失。插件/托管策略/CLI 来源及项目级删除尚未覆盖。
|
||||
|
||||
## 5. P1 功能与边界流程
|
||||
|
||||
### 桌面宠物高级用例
|
||||
|
||||
- [ ] **PET-05|外观与无动画**:将大小从 96 调到 192 px,悬浮窗实时反映且不裁切;关闭“播放动画”后内置与自定义宠物均静止但仍可点击、拖动和导航任务。
|
||||
- [x] **PET-06|单图创建轻动画宠物**:选择“添加宠物 → 用一张图片制作轻动画”,填写合法 ID、名称和描述,导入合规 PNG/WebP;创建后出现在“你的宠物”、自动选中并可显示。确认整个过程不调用当前聊天模型。 — passed:在隔离 Electron 中用仓库真实 `icon.png`(46,061 bytes,SHA-256 `26D6DCC091FB0971ACB78A3A42526F55CCDD58E812C2D15EE09458CFE0E7C2C7`)创建 `manual-canary`;列表立即出现并自动选中,隔离配置生成 `pet.json` + `pet.png`,manifest 为 single-image v1 / `soft-spring-v1`,会话时间戳及聊天内容未变化。
|
||||
- [ ] **PET-07|专业图集导入**:导入合法 1536×2288、8×11 v2 图集;确认逐帧动作、左右跑动、失败、等待、工作、复核及视线方向没有错格、漂移或透明背景异常。
|
||||
- [ ] **PET-08|输入校验与损坏包**:非法 ID、超 8 MB、尺寸越界、错误图集尺寸/格式均应在对话框中给出错误且不创建半成品。向自定义目录放入无效包后刷新,应跳过并显示无效数量;“打开文件夹”打开隔离配置下的正确目录。 — partial:非法下划线 ID 时提交按钮保持 disabled;选择真实损坏的 79-byte `.png` 后对话框保留并显示 `The pet image header is invalid`,未生成半成品。超限、错误图集、损坏包刷新和打开目录仍待执行。
|
||||
- [ ] **PET-09|非桌面能力边界**:在浏览器/H5 打开同一设置页,宠物启停、创建和打开本地目录等桌面专属操作应禁用或不可用,不应调用 Electron IPC、白屏或影响桌面端已保存偏好。
|
||||
- [ ] **PET-10|加载/保存/原生调用失败恢复**:让 preferences 或自定义宠物目录首次加载失败,页面应保留已有内容并提供 Retry;保存失败时开关和选择应回滚;原生 show/hide/context-menu 失败时宠物窗口与设置开关恢复到一致状态且显示可重试错误。
|
||||
|
||||
### Provider 与上下文
|
||||
|
||||
- [x] **PRO-01|内置 presets 与创建入口**:首次进入 Provider 设置时“添加 Provider”立即可用,弹窗展示随桌面包内置的预设;确认页面不需要先成功请求 `/api/providers/presets` 才能打开创建界面。 — passed:Computer Use 反复打开成功,DeepSeek、Zhipu GLM、Kimi、MiniMax、LM Studio、Ollama、Custom、接口AI、胜算云、TeamoRouter 均可见。
|
||||
- [ ] **PRO-02|Kimi K3 预设**:选择 Kimi,确认 Base URL 为 `https://api.kimi.com/coding/`,主模型及 Haiku/Sonnet/Opus 均为 `k3`,鉴权为 API Key,官网/密钥入口指向 Kimi Code,上下文上限识别为 262,144,并可选择到 `max` effort。已保存的旧 Kimi URL、模型和 auth token 配置在编辑时不得被静默覆盖。 — partial:Computer Use 确认新建预设的 Base URL、API Key 鉴权、四个 `k3` 模型、`k3: 262,144`,以及 capability preview 包含 `effort,max_effort`;旧配置保真和密钥官网跳转仍待执行。
|
||||
- [ ] **PRO-03|旧/未知 preset 编辑回退**:打开一个 presetId 已不存在或 presets 加载异常的旧 Provider,编辑表单仍应显示已有 Base URL、模型和鉴权值,不白屏、不丢配置;保存失败与“弹窗可打开”分别记录。
|
||||
- [ ] **CTX-01|空/零 usage 的上下文估算**:用真实且确实返回 `{}` 或全零 usage 的 Provider/现场 transcript 建立有明显长度的会话,关闭并恢复;上下文指示器不得空白或归零,应按 transcript 和实际运行模型窗口给出合理非零估算。继续对话后估算应单调合理变化,不能错误显示已满。找不到这种真实 Provider 证据时标记 blocked,不能用 mock 结果冒充人工通过。
|
||||
|
||||
### SubAgent 与任务异常边界
|
||||
|
||||
- [ ] **SUB-03|错误、切换与空记录**:运行中详情刷新临时失败时标签保持打开、已有内容保留并可重试;快速切换两个 SubAgent 时旧响应不能覆盖当前页;确实无本地记录时显示明确空状态而非崩溃。
|
||||
- [ ] **TASK-03|轮询抖动与乱序**:在测试代理中延迟旧请求并让新请求先返回,界面不得被旧状态回滚;临时任务轮询失败时保留最后已知任务且不闪空,首次进入新会话请求失败则保持空列表而非带入旧任务。
|
||||
|
||||
### Agent 管理边界
|
||||
|
||||
- [ ] **AGT-08|校验、错误与非阻塞警告**:名称超过 64 位或格式非法、描述为空、新建时 system prompt 为空均应前端拦截。模拟保存失败时表单保持打开并显示错误;模拟定义已写入但热重载/刷新失败时显示非阻塞警告和重试,已保存文件不得回滚。
|
||||
|
||||
## 6. P1 Windows / Electron 稳定性专项
|
||||
|
||||
- [x] **WIN-01|Renderer 异常退出自动恢复**:在 Windows 专用测试构建中仅终止 Renderer 子进程。主窗口应自动 reload 一次并恢复可操作,不应长期白屏、退出主进程或重复 reload;会话标签、设置和历史从持久状态恢复。隔离配置的 `cc-haha/diagnostics/electron-host.log` 应记录 process-gone、recovery-started 和 recovery-loaded。 — passed:关闭宠物窗口后确认隔离 Electron 仅有一个主 Renderer(PID 40812),只终止该子进程;3 秒内新 Renderer(PID 5696)启动,Settings/Pets 页面、两个会话标签、Huhu/192px 与关闭状态均恢复且可操作。日志依次记录 `process-gone`、`recovery-started`、`recovery-loaded`。
|
||||
- [ ] **WIN-02|短暂与持续无响应**:制造短于 10 秒的 Renderer 卡顿后恢复,应用不应误 reload;再制造持续超过约 10 秒的无响应,应只自动恢复一次。若再次故障,显示“界面恢复失败 / Interface Recovery Failed”双语错误框并留下诊断记录。
|
||||
- [ ] **WIN-03|终端与窗口销毁竞态**:让内置终端持续输出,同时关闭终端标签、reload Renderer、关闭主窗口各一次;不得出现 `Object has been destroyed`、主进程崩溃、幽灵输出或无法再次打开终端。
|
||||
- [ ] **WIN-04|系统代理/PAC 波动**:启用受控系统代理或 PAC,聊天请求建立期间切换节点、断开 CONNECT、短暂断网再恢复;应用不得弹出主进程 JavaScript 异常,恢复网络后无需重启即可再次请求,代理认证失败不得静默回退 DIRECT。
|
||||
- [ ] **WIN-05|IM WebSocket 握手中销毁**:启动 IM/H5 连接后,在 WebSocket 尚处于 CONNECTING 时切换会话、重置或退出适配器;不得产生未处理 `error`、主进程退出或残留连接,重连后新会话可正常收发。
|
||||
- [x] **WIN-06|Context 超时不能拖垮本地 REST** — passed:Chromium netlog 将故障精确定位为复用已空闲约 104 秒、但 Bun 已按 60 秒 `idleTimeout` 停止处理的 HTTP/1.1 socket;`GET /api/tasks/lists/<session>` 写入后等待响应头 120,011 ms,turn-checkpoints/workspace-status 同期超时。改为 `idleTimeout: 0` 后重建真实 Windows sidecar,并合并同一会话任务轮询;应用空闲 85 秒后执行 MiniMax 并发 Task 全流程,task-list 请求最慢 235 ms,所有本地 API 最慢 578 ms。运行 137 秒后仍为 0 `CloseWait` / 0 `FinWait2`、无新增 timeout。再用项目声明版本的官方 Bun 1.3.14 Windows binary 编译 sidecar,空闲 106 秒后真实 MiniMax 返回 `IDLE0_BUN1314_OK`,运行 131 秒仍为 0 / 0 且无新增 timeout,详见 BUG-009。
|
||||
|
||||
## 7. P2 可访问性、隐私与兼容性
|
||||
|
||||
- [ ] **A11Y-01|Reduced Motion**:系统开启 reduced-motion 后,内置和自定义宠物都不播放动画;运行中的 SubAgent 状态仍可通过文字/非动画标记识别。
|
||||
- [ ] **I18N-01|新增界面语言**:依次切换简中、繁中、英文、日文、韩文,抽查宠物、Agent 管理和 SubAgent 详情;不得显示原始 i18n key、截断关键按钮或出现不可读混排。
|
||||
- [ ] **ATT-05|终端 FeedbackSurvey 转录分享隐私**:从 CLI/Ink 的 FeedbackSurvey 入口在隔离环境中提交包含附件的反馈分享,检查测试接收端或抓包证据;结构化消息和原始 JSONL 均不得包含 `type: attachment` 记录、附件正文或本地敏感路径,普通对话和 SubAgent 信息仍可提交。此项不是桌面 Settings/聊天反馈入口。
|
||||
|
||||
### CLI / TUI Agent 行为
|
||||
|
||||
- [ ] **CLI-01|effort 参数与 model-info**:在终端真实运行 `--effort xhigh`、`--effort max` 和合法整数,并验证非法值被明确拒绝;headless/model-info 应只展示当前模型实际支持的 effort levels。
|
||||
- [ ] **CLI-02|Agent effort 启动与恢复**:用 `--agent` 启动真实 Provider 编程任务,核验 Agent effort 生效;显式 CLI `--effort` 应按约定优先。退出并 resume 后从 trace 再次确认 model/effort 不丢失。
|
||||
- [ ] **CLI-03|TUI Agent 编辑字段保真**:在终端 AgentEditor 中编辑含 Markdown tools、system prompt 和未知高级 frontmatter 的 Agent;保存并重开后字段和值保持,不被简化或损坏。
|
||||
|
||||
## 8. 已知风险与待确认项
|
||||
|
||||
- [ ] **RISK-01|Agent effort 优先级契约**:当前实现及测试允许“标记为 request-scoped 的 Agent effort”覆盖 `CLAUDE_CODE_EFFORT_LEVEL`,但 `docs/agent/01-usage-guide.md` 写的是环境变量优先。使用同一支持 effort 的模型设置会话 `high`、Agent `xhigh`,记录实际请求值;发布前由维护者确认期望契约并统一代码、测试和中英文文档。本清单不把存在冲突的任一结果直接判为通过。
|
||||
- [ ] **RISK-02|live-provider 证据**:配置值检查、mock 或本地契约测试不等同真实 Kimi/Provider 请求。没有额度授权时标记 `blocked` 或 `skipped`,不得写成 passed。
|
||||
- [ ] **RISK-03|平台证据**:Renderer、系统代理、终端 native 模块的自动化结果不等同 Windows 实机;没有 Windows 安装包实测时 WIN 项不得写成 passed。
|
||||
|
||||
## 9. 通常不需要普通 UI 人工执行的内部变化
|
||||
|
||||
- CI/release workflow、Electron 输出目录 guard、Pet atlas 组装脚本和构建清理。
|
||||
- SDK schema、模型 alias、Provider capability 环境变量等内部协议传播;Task `alwaysLoad`/并发标记和 CLI effort 的用户可见结果已分别纳入 TASK-01、CLI-01~CLI-03。
|
||||
- WebSocket teardown error sink、代理 socket 清理和销毁后 terminal IPC 防护已合并到 WIN-03~WIN-05,不在日常跨平台冒烟中重复拆项。
|
||||
- 自动化测试本身不是人工通过证据;人工结论只记录本清单实际执行结果。
|
||||
|
||||
## 10. 完成判定
|
||||
|
||||
- [ ] 所有适用于发布平台的 P0 项为 `passed`,没有未解释的 failed/blocked。
|
||||
- [ ] P1/P2 的 failed、blocked、skipped 均有缺陷编号、风险接受人或明确后续计划。
|
||||
- [ ] 每个提交至少有一条人工证据,或在第 9 节明确归为内部变化。
|
||||
- [ ] 回归后重新核对 commit;若 HEAD 已超过 `91829e91`,先补做新增提交分析再沿用本清单。
|
||||
|
||||
## 11. 本轮执行收口
|
||||
|
||||
- [x] 回归结束时 HEAD 仍为 `91829e91`,提交分析范围未漂移。
|
||||
- [x] 真实 Provider 证据来自 DeepSeek 与 MiniMax;图片、多附件、恢复、权限拒绝、SubAgent、Task V2 与真实代码测试均未使用 mock。
|
||||
- [x] 完整重跑 `check:desktop`、`check:server`、`check:provider-contract`、`check:chat-contract`、`check:adapters`、`check:native`、`check:coverage`,均通过;`check:impact` 仅因 CLI core 变更需要 PR 标签和维护者批准而处于 policy blocked,不是测试失败。
|
||||
- [ ] 发布签字仍需处理本清单未勾选/partial 项;尤其 PET-02~05/07/09/10、WIN-02~05、CLI-01~03 和 H5 长连接资源风险不能从本轮证据外推为 passed。
|
||||
Reference in New Issue
Block a user