feat(web-shell): add workspace agent management (#7572)

* feat(web-shell): add workspace agent management

* test(web-shell): remove unstable extensions page tests

* fix(agents): address management review feedback

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>
This commit is contained in:
ytahdn 2026-07-23 16:42:08 +08:00 committed by GitHub
parent 74a786dac3
commit c0fee1b609
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 3998 additions and 2947 deletions

View file

@ -0,0 +1,77 @@
# Workspace Agents API
## Goal
Expose the complete persisted subagent definition through daemon CRUD APIs,
while keeping prompt generation a generic workspace text-generation concern.
## Resource shape
Agent list entries expose all YAML frontmatter fields that affect execution:
- `name`, `description`, `level`, source/read-only metadata
- `tools` and `disallowedTools`, including MCP tool names
- `model`, `approvalMode`, `permissionMode`, `maxTurns`, and `color`
- MCP server names, Hook event names, `background`, and legacy `runConfig`
`GET /workspace/agents/:agentType` returns the same shape plus
`systemPrompt`, `mcpServers`, and complete `hooks`. MCP environment variables,
headers, and OAuth client secrets use the same `__redacted__` placeholders as
the settings API. The list intentionally omits those three potentially large
or sensitive values and returns `mcpServerNames` / `hookEvents` instead.
Existing summary fields remain for compatibility.
`POST /workspace/agents` and `POST /workspace/agents/:agentType` accept the
same persisted fields. The route validates optional values strictly before
writing; invalid API input returns `422 invalid_config` rather than being
silently dropped on the next disk read. Empty arrays and records are valid on
updates and clear the corresponding frontmatter field. `null` clears optional
scalar policy fields on updates.
Workspace-qualified routes use the same resource shape and validation, while
remaining project-scope only.
## Prompt generation
The create and edit pages send their prompt to `POST /workspace/generate`,
consume the standard workspace generation SSE envelope, and generate the
description and system prompt as two plain-text requests. This avoids coupling
the generic endpoint to an Agent JSON schema.
Generation is an optional dialog launched below the system-prompt field. Both
results stream into reviewable draft fields inside the dialog; cancelling
leaves the form untouched, while confirming copies both drafts into the form.
There is no separate generation mode in the persisted Agent resource.
The existing `POST /workspace/agents/generate` compatibility route remains
unchanged, but this UI does not use its structured
`{name, description, systemPrompt}` response. The name and execution policy
remain user-owned form fields; the generated description and system prompt are
both reviewable before saving.
The editor preheats the workspace ACP runtime before loading the tool catalog.
It obtains built-in tools from the workspace tools status, initializes MCP
discovery using the same polling flow as the MCP manager, and then loads MCP
tools from each workspace MCP server's tools endpoint. MCP tools are never
inferred from the workspace tools response, so the two sources remain distinct.
Allowed and disallowed tools use the same cascading picker: choose built-in or
MCP, choose an MCP server when applicable, then choose a canonical tool. The
selected tools remain visible as removable rows. Configured MCP servers use a
compact select-and-add control instead of free-form JSON or an expanded list.
An empty allowed-tool selection preserves the documented "inherit all tools"
behavior.
When the editor sends a selected server configuration back, the route restores
redacted MCP values from the effective workspace settings (or the existing
Agent during an edit) before writing. Responses remain redacted, so secrets do
not cross the daemon HTTP boundary.
## Compatibility
- Existing Agent CRUD fields and response fields remain valid.
- `hasTools` remains as a derived compatibility field.
- `runConfig.max_turns` remains readable/writable, while `maxTurns` is the
canonical documented turn limit and wins at runtime.
- Existing Agent-specific generation endpoints and SDK helpers remain
available for compatibility.

View file

@ -814,10 +814,17 @@ export interface ServeWorkspaceAgentSummary {
isBuiltin: boolean;
/** Whether this agent restricts the tool set via `tools:` frontmatter. */
hasTools: boolean;
tools?: string[];
disallowedTools?: string[];
model?: string;
color?: string;
background?: boolean;
approvalMode?: string;
permissionMode?: string;
maxTurns?: number;
mcpServerNames?: string[];
hookEvents?: string[];
runConfig?: { max_time_minutes?: number; max_turns?: number };
extensionName?: string;
/** Absolute path to the file backing this agent (or sentinel for built-ins). */
filePath?: string;
@ -825,9 +832,8 @@ export interface ServeWorkspaceAgentSummary {
export interface ServeWorkspaceAgentDetail extends ServeWorkspaceAgentSummary {
systemPrompt: string;
tools?: string[];
disallowedTools?: string[];
runConfig?: { max_time_minutes?: number; max_turns?: number };
mcpServers?: Record<string, unknown>;
hooks?: Record<string, unknown>;
}
export interface ServeWorkspaceAgentsStatus {

View file

@ -208,6 +208,199 @@ describe('workspace agents routes', () => {
expect(reviewerEntry?.systemPrompt).toBeUndefined();
});
it('round-trips complete frontmatter metadata through create, list, and detail', async () => {
const bridge = buildBridgeStub();
const app = buildApp({ bridge, boundWorkspace: workspace });
const config = {
name: 'complete-agent',
description: 'Exercises the complete daemon Agent contract',
systemPrompt: 'You are a complete test agent.',
scope: 'workspace',
tools: ['read_file', 'mcp__github__search'],
disallowedTools: ['run_shell_command', 'mcp__slack'],
model: 'fast',
approvalMode: 'bubble',
maxTurns: 12,
color: 'cyan',
mcpServers: {
filesystem: { type: 'stdio', command: 'node', args: ['server.js'] },
},
hooks: {
PreToolUse: [
{
matcher: 'run_shell_command',
hooks: [{ type: 'command', command: 'echo checking' }],
},
],
},
};
const create = await request(app).post('/workspace/agents').send(config);
expect(create.status).toBe(201);
const { scope: _scope, ...persistedConfig } = config;
expect(create.body.agent).toMatchObject({
...persistedConfig,
level: 'project',
});
const list = await request(app).get('/workspace/agents');
const summary = list.body.agents.find(
(agent: { name: string }) => agent.name === config.name,
);
expect(summary).toMatchObject({
name: config.name,
tools: config.tools,
disallowedTools: config.disallowedTools,
model: config.model,
approvalMode: config.approvalMode,
maxTurns: config.maxTurns,
color: config.color,
mcpServerNames: ['filesystem'],
hookEvents: ['PreToolUse'],
});
expect(summary.systemPrompt).toBeUndefined();
const detail = await request(app).get(`/workspace/agents/${config.name}`);
expect(detail.status).toBe(200);
expect(detail.body).toMatchObject({
...persistedConfig,
level: 'project',
});
});
it('restores selected MCP server secrets before writing an agent', async () => {
const settingsDir = path.join(workspace, QWEN_DIR);
await fs.mkdir(settingsDir, { recursive: true });
await fs.writeFile(
path.join(settingsDir, 'settings.json'),
JSON.stringify({
mcpServers: {
private: {
command: 'private-server',
env: { PRIVATE_TOKEN: 'secret-value' },
},
},
}),
'utf8',
);
const app = buildApp({
bridge: buildBridgeStub(),
boundWorkspace: workspace,
});
const create = await request(app)
.post('/workspace/agents')
.send({
name: 'private-agent',
description: 'Uses a selected private MCP server',
systemPrompt: 'Use the private MCP server.',
scope: 'workspace',
mcpServers: {
private: {
command: 'private-server',
env: { PRIVATE_TOKEN: '__redacted__' },
},
},
});
expect(create.status).toBe(201);
expect(create.body.agent.mcpServers.private.env).toEqual({
PRIVATE_TOKEN: '__redacted__',
});
const onDisk = await fs.readFile(
path.join(workspace, QWEN_DIR, 'agents', 'private-agent.md'),
'utf8',
);
expect(onDisk).toContain('PRIVATE_TOKEN: secret-value');
expect(onDisk).not.toContain('__redacted__');
});
it('keeps the existing structured agent generation route unchanged', async () => {
const bridge = buildBridgeStub();
const generateWorkspaceAgent = vi.fn().mockResolvedValue({
name: 'generated-agent',
description: 'generated description',
systemPrompt: 'generated prompt',
});
bridge.generateWorkspaceAgent = generateWorkspaceAgent;
const app = buildApp({ bridge, boundWorkspace: workspace });
const res = await request(app)
.post('/workspace/agents/generate')
.send({ description: 'generate an agent' });
expect(res.status).toBe(200);
expect(res.body).toEqual({
name: 'generated-agent',
description: 'generated description',
systemPrompt: 'generated prompt',
});
expect(generateWorkspaceAgent).toHaveBeenCalledWith(
'generate an agent',
undefined,
);
});
it('updates advanced agent metadata', async () => {
const bridge = buildBridgeStub();
const app = buildApp({ bridge, boundWorkspace: workspace });
await request(app).post('/workspace/agents').send({
name: 'editable-agent',
description: 'before',
systemPrompt: 'before prompt',
scope: 'workspace',
});
const update = await request(app)
.post('/workspace/agents/editable-agent')
.send({
description: 'after',
systemPrompt: 'after prompt',
tools: ['read_file', 'mcp__github__search'],
disallowedTools: ['run_shell_command'],
model: 'fast',
approvalMode: 'bubble',
maxTurns: 8,
color: 'purple',
mcpServers: { github: { type: 'http', url: 'https://example.com' } },
hooks: { PreToolUse: [] },
});
expect(update.status).toBe(200);
expect(update.body.agent).toMatchObject({
description: 'after',
systemPrompt: 'after prompt',
tools: ['read_file', 'mcp__github__search'],
disallowedTools: ['run_shell_command'],
model: 'fast',
approvalMode: 'bubble',
maxTurns: 8,
color: 'purple',
mcpServers: { github: { type: 'http', url: 'https://example.com' } },
hooks: { PreToolUse: [] },
});
const clear = await request(app)
.post('/workspace/agents/editable-agent')
.send({
model: null,
approvalMode: null,
maxTurns: null,
color: null,
tools: [],
disallowedTools: [],
mcpServers: {},
hooks: {},
});
expect(clear.status).toBe(200);
expect(clear.body.agent).not.toHaveProperty('model');
expect(clear.body.agent).not.toHaveProperty('approvalMode');
expect(clear.body.agent).not.toHaveProperty('maxTurns');
expect(clear.body.agent).not.toHaveProperty('color');
expect(clear.body.agent).not.toHaveProperty('mcpServers');
expect(clear.body.agent).not.toHaveProperty('hooks');
});
it('GET /workspace/agents reflects out-of-band agent file changes', async () => {
const bridge = buildBridgeStub();
const app = buildApp({ bridge, boundWorkspace: workspace });
@ -261,6 +454,31 @@ describe('workspace agents routes', () => {
expect(res.body.level).toBe('project');
});
it('returns a shadowed user agent when GET specifies global scope', async () => {
const bridge = buildBridgeStub();
const app = buildApp({ bridge, boundWorkspace: workspace });
await request(app).post('/workspace/agents').send({
name: 'shadowed-agent',
description: 'project description',
systemPrompt: 'project prompt',
scope: 'workspace',
});
await request(app).post('/workspace/agents').send({
name: 'shadowed-agent',
description: 'user description',
systemPrompt: 'user prompt',
scope: 'global',
});
const res = await request(app).get(
'/workspace/agents/shadowed-agent?scope=global',
);
expect(res.status).toBe(200);
expect(res.body.level).toBe('user');
expect(res.body.systemPrompt).toBe('user prompt');
});
it('returns 404 agent_not_found for unknown agent', async () => {
const bridge = buildBridgeStub();
const app = buildApp({ bridge, boundWorkspace: workspace });
@ -559,6 +777,43 @@ describe('workspace agents routes', () => {
expect(res.body.error).toMatch(/approvalMode/);
});
it('returns 422 invalid_config for unknown permissionMode', async () => {
const bridge = buildBridgeStub();
const app = buildApp({ bridge, boundWorkspace: workspace });
const res = await request(app).post('/workspace/agents').send({
name: 'bad-permission-mode',
description: 'a description longer than ten chars',
systemPrompt: 'you are a bad permission mode test agent',
scope: 'workspace',
permissionMode: 'invalid',
});
expect(res.status).toBe(422);
expect(res.body.code).toBe('invalid_config');
expect(res.body.error).toMatch(/permissionMode/);
});
it('round-trips and clears permissionMode without approvalMode', async () => {
const bridge = buildBridgeStub();
const app = buildApp({ bridge, boundWorkspace: workspace });
const create = await request(app).post('/workspace/agents').send({
name: 'permission-mode-agent',
description: 'an agent using the compatibility permission mode',
systemPrompt: 'you are a permission mode test agent',
scope: 'workspace',
permissionMode: 'plan',
});
expect(create.status).toBe(201);
expect(create.body.agent.permissionMode).toBe('plan');
const clear = await request(app)
.post('/workspace/agents/permission-mode-agent')
.send({ permissionMode: null });
expect(clear.status).toBe(200);
expect(clear.body.agent).not.toHaveProperty('permissionMode');
});
it('strips unknown runConfig keys and rejects malformed values', async () => {
const bridge = buildBridgeStub();
const app = buildApp({ bridge, boundWorkspace: workspace });
@ -951,6 +1206,36 @@ describe('workspace agents routes', () => {
});
});
it('detects unchanged MCP servers and hooks as a no-op', async () => {
const bridge = buildBridgeStub();
const app = buildApp({ bridge, boundWorkspace: workspace });
const mcpServers = {
filesystem: { type: 'stdio', command: 'node', args: ['server.js'] },
};
const hooks = {
PreToolUse: [{ matcher: 'read_file', hooks: [] }],
};
await request(app).post('/workspace/agents').send({
name: 'record-noop',
description: 'a description longer than ten chars',
systemPrompt: 'you are a record-noop agent',
scope: 'workspace',
mcpServers,
hooks,
});
const eventsBefore = (bridge as unknown as { events: RecordedEvent[] })
.events.length;
const res = await request(app)
.post('/workspace/agents/record-noop')
.send({ mcpServers, hooks });
expect(res.status).toBe(200);
expect(res.body.changed).toBe(false);
const events = (bridge as unknown as { events: RecordedEvent[] }).events;
expect(events.length).toBe(eventsBefore);
});
it('short-circuits no-op updates with changed: false and no event', async () => {
const bridge = buildBridgeStub();
const app = buildApp({ bridge, boundWorkspace: workspace });

View file

@ -5,6 +5,7 @@
*/
import { promises as fs } from 'node:fs';
import { isDeepStrictEqual } from 'node:util';
import type { Application, Request, RequestHandler, Response } from 'express';
import {
APPROVAL_MODES,
@ -16,6 +17,11 @@ import {
type SubagentConfig,
type SubagentLevel,
} from '@qwen-code/qwen-code-core';
import {
redactMcpServersSetting,
restoreRedactedMcpServersSetting,
} from '../config/mcp-server-secrets.js';
import { loadSettings } from '../config/settings.js';
import { writeStderrLine } from '../utils/stdioHelpers.js';
import { isServeDebugMode } from './debug-mode.js';
import {
@ -74,6 +80,27 @@ const MAX_DESCRIPTION_BYTES = 256 * 1024;
const MAX_SYSTEM_PROMPT_BYTES = 256 * 1024;
const MAX_TOOLS_ENTRIES = 256;
const MAX_TOOL_ID_LENGTH = 256;
const MAX_RECORD_ENTRIES = 128;
const SUBAGENT_APPROVAL_MODES = [...APPROVAL_MODES, 'bubble'] as const;
const SUBAGENT_PERMISSION_MODES = [
'acceptEdits',
'auto',
'bypassPermissions',
'default',
'dontAsk',
'plan',
] as const;
const SUBAGENT_COLORS = [
'auto',
'red',
'blue',
'green',
'yellow',
'purple',
'orange',
'pink',
'cyan',
] as const;
import {
STATUS_SCHEMA_VERSION,
type ServeWorkspaceAgentDetail,
@ -188,7 +215,11 @@ export function mountWorkspaceAgentsRoutes(
}
const level: SubagentLevel = scope === 'workspace' ? 'project' : 'user';
const config = parseAgentConfig(body, level, res);
const config = parseAgentConfig(
restoreAgentMcpServerSecrets(body, deps.boundWorkspace, level),
level,
res,
);
if (!config) return;
// `manager.createSubagent` only checks whether the default
@ -349,7 +380,9 @@ export function mountWorkspaceAgentsRoutes(
const agentType = validateAgentType(req, res);
if (agentType === null) return;
try {
const config = await manager.loadSubagent(agentType);
const scopedLevel = parseScopeQuery(req, res);
if (scopedLevel === null) return;
const config = await manager.loadSubagent(agentType, scopedLevel);
if (!config) {
res.status(404).json({
error: `Subagent "${agentType}" not found`,
@ -382,10 +415,6 @@ export function mountWorkspaceAgentsRoutes(
if (clientIdResult === null) return;
const originatorClientId = clientIdResult;
const body = deps.safeBody(req);
const updates = parseAgentUpdates(body, res);
if (!updates) return;
const preferredLevel = parseScopeQuery(req, res);
if (preferredLevel === null) return;
@ -401,6 +430,14 @@ export function mountWorkspaceAgentsRoutes(
if (assertMutableLevel(existing, agentType, res)) {
return;
}
const body = restoreAgentMcpServerSecrets(
deps.safeBody(req),
deps.boundWorkspace,
existing.level,
existing.mcpServers,
);
const updates = parseAgentUpdates(body, res);
if (!updates) return;
// Empty / no-op update detection. An empty body or a body whose
// recognized fields all match `existing` would otherwise rewrite
@ -742,7 +779,11 @@ export function mountWorkspaceQualifiedAgentsRoutes(
const level = parseWorkspaceOnlyAgentBodyScope(body, res);
if (level === null) return;
const manager = createDaemonSubagentManager(runtime.workspaceCwd);
const config = parseAgentConfig(body, level, res);
const config = parseAgentConfig(
restoreAgentMcpServerSecrets(body, runtime.workspaceCwd, level),
level,
res,
);
if (!config) return;
const collision = await manager.loadSubagent(config.name, level);
@ -843,9 +884,6 @@ export function mountWorkspaceQualifiedAgentsRoutes(
if (clientIdResult === null) return;
const originatorClientId = clientIdResult;
const body = deps.safeBody(req);
const updates = parseAgentUpdates(body, res);
if (!updates) return;
const manager = createDaemonSubagentManager(runtime.workspaceCwd);
const existing = await manager.loadSubagent(agentType, scopedLevel);
if (!existing) {
@ -857,6 +895,14 @@ export function mountWorkspaceQualifiedAgentsRoutes(
return;
}
if (assertMutableLevel(existing, agentType, res)) return;
const body = restoreAgentMcpServerSecrets(
deps.safeBody(req),
runtime.workspaceCwd,
existing.level,
existing.mcpServers,
);
const updates = parseAgentUpdates(body, res);
if (!updates) return;
if (Object.keys(updates).length === 0) {
res.status(400).json({
@ -1196,6 +1242,34 @@ function rejectWorkspaceQualifiedAgentScope(
return null;
}
function restoreAgentMcpServerSecrets(
body: Record<string, unknown>,
workspace: string,
level: SubagentLevel,
currentAgentServers?: Record<string, unknown>,
): Record<string, unknown> {
const incoming = body['mcpServers'];
if (
typeof incoming !== 'object' ||
incoming === null ||
Array.isArray(incoming)
) {
return body;
}
const settings = loadSettings(workspace);
const configuredServers =
level === 'user'
? (settings.user.settings.mcpServers ?? {})
: (settings.merged.mcpServers ?? {});
return {
...body,
mcpServers: restoreRedactedMcpServersSetting(incoming, {
...configuredServers,
...currentAgentServers,
}),
};
}
function sendCreateAgentError(
res: Response,
err: unknown,
@ -1401,25 +1475,81 @@ function parseAgentConfig(
// 201 with no `model` field on the file (masking client-serialization
// bugs).
if (rejectIfPresentWrongType(body, 'model', 'string', res)) return undefined;
if (typeof body['model'] === 'string') config.model = body['model'];
if (typeof body['model'] === 'string') {
if (!body['model'].trim()) {
return sendInvalidConfig(res, '`model` must not be empty when provided');
}
config.model = body['model'].trim();
}
if (rejectIfPresentWrongType(body, 'color', 'string', res)) return undefined;
if (typeof body['color'] === 'string') config.color = body['color'];
if (typeof body['color'] === 'string') {
if (!SUBAGENT_COLORS.includes(body['color'] as never)) {
return sendInvalidConfig(
res,
`\`color\` must be one of ${JSON.stringify(SUBAGENT_COLORS)}`,
);
}
config.color = body['color'];
}
if (rejectIfPresentWrongType(body, 'approvalMode', 'string', res)) {
return undefined;
}
if (typeof body['approvalMode'] === 'string') {
if (!APPROVAL_MODES.includes(body['approvalMode'] as never)) {
res.status(422).json({
error: `\`approvalMode\` must be one of ${JSON.stringify(APPROVAL_MODES)}`,
code: 'invalid_config',
});
return undefined;
if (!SUBAGENT_APPROVAL_MODES.includes(body['approvalMode'] as never)) {
return sendInvalidConfig(
res,
`\`approvalMode\` must be one of ${JSON.stringify(SUBAGENT_APPROVAL_MODES)}`,
);
}
config.approvalMode = body['approvalMode'];
}
if (rejectIfPresentWrongType(body, 'permissionMode', 'string', res)) {
return undefined;
}
if (typeof body['permissionMode'] === 'string') {
if (!SUBAGENT_PERMISSION_MODES.includes(body['permissionMode'] as never)) {
return sendInvalidConfig(
res,
`\`permissionMode\` must be one of ${JSON.stringify(SUBAGENT_PERMISSION_MODES)}`,
);
}
config.permissionMode = body['permissionMode'];
}
if ('maxTurns' in body) {
const maxTurns = parseMaxTurns(body['maxTurns'], res);
if (maxTurns === null) return undefined;
config.maxTurns = maxTurns;
}
if ('mcpServers' in body) {
const mcpServers = parseRecordField(
body['mcpServers'],
'mcpServers',
(value) =>
typeof value === 'object' && value !== null && !Array.isArray(value),
'an object of server names to server configuration objects',
res,
);
if (mcpServers === null) return undefined;
if (Object.keys(mcpServers).length > 0) config.mcpServers = mcpServers;
}
if ('hooks' in body) {
const hooks = parseRecordField(
body['hooks'],
'hooks',
Array.isArray,
'an object of hook event names to matcher arrays',
res,
);
if (hooks === null) return undefined;
if (Object.keys(hooks).length > 0) config.hooks = hooks;
}
if (rejectIfPresentWrongType(body, 'background', 'boolean', res)) {
return undefined;
}
@ -1507,26 +1637,94 @@ function parseAgentUpdates(
// Optional scalar fields. Match the create-side fail-closed posture
// so a typo like `model: 123` returns 422 instead of silently
// succeeding with no model change.
if (rejectIfPresentWrongType(body, 'model', 'string', res)) return undefined;
if (typeof body['model'] === 'string') updates.model = body['model'];
if (rejectIfPresentWrongType(body, 'color', 'string', res)) return undefined;
if (typeof body['color'] === 'string') updates.color = body['color'];
if (rejectIfPresentWrongType(body, 'approvalMode', 'string', res)) {
if (body['model'] === null) {
updates.model = undefined;
} else if (rejectIfPresentWrongType(body, 'model', 'string', res)) {
return undefined;
} else if (typeof body['model'] === 'string') {
if (!body['model'].trim()) {
return sendInvalidConfig(res, '`model` must not be empty when provided');
}
updates.model = body['model'].trim();
}
if (typeof body['approvalMode'] === 'string') {
if (!APPROVAL_MODES.includes(body['approvalMode'] as never)) {
res.status(422).json({
error: `\`approvalMode\` must be one of ${JSON.stringify(APPROVAL_MODES)}`,
code: 'invalid_config',
});
return undefined;
if (body['color'] === null) {
updates.color = undefined;
} else if (rejectIfPresentWrongType(body, 'color', 'string', res)) {
return undefined;
} else if (typeof body['color'] === 'string') {
if (!SUBAGENT_COLORS.includes(body['color'] as never)) {
return sendInvalidConfig(
res,
`\`color\` must be one of ${JSON.stringify(SUBAGENT_COLORS)}`,
);
}
updates.color = body['color'];
}
if (body['approvalMode'] === null) {
updates.approvalMode = undefined;
} else if (rejectIfPresentWrongType(body, 'approvalMode', 'string', res)) {
return undefined;
} else if (typeof body['approvalMode'] === 'string') {
if (!SUBAGENT_APPROVAL_MODES.includes(body['approvalMode'] as never)) {
return sendInvalidConfig(
res,
`\`approvalMode\` must be one of ${JSON.stringify(SUBAGENT_APPROVAL_MODES)}`,
);
}
updates.approvalMode = body['approvalMode'];
}
if (body['permissionMode'] === null) {
updates.permissionMode = undefined;
} else if (rejectIfPresentWrongType(body, 'permissionMode', 'string', res)) {
return undefined;
} else if (typeof body['permissionMode'] === 'string') {
if (!SUBAGENT_PERMISSION_MODES.includes(body['permissionMode'] as never)) {
return sendInvalidConfig(
res,
`\`permissionMode\` must be one of ${JSON.stringify(SUBAGENT_PERMISSION_MODES)}`,
);
}
updates.permissionMode = body['permissionMode'];
}
if ('maxTurns' in body) {
if (body['maxTurns'] === null) {
updates.maxTurns = undefined;
} else {
const maxTurns = parseMaxTurns(body['maxTurns'], res);
if (maxTurns === null) return undefined;
updates.maxTurns = maxTurns;
}
}
if ('mcpServers' in body) {
const mcpServers = parseRecordField(
body['mcpServers'],
'mcpServers',
(value) =>
typeof value === 'object' && value !== null && !Array.isArray(value),
'an object of server names to server configuration objects',
res,
);
if (mcpServers === null) return undefined;
updates.mcpServers = mcpServers;
}
if ('hooks' in body) {
const hooks = parseRecordField(
body['hooks'],
'hooks',
Array.isArray,
'an object of hook event names to matcher arrays',
res,
);
if (hooks === null) return undefined;
updates.hooks = hooks;
}
if (rejectIfPresentWrongType(body, 'background', 'boolean', res)) {
return undefined;
}
@ -1572,6 +1770,61 @@ function parseStringArray(
return value as string[];
}
function sendInvalidConfig(res: Response, error: string): undefined {
res.status(422).json({ error, code: 'invalid_config' });
return undefined;
}
function parseMaxTurns(value: unknown, res: Response): number | null {
if (
typeof value !== 'number' ||
!Number.isFinite(value) ||
!Number.isInteger(value) ||
value <= 0
) {
sendInvalidConfig(res, '`maxTurns` must be a positive integer');
return null;
}
return value;
}
function parseRecordField(
value: unknown,
field: string,
isValidEntry: (value: unknown) => boolean,
expected: string,
res: Response,
): Record<string, unknown> | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
sendInvalidConfig(res, `\`${field}\` must be ${expected}`);
return null;
}
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length > MAX_RECORD_ENTRIES) {
sendInvalidConfig(
res,
`\`${field}\` exceeds the ${MAX_RECORD_ENTRIES}-entry limit`,
);
return null;
}
const output: Record<string, unknown> = {};
for (const [key, entry] of entries) {
if (
!key ||
key.length > MAX_TOOL_ID_LENGTH ||
key === '__proto__' ||
key === 'constructor' ||
key === 'prototype' ||
!isValidEntry(entry)
) {
sendInvalidConfig(res, `\`${field}\` must be ${expected}`);
return null;
}
output[key] = entry;
}
return output;
}
/**
* Returns `true` and sends a 422 when `body[key]` is present but the
* wrong scalar type. The caller then returns `undefined` to short-
@ -1630,18 +1883,45 @@ function isNoOpUpdate(
) {
return false;
}
if (updates.model !== undefined && updates.model !== existing.model) {
if ('model' in updates && updates.model !== existing.model) {
return false;
}
if (updates.color !== undefined && updates.color !== existing.color) {
if ('color' in updates && updates.color !== existing.color) {
return false;
}
if (
updates.approvalMode !== undefined &&
'approvalMode' in updates &&
updates.approvalMode !== existing.approvalMode
) {
return false;
}
if (
'permissionMode' in updates &&
updates.permissionMode !== existing.permissionMode
) {
return false;
}
if ('maxTurns' in updates && updates.maxTurns !== existing.maxTurns) {
return false;
}
if (
updates.mcpServers !== undefined &&
!isDeepStrictEqual(
normalizedRecord(updates.mcpServers),
normalizedRecord(existing.mcpServers),
)
) {
return false;
}
if (
updates.hooks !== undefined &&
!isDeepStrictEqual(
normalizedRecord(updates.hooks),
normalizedRecord(existing.hooks),
)
) {
return false;
}
if (
updates.background !== undefined &&
updates.background !== existing.background
@ -1669,6 +1949,23 @@ function isNoOpUpdate(
return true;
}
function normalizedRecord(
value: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!value || Object.keys(value).length === 0) return undefined;
return normalizeRecordValue(value) as Record<string, unknown>;
}
function normalizeRecordValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(normalizeRecordValue);
if (typeof value !== 'object' || value === null) return value;
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.filter(([, entry]) => entry !== undefined)
.map(([key, entry]) => [key, normalizeRecordValue(entry)]),
);
}
function shallowArrayEqual(
a: readonly string[] | undefined,
b: readonly string[] | undefined,
@ -1748,10 +2045,30 @@ export function toSummary(config: SubagentConfig): ServeWorkspaceAgentSummary {
isBuiltin: config.isBuiltin === true || config.level === 'builtin',
hasTools: Array.isArray(config.tools) && config.tools.length > 0,
};
if (config.tools) summary.tools = [...config.tools];
if (config.disallowedTools) {
summary.disallowedTools = [...config.disallowedTools];
}
if (config.model) summary.model = config.model;
if (config.color) summary.color = config.color;
if (config.background !== undefined) summary.background = config.background;
if (config.approvalMode) summary.approvalMode = config.approvalMode;
if (config.permissionMode) summary.permissionMode = config.permissionMode;
if (config.maxTurns !== undefined) summary.maxTurns = config.maxTurns;
if (config.mcpServers) {
summary.mcpServerNames = Object.keys(config.mcpServers);
}
if (config.hooks) summary.hookEvents = Object.keys(config.hooks);
if (config.runConfig) {
const runConfig: NonNullable<ServeWorkspaceAgentSummary['runConfig']> = {};
if (typeof config.runConfig.max_time_minutes === 'number') {
runConfig.max_time_minutes = config.runConfig.max_time_minutes;
}
if (typeof config.runConfig.max_turns === 'number') {
runConfig.max_turns = config.runConfig.max_turns;
}
summary.runConfig = runConfig;
}
if (config.extensionName) summary.extensionName = config.extensionName;
if (config.filePath) summary.filePath = config.filePath;
return summary;
@ -1762,26 +2079,13 @@ export function toDetail(config: SubagentConfig): ServeWorkspaceAgentDetail {
...toSummary(config),
systemPrompt: config.systemPrompt,
};
if (config.tools) detail.tools = [...config.tools];
if (config.disallowedTools) {
detail.disallowedTools = [...config.disallowedTools];
}
if (config.runConfig) {
// Explicit field pick rather than spread-with-cast. If
// `SubagentConfig.runConfig` gains new fields in core, the
// spread-then-cast pattern would silently leak them through the
// HTTP response without a compile error. Picking `max_time_minutes`
// and `max_turns` by name forces a deliberate schema bump if a
// future core field needs to surface on the daemon route.
const runConfig: ServeWorkspaceAgentDetail['runConfig'] = {};
if (typeof config.runConfig.max_time_minutes === 'number') {
runConfig.max_time_minutes = config.runConfig.max_time_minutes;
}
if (typeof config.runConfig.max_turns === 'number') {
runConfig.max_turns = config.runConfig.max_turns;
}
detail.runConfig = runConfig;
if (config.mcpServers) {
detail.mcpServers = redactMcpServersSetting(config.mcpServers) as Record<
string,
unknown
>;
}
if (config.hooks) detail.hooks = config.hooks;
return detail;
}

View file

@ -245,6 +245,13 @@ export function workspaceWriteTools(state: BridgeState): any[] {
.optional()
.describe('Disallowed tool names.'),
model: z.string().optional().describe('Model ID for the agent.'),
approval_mode: z.string().optional().describe('Approval mode.'),
permission_mode: z.string().optional().describe('Permission mode.'),
max_turns: z.number().int().positive().optional(),
color: z.string().optional(),
mcp_servers: z.record(z.string(), z.unknown()).optional(),
hooks: z.record(z.string(), z.unknown()).optional(),
background: z.boolean().optional(),
},
handler(async (args) => handleAgentsManage(state, args)),
),
@ -295,7 +302,10 @@ async function handleAgentGet(state: BridgeState, args: any): Promise<any> {
return formatToolError('agent_type is required for get action.');
}
return formatJsonResult(
await state.client.getWorkspaceAgent(args.agent_type),
await state.client.getWorkspaceAgent(
args.agent_type,
args.scope ? { scope: args.scope } : {},
),
);
}
@ -314,6 +324,13 @@ async function handleAgentCreate(state: BridgeState, args: any): Promise<any> {
tools: args.tools,
disallowedTools: args.disallowed_tools,
model: args.model,
approvalMode: args.approval_mode,
permissionMode: args.permission_mode,
maxTurns: args.max_turns,
color: args.color,
mcpServers: args.mcp_servers,
hooks: args.hooks,
background: args.background,
}),
);
}
@ -327,10 +344,17 @@ async function handleAgentUpdate(state: BridgeState, args: any): Promise<any> {
args.system_prompt !== undefined ||
args.tools !== undefined ||
args.disallowed_tools !== undefined ||
args.model !== undefined;
args.model !== undefined ||
args.approval_mode !== undefined ||
args.permission_mode !== undefined ||
args.max_turns !== undefined ||
args.color !== undefined ||
args.mcp_servers !== undefined ||
args.hooks !== undefined ||
args.background !== undefined;
if (!hasField) {
return formatToolError(
'At least one field to update must be provided (description, system_prompt, tools, disallowed_tools, or model).',
'At least one field to update must be provided (description, system_prompt, tools, disallowed_tools, model, approval_mode, permission_mode, max_turns, color, mcp_servers, hooks, or background).',
);
}
return formatJsonResult(
@ -342,6 +366,13 @@ async function handleAgentUpdate(state: BridgeState, args: any): Promise<any> {
tools: args.tools,
disallowedTools: args.disallowed_tools,
model: args.model,
approvalMode: args.approval_mode,
permissionMode: args.permission_mode,
maxTurns: args.max_turns,
color: args.color,
mcpServers: args.mcp_servers,
hooks: args.hooks,
background: args.background,
},
{ scope: args.scope },
),

View file

@ -1913,9 +1913,13 @@ export class DaemonClient {
async getWorkspaceAgent(
agentType: string,
opts: { scope?: 'workspace' | 'global' } = {},
): Promise<DaemonWorkspaceAgentDetail> {
const url = opts.scope
? `${this.baseUrl}/workspace/agents/${urlEncode(agentType)}?scope=${urlEncode(opts.scope)}`
: `${this.baseUrl}/workspace/agents/${urlEncode(agentType)}`;
return await this.fetchWithTimeout(
`${this.baseUrl}/workspace/agents/${urlEncode(agentType)}`,
url,
{ headers: this.headers() },
async (res) => {
if (!res.ok) {

View file

@ -1721,10 +1721,17 @@ export interface DaemonWorkspaceAgentSummary {
level: DaemonAgentLevel;
isBuiltin: boolean;
hasTools: boolean;
tools?: string[];
disallowedTools?: string[];
model?: string;
color?: string;
background?: boolean;
approvalMode?: string;
permissionMode?: string;
maxTurns?: number;
mcpServerNames?: string[];
hookEvents?: string[];
runConfig?: { max_time_minutes?: number; max_turns?: number };
extensionName?: string;
filePath?: string;
}
@ -1732,9 +1739,8 @@ export interface DaemonWorkspaceAgentSummary {
export interface DaemonWorkspaceAgentDetail
extends DaemonWorkspaceAgentSummary {
systemPrompt: string;
tools?: string[];
disallowedTools?: string[];
runConfig?: { max_time_minutes?: number; max_turns?: number };
mcpServers?: Record<string, unknown>;
hooks?: Record<string, unknown>;
}
export interface DaemonWorkspaceAgentsStatus {
@ -1760,6 +1766,10 @@ export interface DaemonCreateAgentRequest {
runConfig?: { max_time_minutes?: number; max_turns?: number };
color?: string;
approvalMode?: string;
permissionMode?: string;
maxTurns?: number;
mcpServers?: Record<string, unknown>;
hooks?: Record<string, unknown>;
background?: boolean;
}
@ -1783,10 +1793,14 @@ export interface DaemonUpdateAgentRequest {
systemPrompt?: string;
tools?: string[];
disallowedTools?: string[];
model?: string;
model?: string | null;
runConfig?: { max_time_minutes?: number; max_turns?: number };
color?: string;
approvalMode?: string;
color?: string | null;
approvalMode?: string | null;
permissionMode?: string | null;
maxTurns?: number | null;
mcpServers?: Record<string, unknown>;
hooks?: Record<string, unknown>;
background?: boolean;
}

View file

@ -5884,6 +5884,24 @@ describe('DaemonClient', () => {
expect(calls[0]?.url).toBe('http://daemon/workspace/agents/with%2Fslash');
});
it('getWorkspaceAgent forwards the optional scope query', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(200, {
name: 'reviewer',
description: 'user reviewer',
level: 'user',
systemPrompt: 'review globally',
}),
);
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await client.getWorkspaceAgent('reviewer', { scope: 'global' });
expect(calls[0]?.url).toBe(
'http://daemon/workspace/agents/reviewer?scope=global',
);
});
it('streams stateless workspace generation with the session envelope', async () => {
const { fetch } = recordingFetch(() =>
sseResponse(

View file

@ -601,6 +601,38 @@ describe('serve-bridge', () => {
expect(result.isError).toBeUndefined();
});
it('should forward scope for agents_manage get', async () => {
const { state, calls } = makeMockState({
defaultSessionId: 'test-session',
fetchReply: () =>
jsonResponse(200, {
kind: 'agent',
name: 'reviewer',
description: 'Reviews code',
level: 'user',
isBuiltin: false,
hasTools: false,
systemPrompt: 'Review the code.',
}),
});
const { workspaceWriteTools } = await import(
'../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js'
);
const agentsTool = workspaceWriteTools(state).find(
(tool: { name: string }) => tool.name === 'workspace_agents_manage',
);
await agentsTool.handler(
{ action: 'get', agent_type: 'reviewer', scope: 'global' },
{},
);
expect(calls[0]?.url).toBe(
'http://127.0.0.1:4170/workspace/agents/reviewer?scope=global',
);
});
it('should reject file_write replace mode without expected_hash', async () => {
const { state } = makeMockState({
defaultSessionId: 'test-session',
@ -689,5 +721,52 @@ describe('serve-bridge', () => {
'At least one field to update must be provided',
);
});
it('should forward advanced agents_manage update fields', async () => {
const { state, calls } = makeMockState({
defaultSessionId: 'test-session',
fetchReply: () =>
jsonResponse(200, {
ok: true,
agent: {
kind: 'agent',
name: 'test-agent',
description: 'test',
level: 'project',
isBuiltin: false,
hasTools: true,
systemPrompt: 'test',
},
}),
});
state.allowGlobalScope = true;
const { workspaceWriteTools } = await import(
'../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js'
);
const agentsTool = workspaceWriteTools(state).find(
(tool: { name: string }) => tool.name === 'workspace_agents_manage',
);
await agentsTool.handler(
{
action: 'update',
agent_type: 'test-agent',
approval_mode: 'bubble',
max_turns: 6,
color: 'cyan',
mcp_servers: { github: { type: 'http' } },
hooks: { PreToolUse: [] },
},
{},
);
expect(JSON.parse(calls[0]?.body ?? '{}')).toMatchObject({
approvalMode: 'bubble',
maxTurns: 6,
color: 'cyan',
mcpServers: { github: { type: 'http' } },
hooks: { PreToolUse: [] },
});
});
});
});

View file

@ -824,7 +824,7 @@ mockComponent(
'ReleaseSessionDialog',
);
mockComponent('./components/dialogs/RewindDialog', 'RewindDialog');
mockComponent('./components/messages/AgentsMessage', 'AgentsMessage');
mockComponent('./components/agents/AgentsManagerPage', 'AgentsManagerPage');
mockComponent('./components/messages/MemoryMessage', 'MemoryMessage');
mockComponent('./components/messages/AuthMessage', 'AuthMessage');
// Record keyboardActive so app-level tests can assert the overlay is told to
@ -3496,6 +3496,7 @@ describe('App session callbacks', () => {
'Extensions',
'MCP',
'Skills',
'Agents',
]);
expect(extensionsTab?.getAttribute('aria-selected')).toBe('true');
expect(document.activeElement).toBe(extensionsTab);

View file

@ -71,10 +71,7 @@ import {
type ModelDialogMode,
} from './components/dialogs/ModelDialog';
import { ModelFallbacksDialog } from './components/dialogs/ModelFallbacksDialog';
import {
AgentsMessage,
type AgentsInitialMode,
} from './components/messages/AgentsMessage';
import { AgentsManagerPage } from './components/agents/AgentsManagerPage';
import { MemoryMessage } from './components/messages/MemoryMessage';
import { AuthMessage } from './components/messages/AuthMessage';
import { ToolsDialog } from './components/dialogs/ToolsDialog';
@ -2500,6 +2497,7 @@ export function App({
| 'mcp'
| 'skills'
| 'plugins'
| 'agents'
| null
>(null);
const closePanel = useCallback(() => setActivePanel(null), []);
@ -2528,7 +2526,8 @@ export function App({
| 'extensions'
| 'mcp'
| 'skills'
| 'plugins',
| 'plugins'
| 'agents',
) => {
setMainView('chat');
setActivePanel(panel);
@ -2931,8 +2930,9 @@ export function App({
const [memoryAddScope, setMemoryAddScope] = useState<'workspace' | 'global'>(
'workspace',
);
const [agentsDialogMode, setAgentsDialogMode] =
useState<AgentsInitialMode | null>(null);
const [agentsCreateScope, setAgentsCreateScope] = useState<
'workspace' | 'global' | null
>(null);
const [escapeHintVisible, setEscapeHintVisible] = useState(false);
// Whether the first Esc has armed a stream cancellation; the composer's send
// button shows an "Esc again to stop" affordance while true.
@ -3229,7 +3229,6 @@ export function App({
showApprovalModeDialog ||
tasksDialogMessage !== null ||
mcpDialogMessage !== null ||
agentsDialogMode !== null ||
showMemoryDialog ||
showAuthDialog ||
showAddWorkspaceDialog ||
@ -5415,23 +5414,22 @@ export function App({
}
if (cmd === 'agents') {
const subCommand = text.slice(match[0].length).trim().toLowerCase();
let agentsMode: AgentsInitialMode = 'menu';
if (subCommand === 'create') {
agentsMode = 'create';
setAgentsCreateScope('global');
} else if (
subCommand === 'create user' ||
subCommand === 'create global'
) {
agentsMode = 'create-user';
setAgentsCreateScope('global');
} else if (
subCommand === 'create project' ||
subCommand === 'create workspace'
) {
agentsMode = 'create-project';
} else if (subCommand === 'manage') {
agentsMode = 'manage';
setAgentsCreateScope('workspace');
} else {
setAgentsCreateScope(null);
}
setAgentsDialogMode(agentsMode);
openPanel('agents');
return true;
}
if (cmd === 'extensions') {
@ -6773,26 +6771,6 @@ export function App({
/>
</DialogShell>
)}
{agentsDialogMode && (
<DialogShell
title={
agentsDialogMode === 'manage'
? t('agent.manage')
: agentsDialogMode === 'menu'
? t('agents.title')
: t('agent.create')
}
size="lg"
onClose={() => setAgentsDialogMode(null)}
>
<AgentsMessage
mode={agentsDialogMode}
embedded
onMessage={(text) => store.dispatch([{ type: 'status', text }])}
onClose={() => setAgentsDialogMode(null)}
/>
</DialogShell>
)}
{showMemoryDialog && (
<DialogShell
title={t('memory.menu')}
@ -7150,6 +7128,8 @@ export function App({
? t('mcp.title')
: activePanel === 'skills'
? t('skills.title')
: activePanel === 'agents'
? t('agents.title')
: activePanel === 'plugins'
? t('plugins.title')
: t('sessionsOverview.title')
@ -7158,6 +7138,7 @@ export function App({
{activePanel !== 'extensions' &&
activePanel !== 'mcp' &&
activePanel !== 'skills' &&
activePanel !== 'agents' &&
activePanel !== 'plugins' && (
<div className={styles.panelHeader}>
<button
@ -7302,6 +7283,14 @@ export function App({
onClose={closePanel}
onUseSkill={handleUseSkill}
/>
) : activePanel === 'agents' ? (
<AgentsManagerPage
onClose={() => {
setAgentsCreateScope(null);
closePanel();
}}
initialCreateScope={agentsCreateScope}
/>
) : activePanel === 'plugins' ? (
<PluginManagerPage
mcpMessage={mcpDialogMessage}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
.agentGrid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 12px;
}
@container panel-body (min-width: 600px) {
.agentGrid[data-column-count='2'],
.agentGrid[data-column-count='3'],
.agentGrid[data-column-count='4'] {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@container panel-body (min-width: 900px) {
.agentGrid[data-column-count='3'],
.agentGrid[data-column-count='4'] {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@container panel-body (min-width: 1200px) {
.agentGrid[data-column-count='4'] {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}

View file

@ -0,0 +1,773 @@
import { useEffect, useMemo, useState } from 'react';
import {
ArrowLeftIcon,
BotIcon,
EllipsisVerticalIcon,
PencilIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
Trash2Icon,
} from 'lucide-react';
import {
DAEMON_APPROVAL_MODES,
useAgents,
type DaemonWorkspaceAgentDetail,
} from '@qwen-code/webui/daemon-react-sdk';
import { useI18n } from '../../i18n';
import {
canModifyAgent,
filterAgents,
isOverridden,
preserveAgentSelection,
scopeForLevel,
type AgentSelection,
type AgentLevelFilter,
} from './agents-manager-logic';
import { AgentCreatePage } from './AgentCreatePage';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '../ui/alert-dialog';
import { Badge } from '../ui/badge';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '../ui/breadcrumb';
import { Button } from '../ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '../ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle } from '../ui/empty';
import { Input } from '../ui/input';
import { ManagementNotice } from '../ui/management-notice';
import { Spinner } from '../ui/spinner';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '../ui/tooltip';
import type { EmbeddedManagerPage } from '../plugins/manager-page';
import styles from './AgentsManagerPage.module.css';
interface AgentsManagerPageProps {
onClose: () => void;
embedded?: EmbeddedManagerPage;
initialCreateScope?: 'workspace' | 'global' | null;
}
function levelLabel(level: string, t: ReturnType<typeof useI18n>['t']): string {
if (level === 'project') return t('agent.level.project');
if (level === 'user') return t('agent.level.user');
if (level === 'builtin') return t('agent.level.builtin');
if (level === 'extension') return t('agent.level.extension');
return level;
}
function approvalModeLabel(
mode: string | undefined,
t: ReturnType<typeof useI18n>['t'],
): string {
if (!mode) return '—';
if (mode === 'inherit' || mode === 'bubble') {
return t(`agent.approval.${mode}`);
}
if (DAEMON_APPROVAL_MODES.some((value) => value === mode)) {
return t(`mode.listLabel.${mode}`);
}
return mode;
}
function DetailField({ label, value }: { label: string; value: string }) {
return (
<div className="flex min-w-0 flex-col gap-1">
<div className="text-sm font-medium">{label}</div>
<div className="break-words text-sm text-muted-foreground">{value}</div>
</div>
);
}
function jsonText(value: Record<string, unknown> | undefined): string {
return value ? JSON.stringify(value, null, 2) : '—';
}
function unwrapPlainText(value: string): string {
return value
.replace(/\r\n?/g, '\n')
.replace(
/(?<!\n)\n(?!\n|[ \t]*(?:#{1,6}\s|[-*+]\s|\d+\.\s|```|>\s|\*\*[^*\n]+\*\*:))/g,
' ',
);
}
export function AgentsManagerPage({
onClose,
embedded,
initialCreateScope,
}: AgentsManagerPageProps) {
const { t } = useI18n();
const {
agents,
loading,
error: agentsError,
reload,
getAgent,
deleteAgent,
} = useAgents({ autoLoad: true });
const [query, setQuery] = useState('');
const [levelFilter, setLevelFilter] = useState<AgentLevelFilter>('all');
const [selection, setSelection] = useState<AgentSelection | null>(null);
const [detail, setDetail] = useState<DaemonWorkspaceAgentDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(() =>
Boolean(initialCreateScope),
);
const [editOpen, setEditOpen] = useState(false);
const [listNotice, setListNotice] = useState<string | null>(null);
const [mutationError, setMutationError] = useState<string | null>(null);
const [detailError, setDetailError] = useState<string | null>(null);
const [listErrorDismissed, setListErrorDismissed] = useState(false);
const filteredAgents = useMemo(
() => filterAgents(agents, query, levelFilter),
[agents, query, levelFilter],
);
const selectedAgent = useMemo(
() => preserveAgentSelection(selection, agents),
[agents, selection],
);
const selectedName = selection?.name ?? null;
useEffect(() => {
setSelection((current) => preserveAgentSelection(current, agents));
}, [agents]);
useEffect(() => {
embedded?.onDetailChange(Boolean(selectedName || createOpen || editOpen));
}, [createOpen, editOpen, embedded, selectedName]);
useEffect(() => {
if (!selection) {
setDetail(null);
setDetailError(null);
return;
}
let active = true;
setDetail(null);
setDetailError(null);
setDetailLoading(true);
getAgent(selection.name, scopeForLevel(selection.level))
.then((nextDetail) => {
if (active) setDetail(nextDetail);
})
.catch((e: unknown) => {
if (active) setDetailError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (active) setDetailLoading(false);
});
return () => {
active = false;
};
}, [selection, getAgent]);
useEffect(() => {
setListErrorDismissed(false);
}, [agentsError]);
useEffect(() => {
if (initialCreateScope) setCreateOpen(true);
}, [initialCreateScope]);
function returnToList(): void {
setCreateOpen(false);
setEditOpen(false);
setSelection(null);
setDetail(null);
setMutationError(null);
void reload();
}
async function handleDelete(): Promise<void> {
if (!detail || !selectedAgent) return;
const scope = scopeForLevel(selectedAgent.level);
if (!scope) return;
setBusy(true);
try {
await deleteAgent(selectedAgent.name, scope);
setDeleteOpen(false);
setSelection(null);
setDetail(null);
setListNotice(t('agent.deleted', { name: detail.name }));
await reload();
} catch (e) {
setDeleteOpen(false);
setMutationError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
const levelOptions: Array<{ value: AgentLevelFilter; label: string }> = [
{ value: 'all', label: t('skills.filter.all') },
{ value: 'project', label: t('agent.level.project') },
{ value: 'user', label: t('agent.level.user') },
{ value: 'builtin', label: t('agent.level.builtin') },
{ value: 'extension', label: t('agent.level.extension') },
];
const subpageTitle = editOpen
? t('agent.edit')
: (selectedName ?? (createOpen ? t('agent.create.button') : null));
const standaloneNavigation = (
<Breadcrumb className="sticky -top-4 z-10 -mx-5 -mt-4 border-b bg-background px-5 py-3">
<BreadcrumbList className="text-base">
<BreadcrumbItem>
<Button
variant="ghost"
size="icon"
onClick={onClose}
aria-label={t('common.back')}
>
<ArrowLeftIcon />
</Button>
</BreadcrumbItem>
<BreadcrumbItem>
{subpageTitle ? (
<BreadcrumbLink asChild>
<button type="button" onClick={returnToList}>
{t('agents.title')}
</button>
</BreadcrumbLink>
) : (
<BreadcrumbPage>{t('agents.title')}</BreadcrumbPage>
)}
</BreadcrumbItem>
{subpageTitle ? <BreadcrumbSeparator /> : null}
{subpageTitle ? (
<BreadcrumbItem>
<BreadcrumbPage>{subpageTitle}</BreadcrumbPage>
</BreadcrumbItem>
) : null}
</BreadcrumbList>
</Breadcrumb>
);
const navigation = embedded ? (
subpageTitle ? (
<Breadcrumb className="sticky -top-4 z-10 -mx-5 -mt-4 border-b bg-background px-5 py-3">
<BreadcrumbList className="h-8 text-sm">
<BreadcrumbItem>
<BreadcrumbLink asChild>
<button
type="button"
onClick={() => {
returnToList();
embedded.onDetailChange(false);
}}
>
{t('agents.title')}
</button>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{subpageTitle}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
) : null
) : (
standaloneNavigation
);
// ── Create view ──
if (createOpen) {
return (
<div className="flex w-full flex-col gap-6 pb-8">
{navigation}
<AgentCreatePage
initialScope={initialCreateScope ?? 'global'}
onCancel={returnToList}
onCreated={(name) => {
setCreateOpen(false);
setListNotice(t('agent.created', { name }));
void reload();
}}
/>
</div>
);
}
if (editOpen && detail) {
return (
<div className="flex w-full flex-col gap-6 pb-8">
{navigation}
<AgentCreatePage
agent={detail}
onCancel={() => setEditOpen(false)}
onCreated={(name) => {
setEditOpen(false);
setSelection(null);
setDetail(null);
setListNotice(t('agent.updated', { name }));
void reload();
}}
/>
</div>
);
}
// ── Detail view ──
if (selectedName && detail) {
const mutable = canModifyAgent(detail);
const toolsText =
!detail.tools || detail.tools.length === 0 || detail.tools.includes('*')
? t('agent.create.tools.all')
: detail.tools.join(', ');
const disallowedToolsText = detail.disallowedTools?.join(', ') || '—';
return (
<div className="flex w-full flex-col gap-6 pb-8">
{navigation}
<div className="flex w-full flex-col gap-6">
<div className="flex items-center gap-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-muted">
<BotIcon />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="break-words text-xl font-semibold text-balance">
{detail.name}
</h1>
<Badge variant="outline">{levelLabel(detail.level, t)}</Badge>
{isOverridden(detail, agents) ? (
<Badge variant="secondary">
{t('agent.overriddenBadge')}
</Badge>
) : null}
</div>
</div>
{mutable ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={busy}
aria-label={t('agent.chooseAction', {
name: detail.name,
})}
>
{busy ? <Spinner /> : <EllipsisVerticalIcon />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuGroup>
<DropdownMenuItem onSelect={() => setEditOpen(true)}>
<PencilIcon data-icon="inline-start" />
{t('agent.edit')}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
disabled={busy}
onSelect={() => setDeleteOpen(true)}
>
<Trash2Icon data-icon="inline-start" />
{t('agent.delete')}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
{mutationError ? (
<ManagementNotice
tone="error"
noticeKey={mutationError}
closeLabel={t('common.close')}
onDismiss={() => setMutationError(null)}
>
{mutationError}
</ManagementNotice>
) : null}
<Tabs defaultValue="overview">
<TabsList className="max-w-full overflow-x-auto">
<TabsTrigger value="overview">
{t('agent.detail.overview')}
</TabsTrigger>
<TabsTrigger value="prompt">
{t('agent.detail.systemPrompt')}
</TabsTrigger>
<TabsTrigger value="tools">{t('agent.detail.tools')}</TabsTrigger>
<TabsTrigger value="mcp">{t('agent.detail.mcp')}</TabsTrigger>
<TabsTrigger value="hooks">{t('agent.detail.hooks')}</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="pt-4">
<Card>
<CardHeader>
<CardTitle className="text-sm">
{t('agent.descriptionLabel')}
</CardTitle>
<CardDescription>{detail.description || '—'}</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 sm:grid-cols-2">
<DetailField
label={t('agent.filePathLabel')}
value={detail.filePath || '—'}
/>
<DetailField
label={t('agent.modelLabel')}
value={detail.model || '—'}
/>
<DetailField
label={t('agent.level.label')}
value={levelLabel(detail.level, t)}
/>
<DetailField
label={t('agent.create.approvalMode')}
value={approvalModeLabel(
detail.approvalMode || detail.permissionMode,
t,
)}
/>
<DetailField
label={t('agent.create.maxTurns')}
value={detail.maxTurns?.toString() || '—'}
/>
<DetailField
label={t('agent.create.color')}
value={detail.color || '—'}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="prompt" className="pt-4">
<Card>
<CardContent>
<div className="max-h-[60vh] w-full overflow-auto break-words whitespace-pre-line text-sm leading-6 text-muted-foreground">
{detail.systemPrompt
? unwrapPlainText(detail.systemPrompt)
: '—'}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="tools" className="pt-4">
<Card>
<CardHeader>
<CardTitle className="text-sm">
{t('agent.toolsLabel')}
</CardTitle>
<CardDescription>{toolsText}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<DetailField
label={t('agent.create.disallowedTools')}
value={disallowedToolsText}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="mcp" className="pt-4">
<Card>
<CardHeader>
<CardTitle className="text-sm">
{t('agent.create.mcpServers')}
</CardTitle>
</CardHeader>
<CardContent>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words text-xs text-muted-foreground">
{jsonText(detail.mcpServers)}
</pre>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="hooks" className="pt-4">
<Card>
<CardHeader>
<CardTitle className="text-sm">
{t('agent.detail.hooks')}
</CardTitle>
</CardHeader>
<CardContent>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words text-xs text-muted-foreground">
{jsonText(detail.hooks)}
</pre>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<AlertDialog
open={deleteOpen}
onOpenChange={(open) => {
if (!open && busy) return;
setDeleteOpen(open);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t('agent.delete.title', { name: detail.name })}
</AlertDialogTitle>
<AlertDialogDescription>
{t('agent.delete.confirm', { name: detail.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy}>
{t('common.cancel')}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={busy}
onClick={(event) => {
event.preventDefault();
void handleDelete();
}}
>
{busy ? <Spinner data-icon="inline-start" /> : null}
{t('agent.delete.yes')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
);
}
// ── Detail loading ──
if (selectedName && detailLoading) {
return (
<div className="flex w-full flex-col gap-6 pb-8">
{navigation}
<div className="flex items-center justify-center py-12">
<Spinner className="size-6" />
</div>
</div>
);
}
if (selectedName && detailError) {
return (
<div className="flex w-full flex-col gap-6 pb-8">
{navigation}
<ManagementNotice
tone="error"
noticeKey={detailError}
closeLabel={t('common.close')}
onDismiss={returnToList}
>
{detailError}
</ManagementNotice>
</div>
);
}
// ── List view ──
return (
<div className="flex w-full flex-col gap-6 pb-8">
{navigation}
<div className="flex w-full flex-col gap-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-balance">
{t('agents.title')}
</h1>
<p className="mt-1 text-sm text-muted-foreground tabular-nums">
{t('agent.count', { count: agents.length })}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
disabled={loading}
onClick={() => void reload()}
>
{loading ? (
<Spinner data-icon="inline-start" />
) : (
<RefreshCwIcon data-icon="inline-start" />
)}
{t('common.refresh')}
</Button>
<Button onClick={() => setCreateOpen(true)}>
<PlusIcon data-icon="inline-start" />
{t('agent.create.button')}
</Button>
</div>
</div>
{agentsError && !listErrorDismissed ? (
<ManagementNotice
tone="error"
noticeKey={agentsError.message}
closeLabel={t('common.close')}
onDismiss={() => setListErrorDismissed(true)}
>
{agentsError.message}
</ManagementNotice>
) : null}
{listNotice ? (
<ManagementNotice
tone="success"
noticeKey={listNotice}
closeLabel={t('common.close')}
onDismiss={() => setListNotice(null)}
>
{listNotice}
</ManagementNotice>
) : null}
<div className="relative">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
name="agent-search"
aria-label={t('common.search')}
autoComplete="off"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('common.search')}
className="pl-9"
/>
</div>
<ToggleGroup
type="single"
value={levelFilter}
onValueChange={(value) => {
if (value) setLevelFilter(value as AgentLevelFilter);
}}
variant="outline"
size="sm"
aria-label={t('agent.level.filter')}
>
{levelOptions.map((option) => (
<ToggleGroupItem key={option.value} value={option.value}>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
{filteredAgents.length ? (
<div
className={styles.agentGrid}
data-column-count={Math.min(filteredAgents.length, 4)}
>
{filteredAgents.map((agent) => (
<Card
key={`${agent.level}:${agent.name}`}
size="sm"
role="button"
tabIndex={0}
aria-label={agent.name}
className="cursor-pointer transition-colors hover:bg-accent/30 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
onClick={() => {
setListNotice(null);
setMutationError(null);
setSelection({ name: agent.name, level: agent.level });
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setListNotice(null);
setMutationError(null);
setSelection({ name: agent.name, level: agent.level });
}
}}
>
<CardHeader className="block">
<div className="flex items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<BotIcon className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-start justify-between gap-2">
<CardTitle className="min-w-0 flex-1 truncate">
{agent.name}
</CardTitle>
<div className="flex shrink-0 gap-1">
<Badge variant="outline" className="text-[10px]">
{levelLabel(agent.level, t)}
</Badge>
{isOverridden(agent, agents) ? (
<Badge variant="secondary" className="text-[10px]">
{t('agent.overriddenBadge')}
</Badge>
) : null}
</div>
</div>
<CardDescription className="mt-1 min-w-0 text-xs">
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span className="block truncate">
{agent.description || '—'}
</span>
</TooltipTrigger>
<TooltipContent>
{agent.description || '—'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardDescription>
</div>
</div>
</CardHeader>
</Card>
))}
</div>
) : (
<Empty className="border">
<EmptyHeader>
<EmptyMedia variant="icon">
{query || levelFilter !== 'all' ? <SearchIcon /> : <BotIcon />}
</EmptyMedia>
<EmptyTitle>
{query || levelFilter !== 'all'
? t('agent.noMatches')
: t('agent.empty')}
</EmptyTitle>
</EmptyHeader>
</Empty>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import {
canAddSelection,
selectBuiltInTools,
selectDiscoverableMcpServerNames,
} from './agent-tool-options';
describe('canAddSelection', () => {
it('allows the same retained select value after its tag is removed', () => {
const selected = new Set(['read_file']);
expect(canAddSelection(selected, 'read_file')).toBe(false);
selected.delete('read_file');
expect(canAddSelection(selected, 'read_file')).toBe(true);
});
});
describe('selectBuiltInTools', () => {
it('excludes disabled and MCP tools from a mixed workspace response', () => {
const tools = [
{
name: 'read_file',
displayName: 'ReadFile',
enabled: true,
},
{
name: 'disabled_builtin',
displayName: 'Disabled',
enabled: false,
},
{
name: 'mcp__code__search',
displayName: 'Search',
enabled: true,
},
{
name: 'legacy_mcp_name',
displayName: 'Legacy MCP tool',
enabled: true,
},
];
expect(
selectBuiltInTools(tools, {
code: [
{
name: 'legacy_mcp_name',
serverToolName: 'search',
isValid: true,
},
],
}),
).toEqual([tools[0]]);
});
});
describe('selectDiscoverableMcpServerNames', () => {
it('loads tools only from connected, enabled servers', () => {
expect(
selectDiscoverableMcpServerNames([
{
name: 'connected',
disabled: false,
status: 'ok',
mcpStatus: 'connected',
},
{
name: 'disconnected',
disabled: false,
status: 'error',
mcpStatus: 'disconnected',
},
{
name: 'disabled',
disabled: true,
status: 'ok',
mcpStatus: 'connected',
},
{ name: 'legacy', disabled: false, status: 'ok' },
]),
).toEqual(['connected', 'legacy']);
});
});

View file

@ -0,0 +1,44 @@
import type {
DaemonWorkspaceMcpToolStatus,
DaemonWorkspaceToolStatus,
} from '@qwen-code/webui/daemon-react-sdk';
export function canAddSelection(
selection: ReadonlySet<string>,
value: string,
): boolean {
return Boolean(value) && !selection.has(value);
}
export function selectBuiltInTools(
tools: DaemonWorkspaceToolStatus[],
mcpTools: Record<string, DaemonWorkspaceMcpToolStatus[]>,
): DaemonWorkspaceToolStatus[] {
const mcpNames = new Set(
Object.values(mcpTools).flatMap((items) => items.map((tool) => tool.name)),
);
return tools.filter(
(tool) =>
tool.enabled &&
!tool.name.startsWith('mcp__') &&
!mcpNames.has(tool.name),
);
}
export function selectDiscoverableMcpServerNames(
servers: Array<{
name: string;
disabled: boolean;
status: string;
mcpStatus?: 'connected' | 'connecting' | 'disconnected';
}>,
): string[] {
return servers
.filter(
(server) =>
!server.disabled &&
(server.mcpStatus === 'connected' ||
(server.mcpStatus === undefined && server.status === 'ok')),
)
.map((server) => server.name);
}

View file

@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest';
import type { DaemonWorkspaceAgentSummary } from '@qwen-code/webui/daemon-react-sdk';
import {
canModifyAgent,
filterAgents,
isOverridden,
preserveAgentSelection,
} from './agents-manager-logic';
const agents: DaemonWorkspaceAgentSummary[] = [
{
kind: 'agent',
name: 'code-reviewer',
description: 'Reviews code',
level: 'project',
isBuiltin: false,
hasTools: true,
},
{
kind: 'agent',
name: 'code-reviewer',
description: 'User-level reviewer',
level: 'user',
isBuiltin: false,
hasTools: false,
},
{
kind: 'agent',
name: 'Explore',
description: 'Fast explorer',
level: 'builtin',
isBuiltin: true,
hasTools: true,
},
{
kind: 'agent',
name: 'ext-helper',
description: 'Extension agent',
level: 'extension',
isBuiltin: false,
hasTools: false,
extensionName: 'my-ext',
},
];
describe('agents manager logic', () => {
it('filters agents by name query (case-insensitive)', () => {
expect(filterAgents(agents, 'CODE')).toEqual([agents[0], agents[1]]);
expect(filterAgents(agents, 'explore')).toEqual([agents[2]]);
expect(filterAgents(agents, 'nonexistent')).toEqual([]);
});
it('filters agents by level', () => {
expect(filterAgents(agents, '', 'project')).toEqual([agents[0]]);
expect(filterAgents(agents, '', 'builtin')).toEqual([agents[2]]);
expect(filterAgents(agents, 'code', 'user')).toEqual([agents[1]]);
});
it('combines query and level filter', () => {
expect(filterAgents(agents, 'code', 'project')).toEqual([agents[0]]);
expect(filterAgents(agents, 'code', 'builtin')).toEqual([]);
});
it('preserves only a selection that still exists', () => {
expect(
preserveAgentSelection({ name: 'Explore', level: 'builtin' }, agents),
).toBe(agents[2]);
expect(
preserveAgentSelection({ name: 'removed', level: 'project' }, agents),
).toBeNull();
expect(preserveAgentSelection(null, agents)).toBeNull();
});
it('preserves the selected level when names are shadowed', () => {
expect(
preserveAgentSelection({ name: 'code-reviewer', level: 'user' }, agents),
).toBe(agents[1]);
});
it('detects overridden user-level agents', () => {
expect(isOverridden(agents[1], agents)).toBe(true);
expect(isOverridden(agents[0], agents)).toBe(false);
expect(isOverridden(agents[2], agents)).toBe(false);
});
it('identifies modifiable agents', () => {
expect(canModifyAgent(agents[0])).toBe(true);
expect(canModifyAgent(agents[1])).toBe(true);
expect(canModifyAgent(agents[2])).toBe(false);
expect(canModifyAgent(agents[3])).toBe(false);
});
});

View file

@ -0,0 +1,56 @@
import type { DaemonWorkspaceAgentSummary } from '@qwen-code/webui/daemon-react-sdk';
export type AgentLevelFilter = 'all' | DaemonWorkspaceAgentSummary['level'];
export type AgentSelection = Pick<
DaemonWorkspaceAgentSummary,
'name' | 'level'
>;
export function filterAgents(
agents: readonly DaemonWorkspaceAgentSummary[],
query: string,
level: AgentLevelFilter = 'all',
): DaemonWorkspaceAgentSummary[] {
const normalized = query.trim().toLowerCase();
return agents.filter((agent) => {
if (level !== 'all' && agent.level !== level) return false;
if (!normalized) return true;
return agent.name.toLowerCase().includes(normalized);
});
}
export function preserveAgentSelection(
selection: AgentSelection | null,
agents: readonly DaemonWorkspaceAgentSummary[],
): DaemonWorkspaceAgentSummary | null {
if (!selection) return null;
return (
agents.find(
(agent) =>
agent.name === selection.name && agent.level === selection.level,
) ?? null
);
}
export function isOverridden(
agent: DaemonWorkspaceAgentSummary,
allAgents: readonly DaemonWorkspaceAgentSummary[],
): boolean {
if (agent.level !== 'user') return false;
return allAgents.some((a) => a.level === 'project' && a.name === agent.name);
}
export function canModifyAgent(agent: DaemonWorkspaceAgentSummary): boolean {
return (
(agent.level === 'project' || agent.level === 'user') && !agent.isBuiltin
);
}
export function scopeForLevel(
level: string,
): 'workspace' | 'global' | undefined {
if (level === 'project') return 'workspace';
if (level === 'user') return 'global';
return undefined;
}

View file

@ -1,914 +0,0 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
DaemonHttpError,
type DaemonExtensionEntry,
type ExtensionOperationStatus,
} from '@qwen-code/sdk/daemon';
import { I18nProvider } from '../../i18n';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const { actions, connection, signals } = vi.hoisted(() => ({
actions: {
loadExtensionsStatus: vi.fn(),
installExtension: vi.fn(),
activeExtensionOperations: vi.fn(),
extensionOperationStatus: vi.fn(),
respondToExtensionInteraction: vi.fn(),
checkExtensionUpdates: vi.fn(),
refreshExtensions: vi.fn(),
enableExtension: vi.fn(),
disableExtension: vi.fn(),
updateExtension: vi.fn(),
uninstallExtension: vi.fn(),
},
connection: { clientId: 'client-1' as string | undefined },
signals: { extensionsVersion: 0 },
}));
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
useConnection: () => connection,
useWorkspaceActions: () => actions,
useWorkspaceEventSignals: () => signals,
}));
const { ExtensionsManagerPage } = await import('./ExtensionsManagerPage');
let container: HTMLDivElement | null = null;
let root: Root | null = null;
async function flush() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
function extension(
updateState?: DaemonExtensionEntry['updateState'],
): DaemonExtensionEntry {
return {
kind: 'extension',
id: 'demo',
name: 'demo',
displayName: 'Demo',
version: '1.0.0',
isActive: true,
path: '/tmp/demo',
updateState,
capabilities: {
mcpServerCount: 0,
skillCount: 0,
agentCount: 0,
hookCount: 0,
commandCount: 0,
contextFileCount: 0,
channelCount: 0,
hasSettings: false,
},
};
}
function renderPage() {
root?.render(
<I18nProvider language="en">
<ExtensionsManagerPage onClose={vi.fn()} />
</I18nProvider>,
);
}
async function mount(
extensions: DaemonExtensionEntry[] = [],
activeOperations: ExtensionOperationStatus[] = [],
) {
actions.activeExtensionOperations.mockResolvedValue({
v: 1,
operations: activeOperations,
});
if (!actions.checkExtensionUpdates.getMockImplementation()) {
actions.checkExtensionUpdates.mockResolvedValue({ states: {} });
}
actions.loadExtensionsStatus.mockResolvedValue({
v: 1,
workspaceCwd: '/workspace',
initialized: true,
extensions,
});
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
renderPage();
});
await flush();
}
function buttonIncluding(text: string): HTMLButtonElement | undefined {
return Array.from(document.querySelectorAll('button')).find((button) =>
button.textContent?.includes(text),
);
}
function elementIncluding(selector: string, text: string): Element | undefined {
return Array.from(document.querySelectorAll(selector)).find((element) =>
element.textContent?.includes(text),
);
}
function click(element: Element | undefined) {
if (!element) throw new Error('click target not found');
act(() => {
element.dispatchEvent(
new MouseEvent('click', { bubbles: true, cancelable: true }),
);
});
}
function pointerDown(element: Element | undefined) {
if (!element) throw new Error('pointer target not found');
act(() => {
element.dispatchEvent(
new MouseEvent('pointerdown', {
bubbles: true,
cancelable: true,
button: 0,
}),
);
});
}
function changeInput(input: HTMLInputElement | null, value: string) {
if (!input) throw new Error('input not found');
act(() => {
const setter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)?.set;
setter?.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
});
}
async function startInstall() {
click(buttonIncluding('Add Extension'));
changeInput(document.querySelector('#extension-source'), 'owner/repo');
click(buttonIncluding('Install'));
await flush();
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
connection.clientId = 'client-1';
signals.extensionsVersion = 0;
vi.resetAllMocks();
});
describe('ExtensionsManagerPage', () => {
it('reports recovery failures and clears the error after retry', async () => {
vi.useFakeTimers();
actions.activeExtensionOperations
.mockRejectedValueOnce(new Error('Could not recover operations'))
.mockResolvedValue({ v: 1, operations: [] });
try {
await mount();
expect(document.body.textContent).toContain(
'Could not recover operations',
);
expect(buttonIncluding('Add Extension')?.disabled).toBe(true);
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
await flush();
expect(actions.activeExtensionOperations).toHaveBeenCalledTimes(2);
expect(document.body.textContent).not.toContain(
'Could not recover operations',
);
expect(buttonIncluding('Add Extension')?.disabled).toBe(false);
} finally {
vi.useRealTimers();
}
});
it('recovers an active extension operation when reopened', async () => {
actions.extensionOperationStatus.mockResolvedValue({
v: 1,
operationId: 'op-active',
operation: 'install',
status: 'succeeded',
createdAt: 1,
updatedAt: 2,
source: 'owner/repo',
result: { status: 'installed', name: 'demo' },
});
await mount(
[],
[
{
v: 1,
operationId: 'op-active',
operation: 'install',
status: 'running',
createdAt: 1,
updatedAt: 1,
source: 'owner/repo',
},
],
);
expect(actions.extensionOperationStatus).toHaveBeenCalledWith('op-active');
expect(document.body.textContent).toContain('installed');
});
it('recovers the newest install when multiple operations are active', async () => {
actions.extensionOperationStatus.mockResolvedValue({
v: 1,
operationId: 'op-new',
operation: 'install',
status: 'succeeded',
createdAt: 2,
updatedAt: 3,
source: 'owner/new',
result: { status: 'installed', name: 'new' },
});
await mount(
[],
[
{
v: 1,
operationId: 'op-old',
operation: 'install',
status: 'running',
createdAt: 1,
updatedAt: 2,
source: 'owner/old',
},
{
v: 1,
operationId: 'op-new',
operation: 'install',
status: 'queued',
createdAt: 2,
updatedAt: 2,
source: 'owner/new',
},
],
);
expect(actions.extensionOperationStatus).toHaveBeenCalledWith('op-new');
});
it('recovers and completes an active extension mutation', async () => {
vi.useFakeTimers();
actions.extensionOperationStatus
.mockResolvedValueOnce({
v: 1,
operationId: 'op-update',
operation: 'update',
status: 'running',
createdAt: 1,
updatedAt: 2,
name: 'demo',
})
.mockResolvedValueOnce({
v: 1,
operationId: 'op-update',
operation: 'update',
status: 'succeeded',
createdAt: 1,
updatedAt: 3,
name: 'demo',
result: { status: 'updated', name: 'demo' },
});
try {
await mount(
[extension()],
[
{
v: 1,
operationId: 'op-update',
operation: 'update',
status: 'running',
createdAt: 1,
updatedAt: 1,
name: 'demo',
},
],
);
expect(buttonIncluding('Add Extension')?.disabled).toBe(true);
await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
});
await flush();
expect(actions.extensionOperationStatus).toHaveBeenCalledTimes(2);
expect(buttonIncluding('Add Extension')?.disabled).toBe(false);
expect(actions.loadExtensionsStatus).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it('reloads the extension list without refreshing daemon sessions', async () => {
await mount();
actions.loadExtensionsStatus.mockClear();
click(buttonIncluding('refresh'));
await flush();
expect(actions.loadExtensionsStatus).toHaveBeenCalledOnce();
expect(actions.refreshExtensions).not.toHaveBeenCalled();
});
it('disables adding another extension while an install is pending', async () => {
actions.installExtension.mockResolvedValue({
accepted: true,
operationId: 'op-1',
});
actions.extensionOperationStatus.mockResolvedValue({
v: 1,
operationId: 'op-1',
operation: 'install',
status: 'running',
createdAt: 1,
updatedAt: 1,
});
await mount();
await startInstall();
expect(actions.installExtension).toHaveBeenCalledOnce();
expect(buttonIncluding('Add Extension')?.disabled).toBe(true);
});
it('keeps the add dialog open until the install request is accepted', async () => {
let acceptInstall:
| ((value: { accepted: true; operationId: string }) => void)
| undefined;
actions.installExtension.mockImplementation(
() =>
new Promise((resolve) => {
acceptInstall = resolve;
}),
);
actions.extensionOperationStatus.mockResolvedValue({
v: 1,
operationId: 'op-1',
operation: 'install',
status: 'running',
createdAt: 1,
updatedAt: 1,
});
await mount();
click(buttonIncluding('Add Extension'));
changeInput(document.querySelector('#extension-source'), 'owner/repo');
click(buttonIncluding('Install'));
await flush();
expect(document.querySelector('#extension-source')).not.toBeNull();
expect(buttonIncluding('Install')?.disabled).toBe(true);
await act(async () => {
acceptInstall?.({ accepted: true, operationId: 'op-1' });
});
await flush();
expect(document.querySelector('#extension-source')).toBeNull();
});
it('closes a failed interaction and resumes polling the install', async () => {
actions.installExtension.mockResolvedValue({
accepted: true,
operationId: 'op-1',
});
actions.extensionOperationStatus
.mockResolvedValueOnce({
v: 1,
operationId: 'op-1',
operation: 'install',
status: 'waiting_for_input',
createdAt: 1,
updatedAt: 2,
interaction: {
id: 'interaction-1',
kind: 'setting',
setting: {
name: 'API key',
description: 'Enter an API key',
sensitive: true,
},
},
})
.mockResolvedValueOnce({
v: 1,
operationId: 'op-1',
operation: 'install',
status: 'waiting_for_input',
createdAt: 1,
updatedAt: 3,
interaction: {
id: 'interaction-2',
kind: 'setting',
setting: {
name: 'Second API key',
description: 'Enter another API key',
sensitive: true,
},
},
});
actions.respondToExtensionInteraction.mockRejectedValue(
new Error('Interaction expired'),
);
await mount();
await startInstall();
changeInput(
document.querySelector('input[aria-label="API key"]'),
'secret',
);
act(() => {
document
.querySelector('input[aria-label="API key"]')
?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
});
await flush();
expect(actions.respondToExtensionInteraction).toHaveBeenCalledWith(
'op-1',
'interaction-1',
{ value: 'secret' },
'client-1',
);
expect(actions.extensionOperationStatus).toHaveBeenCalledTimes(2);
expect(document.body.textContent).toContain('Interaction expired');
expect(
(
document.querySelector(
'input[aria-label="Second API key"]',
) as HTMLInputElement | null
)?.value,
).toBe('');
});
it('submits a marketplace plugin selection while installing', async () => {
actions.installExtension.mockResolvedValue({
accepted: true,
operationId: 'op-marketplace',
});
actions.extensionOperationStatus
.mockResolvedValueOnce({
v: 1,
operationId: 'op-marketplace',
operation: 'install',
status: 'waiting_for_input',
createdAt: 1,
updatedAt: 2,
interaction: {
id: 'interaction-marketplace',
kind: 'marketplace_plugin',
marketplace: { name: 'Example Marketplace' },
plugins: [
{
name: 'example-plugin',
description: 'Example plugin description',
},
],
},
})
.mockResolvedValueOnce({
v: 1,
operationId: 'op-marketplace',
operation: 'install',
status: 'succeeded',
createdAt: 1,
updatedAt: 3,
});
actions.respondToExtensionInteraction.mockResolvedValue({ accepted: true });
await mount();
await startInstall();
expect(document.body.textContent).toContain('Example plugin description');
click(document.querySelector('[role="radio"]') ?? undefined);
click(buttonIncluding('Install'));
await flush();
expect(actions.respondToExtensionInteraction).toHaveBeenCalledWith(
'op-marketplace',
'interaction-marketplace',
{ pluginName: 'example-plugin' },
'client-1',
);
expect(actions.extensionOperationStatus).toHaveBeenCalledTimes(2);
});
it('keeps polling while an interaction is waiting', async () => {
vi.useFakeTimers();
actions.installExtension.mockResolvedValue({
accepted: true,
operationId: 'op-waiting',
});
actions.extensionOperationStatus
.mockResolvedValueOnce({
v: 1,
operationId: 'op-waiting',
operation: 'install',
status: 'waiting_for_input',
createdAt: 1,
updatedAt: 2,
interaction: {
id: 'interaction-waiting',
kind: 'setting',
setting: {
name: 'API key',
description: 'Enter an API key',
sensitive: true,
},
},
})
.mockResolvedValueOnce({
v: 1,
operationId: 'op-waiting',
operation: 'install',
status: 'failed',
createdAt: 1,
updatedAt: 3,
error: 'Extension interaction timed out',
});
try {
await mount();
await startInstall();
expect(document.body.textContent).toContain('API key');
await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
});
await flush();
expect(actions.extensionOperationStatus).toHaveBeenCalledTimes(2);
expect(document.body.textContent).toContain(
'Extension interaction timed out',
);
expect(buttonIncluding('Add Extension')?.disabled).toBe(false);
} finally {
vi.useRealTimers();
}
});
it('retains operation tracking after a transient status error', async () => {
vi.useFakeTimers();
actions.installExtension.mockResolvedValue({
accepted: true,
operationId: 'op-retry',
});
actions.extensionOperationStatus
.mockRejectedValueOnce(new Error('Temporary network error'))
.mockResolvedValueOnce({
v: 1,
operationId: 'op-retry',
operation: 'install',
status: 'succeeded',
createdAt: 1,
updatedAt: 2,
result: { status: 'installed', name: 'demo' },
});
try {
await mount();
await startInstall();
expect(document.body.textContent).toContain('Temporary network error');
expect(buttonIncluding('Add Extension')?.disabled).toBe(true);
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
await flush();
expect(actions.extensionOperationStatus).toHaveBeenCalledTimes(2);
expect(document.body.textContent).toContain('installed');
expect(buttonIncluding('Add Extension')?.disabled).toBe(false);
} finally {
vi.useRealTimers();
}
});
it('stops tracking an operation that is no longer on the daemon', async () => {
actions.installExtension.mockResolvedValue({
accepted: true,
operationId: 'op-missing',
});
actions.extensionOperationStatus.mockRejectedValue(
new DaemonHttpError(404, {}, 'Operation not found'),
);
await mount();
await startInstall();
expect(actions.extensionOperationStatus).toHaveBeenCalledOnce();
expect(buttonIncluding('Add Extension')?.disabled).toBe(false);
expect(document.body.textContent).toContain('Operation not found');
});
it('checks for updates automatically after loading extensions', async () => {
actions.checkExtensionUpdates.mockResolvedValue({
states: { demo: 'update available' },
});
await mount([extension()]);
expect(actions.checkExtensionUpdates).toHaveBeenCalledWith('client-1');
expect(document.body.textContent).toContain('update available');
});
it('updates an extension without a session client id', async () => {
connection.clientId = undefined;
actions.checkExtensionUpdates.mockResolvedValue({
states: { demo: 'update available' },
});
actions.updateExtension.mockResolvedValue({
accepted: true,
operationId: 'op-update',
});
actions.extensionOperationStatus
.mockResolvedValueOnce({
v: 1,
operationId: 'op-update',
operation: 'update',
status: 'waiting_for_input',
createdAt: 1,
updatedAt: 2,
interaction: {
id: 'interaction-update',
kind: 'setting',
setting: {
name: 'Optional setting',
description: 'May be left empty',
sensitive: false,
},
},
})
.mockResolvedValueOnce({
v: 1,
operationId: 'op-update',
operation: 'update',
status: 'succeeded',
createdAt: 1,
updatedAt: 3,
result: { status: 'updated', name: 'demo' },
});
actions.respondToExtensionInteraction.mockResolvedValue({ accepted: true });
await mount([extension()]);
click(document.querySelector('[data-slot="card"]') ?? undefined);
await flush();
pointerDown(
document.querySelector('button[aria-label="Extension actions"]') ??
undefined,
);
await flush();
click(
elementIncluding('[data-slot="dropdown-menu-item"]', 'Update Extension'),
);
await flush();
expect(actions.updateExtension).toHaveBeenCalledWith('demo', undefined);
expect(actions.extensionOperationStatus).toHaveBeenCalledWith('op-update');
expect(document.body.textContent).toContain('Optional setting');
click(buttonIncluding('Update'));
await flush();
expect(actions.respondToExtensionInteraction).toHaveBeenCalledWith(
'op-update',
'interaction-update',
{ value: '' },
undefined,
);
expect(actions.extensionOperationStatus).toHaveBeenCalledTimes(2);
expect(document.body.textContent).not.toContain(
'Wait for the session to connect',
);
});
it('keeps uninstall progress on the detail page and returns silently to the list', async () => {
vi.useFakeTimers();
let acceptUninstall:
| ((value: { accepted: true; operationId: string }) => void)
| undefined;
actions.uninstallExtension.mockImplementation(
() =>
new Promise((resolve) => {
acceptUninstall = resolve;
}),
);
actions.extensionOperationStatus
.mockResolvedValueOnce({
v: 1,
operationId: 'op-uninstall',
operation: 'uninstall',
status: 'running',
createdAt: 1,
updatedAt: 2,
name: 'demo',
})
.mockResolvedValueOnce({
v: 1,
operationId: 'op-uninstall',
operation: 'uninstall',
status: 'succeeded',
createdAt: 1,
updatedAt: 3,
name: 'demo',
result: { status: 'uninstalled', name: 'demo' },
});
try {
await mount([extension()]);
click(document.querySelector('[data-slot="card"]') ?? undefined);
await flush();
pointerDown(
document.querySelector('button[aria-label="Extension actions"]') ??
undefined,
);
await flush();
click(
elementIncluding(
'[data-slot="dropdown-menu-item"]',
'Uninstall Extension',
),
);
await flush();
click(buttonIncluding('Uninstall Extension'));
await flush();
expect(document.querySelector('h1')?.textContent).toContain('Demo');
expect(document.body.textContent).toContain(
'Uninstalling extension "demo"',
);
expect(
document.querySelector<HTMLButtonElement>(
'button[aria-label="Extension actions"]',
)?.disabled,
).toBe(true);
expect(actions.extensionOperationStatus).not.toHaveBeenCalled();
await act(async () => {
acceptUninstall?.({
accepted: true,
operationId: 'op-uninstall',
});
});
await flush();
actions.loadExtensionsStatus.mockResolvedValue({
v: 1,
workspaceCwd: '/workspace',
initialized: true,
extensions: [],
});
signals.extensionsVersion += 1;
await act(async () => renderPage());
await flush();
expect(document.querySelector('h1')?.textContent).toContain('Demo');
expect(document.body.textContent).toContain(
'Uninstalling extension "demo"',
);
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
await flush();
expect(document.querySelector('h1')?.textContent).toContain(
'Manage Extensions',
);
expect(document.body.textContent).not.toContain(
'Extension "demo" uninstalled.',
);
} finally {
vi.useRealTimers();
}
});
it('shows disable progress and disables detail actions', async () => {
actions.disableExtension.mockResolvedValue({
accepted: true,
operationId: 'op-disable',
});
actions.extensionOperationStatus.mockResolvedValue({
v: 1,
operationId: 'op-disable',
operation: 'disable',
status: 'running',
createdAt: 1,
updatedAt: 2,
name: 'demo',
});
await mount([extension()]);
click(document.querySelector('[data-slot="card"]') ?? undefined);
await flush();
pointerDown(
document.querySelector('button[aria-label="Extension actions"]') ??
undefined,
);
await flush();
click(
elementIncluding('[data-slot="dropdown-menu-item"]', 'Disable Extension'),
);
await flush();
expect(document.body.textContent).toContain('Disabling extension "demo"');
expect(
document.querySelector<HTMLButtonElement>(
'button[aria-label="Extension actions"]',
)?.disabled,
).toBe(true);
});
it('opens extension details with the keyboard', async () => {
await mount([extension()]);
const card = document.querySelector('[data-slot="card"]');
expect(card?.getAttribute('role')).toBe('button');
expect(card?.getAttribute('aria-label')).toBe('Demo');
act(() => {
card?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
});
await flush();
expect(document.querySelector('h1')?.textContent).toContain('Demo');
});
it('shows the extension description only once on the detail page', async () => {
await mount([
{ ...extension(), description: 'A single extension description' },
]);
click(document.querySelector('[data-slot="card"]') ?? undefined);
await flush();
expect(
document.body.textContent?.match(/A single extension description/g),
).toHaveLength(1);
});
it('keeps only the status beside the card title without a footer', async () => {
await mount([extension()]);
const card = document.querySelector('[data-slot="card"]');
const titleRow = card?.querySelector(
'[data-slot="card-title"]',
)?.parentElement;
expect(titleRow?.textContent).toContain('Demo');
expect(titleRow?.textContent).toContain('enabled');
expect(card?.textContent).not.toContain('v1.0.0');
expect(card?.querySelector('[data-slot="card-footer"]')).toBeNull();
expect(
card
?.querySelector('[data-slot="card-description"]')
?.classList.contains('truncate'),
).toBe(true);
});
it('clears stale update states when the extensions signal changes', async () => {
actions.checkExtensionUpdates
.mockResolvedValueOnce({ states: { demo: 'update available' } })
.mockResolvedValueOnce({ states: { demo: 'up to date' } });
await mount([extension('up to date')]);
expect(document.body.textContent).toContain('update available');
actions.loadExtensionsStatus.mockResolvedValue({
v: 1,
workspaceCwd: '/workspace',
initialized: true,
extensions: [{ ...extension('up to date') }],
});
signals.extensionsVersion = 1;
await act(async () => {
renderPage();
});
await flush();
expect(actions.checkExtensionUpdates).toHaveBeenCalledTimes(2);
expect(document.body.textContent).not.toContain('update available');
});
});

View file

@ -25,11 +25,13 @@ import {
DaemonHttpError,
type DaemonExtensionEntry,
type DaemonExtensionUpdateState,
type ExtensionActivationState,
type ExtensionInteractionResponse,
type ExtensionPendingInteraction,
} from '@qwen-code/sdk/daemon';
import {
useConnection,
useWorkspace,
useWorkspaceActions,
useWorkspaceEventSignals,
} from '@qwen-code/webui/daemon-react-sdk';
@ -41,6 +43,10 @@ import {
preserveSelectedExtensionName,
} from './extensions-manager-logic';
import { Alert, AlertDescription } from '../ui/alert';
import {
ManagementNotice,
type ManagementNoticeTone,
} from '../ui/management-notice';
import {
AlertDialog,
AlertDialogAction,
@ -93,12 +99,28 @@ import {
} from '../ui/empty';
import { Input } from '../ui/input';
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import { Separator } from '../ui/separator';
import { Spinner } from '../ui/spinner';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '../ui/tooltip';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import type { EmbeddedManagerPage } from '../plugins/manager-page';
type Scope = 'user' | 'workspace';
type Mutation = 'enable' | 'disable';
type ManagedExtensionEntry = DaemonExtensionEntry & {
defaultActivation?: ExtensionActivationState;
workspaceActivation?: 'inherit' | ExtensionActivationState;
};
type T = ReturnType<typeof useI18n>['t'];
type PendingInteractionState = {
operationId: string;
@ -118,8 +140,20 @@ function extensionTitle(extension: DaemonExtensionEntry): string {
return extension.displayName || extension.name;
}
function statusLabel(extension: DaemonExtensionEntry, t: T): string {
return extension.isActive
function extensionIsActive(extension: ManagedExtensionEntry): boolean {
if (
extension.workspaceActivation &&
extension.workspaceActivation !== 'inherit'
) {
return extension.workspaceActivation === 'enabled';
}
return extension.defaultActivation
? extension.defaultActivation === 'enabled'
: extension.isActive;
}
function statusLabel(extension: ManagedExtensionEntry, t: T): string {
return extensionIsActive(extension)
? t('extensions.manage.status.enabled')
: t('extensions.manage.status.disabled');
}
@ -390,9 +424,10 @@ export function ExtensionsManagerPage({
}: ExtensionsManagerPageProps) {
const { t } = useI18n();
const connection = useConnection();
const workspace = useWorkspace();
const actions = useWorkspaceActions();
const signals = useWorkspaceEventSignals();
const [extensions, setExtensions] = useState<DaemonExtensionEntry[]>([]);
const [extensions, setExtensions] = useState<ManagedExtensionEntry[]>([]);
const [selectedName, setSelectedName] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [updateStates, setUpdateStates] = useState<
@ -402,6 +437,8 @@ export function ExtensionsManagerPage({
const [checkingName, setCheckingName] = useState<string | null>(null);
const [busyName, setBusyName] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [messageTone, setMessageTone] = useState<ManagementNoticeTone>('info');
const [messageOwner, setMessageOwner] = useState<string | null>(null);
const [recoveryError, setRecoveryError] = useState<string | null>(null);
const [actionsOpen, setActionsOpen] = useState(false);
const [uninstallName, setUninstallName] = useState<string | null>(null);
@ -461,10 +498,30 @@ export function ExtensionsManagerPage({
const load = useCallback(
(preserveMessage = false) => {
setLoading(true);
return actions
.loadExtensionsStatus()
.then((status) => {
const nextExtensions = status.extensions ?? [];
const projection = workspace.workspaceCwd
? workspace.client
.workspaceByCwd(workspace.workspaceCwd)
.workspaceExtensions()
.catch(() => null)
: Promise.resolve(null);
return Promise.all([actions.loadExtensionsStatus(), projection])
.then(([status, activation]) => {
const activations = new Map(
(activation?.extensions ?? []).map((entry) => [
entry.extensionId,
entry,
]),
);
const nextExtensions = (status.extensions ?? []).map((extension) => {
const entry = activations.get(extension.id);
return entry
? {
...extension,
defaultActivation: entry.defaultActivation,
workspaceActivation: entry.workspaceActivation ?? 'inherit',
}
: extension;
});
setExtensions((current) => {
const uninstallName = uninstallInFlightNameRef.current;
if (
@ -483,6 +540,8 @@ export function ExtensionsManagerPage({
: nextExtensions;
});
if (!preserveMessage) {
setMessageOwner(null);
setMessageTone(status.errors?.[0] ? 'error' : 'info');
setMessage(status.errors?.[0]?.error ?? null);
}
setSelectedName((name) =>
@ -492,22 +551,17 @@ export function ExtensionsManagerPage({
);
})
.catch((error: unknown) => {
setMessage(error instanceof Error ? error.message : String(error));
if (!preserveMessage) {
setMessageOwner(null);
setMessageTone('error');
setMessage(error instanceof Error ? error.message : String(error));
}
})
.finally(() => setLoading(false));
},
[actions],
[actions, workspace.client, workspace.workspaceCwd],
);
const checkAllUpdates = useCallback(() => {
return actions
.checkExtensionUpdates(connection.clientId)
.then((result) => setUpdateStates(result.states))
.catch((error: unknown) => {
setMessage(error instanceof Error ? error.message : String(error));
});
}, [actions, connection.clientId]);
useEffect(() => {
void load();
}, [load]);
@ -581,11 +635,6 @@ export function ExtensionsManagerPage({
}
}, [load, signals?.extensionsVersion]);
useEffect(() => {
if (extensions.length === 0) return;
void checkAllUpdates();
}, [checkAllUpdates, extensions]);
useEffect(() => {
if (!pendingInstall) return;
@ -608,6 +657,7 @@ export function ExtensionsManagerPage({
);
timer = setTimeout(() => void poll(), 5000);
} else {
setMessageTone('error');
setMessage(t('extensions.manage.operationFailed'));
clearInteraction(pendingInstall.operationId);
setPendingInstall(null);
@ -615,6 +665,7 @@ export function ExtensionsManagerPage({
return;
}
if (operation.status === 'failed') {
setMessageTone('error');
setMessage(
t('extensions.install.failed', {
source: pendingInstall.source,
@ -629,6 +680,11 @@ export function ExtensionsManagerPage({
operation.status === 'succeeded' ||
operation.status === 'succeeded_with_refresh_error'
) {
setMessageTone(
operation.status === 'succeeded_with_refresh_error'
? 'error'
: 'success',
);
setMessage(
operation.status === 'succeeded_with_refresh_error'
? t('extensions.manage.refreshFailed', {
@ -643,6 +699,7 @@ export function ExtensionsManagerPage({
void load(true);
return;
}
setMessageTone('progress');
setMessage(
t('extensions.install.started', {
source: pendingInstall.source,
@ -651,6 +708,7 @@ export function ExtensionsManagerPage({
timer = setTimeout(() => void poll(), 1000);
} catch (error) {
if (cancelled) return;
setMessageTone('error');
setMessage(error instanceof Error ? error.message : String(error));
if (error instanceof DaemonHttpError && error.status === 404) {
clearInteraction(pendingInstall.operationId);
@ -693,6 +751,7 @@ export function ExtensionsManagerPage({
restartPolling();
})
.catch((error: unknown) => {
setMessageTone('error');
setMessage(error instanceof Error ? error.message : String(error));
clearInteraction(pendingInteraction.operationId);
restartPolling();
@ -724,6 +783,7 @@ export function ExtensionsManagerPage({
);
timer = setTimeout(() => void poll(), 5000);
} else {
setMessageTone('error');
setMessage(t('extensions.manage.operationFailed'));
clearInteraction(pendingMutation.operationId);
setPendingMutation(null);
@ -737,6 +797,7 @@ export function ExtensionsManagerPage({
return;
}
if (operation.status === 'failed') {
setMessageTone('error');
setMessage(operation.error ?? t('extensions.manage.operationFailed'));
clearInteraction(pendingMutation.operationId);
setPendingMutation(null);
@ -753,6 +814,7 @@ export function ExtensionsManagerPage({
operation.status === 'succeeded_with_refresh_error'
) {
if (operation.status === 'succeeded_with_refresh_error') {
setMessageTone('error');
setMessage(
t('extensions.manage.refreshFailed', {
error: operation.result?.error ?? '',
@ -761,6 +823,7 @@ export function ExtensionsManagerPage({
} else if (operation.operation === 'uninstall') {
setMessage(null);
} else {
setMessageTone('success');
setMessage(
mutationSuccessMessage(
operation.operation,
@ -775,6 +838,7 @@ export function ExtensionsManagerPage({
mutationInFlightRef.current = false;
if (operation.operation === 'uninstall') {
uninstallInFlightNameRef.current = null;
setMessageOwner(null);
setSelectedName(null);
}
if (operation.operation === 'update') {
@ -787,12 +851,14 @@ export function ExtensionsManagerPage({
void load(true);
return;
}
setMessageTone('progress');
setMessage(
mutationMessage(operation.operation, pendingMutation.name, t),
);
timer = setTimeout(() => void poll(), 1000);
} catch (error) {
if (cancelled) return;
setMessageTone('error');
setMessage(error instanceof Error ? error.message : String(error));
if (error instanceof DaemonHttpError && error.status === 404) {
clearInteraction(pendingMutation.operationId);
@ -818,6 +884,8 @@ export function ExtensionsManagerPage({
}, [actions, clearInteraction, load, pendingMutation, showInteraction, t]);
const refreshList = useCallback(() => {
setMessageOwner(null);
setMessageTone('info');
setMessage(null);
void load();
}, [load]);
@ -825,6 +893,8 @@ export function ExtensionsManagerPage({
const checkUpdates = useCallback(
(name: string) => {
setCheckingName(name);
setMessageOwner(selectedName === name ? name : null);
setMessageTone('info');
setMessage(null);
setUpdateStates((current) => ({
...current,
@ -838,11 +908,12 @@ export function ExtensionsManagerPage({
})
.catch((error: unknown) => {
setUpdateStates((current) => ({ ...current, [name]: 'error' }));
setMessageTone('error');
setMessage(error instanceof Error ? error.message : String(error));
})
.finally(() => setCheckingName(null));
},
[actions, connection.clientId, t],
[actions, connection.clientId, selectedName, t],
);
const installExtension = useCallback(() => {
@ -857,6 +928,8 @@ export function ExtensionsManagerPage({
)
return;
setInstalling(true);
setMessageOwner(null);
setMessageTone('progress');
setMessage(null);
actions
.installExtension({ source, consent: true }, clientId)
@ -866,6 +939,7 @@ export function ExtensionsManagerPage({
setInstallOpen(false);
})
.catch((error: unknown) => {
setMessageTone('error');
setMessage(error instanceof Error ? error.message : String(error));
})
.finally(() => setInstalling(false));
@ -899,6 +973,8 @@ export function ExtensionsManagerPage({
uninstallInFlightNameRef.current = name;
}
setBusyName(name);
setMessageOwner(selectedName === name ? name : null);
setMessageTone('progress');
setMessage(options.startMessage ?? null);
let startedPolling = false;
run(clientId)
@ -922,6 +998,7 @@ export function ExtensionsManagerPage({
setMessage(t('extensions.manage.queued', { name }));
})
.catch((error: unknown) => {
setMessageTone('error');
setMessage(error instanceof Error ? error.message : String(error));
})
.finally(() => {
@ -943,10 +1020,87 @@ export function ExtensionsManagerPage({
operationsRecovered,
pendingInstall,
pendingMutation,
selectedName,
t,
],
);
const setScopeActivation = useCallback(
async (
extension: ManagedExtensionEntry,
scope: Scope,
activation: 'inherit' | ExtensionActivationState,
) => {
if (
busyName ||
pendingInstall ||
pendingMutation ||
checkingName ||
!workspace.workspaceCwd
) {
return;
}
const operation =
activation === 'enabled'
? 'enable'
: activation === 'disabled'
? 'disable'
: 'inherit';
setBusyName(extension.name);
setMessageOwner(extension.name);
setMessageTone('progress');
setMessage(
operation === 'inherit'
? t('extensions.manage.inheriting', { name: extension.name })
: mutationMessage(operation, extension.name, t),
);
try {
const result =
scope === 'user'
? await workspace.client.setExtensionDefaultActivation(
extension.id,
activation as ExtensionActivationState,
)
: activation === 'inherit'
? await workspace.client
.workspaceByCwd(workspace.workspaceCwd)
.clearExtensionActivation(extension.id)
: await workspace.client
.workspaceByCwd(workspace.workspaceCwd)
.setExtensionActivation(extension.id, activation);
const completed =
await workspace.client.waitForExtensionOperation(result);
if (completed.status === 'failed') {
throw new Error(
completed.error ?? t('extensions.manage.operationFailed'),
);
}
await load(true);
setMessageTone('success');
setMessage(
operation === 'inherit'
? t('extensions.manage.inherited', { name: extension.name })
: mutationSuccessMessage(operation, extension.name, t),
);
} catch (error) {
setMessageTone('error');
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setBusyName(null);
}
},
[
busyName,
checkingName,
load,
pendingInstall,
pendingMutation,
t,
workspace.client,
workspace.workspaceCwd,
],
);
const selectedExtension = useMemo(
() => extensions.find((extension) => extension.name === selectedName),
[extensions, selectedName],
@ -1037,29 +1191,10 @@ export function ExtensionsManagerPage({
busyName !== null ||
pendingMutation !== null;
const checking = checkingName === selectedExtension.name;
const mutation: Mutation = selectedExtension.isActive
? 'disable'
: 'enable';
const toggleScope = (scope: Scope) =>
runMutation(
selectedExtension.name,
(clientId) =>
mutation === 'enable'
? actions.enableExtension(
selectedExtension.name,
{ scope },
clientId,
)
: actions.disableExtension(
selectedExtension.name,
{ scope },
clientId,
),
{
operation: mutation,
startMessage: mutationMessage(mutation, selectedExtension.name, t),
},
);
const userActivation = selectedExtension.defaultActivation;
const workspaceActivation = selectedExtension.workspaceActivation;
const activationUnavailable =
userActivation === undefined || workspaceActivation === undefined;
const commands = details?.commands ?? [];
const skills = details?.skills ?? [];
const agents = details?.agents ?? [];
@ -1076,14 +1211,14 @@ export function ExtensionsManagerPage({
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="break-words text-2xl font-semibold">
<h1 className="break-words text-xl font-semibold">
{extensionTitle(selectedExtension)}
</h1>
<Badge variant="outline">v{selectedExtension.version}</Badge>
<Badge
variant="secondary"
className={
selectedExtension.isActive
extensionIsActive(selectedExtension)
? 'bg-[var(--success-bg)] text-[var(--success-color)]'
: undefined
}
@ -1136,24 +1271,6 @@ export function ExtensionsManagerPage({
>
{t('extensions.manage.update')}
</DropdownMenuItem>
<DropdownMenuItem
disabled={busy || checking}
onSelect={() => toggleScope('user')}
>
{mutation === 'enable'
? t('extensions.manage.enable')
: t('extensions.manage.disable')}
· {t('settings.scope.user')}
</DropdownMenuItem>
<DropdownMenuItem
disabled={busy || checking}
onSelect={() => toggleScope('workspace')}
>
{mutation === 'enable'
? t('extensions.manage.enable')
: t('extensions.manage.disable')}
· {t('settings.scope.workspace')}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
@ -1173,15 +1290,106 @@ export function ExtensionsManagerPage({
</DropdownMenu>
</div>
{message || recoveryError ? (
<Alert>
{messageOwner === selectedExtension.name && message ? (
<ManagementNotice
tone={messageTone}
noticeKey={message}
closeLabel={t('common.close')}
onDismiss={() => setMessage(null)}
className="break-words"
>
{message}
</ManagementNotice>
) : null}
{activationUnavailable ? (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertDescription className="break-words">
{message ?? recoveryError}
<AlertDescription>
{t('extensions.manage.setting.unavailableDescription')}
</AlertDescription>
</Alert>
) : null}
<Card className="gap-0 py-1">
<CardContent className="flex flex-col p-0">
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0">
<p className="font-medium">
{t('extensions.manage.userSetting')}
</p>
<p className="text-sm text-muted-foreground">
{t('extensions.manage.userSettingDescription')}
</p>
</div>
<Select
value={userActivation}
disabled={busy || checking || activationUnavailable}
onValueChange={(value) =>
void setScopeActivation(
selectedExtension,
'user',
value as ExtensionActivationState,
)
}
>
<SelectTrigger className="w-28 shrink-0">
<SelectValue
placeholder={t('extensions.manage.setting.unknown')}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="enabled">
{t('extensions.manage.setting.enabled')}
</SelectItem>
<SelectItem value="disabled">
{t('extensions.manage.setting.disabled')}
</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0">
<p className="font-medium">
{t('extensions.manage.workspaceSetting')}
</p>
<p className="text-sm text-muted-foreground">
{t('extensions.manage.workspaceSettingDescription')}
</p>
</div>
<Select
value={workspaceActivation}
disabled={busy || checking || activationUnavailable}
onValueChange={(value) =>
void setScopeActivation(
selectedExtension,
'workspace',
value as 'inherit' | ExtensionActivationState,
)
}
>
<SelectTrigger className="w-28 shrink-0">
<SelectValue
placeholder={t('extensions.manage.setting.unknown')}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit">
{t('extensions.manage.setting.default')}
</SelectItem>
<SelectItem value="enabled">
{t('extensions.manage.setting.enabled')}
</SelectItem>
<SelectItem value="disabled">
{t('extensions.manage.setting.disabled')}
</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Tabs defaultValue="overview">
<TabsList className="max-w-full overflow-x-auto">
<TabsTrigger value="overview">
@ -1364,7 +1572,7 @@ export function ExtensionsManagerPage({
<h1
ref={initialFocusRef}
tabIndex={-1}
className="text-2xl font-semibold outline-none"
className="text-xl font-semibold outline-none"
>
{t('extensions.manage.title')}
</h1>
@ -1394,13 +1602,21 @@ export function ExtensionsManagerPage({
</div>
</div>
{message || recoveryError ? (
<Alert>
<AlertCircleIcon />
<AlertDescription className="break-words">
{message ?? recoveryError}
</AlertDescription>
</Alert>
{(messageOwner === null && message) || recoveryError ? (
<ManagementNotice
tone={recoveryError ? 'error' : messageTone}
noticeKey={
(messageOwner === null ? message : null) ?? recoveryError ?? ''
}
closeLabel={t('common.close')}
onDismiss={() => {
setMessage(null);
setRecoveryError(null);
}}
className="break-words"
>
{(messageOwner === null ? message : null) ?? recoveryError}
</ManagementNotice>
) : null}
<div className="relative">
@ -1458,7 +1674,7 @@ export function ExtensionsManagerPage({
<Badge
variant="secondary"
className={
extension.isActive
extensionIsActive(extension)
? 'bg-[var(--success-bg)] text-[10px] text-[var(--success-color)]'
: 'text-[10px]'
}
@ -1474,9 +1690,21 @@ export function ExtensionsManagerPage({
</Badge>
</div>
) : null}
<CardDescription className="mt-1 truncate text-xs">
{extension.description ||
t('extensions.manage.noDescription')}
<CardDescription className="mt-1 min-w-0 text-xs">
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span className="block truncate">
{extension.description ||
t('extensions.manage.noDescription')}
</span>
</TooltipTrigger>
<TooltipContent>
{extension.description ||
t('extensions.manage.noDescription')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardDescription>
</div>
</div>

View file

@ -4,7 +4,6 @@ import {
ArrowLeftIcon,
DatabaseIcon,
EllipsisVerticalIcon,
InfoIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
@ -26,6 +25,10 @@ import { extractErrorDetail } from '../../utils/errorDetail';
import styles from './McpManagerPage.module.css';
import type { SerializedMcpStatusMessage } from '../messages/McpStatusMessage';
import { Alert, AlertDescription, AlertTitle } from '../ui/alert';
import {
ManagementNotice,
type ManagementNoticeTone,
} from '../ui/management-notice';
import { Badge } from '../ui/badge';
import {
Breadcrumb,
@ -78,6 +81,12 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group';
import { Textarea } from '../ui/textarea';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '../ui/tooltip';
import type { EmbeddedManagerPage } from '../plugins/manager-page';
type McpStatus = Awaited<ReturnType<DaemonWorkspaceActions['loadMcpStatus']>>;
@ -406,8 +415,17 @@ export function McpManagerPage({
serverName?: string;
text: string;
error?: boolean;
success?: boolean;
progress?: boolean;
authUrl?: string;
} | null>(null);
const noticeTone: ManagementNoticeTone = notice?.error
? 'error'
: notice?.success
? 'success'
: notice?.progress
? 'progress'
: 'info';
const [loadErrorsByServer, setLoadErrorsByServer] = useState<
Record<string, { tools?: string; resources?: string }>
>({});
@ -661,6 +679,7 @@ export function McpManagerPage({
? t(editing ? 'mcp.edit.done' : 'mcp.add.done', { name })
: t('mcp.runtime.notUpdated'),
error: !runtimeUpdated,
success: runtimeUpdated,
});
setAddDialogOpen(false);
setEditingServer(null);
@ -738,6 +757,7 @@ export function McpManagerPage({
setNotice({
serverName: serverToRemove.name,
text: t('mcp.action.running', { action: t('mcp.action.remove') }),
progress: true,
});
const runtimeUpdated = await startDiscovery('reload', false, true).catch(
() => false,
@ -838,6 +858,7 @@ export function McpManagerPage({
action.id === 'authenticate'
? oauthMessage(server.name, t)
: t('mcp.action.running', { action: action.label }),
progress: true,
});
try {
let detail = '';
@ -880,6 +901,7 @@ export function McpManagerPage({
setNotice({
serverName: server.name,
text: oauthMessage(server.name, t, detail),
progress: true,
...(authUrl ? { authUrl } : {}),
});
}
@ -966,6 +988,7 @@ export function McpManagerPage({
: action.id === 'authenticate' && detail
? oauthMessage(server.name, t, detail)
: detail || t('mcp.action.done', { action: action.label }),
success: true,
...(!pendingAuthentication && authUrl ? { authUrl } : {}),
});
} catch (error) {
@ -1280,7 +1303,7 @@ export function McpManagerPage({
<WrenchIcon />
</div>
<div className="min-w-0">
<h1 className="break-words text-2xl font-semibold">
<h1 className="break-words text-xl font-semibold">
{selectedTool.name}
</h1>
<p className="text-sm text-muted-foreground">
@ -1310,7 +1333,7 @@ export function McpManagerPage({
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-muted">
<DatabaseIcon />
</div>
<h1 className="min-w-0 break-words text-2xl font-semibold">
<h1 className="min-w-0 break-words text-xl font-semibold">
{selectedResource.title ||
selectedResource.name ||
selectedResource.uri}
@ -1344,7 +1367,7 @@ export function McpManagerPage({
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="break-words text-2xl font-semibold">
<h1 className="break-words text-xl font-semibold">
{selectedServer.name}
</h1>
<Badge
@ -1398,22 +1421,25 @@ export function McpManagerPage({
</div>
{notice?.serverName === selectedServer.name ? (
<Alert variant={notice.error ? 'destructive' : 'default'}>
{notice.error ? <AlertCircleIcon /> : <InfoIcon />}
<AlertDescription className="whitespace-pre-wrap break-words">
<p>{notice.text}</p>
{notice.authUrl && isHttpUrl(notice.authUrl) ? (
<a
className="mt-2 inline-block underline underline-offset-3"
href={notice.authUrl}
target="_blank"
rel="noreferrer"
>
{t('mcp.oauth.open')}
</a>
) : null}
</AlertDescription>
</Alert>
<ManagementNotice
tone={noticeTone}
noticeKey={notice.text}
closeLabel={t('common.close')}
onDismiss={() => setNotice(null)}
className="whitespace-pre-wrap break-words"
>
<p>{notice.text}</p>
{notice.authUrl && isHttpUrl(notice.authUrl) ? (
<a
className="mt-2 inline-block underline underline-offset-3"
href={notice.authUrl}
target="_blank"
rel="noreferrer"
>
{t('mcp.oauth.open')}
</a>
) : null}
</ManagementNotice>
) : null}
<Tabs
@ -1596,7 +1622,7 @@ export function McpManagerPage({
<div className="flex w-full flex-col gap-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-balance">
<h1 className="text-xl font-semibold text-balance">
{t('mcp.title')}
</h1>
<p className="mt-1 text-sm text-muted-foreground tabular-nums">
@ -1635,25 +1661,43 @@ export function McpManagerPage({
</div>
{initializing && connectingCount === 0 ? (
<Alert>
<RefreshCwIcon className="animate-spin" />
<AlertTitle>{t('mcp.discovery.initializing')}</AlertTitle>
<AlertDescription>{t('mcp.startingNote')}</AlertDescription>
</Alert>
<ManagementNotice
tone="progress"
noticeKey="mcp-initializing"
closeLabel={t('common.close')}
onDismiss={() => undefined}
>
<span className="grid gap-1">
<span className="font-medium">
{t('mcp.discovery.initializing')}
</span>
<span>{t('mcp.startingNote')}</span>
</span>
</ManagementNotice>
) : connectingCount > 0 ? (
<Alert>
<RefreshCwIcon />
<AlertTitle>
{t('mcp.starting', { count: connectingCount })}
</AlertTitle>
<AlertDescription>{t('mcp.startingNote')}</AlertDescription>
</Alert>
<ManagementNotice
tone="progress"
noticeKey={`mcp-connecting-${connectingCount}`}
closeLabel={t('common.close')}
onDismiss={() => undefined}
>
<span className="grid gap-1">
<span className="font-medium">
{t('mcp.starting', { count: connectingCount })}
</span>
<span>{t('mcp.startingNote')}</span>
</span>
</ManagementNotice>
) : null}
{notice && !notice.serverName ? (
<Alert variant={notice.error ? 'destructive' : 'default'}>
<AlertCircleIcon />
<AlertDescription>{notice.text}</AlertDescription>
</Alert>
<ManagementNotice
tone={noticeTone}
noticeKey={notice.text}
closeLabel={t('common.close')}
onDismiss={() => setNotice(null)}
>
{notice.text}
</ManagementNotice>
) : null}
{(status.errors ?? []).map((error, index) => (
<Alert key={`${error.kind}-${index}`} variant="destructive">
@ -1740,8 +1784,19 @@ export function McpManagerPage({
{statusLabel(server, t)}
</Badge>
</div>
<CardDescription className="mt-1 truncate text-xs">
{server.description?.trim() || '-'}
<CardDescription className="mt-1 min-w-0 text-xs">
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span className="block truncate">
{server.description?.trim() || '-'}
</span>
</TooltipTrigger>
<TooltipContent>
{server.description?.trim() || '-'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardDescription>
</div>
</div>

View file

@ -1,607 +0,0 @@
.panel {
margin: 0px 0 12px 14px;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 4px;
font-family: var(--font-mono);
font-size: 13px;
color: var(--foreground);
background: var(--background);
max-width: min(var(--chat-regular-content-width, 1000px), calc(100vw - 64px));
}
.embedded {
max-width: none;
margin: 0;
padding: 0;
border: 0;
background: transparent;
}
.embedded .footer {
display: none;
}
.titleLine {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.icon {
display: none;
color: var(--agent-blue-500);
font-weight: bold;
font-size: 14px;
}
.title {
font-weight: bold;
color: var(--foreground);
}
.subtitle {
display: none;
color: var(--muted-foreground);
}
.text {
font-size: 13px;
color: var(--foreground);
margin-bottom: 12px;
}
.options {
display: flex;
flex-direction: column;
gap: 2px;
margin-left: 0;
}
.option {
display: flex;
align-items: center;
gap: 8px;
min-height: 32px;
border: 1px solid transparent;
border-radius: 6px;
padding: 5px 8px;
cursor: pointer;
}
.option:hover {
background: var(--secondary);
}
.optionActive {
background: var(--secondary);
}
.optionActive .optionLabel,
.optionSelected .optionLabel {
color: var(--agent-blue-500);
}
.optionIcon {
position: relative;
flex: 0 0 auto;
width: 14px;
height: 14px;
border: 1.5px solid currentColor;
border-radius: 4px;
color: var(--muted-foreground);
}
.optionIcon::before,
.optionIcon::after {
position: absolute;
left: 3px;
right: 3px;
height: 1.5px;
border-radius: 999px;
background: currentColor;
content: '';
}
.optionIcon::before {
top: 4px;
}
.optionIcon::after {
top: 8px;
}
.optionNum {
display: none;
}
.optionContent {
display: flex;
flex-direction: column;
gap: 1px;
}
.optionLabel {
color: var(--foreground);
}
.optionDesc {
font-size: 12px;
color: var(--muted-foreground);
padding-left: 0;
}
.badge {
font-size: 13px;
color: var(--muted-foreground);
border: none;
border-radius: 0;
padding: 0;
margin-left: 4px;
}
.detail {
margin: 8px;
padding: 8px;
border-radius: 4px;
background: var(--background);
}
.detailTitle {
font-weight: bold;
color: var(--foreground);
margin-bottom: 4px;
}
.detailMeta {
font-size: 12px;
color: var(--muted-foreground);
margin-bottom: 6px;
}
.detailBody {
font-size: 12px;
color: var(--foreground);
white-space: pre-wrap;
word-break: break-word;
}
.toolDetail {
margin: 8px;
color: var(--muted-foreground);
}
.toolDetailTitle {
color: var(--muted-foreground);
margin-bottom: 4px;
}
.toolDetailBody,
.toolList {
color: var(--foreground);
white-space: pre-wrap;
word-break: break-word;
}
.toolList {
max-height: 120px;
overflow-y: auto;
padding-left: 12px;
}
.textInput {
width: 100%;
padding: 4px 8px;
background: transparent;
border: none;
border-bottom: 1px solid var(--agent-blue-500);
color: var(--foreground);
font-family: var(--font-mono);
font-size: 13px;
outline: none;
margin-bottom: 8px;
box-sizing: border-box;
}
.textInput:focus {
border-bottom-color: var(--agent-blue-500);
}
.textArea {
width: 100%;
min-height: 80px;
padding: 6px 8px;
background: transparent;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--foreground);
font-family: var(--font-mono);
font-size: 13px;
outline: none;
resize: vertical;
margin-bottom: 8px;
box-sizing: border-box;
}
.textArea:focus {
border-color: var(--agent-blue-500);
}
.summary {
margin: 8px;
padding: 8px;
border-radius: 4px;
background: var(--background);
}
.summaryRow {
display: flex;
gap: 8px;
padding: 4px 6px;
}
.summaryLabel {
color: var(--muted-foreground);
flex-shrink: 0;
min-width: 80px;
}
.summaryValue {
color: var(--agent-blue-500);
font-weight: bold;
}
.summaryBlockTitle {
color: var(--foreground);
margin-top: 10px;
}
.summaryBlock {
color: var(--foreground);
white-space: pre-wrap;
word-break: break-word;
padding: 6px 10px 0;
max-height: 160px;
overflow: auto;
}
.groupLabel {
font-size: 13px;
font-weight: bold;
color: var(--foreground);
text-transform: none;
letter-spacing: 0;
margin: 10px 0 6px 0;
}
.groupLabel:first-child {
margin-top: 0;
}
.footer {
margin-top: 14px;
padding-top: 8px;
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--muted-foreground);
}
.agentCount {
margin-top: 10px;
color: var(--muted-foreground);
}
.manageList {
display: flex;
flex-direction: column;
gap: 3px;
}
.manageItem {
display: flex;
flex-direction: column;
border-radius: 8px;
}
.manageItemExpanded {
background: var(--secondary);
padding: 4px;
}
.manageRow {
display: flex;
align-items: center;
gap: 8px;
min-height: 34px;
width: 100%;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--foreground);
cursor: pointer;
font: inherit;
padding: 5px 8px;
text-align: left;
}
.manageRow:hover,
.manageRowActive {
background: var(--secondary);
}
.manageIcon {
position: relative;
flex: 0 0 auto;
width: 14px;
height: 14px;
border: 1.5px solid currentColor;
border-radius: 4px;
color: var(--muted-foreground);
}
.manageIcon::before,
.manageIcon::after {
position: absolute;
left: 3px;
right: 3px;
height: 1.5px;
border-radius: 999px;
background: currentColor;
content: '';
}
.manageIcon::before {
top: 4px;
}
.manageIcon::after {
top: 8px;
}
.manageName {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.levelTag {
flex: 0 0 auto;
border-radius: 4px;
background: var(--muted);
color: var(--muted-foreground);
font-size: 12px;
padding: 1px 5px;
white-space: nowrap;
}
.manageChevron {
flex: 0 0 auto;
width: 14px;
height: 14px;
color: var(--muted-foreground);
}
.manageChevron::before {
display: block;
width: 7px;
height: 7px;
border-top: 1.5px solid currentColor;
border-right: 1.5px solid currentColor;
content: '';
transform: translate(2px, 3px) rotate(45deg);
transition: transform 120ms ease;
}
.manageChevronExpanded::before {
transform: translate(2px, 1px) rotate(135deg);
}
.manageDetail {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0;
}
.manageDetailInner {
display: flex;
flex-direction: column;
gap: 8px;
margin: 8px;
border-radius: 6px;
background: var(--background);
padding: 8px;
}
.manageDetailHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.manageActions {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-left: auto;
}
.manageButton {
border: 1px solid var(--border);
border-radius: 5px;
background: var(--muted);
color: var(--foreground);
cursor: pointer;
font: inherit;
font-size: 12px;
padding: 4px 8px;
}
.manageButton:hover:not(:disabled) {
border-color: var(--agent-blue-500);
}
.manageButton:disabled {
cursor: default;
opacity: 0.55;
}
.dangerButton {
color: var(--error-color);
}
.deleteText {
color: var(--muted-foreground);
font-size: 12px;
line-height: 24px;
}
.viewer {
display: flex;
flex-direction: column;
gap: 8px;
max-height: min(420px, 55vh);
overflow-y: auto;
padding: 0 2px 2px 0;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.viewer::-webkit-scrollbar {
width: 6px;
}
.viewer::-webkit-scrollbar-track {
background: transparent;
}
.viewer::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
.viewerRow {
display: flex;
gap: 4px;
align-items: baseline;
min-width: 0;
border-radius: 5px;
padding: 4px 6px;
}
.viewerRow:hover {
background: var(--muted);
}
.viewerLabel {
color: var(--foreground);
font-size: 12px;
font-weight: 500;
flex-shrink: 0;
}
.viewerField {
border-radius: 5px;
padding: 4px 6px;
}
.viewerSectionTitle {
color: var(--foreground);
font-size: 12px;
font-weight: 500;
}
.viewerBlock {
color: var(--foreground);
white-space: pre-wrap;
word-break: break-word;
}
.closed {
opacity: 0.6;
}
.closedText {
font-size: 12px;
color: var(--muted-foreground);
font-style: italic;
}
.loading {
color: var(--muted-foreground);
font-style: italic;
}
.error {
color: var(--error-color, #ef4444);
font-size: 12px;
margin-bottom: 8px;
}
.createWizard {
display: flex;
flex-direction: column;
gap: 14px;
}
.createSteps {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.createStepPill {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--muted-foreground);
font-size: 12px;
padding: 3px 8px;
}
.createStepPillActive {
border-color: var(--agent-blue-500);
color: var(--agent-blue-500);
}
.createStepPillDone {
color: var(--foreground);
}
.createStepNumber {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--muted);
font-size: 11px;
}
.createStepLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.createBody {
min-height: 180px;
}
.createActions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 10px;
border-top: 1px solid var(--border);
}

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
import { useCallback, useMemo, useState, type Ref } from 'react';
import { SearchIcon, ServerIcon } from 'lucide-react';
import type { SerializedMcpStatusMessage } from '../messages/McpStatusMessage';
import { AgentsManagerPage } from '../agents/AgentsManagerPage';
import { ExtensionsManagerPage } from '../extensions/ExtensionsManagerPage';
import { McpManagerPage } from '../mcp/McpManagerPage';
import { SkillsManagerPage } from '../skills/SkillsManagerPage';
@ -18,7 +19,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { useI18n } from '../../i18n';
import type { EmbeddedManagerPage } from './manager-page';
type PluginTab = 'extensions' | 'mcp' | 'skills';
type PluginTab = 'extensions' | 'mcp' | 'skills' | 'agents';
interface PluginManagerPageProps {
mcpMessage: SerializedMcpStatusMessage | null;
@ -81,6 +82,7 @@ export function PluginManagerPage({
</TabsTrigger>
<TabsTrigger value="mcp">{t('plugins.mcp')}</TabsTrigger>
<TabsTrigger value="skills">{t('plugins.skills')}</TabsTrigger>
<TabsTrigger value="agents">{t('plugins.agents')}</TabsTrigger>
</TabsList>
</div>
) : null}
@ -99,6 +101,12 @@ export function PluginManagerPage({
onUseSkill={onUseSkill}
embedded={embedded}
/>
) : activeTab === 'agents' ? (
<AgentsManagerPage
key={`agents-${pageRevision}`}
onClose={onClose}
embedded={embedded}
/>
) : mcpLoadError ? (
<Alert variant="destructive" className="mt-4">
<AlertTitle>{t('plugins.mcpLoadFailed')}</AlertTitle>

View file

@ -3,7 +3,6 @@ import {
AlertCircleIcon,
ArrowLeftIcon,
EllipsisVerticalIcon,
InfoIcon,
PlayIcon,
PlusIcon,
RefreshCwIcon,
@ -23,6 +22,7 @@ import {
type SkillStatusFilter,
} from './skills-manager-logic';
import { Alert, AlertDescription } from '../ui/alert';
import { ManagementNotice } from '../ui/management-notice';
import { Badge } from '../ui/badge';
import {
Breadcrumb,
@ -388,7 +388,7 @@ export function SkillsManagerPage({
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="break-words text-2xl font-semibold text-balance">
<h1 className="break-words text-xl font-semibold text-balance">
{selectedSkill.name}
</h1>
<Badge variant="outline">
@ -471,10 +471,14 @@ export function SkillsManagerPage({
</div>
{notice?.skillName === selectedSkill.name ? (
<Alert variant={notice.error ? 'destructive' : 'default'}>
{notice.error ? <AlertCircleIcon /> : <InfoIcon />}
<AlertDescription>{notice.text}</AlertDescription>
</Alert>
<ManagementNotice
tone={notice.error ? 'error' : 'success'}
noticeKey={notice.text}
closeLabel={t('common.close')}
onDismiss={() => setNotice(null)}
>
{notice.text}
</ManagementNotice>
) : null}
{message || selectedSkill.error ? (
@ -570,7 +574,7 @@ export function SkillsManagerPage({
<div className="flex w-full flex-col gap-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-balance">
<h1 className="text-xl font-semibold text-balance">
{t('skills.title')}
</h1>
<p className="mt-1 text-sm text-muted-foreground tabular-nums">
@ -582,12 +586,6 @@ export function SkillsManagerPage({
</p>
</div>
<div className="flex gap-2">
{canManageSkills ? (
<Button onClick={() => setInstallOpen(true)}>
<PlusIcon data-icon="inline-start" />
{t('skills.install.action')}
</Button>
) : null}
<Button
variant="outline"
disabled={loading}
@ -600,6 +598,12 @@ export function SkillsManagerPage({
)}
{t('common.refresh')}
</Button>
{canManageSkills ? (
<Button onClick={() => setInstallOpen(true)}>
<PlusIcon data-icon="inline-start" />
{t('skills.install.action')}
</Button>
) : null}
</div>
</div>
@ -611,10 +615,14 @@ export function SkillsManagerPage({
) : null}
{listNotice ? (
<Alert>
<InfoIcon />
<AlertDescription>{listNotice}</AlertDescription>
</Alert>
<ManagementNotice
tone="success"
noticeKey={listNotice}
closeLabel={t('common.close')}
onDismiss={() => setListNotice(null)}
>
{listNotice}
</ManagementNotice>
) : null}
<div className="relative">

View file

@ -11,6 +11,8 @@ const alertVariants = cva(
default: 'bg-card text-card-foreground',
destructive:
'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
success:
'border-[var(--success-color)]/30 bg-[var(--success-bg)] text-[var(--success-color)] *:data-[slot=alert-description]:text-[var(--success-color)]',
},
},
defaultVariants: {

View file

@ -0,0 +1,75 @@
// @vitest-environment jsdom
import { act, type ComponentProps } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ManagementNotice } from './management-notice';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const mounted: Array<{ container: HTMLElement; root: Root }> = [];
function renderNotice(
tone: ComponentProps<typeof ManagementNotice>['tone'],
onDismiss = vi.fn(),
) {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ container, root });
act(() =>
root.render(
<ManagementNotice
tone={tone}
noticeKey={tone}
closeLabel="Close"
onDismiss={onDismiss}
>
Notice
</ManagementNotice>,
),
);
return { container, onDismiss };
}
describe('ManagementNotice', () => {
afterEach(() => {
for (const { container, root } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
}
vi.useRealTimers();
});
it.each(['success', 'info'] as const)('auto-dismisses %s notices', (tone) => {
vi.useFakeTimers();
const onDismiss = vi.fn();
renderNotice(tone, onDismiss);
act(() => vi.advanceTimersByTime(3_000));
expect(onDismiss).toHaveBeenCalledOnce();
});
it('keeps errors visible and allows manual dismissal', () => {
vi.useFakeTimers();
const onDismiss = vi.fn();
const view = renderNotice('error', onDismiss);
act(() => vi.advanceTimersByTime(10_000));
expect(onDismiss).not.toHaveBeenCalled();
const button = view.container.querySelector<HTMLButtonElement>(
'button[aria-label="Close"]',
);
expect(button).not.toBeNull();
act(() => button?.click());
expect(onDismiss).toHaveBeenCalledOnce();
});
it('keeps progress visible and cannot be dismissed', () => {
vi.useFakeTimers();
const onDismiss = vi.fn();
const view = renderNotice('progress', onDismiss);
expect(
view.container.querySelector('button[aria-label="Close"]'),
).toBeNull();
act(() => vi.advanceTimersByTime(10_000));
expect(onDismiss).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,76 @@
import { useEffect, useRef, type ReactNode } from 'react';
import {
AlertCircleIcon,
CircleCheckIcon,
InfoIcon,
XIcon,
} from 'lucide-react';
import { Alert, AlertAction, AlertDescription } from './alert';
import { Button } from './button';
import { Spinner } from './spinner';
export type ManagementNoticeTone = 'error' | 'success' | 'info' | 'progress';
interface ManagementNoticeProps {
children: ReactNode;
closeLabel: string;
noticeKey: string;
onDismiss: () => void;
tone: ManagementNoticeTone;
className?: string;
}
export function ManagementNotice({
children,
closeLabel,
noticeKey,
onDismiss,
tone,
className,
}: ManagementNoticeProps) {
const onDismissRef = useRef(onDismiss);
onDismissRef.current = onDismiss;
useEffect(() => {
if (tone === 'error' || tone === 'progress') return;
const timer = window.setTimeout(() => onDismissRef.current(), 3_000);
return () => window.clearTimeout(timer);
}, [noticeKey, tone]);
return (
<Alert
variant={
tone === 'error'
? 'destructive'
: tone === 'success'
? 'success'
: 'default'
}
>
{tone === 'error' ? (
<AlertCircleIcon />
) : tone === 'success' ? (
<CircleCheckIcon />
) : tone === 'progress' ? (
<Spinner />
) : (
<InfoIcon />
)}
<AlertDescription className={className}>{children}</AlertDescription>
{tone !== 'progress' ? (
<AlertAction>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={closeLabel}
title={closeLabel}
onClick={onDismiss}
>
<XIcon />
</Button>
</AlertAction>
) : null}
</Alert>
);
}

View file

@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest';
import { AlertDialogContent, AlertDialogOverlay } from './alert-dialog';
import { Button } from './button';
import { Checkbox } from './checkbox';
import { DialogContent, DialogOverlay } from './dialog';
import { DrawerContent, DrawerOverlay } from './drawer';
import { DropdownMenuSubTrigger, DropdownMenuTrigger } from './dropdown-menu';
@ -22,6 +23,7 @@ describe('React 18 ref compatibility', () => {
['AlertDialogContent', AlertDialogContent],
['AlertDialogOverlay', AlertDialogOverlay],
['Button', Button],
['Checkbox', Checkbox],
['DialogContent', DialogContent],
['DialogOverlay', DialogOverlay],
['DrawerContent', DrawerContent],
@ -49,4 +51,17 @@ describe('React 18 ref compatibility', () => {
act(() => root.unmount());
container.remove();
});
it('forwards a Checkbox ref to its DOM element', () => {
const ref = React.createRef<HTMLButtonElement>();
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(<Checkbox ref={ref} />));
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
act(() => root.unmount());
container.remove();
});
});

View file

@ -93,7 +93,7 @@ const EN: Messages = {
'agent.create.confirm': 'Confirm and Save',
'agent.create.desc': 'Create a new subagent',
'agent.create.descPlaceholder': 'What does this agent do?',
'agent.create.describeAgent': 'Describe Your Subagent',
'agent.create.describeAgent': 'Describe requirements',
'agent.create.description': 'Description',
'agent.create.editAgain': 'Edit',
'agent.create.enterDescription': 'Enter Description',
@ -101,7 +101,11 @@ const EN: Messages = {
'agent.create.enterPrompt': 'Enter System Prompt',
'agent.create.generateFailed': (v) =>
`Failed to generate subagent: ${v?.error ?? 'Unknown error'}`,
'agent.create.generate': 'Generate',
'agent.create.generatedDescription': 'Description',
'agent.create.generatedSystemPrompt': 'System prompt',
'agent.create.generatingConfig': 'Generating subagent configuration...',
'agent.create.generatingPrompt': 'Generating...',
'agent.create.loading': 'Creating...',
'agent.create.location': 'Choose Location',
'agent.create.method': 'Choose Generation Method',
@ -110,12 +114,12 @@ const EN: Messages = {
'agent.create.method.qwen.recommended':
'Generate with Qwen Code (Recommended)',
'agent.create.method.qwen.desc':
'LLM generates name, prompt, and description',
'LLM generates the description and system prompt',
'agent.create.name': 'Name',
'agent.create.namePlaceholder': 'my-agent',
'agent.create.nameHelp': 'Enter a clear, unique name for this subagent.',
'agent.create.project': 'Project',
'agent.create.project.cli': 'Project-level (.qwen/agents/)',
'agent.create.project.cli': 'Workspace',
'agent.create.project.desc': 'Create a project-level subagent',
'agent.create.prompt': 'System prompt',
'agent.create.promptPlaceholder': 'You are a specialized agent that...',
@ -125,16 +129,50 @@ const EN: Messages = {
"Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.",
'agent.create.preview': 'Preview',
'agent.create.qwenHint':
'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)',
'Explain the responsibilities, usage scenarios, and important constraints, then generate each field separately.',
'agent.create.qwenPlaceholder':
'> e.g., Expert code reviewer that reviews code based on best practices...',
'agent.create.regenerate': 'Regenerate draft',
'agent.create.required': 'Name, description, and system prompt are required.',
'agent.create.tools': 'Allowed tools',
'agent.create.toolsHelp':
'Enter canonical tool names separated by commas or lines. MCP tools use mcp__server__tool; leave blank to inherit all tools.',
'agent.create.disallowedTools': 'Disallowed tools',
'agent.create.model': 'Model',
'agent.create.approvalMode': 'Approval mode',
'agent.create.maxTurns': 'Maximum execution turns',
'agent.create.maxTurnsHelp':
'Limits the model reasoning rounds for a single task. Execution stops at the limit; leave blank for no agent-specific limit.',
'agent.create.maxTurnsInvalid':
'Maximum execution turns must be a positive integer.',
'agent.create.mcpServers': 'MCP servers',
'agent.create.mcpServers.empty': 'No MCP servers are available.',
'agent.create.mcpServers.select': 'Select an MCP Server',
'agent.create.mcpServers.noneSelected': 'No MCP Servers selected.',
'agent.create.modelGenerate': 'Generate with model',
'agent.create.modelGenerate.description':
'Describe what you need, then generate and review the description and system prompt separately.',
'agent.create.color': 'Color',
'agent.create.jsonObjectHelp': 'Enter a JSON object, or leave blank.',
'agent.create.save': 'Save Agent',
'agent.create.scope': 'Choose where to save the subagent',
'agent.create.scope': 'Category',
'agent.create.manualDescHelp':
'Describe when and how this subagent should be used.',
'agent.create.manualDescPlaceholder':
'e.g., Reviews code for best practices and potential bugs.',
'agent.create.tools.builtin': 'Built-in tools',
'agent.create.tools.empty': 'No tools are available.',
'agent.create.tools.mcp': 'MCP tools',
'agent.create.tools.type': 'Tool type',
'agent.create.tools.selectServer': 'Select an MCP Server',
'agent.create.tools.selectTool': 'Select a tool',
'agent.create.tools.noneSelected': 'No tools selected.',
'agent.create.tools.initializing': 'Initializing tool catalogs...',
'agent.create.tools.preheatFailed': 'ACP preheating did not complete.',
'agent.create.tools.loadFailed': 'Failed to load built-in tools.',
'agent.create.removeSelection': (v) => `Remove ${v?.name ?? ''}`,
'agent.create.toolsSelectHelp':
'Select the tools this subagent may use. Leave all unchecked to inherit all tools.',
'agent.create.toolsSelection': 'Select Tools',
'agent.create.tools.all': 'All Tools',
'agent.create.tools.allDefault': 'All Tools (Default)',
@ -150,11 +188,24 @@ const EN: Messages = {
'agent.create.tools.readOnly': 'Read-only Tools',
'agent.create.tools.selected': 'Selected tools:',
'agent.create.user': 'User',
'agent.create.user.cli': 'User-level (~/.qwen/agents/)',
'agent.create.user.cli': 'Global',
'agent.create.user.desc': 'Create a user-level subagent',
'agent.create.useGenerated': 'Use this draft',
'agent.approval.inherit': 'Default',
'agent.approval.default': 'Default approval',
'agent.approval.plan': 'Plan only',
'agent.approval.auto-edit': 'Automatically approve edits',
'agent.approval.yolo': 'Automatically approve all actions',
'agent.approval.bubble': 'Bubble approval',
'agent.approval.desc.inherit': "Use the subagent's default approval rules.",
'agent.approval.desc.bubble':
'Forward tool approval requests to the parent session.',
'agent.createFirstHint':
"Use '/agents create' to create your first subagent.",
'agent.created': (v) => `Created ${v?.name ?? ''}`,
'agent.edit.save': 'Save changes',
'agent.updated': (v) => `Updated ${v?.name ?? ''}`,
'agent.turnsBadge': (v) => `${v?.count ?? 0} turns`,
'agent.delete': 'Delete',
'agent.delete.confirm': (v) =>
`Are you sure you want to delete "${v?.name ?? ''}"?`,
@ -194,6 +245,7 @@ const EN: Messages = {
'agent.level.extension': 'Extension',
'agent.level.project': 'Project',
'agent.level.user': 'User',
'agent.level.filter': 'Filter by agent level',
'agent.manage': 'Manage',
'agent.manage.desc': 'Manage existing subagents',
'agent.modelLabel': 'Model: ',
@ -203,11 +255,14 @@ const EN: Messages = {
'agent.selectAction': 'Select an action',
'agent.descriptionLabel': 'Description:',
'agent.systemPromptLabel': 'System Prompt:',
'agent.level.label': 'Level',
'agent.step': (v) => `Step ${v?.n ?? ''}`,
'agent.tools': 'Tools',
'agent.toolsLabel': 'Tools: ',
'agent.detail.overview': 'Basic Information',
'agent.detail.tools': 'Tools',
'agent.detail.mcp': 'MCP',
'agent.detail.hooks': 'Hooks',
'agent.detail.systemPrompt': 'System Prompt',
'agent.toolsUpdated': (v) => `Updated tools for ${v?.name ?? ''}`,
'agent.usingCount': (v) => `Using: ${v?.count ?? 0} agents`,
@ -300,6 +355,7 @@ const EN: Messages = {
'at.menu': 'Reference menu',
'common.back': 'back',
'common.all': 'All',
'common.add': 'add',
'common.cancel': 'cancel',
'common.close': 'close',
'common.fullscreen': 'Fullscreen',
@ -1319,11 +1375,27 @@ const EN: Messages = {
'extensions.manage.commands': 'Commands:',
'extensions.manage.contextFiles': 'Context files:',
'extensions.manage.count': (v) => `${v?.count ?? 0} extensions installed`,
'extensions.manage.setting.disabled': 'Disabled',
'extensions.manage.setting.default': 'Default',
'extensions.manage.setting.enabled': 'Enabled',
'extensions.manage.setting.unknown': 'Unavailable',
'extensions.manage.setting.unavailableDescription':
'The service did not return scoped extension settings. Restart qwen serve and try again.',
'extensions.manage.userSetting': 'Global setting',
'extensions.manage.userSettingDescription':
'Applies to your workspaces unless a workspace setting overrides it.',
'extensions.manage.workspaceSetting': 'Workspace setting',
'extensions.manage.workspaceSettingDescription':
'Applies only to the current workspace.',
'extensions.manage.disable': 'Disable Extension',
'extensions.manage.disabled': (v) =>
`Extension "${v?.name ?? 'extension'}" disabled.`,
'extensions.manage.disabling': (v) =>
`Disabling extension "${v?.name ?? 'extension'}"…`,
'extensions.manage.inherited': (v) =>
`Extension "${v?.name ?? 'extension'}" now uses the global setting.`,
'extensions.manage.inheriting': (v) =>
`Resetting extension "${v?.name ?? 'extension'}" to the global setting…`,
'extensions.manage.empty': 'No extensions installed.',
'extensions.manage.emptyAgents': 'This extension has no agents.',
'extensions.manage.emptyCommands': 'This extension has no commands.',
@ -2328,7 +2400,7 @@ const ZH: Messages = {
'agent.create.confirm': '确认并保存',
'agent.create.desc': '创建新的智能体',
'agent.create.descPlaceholder': '这个智能体做什么?',
'agent.create.describeAgent': '描述您的子智能体',
'agent.create.describeAgent': '描述需求',
'agent.create.description': '描述',
'agent.create.editAgain': '编辑',
'agent.create.enterDescription': '输入描述',
@ -2336,19 +2408,23 @@ const ZH: Messages = {
'agent.create.enterPrompt': '输入系统提示词',
'agent.create.generateFailed': (v) =>
`生成子智能体失败:${v?.error ?? '未知错误'}`,
'agent.create.generate': '生成',
'agent.create.generatedDescription': '描述',
'agent.create.generatedSystemPrompt': '系统提示词',
'agent.create.generatingConfig': '正在生成子智能体配置...',
'agent.create.generatingPrompt': '生成中...',
'agent.create.loading': '创建中...',
'agent.create.location': '选择位置',
'agent.create.method': '选择生成方式',
'agent.create.method.manual': '手动创建',
'agent.create.method.qwen': '使用 Qwen Code 生成',
'agent.create.method.qwen.recommended': '使用 Qwen Code 生成(推荐)',
'agent.create.method.qwen.desc': 'LLM 生成名称、提示词和描述',
'agent.create.method.qwen.desc': 'LLM 生成描述和系统提示词',
'agent.create.name': '名称',
'agent.create.namePlaceholder': 'my-agent',
'agent.create.nameHelp': '输入此子智能体清晰且唯一的名称。',
'agent.create.project': '项目',
'agent.create.project.cli': '项目级 (.qwen/agents/)',
'agent.create.project.cli': '工作区',
'agent.create.project.desc': '创建项目级智能体',
'agent.create.prompt': '系统提示词',
'agent.create.promptPlaceholder': '你是一个专门的智能体...',
@ -2357,15 +2433,48 @@ const ZH: Messages = {
'编写定义此子智能体行为的系统提示词。为了获得最佳效果,请全面描述。',
'agent.create.preview': '预览',
'agent.create.qwenHint':
'描述此子智能体应该做什么以及何时使用它。(为了获得最佳效果,请全面描述)',
'说明职责、使用场景和重要约束,然后分别生成各项内容。',
'agent.create.qwenPlaceholder':
'> 例如:专业的代码审查员,根据最佳实践审查代码...',
'agent.create.regenerate': '重新生成草稿',
'agent.create.required': '名称、描述和系统提示词均为必填项。',
'agent.create.tools': '允许的工具',
'agent.create.toolsHelp':
'使用逗号或换行分隔 canonical 工具名称MCP 工具使用 mcp__server__tool留空表示继承全部工具。',
'agent.create.disallowedTools': '禁用的工具',
'agent.create.model': '模型',
'agent.create.approvalMode': '审批模式',
'agent.create.maxTurns': '最大执行轮次',
'agent.create.maxTurnsHelp':
'限制智能体完成单次任务时的模型调用轮数,达到上限后将停止执行。留空表示不设置智能体层级的限制。',
'agent.create.maxTurnsInvalid': '最大执行轮次必须是正整数。',
'agent.create.mcpServers': 'MCP Server',
'agent.create.mcpServers.empty': '暂无可用的 MCP Server。',
'agent.create.mcpServers.select': '选择 MCP Server',
'agent.create.mcpServers.noneSelected': '尚未选择 MCP Server。',
'agent.create.modelGenerate': '模型生成',
'agent.create.modelGenerate.description':
'描述你的需求,然后分别生成并检查描述和系统提示词。',
'agent.create.color': '颜色',
'agent.create.jsonObjectHelp': '请输入 JSON 对象,或留空。',
'agent.create.save': '保存智能体',
'agent.create.scope': '选择智能体的保存位置',
'agent.create.scope': '类别',
'agent.create.manualDescHelp': '描述此子智能体应该在何时以及如何使用。',
'agent.create.manualDescPlaceholder':
'例如:根据最佳实践和潜在 bug 审查代码。',
'agent.create.tools.builtin': '内置工具',
'agent.create.tools.empty': '暂无可用工具。',
'agent.create.tools.mcp': 'MCP 工具',
'agent.create.tools.type': '工具类型',
'agent.create.tools.selectServer': '选择 MCP Server',
'agent.create.tools.selectTool': '选择工具',
'agent.create.tools.noneSelected': '尚未选择工具。',
'agent.create.tools.initializing': '正在初始化工具目录...',
'agent.create.tools.preheatFailed': 'ACP 预热未完成。',
'agent.create.tools.loadFailed': '内置工具加载失败。',
'agent.create.removeSelection': (v) => `移除 ${v?.name ?? ''}`,
'agent.create.toolsSelectHelp':
'选择此子智能体可以使用的工具;全部不选表示继承所有工具。',
'agent.create.toolsSelection': '选择工具',
'agent.create.tools.all': '所有工具',
'agent.create.tools.allDefault': '所有工具(默认)',
@ -2380,10 +2489,22 @@ const ZH: Messages = {
'agent.create.tools.readOnly': '只读工具',
'agent.create.tools.selected': '已选择的工具:',
'agent.create.user': '用户',
'agent.create.user.cli': '用户级 (~/.qwen/agents/)',
'agent.create.user.cli': '全局',
'agent.create.user.desc': '创建用户级智能体',
'agent.create.useGenerated': '确认使用',
'agent.approval.inherit': '默认',
'agent.approval.default': '默认审批',
'agent.approval.plan': '仅规划',
'agent.approval.auto-edit': '自动批准编辑',
'agent.approval.yolo': '自动批准所有操作',
'agent.approval.bubble': '气泡审批',
'agent.approval.desc.inherit': '使用智能体默认的审批规则。',
'agent.approval.desc.bubble': '将工具审批请求转交给父会话处理。',
'agent.createFirstHint': "使用 '/agents create' 创建第一个智能体。",
'agent.created': (v) => `已创建 ${v?.name ?? ''}`,
'agent.edit.save': '保存修改',
'agent.updated': (v) => `已更新 ${v?.name ?? ''}`,
'agent.turnsBadge': (v) => `${v?.count ?? 0}`,
'agent.delete': '删除',
'agent.delete.confirm': (v) => `确定要删除 "${v?.name ?? ''}" 吗?`,
'agent.delete.loading': '删除中...',
@ -2419,6 +2540,7 @@ const ZH: Messages = {
'agent.level.extension': '扩展',
'agent.level.project': '项目',
'agent.level.user': '用户',
'agent.level.filter': '按智能体级别筛选',
'agent.manage': '管理',
'agent.manage.desc': '管理已有智能体',
'agent.modelLabel': '模型:',
@ -2428,11 +2550,14 @@ const ZH: Messages = {
'agent.selectAction': '选择操作',
'agent.descriptionLabel': '描述:',
'agent.systemPromptLabel': '系统提示词:',
'agent.level.label': '级别',
'agent.step': (v) => `步骤 ${v?.n ?? ''}`,
'agent.tools': '工具',
'agent.toolsLabel': '工具:',
'agent.detail.overview': '基本信息',
'agent.detail.tools': '工具',
'agent.detail.mcp': 'MCP',
'agent.detail.hooks': 'Hooks',
'agent.detail.systemPrompt': '系统提示词',
'agent.toolsUpdated': (v) => `已更新 ${v?.name ?? ''} 的工具`,
'agent.usingCount': (v) => `使用中:${v?.count ?? 0} 个智能体`,
@ -2523,6 +2648,7 @@ const ZH: Messages = {
'at.menu': '引用菜单',
'common.back': '返回',
'common.all': '全部',
'common.add': '添加',
'common.cancel': '取消',
'common.close': '关闭',
'common.fullscreen': '全屏',
@ -3480,9 +3606,24 @@ const ZH: Messages = {
'extensions.manage.commands': '命令:',
'extensions.manage.contextFiles': '上下文文件:',
'extensions.manage.count': (v) => `已安装 ${v?.count ?? 0} 个扩展`,
'extensions.manage.setting.disabled': '禁用',
'extensions.manage.setting.default': '默认',
'extensions.manage.setting.enabled': '启用',
'extensions.manage.setting.unknown': '状态不可用',
'extensions.manage.setting.unavailableDescription':
'当前服务未返回扩展的全局和工作区设置,请重启 qwen serve 后重试。',
'extensions.manage.userSetting': '全局设置',
'extensions.manage.userSettingDescription':
'适用于用户工作区,工作区设置可单独覆盖。',
'extensions.manage.workspaceSetting': '工作区设置',
'extensions.manage.workspaceSettingDescription': '仅适用于当前工作区。',
'extensions.manage.disable': '禁用扩展',
'extensions.manage.disabled': (v) => `扩展 "${v?.name ?? '扩展'}" 已禁用。`,
'extensions.manage.disabling': (v) => `正在禁用扩展 "${v?.name ?? '扩展'}"…`,
'extensions.manage.inherited': (v) =>
`扩展 "${v?.name ?? '扩展'}" 已恢复为全局设置。`,
'extensions.manage.inheriting': (v) =>
`正在将扩展 "${v?.name ?? '扩展'}" 恢复为全局设置…`,
'extensions.manage.empty': '未安装扩展。',
'extensions.manage.emptyAgents': '此扩展没有智能体。',
'extensions.manage.emptyCommands': '此扩展没有命令。',

View file

@ -36,6 +36,25 @@ describe('workspace actions', () => {
});
});
it('preheats ACP with the requested timeout', async () => {
const workspaceAcpPreheat = vi.fn().mockResolvedValue({
ready: true,
channelLive: true,
durationMs: 2,
});
const actions = createDaemonWorkspaceActions({
getClient: () => ({ workspaceAcpPreheat }) as unknown as DaemonClient,
getWorkspaceCwd: () => '/ws',
baseUrl: '',
});
await expect(actions.preheatAcp(5_000)).resolves.toMatchObject({
ready: true,
channelLive: true,
});
expect(workspaceAcpPreheat).toHaveBeenCalledWith(5_000);
});
it('applies the action timeout to workspace removal', async () => {
vi.useFakeTimers();
const remove = vi.fn(() => new Promise<never>(() => {}));

View file

@ -331,6 +331,15 @@ export function createDaemonWorkspaceActions({
return withActionTimeout(client.workspaceTools(), 'Load tools timed out');
},
async preheatAcp(timeoutMs) {
const client = requireClient(getClient, 'Preheat ACP failed');
return withActionTimeout(
client.workspaceAcpPreheat(timeoutMs),
'Preheat ACP timed out',
timeoutMs === undefined ? undefined : timeoutMs + 2_000,
);
},
async setWorkspaceToolEnabled(toolName, enabled) {
const client = requireClient(getClient, 'Set tool enabled failed');
return withActionTimeout(
@ -401,10 +410,10 @@ export function createDaemonWorkspaceActions({
);
},
async getAgent(agentType) {
async getAgent(agentType, scope) {
const client = requireClient(getClient, 'Get agent failed');
return withActionTimeout(
client.getWorkspaceAgent(agentType),
client.getWorkspaceAgent(agentType, scope ? { scope } : {}),
'Get agent timed out',
);
},

View file

@ -31,6 +31,7 @@ export function useDaemonAgents(options: DaemonResourceOptions = {}) {
getAgent: workspaceActions.getAgent,
createAgent: workspaceActions.createAgent,
generateAgent: workspaceActions.generateAgent,
generateContent: workspaceActions.generateContent,
deleteAgent: workspaceActions.deleteAgent,
updateAgent: workspaceActions.updateAgent,
};

View file

@ -28,6 +28,7 @@ export function useDaemonTools(options: DaemonResourceOptions = {}) {
...result,
status: result.data,
tools: result.data?.tools ?? [],
preheat: workspaceActions.preheatAcp,
setEnabled: workspaceActions.setWorkspaceToolEnabled,
};
}

View file

@ -39,6 +39,7 @@ import type {
DaemonUpdateAgentRequest,
DaemonWorkspaceAgentDetail,
DaemonWorkspaceAgentsStatus,
DaemonWorkspaceAcpPreheatResult,
DaemonWorkspaceEnvStatus,
DaemonWorkspaceExtensionsStatus,
DaemonWorkspaceFile,
@ -398,6 +399,7 @@ export interface DaemonWorkspaceActions {
loadExtensionsStatus(): Promise<DaemonWorkspaceExtensionsStatus>;
// Tools
preheatAcp(timeoutMs?: number): Promise<DaemonWorkspaceAcpPreheatResult>;
loadToolsStatus(): Promise<DaemonWorkspaceToolsStatus>;
setWorkspaceToolEnabled(toolName: string, enabled: boolean): Promise<unknown>;
@ -424,7 +426,10 @@ export interface DaemonWorkspaceActions {
// Agents (CRUD)
listAgents(): Promise<DaemonWorkspaceAgentsStatus>;
getAgent(agentType: string): Promise<DaemonWorkspaceAgentDetail>;
getAgent(
agentType: string,
scope?: 'workspace' | 'global',
): Promise<DaemonWorkspaceAgentDetail>;
createAgent(
req: DaemonCreateAgentRequest,
): Promise<DaemonAgentMutationResult>;