mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-25 08:33:55 +00:00
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:
parent
74a786dac3
commit
c0fee1b609
35 changed files with 3998 additions and 2947 deletions
77
docs/design/workspace-agents-api.md
Normal file
77
docs/design/workspace-agents-api.md
Normal 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.
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
1215
packages/web-shell/client/components/agents/AgentCreatePage.tsx
Normal file
1215
packages/web-shell/client/components/agents/AgentCreatePage.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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': '此扩展没有命令。',
|
||||
|
|
|
|||
|
|
@ -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>(() => {}));
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export function useDaemonTools(options: DaemonResourceOptions = {}) {
|
|||
...result,
|
||||
status: result.data,
|
||||
tools: result.data?.tools ?? [],
|
||||
preheat: workspaceActions.preheatAcp,
|
||||
setEnabled: workspaceActions.setWorkspaceToolEnabled,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue