diff --git a/docs/developers/daemon/15-channel-adapters.md b/docs/developers/daemon/15-channel-adapters.md index d221713372a..1408ec8591b 100644 --- a/docs/developers/daemon/15-channel-adapters.md +++ b/docs/developers/daemon/15-channel-adapters.md @@ -9,7 +9,7 @@ There are two current host modes: - `qwen channel start [name]` is the standalone ACP-backed channel service. It passes adapters an `AcpBridge` implementation of `ChannelAgentBridge`. - `qwen serve --channel ` and `qwen serve --channel all` are experimental daemon-managed modes. Named selections are grouped by owning workspace and `qwen serve` starts one out-of-process worker per owning runtime; each worker connects to the daemon through the SDK and adapters receive a `DaemonChannelBridge`-backed `ChannelAgentBridge` facade. `--channel all` remains a primary-only selection. -In daemon-managed mode, each channel maps inbound chat traffic to daemon sessions under a configurable `SessionScope` (`user`, `thread`, or `single`). The adapter delegates to `DaemonChannelBridge`, which delegates to the SDK's `DaemonSessionClient` (see [`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md)). Every named channel must resolve to one registered, trusted workspace. The worker uses that runtime's canonical cwd, `QWEN_DAEMON_WORKSPACE`, and environment overlay; ownership resolution never falls back to primary. +In daemon-managed mode, each channel maps inbound chat traffic to daemon sessions under a configurable `SessionScope` (`user`, `chat_thread`, or `single`). The legacy Channel value `thread` remains readable and editable for existing configurations, but new Web Shell configurations do not offer it; this is separate from the daemon bridge's own `single`/`thread` session creation knob. The adapter delegates to `DaemonChannelBridge`, which delegates to the SDK's `DaemonSessionClient` (see [`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md)). Every named channel must resolve to one registered, trusted workspace. The worker uses that runtime's canonical cwd, `QWEN_DAEMON_WORKSPACE`, and environment overlay; ownership resolution never falls back to primary. ### Webhook-triggered channel tasks @@ -194,14 +194,14 @@ Adapter `connect()` failures are reported separately from worker lifecycle error `ChannelConfig` (from `packages/channels/base/src/types.ts`): -| Knob | Effect | -| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `sessionScope` | `'user'` (sender + chat), `'thread'` (thread id or chat), `'chat_thread'` (channel + chatId + threadId, for polling adapters), or `'single'` (one shared session per channel). | -| `approvalMode` | `'auto'` (auto-respond) / `'prompt'` (render UI). | -| `allowlist?: string[]` | Sender ids allowed; missing = open. | -| `denylist?: string[]` | Sender ids denied. | -| `chunkSize`, `chunkIntervalMs` | Outbound block streaming settings. | -| `daemon: { baseUrl, token?, clientId? }` | Forwarded to `DaemonChannelSessionFactory`. | +| Knob | Effect | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `sessionScope` | `'user'` (sender + chat), `'chat_thread'` (channel + chatId + threadId), or `'single'` (one shared session per channel). Legacy `'thread'` is preserved when already configured but is not offered for new Web Shell configurations. | +| `approvalMode` | `'auto'` (auto-respond) / `'prompt'` (render UI). | +| `allowlist?: string[]` | Sender ids allowed; missing = open. | +| `denylist?: string[]` | Sender ids denied. | +| `chunkSize`, `chunkIntervalMs` | Outbound block streaming settings. | +| `daemon: { baseUrl, token?, clientId? }` | Forwarded to `DaemonChannelSessionFactory`. | Channel-specific keys layer on top (DingTalk: `streamCredentials`; WeChat: `ilinkUrl`, `botId`; Telegram: `botToken`; Feishu: `clientId` (appId), `clientSecret` (appSecret), `verificationToken`, `encryptKey` (webhook mode)). diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md index 56a03a50f98..8c740e1bcfb 100644 --- a/docs/users/features/channels/overview.md +++ b/docs/users/features/channels/overview.md @@ -61,7 +61,7 @@ Channels are configured under the `channels` key in `settings.json`. Each channe | `model` | No | Model to use for this channel (e.g., `qwen3.5-plus`). Overrides the default model. Useful for multimodal models that support image input | | `senderPolicy` | No | Who can talk to the bot: `allowlist` (default), `open`, or `pairing` | | `allowedUsers` | No | List of user IDs allowed to use the bot (used by `allowlist` and `pairing` policies) | -| `sessionScope` | No | How sessions are scoped: `user` (default), `thread`, or `single` | +| `sessionScope` | No | How sessions are scoped: `user` (default), `chat_thread`, or `single`. Legacy `thread` remains compatible when already configured but is not offered for new Web Shell configurations | | `cwd` | No | Working directory for the agent. Defaults to the current directory | | `approvalMode` | No | Tool approval mode for channel sessions. Unattended webhook tasks require `yolo`; the setting applies to every session on the channel | | `instructions` | No | Custom instructions prepended to the first message of each session | diff --git a/docs/users/features/channels/plugins.md b/docs/users/features/channels/plugins.md index ae108cd5eb9..b2462bcd065 100644 --- a/docs/users/features/channels/plugins.md +++ b/docs/users/features/channels/plugins.md @@ -46,17 +46,17 @@ The `type` must match a channel type registered by an installed extension. Check All standard channel options work with custom channels: -| Option | Description | -| -------------- | ---------------------------------------------- | -| `senderPolicy` | `allowlist`, `pairing`, or `open` | -| `allowedUsers` | Static allowlist of sender IDs | -| `sessionScope` | `user`, `thread`, or `single` | -| `cwd` | Working directory for the agent | -| `instructions` | Prepended to the first message of each session | -| `model` | Model override for the channel | -| `groupPolicy` | `disabled`, `allowlist`, `pairing`, or `open` | -| `dmPolicy` | `open` or `disabled` | -| `groups` | Per-group settings | +| Option | Description | +| -------------- | -------------------------------------------------------------------------------------------------- | +| `senderPolicy` | `allowlist`, `pairing`, or `open` | +| `allowedUsers` | Static allowlist of sender IDs | +| `sessionScope` | `user`, `chat_thread`, or `single`; legacy `thread` remains compatible for existing configurations | +| `cwd` | Working directory for the agent | +| `instructions` | Prepended to the first message of each session | +| `model` | Model override for the channel | +| `groupPolicy` | `disabled`, `allowlist`, `pairing`, or `open` | +| `dmPolicy` | `open` or `disabled` | +| `groups` | Per-group settings | See [Overview](./overview) for details on each option. diff --git a/packages/channels/base/README.md b/packages/channels/base/README.md index f44c1fda6b5..0bd0de3be18 100644 --- a/packages/channels/base/README.md +++ b/packages/channels/base/README.md @@ -121,7 +121,7 @@ Everything between `handleInbound()` and `sendMessage()` is handled by the base | `Envelope` | Normalized inbound message format | | `SenderPolicy` | `'allowlist' \| 'pairing' \| 'open'` | | `GroupPolicy` | `'disabled' \| 'allowlist' \| 'pairing' \| 'open'` | -| `SessionScope` | `'user' \| 'thread' \| 'single'` | +| `SessionScope` | `'user' \| 'chat_thread' \| 'single'`; legacy `'thread'` is deprecated | | `GroupConfig` | Per-group settings (e.g. `requireMention`) | | `SessionTarget` | Maps a session back to its channel/sender/chat | | `ToolCallEvent` | Agent tool-call event delivered to adapters | diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index e2537ce0f54..cfe4462d8d8 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -59,6 +59,7 @@ export interface ChannelConfig { clientSecret?: string; senderPolicy: SenderPolicy; allowedUsers: string[]; + /** Channel routing scope. `thread` is retained for existing configurations only. */ sessionScope: SessionScope; cwd: string; approvalMode?: string; @@ -516,7 +517,10 @@ export interface ChannelPlugin { /** Serializable metadata for safe configuration management. */ management?: ChannelManagementDescriptor; - /** Default session scope for this channel type (applied when config omits sessionScope). */ + /** + * Default Channel routing scope (applied when config omits sessionScope). + * `thread` is retained for existing configurations only. + */ defaultSessionScope?: SessionScope; /** Create a channel adapter instance. */ diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index bb1f00d59fc..54745458235 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -430,6 +430,30 @@ describe('WeComChannel', () => { rmSync(join(tmpdir(), 'channel-files'), { recursive: true, force: true }); }); + it('shares attachment routing across senders in chat_thread scope', () => { + const channel = new WeComChannel( + 'bot', + makeConfig({ sessionScope: 'chat_thread' }), + makeBridge(), + ); + const routeKey = ( + channel as unknown as { + attachmentRouteKey( + senderId: string, + chatId: string, + threadId?: string, + ): string; + } + ).attachmentRouteKey.bind(channel); + + expect(routeKey('alice', 'chat-1', 'topic-1')).toBe( + routeKey('bob', 'chat-1', 'topic-1'), + ); + expect(routeKey('alice', 'chat-1', 'topic-1')).not.toBe( + routeKey('alice', 'chat-2', 'topic-1'), + ); + }); + it('requires botId and secret', () => { expect( () => new WeComChannel('bot', makeConfig({ botId: '' }), makeBridge()), diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 765f58eef64..407a6b157e8 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -770,6 +770,10 @@ export class WeComChannel extends ChannelBase { switch (this.config.sessionScope) { case 'thread': return `${this.name}:${threadId || chatId}`; + case 'chat_thread': + return threadId + ? `${this.name}:${chatId}:${threadId}` + : `${this.name}:${chatId}`; case 'single': return `${this.name}:__single__`; case 'user': diff --git a/packages/cli/src/commands/channel/channel-registry-builtins.test.ts b/packages/cli/src/commands/channel/channel-registry-builtins.test.ts index 4a1678fc951..0e64db3a30f 100644 --- a/packages/cli/src/commands/channel/channel-registry-builtins.test.ts +++ b/packages/cli/src/commands/channel/channel-registry-builtins.test.ts @@ -108,35 +108,26 @@ describe('built-in channel registry', () => { const entry = (await supportedChannelCatalog()).find( (candidate) => candidate.type === 'valid-nested-type-key', ); - expect(entry).toEqual({ + expect(entry).toMatchObject({ type: 'valid-nested-type-key', displayName: 'valid-nested-type-key', manageable: true, - fields: [ - { - key: 'settings', - label: 'Settings', - kind: 'object', - properties: [{ key: 'type', label: 'Type', kind: 'string' }], - }, - // supportedChannelCatalog() injects the session-scope descriptor into - // every manageable entry that does not declare its own. - { - key: 'sessionScope', - label: 'Session scope', - kind: 'enum', - required: true, - default: 'user', - description: - 'Controls which incoming conversations share one agent session.', - options: [ - { value: 'user', label: 'Per user and chat' }, - { value: 'thread', label: 'Per thread' }, - { value: 'chat_thread', label: 'Per chat and thread' }, - { value: 'single', label: 'One shared session' }, - ], - }, - ], }); + expect(entry?.fields[0]).toEqual({ + key: 'settings', + label: 'Settings', + kind: 'object', + properties: [{ key: 'type', label: 'Type', kind: 'string' }], + }); + expect(entry?.fields.map((field) => field.key)).toEqual([ + 'settings', + 'senderPolicy', + 'allowedUsers', + 'groupPolicy', + 'sessionScope', + ]); + expect( + entry?.fields.find((field) => field.key === 'senderPolicy'), + ).toMatchObject({ default: 'pairing' }); }); }); diff --git a/packages/cli/src/commands/channel/channel-registry.test.ts b/packages/cli/src/commands/channel/channel-registry.test.ts index c625cfc13f2..6511a75d74e 100644 --- a/packages/cli/src/commands/channel/channel-registry.test.ts +++ b/packages/cli/src/commands/channel/channel-registry.test.ts @@ -706,6 +706,7 @@ describe('channel registry', () => { const plugin: ChannelPlugin = { channelType: 'valid-optional-required-object', displayName: 'valid-optional-required-object', + defaultSessionScope: 'thread', management: { fields: [ { @@ -738,6 +739,17 @@ describe('channel registry', () => { (candidate) => candidate.type === 'valid-optional-required-object', ); expect(entry?.manageable).toBe(true); + expect( + entry?.fields.find((field) => field.key === 'sessionScope'), + ).toMatchObject({ + default: 'thread', + options: [ + { value: 'user' }, + { value: 'thread' }, + { value: 'chat_thread' }, + { value: 'single' }, + ], + }); }); it('only marks the manually configurable built-in types as manageable', async () => { @@ -770,18 +782,49 @@ describe('channel registry', () => { required: true, }), ); - expect( - catalog.find((entry) => entry.type === 'dingtalk')?.fields, - ).toContainEqual( - expect.objectContaining({ - key: 'sessionScope', + for (const type of ['dingtalk', 'wecom', 'feishu'] as const) { + const fields = catalog.find((entry) => entry.type === type)?.fields; + expect( + fields + ?.find((field) => field.key === 'senderPolicy') + ?.options?.map((option) => option.value), + ).toEqual(['pairing', 'allowlist', 'open']); + expect( + fields?.find((field) => field.key === 'senderPolicy'), + ).toMatchObject({ default: 'pairing' }); + expect(fields).toContainEqual( + expect.objectContaining({ + key: 'allowedUsers', + kind: 'string-list', + }), + ); + expect( + fields + ?.find((field) => field.key === 'groupPolicy') + ?.options?.map((option) => option.value), + ).toEqual(['disabled', 'pairing', 'allowlist', 'open']); + expect( + fields?.find((field) => field.key === 'sessionScope'), + ).toMatchObject({ kind: 'enum', required: true, default: 'user', - }), - ); + options: [ + { value: 'user' }, + { value: 'thread' }, + { value: 'chat_thread' }, + { value: 'single' }, + ], + }); + } for (const type of ['github', 'gitlab'] as const) { const fields = catalog.find((entry) => entry.type === type)?.fields; + expect( + fields?.filter((field) => field.key === 'senderPolicy'), + ).toHaveLength(1); + expect( + fields?.filter((field) => field.key === 'groupPolicy'), + ).toHaveLength(1); expect(fields).toContainEqual( expect.objectContaining({ key: 'groupPolicy', @@ -807,6 +850,16 @@ describe('channel registry', () => { kind: 'string-list', }), ); + expect( + fields?.filter((field) => field.key === 'sessionScope'), + ).toHaveLength(1); + expect( + fields?.find((field) => field.key === 'sessionScope'), + ).toMatchObject({ + kind: 'enum', + required: true, + default: 'chat_thread', + }); } expect( catalog.find((entry) => entry.type === 'github')?.fields, diff --git a/packages/cli/src/commands/channel/channel-registry.ts b/packages/cli/src/commands/channel/channel-registry.ts index 302c7f3badd..593359ca93a 100644 --- a/packages/cli/src/commands/channel/channel-registry.ts +++ b/packages/cli/src/commands/channel/channel-registry.ts @@ -31,6 +31,82 @@ const FIELD_KINDS: ReadonlySet = new Set([ 'object', ]); +const SHARED_ACCESS_FIELDS: readonly ChannelConfigFieldDescriptor[] = [ + { + key: 'senderPolicy', + label: 'Sender Policy', + kind: 'enum', + required: true, + default: 'pairing', + description: 'Controls who can start direct conversations', + options: [ + { value: 'pairing', label: 'Pairing' }, + { value: 'allowlist', label: 'Allowlist' }, + { value: 'open', label: 'Open' }, + ], + }, + { + key: 'allowedUsers', + label: 'Allowed Users', + kind: 'string-list', + description: 'Stable user IDs allowed without pairing', + }, + { + key: 'groupPolicy', + label: 'Group Policy', + kind: 'enum', + required: true, + default: 'disabled', + description: 'Controls which group conversations can use this Channel', + options: [ + { value: 'disabled', label: 'Disabled' }, + { value: 'pairing', label: 'Pairing' }, + { value: 'allowlist', label: 'Allowlist' }, + { value: 'open', label: 'Open' }, + ], + }, +]; + +const SESSION_SCOPE_OPTIONS: ReadonlyArray<{ + value: SessionScope; + label: string; +}> = [ + { value: 'user', label: 'Per User and Chat' }, + { value: 'thread', label: 'Per Thread (Legacy)' }, + { value: 'chat_thread', label: 'Per Chat and Thread' }, + { value: 'single', label: 'One Shared Session' }, +]; + +function managementFieldsWithSharedControls( + fields: readonly ChannelConfigFieldDescriptor[], + defaultSessionScope: SessionScope, +): readonly ChannelConfigFieldDescriptor[] { + const declared = new Set(fields.map((field) => field.key)); + const normalizedFields = fields.map((field) => + field.key === 'sessionScope' && field.default === undefined + ? { ...field, default: defaultSessionScope } + : field, + ); + return [ + ...normalizedFields, + ...SHARED_ACCESS_FIELDS.filter((field) => !declared.has(field.key)), + ...(declared.has('sessionScope') + ? [] + : [ + { + key: 'sessionScope', + label: 'Session Scope', + kind: 'enum' as const, + required: true, + default: defaultSessionScope, + description: + 'Controls how conversations share persistent agent sessions', + options: SESSION_SCOPE_OPTIONS, + }, + ]), + ]; +} + function assertManagementFields( fields: readonly ChannelConfigFieldDescriptor[], parentPath?: string, @@ -205,16 +281,6 @@ function assertManagementDescriptor(plugin: ChannelPlugin): void { } } -const SESSION_SCOPE_OPTIONS: ReadonlyArray<{ - value: SessionScope; - label: string; -}> = [ - { value: 'user', label: 'Per user and chat' }, - { value: 'thread', label: 'Per thread' }, - { value: 'chat_thread', label: 'Per chat and thread' }, - { value: 'single', label: 'One shared session' }, -]; - function ensureBuiltins(): Promise { if (!builtinsPromise) { builtinsPromise = (async () => { @@ -308,35 +374,17 @@ export async function supportedChannelCatalog(): Promise< ChannelTypeDescriptor[] > { await ensureBuiltins(); - return [...registry.values()].map((plugin) => { - const { channelType, displayName, management } = plugin; - const fields = management?.fields ?? []; - const defaultSessionScope = plugin.defaultSessionScope ?? 'user'; - const normalizedFields = fields.map((field) => - field.key === 'sessionScope' && field.default === undefined - ? { ...field, default: defaultSessionScope } - : field, - ); - return { + return [...registry.values()].map( + ({ channelType, displayName, management, defaultSessionScope }) => ({ type: channelType, displayName, manageable: management !== undefined, - fields: - management && !fields.some((field) => field.key === 'sessionScope') - ? [ - ...normalizedFields, - { - key: 'sessionScope', - label: 'Session scope', - kind: 'enum', - required: true, - default: defaultSessionScope, - description: - 'Controls which incoming conversations share one agent session.', - options: SESSION_SCOPE_OPTIONS, - }, - ] - : normalizedFields, - }; - }); + fields: management + ? managementFieldsWithSharedControls( + management.fields, + defaultSessionScope ?? 'user', + ) + : [], + }), + ); } diff --git a/packages/cli/src/commands/channel/config-utils.test.ts b/packages/cli/src/commands/channel/config-utils.test.ts index 3ffa4cbd1eb..68d1a29064e 100644 --- a/packages/cli/src/commands/channel/config-utils.test.ts +++ b/packages/cli/src/commands/channel/config-utils.test.ts @@ -325,7 +325,7 @@ describe('parseChannelConfig', () => { token: 'literal-tok', senderPolicy: 'open', allowedUsers: ['alice'], - sessionScope: 'thread', + sessionScope: 'chat_thread', cwd: '/custom', approvalMode: 'auto', instructions: 'Be helpful', @@ -340,7 +340,7 @@ describe('parseChannelConfig', () => { expect(result.token).toBe('literal-tok'); expect(result.senderPolicy).toBe('open'); expect(result.allowedUsers).toEqual(['alice']); - expect(result.sessionScope).toBe('thread'); + expect(result.sessionScope).toBe('chat_thread'); expect(result.cwd).toBe(path.resolve('/custom')); expect(result.approvalMode).toBe('auto'); expect(result.instructions).toBe('Be helpful'); @@ -358,6 +358,15 @@ describe('parseChannelConfig', () => { expect(result.groups).toEqual({ g1: { mentionKeywords: ['@bot'] } }); }); + it('preserves the deprecated thread scope for existing routes', async () => { + const result = await parseChannelConfig('bot', { + type: 'bare', + sessionScope: 'thread', + }); + + expect(result.sessionScope).toBe('thread'); + }); + it('uses plugin defaultSessionScope when sessionScope is not configured', async () => { const result = await parseChannelConfig('bot', { type: 'github', diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts index 9ffe908120b..68c55d66d4e 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -451,6 +451,10 @@ export async function parseChannelConfig( 'clientSecret', envResolution, ); + const configuredSessionScope = + (rawConfig['sessionScope'] as ChannelConfig['sessionScope']) || + plugin.defaultSessionScope || + 'user'; return { ...resolvedRawConfig, @@ -462,10 +466,7 @@ export async function parseChannelConfig( (rawConfig['senderPolicy'] as ChannelConfig['senderPolicy']) || 'allowlist', allowedUsers: (rawConfig['allowedUsers'] as string[]) || [], - sessionScope: - (rawConfig['sessionScope'] as ChannelConfig['sessionScope']) || - plugin?.defaultSessionScope || - 'user', + sessionScope: configuredSessionScope, cwd: resolveChannelCwd(rawConfig['cwd'] as string | undefined, defaultCwd), approvalMode: parseApprovalModeConfig(name, rawConfig), instructions: rawConfig['instructions'] as string | undefined, diff --git a/packages/cli/src/serve/channel-settings-store.test.ts b/packages/cli/src/serve/channel-settings-store.test.ts index 32764641e17..84b50001e14 100644 --- a/packages/cli/src/serve/channel-settings-store.test.ts +++ b/packages/cli/src/serve/channel-settings-store.test.ts @@ -419,6 +419,28 @@ describe('WorkspaceChannelSettingsStore', () => { clientSecret: { operation: 'replace', value: 'secret' } as const, }, }, + { + label: 'invalid group allowlist entry', + config: { + type: 'management-validation-test', + clientId: 'client-id', + groups: { 'group-1': { dispatchMode: 'invalid' } }, + }, + secrets: { + clientSecret: { operation: 'replace', value: 'secret' } as const, + }, + }, + { + label: 'wrong nested dispatch mode kind', + config: { + type: 'management-validation-test', + clientId: 'client-id', + groups: { 'group-1': { dispatchMode: ['collect'] } }, + }, + secrets: { + clientSecret: { operation: 'replace', value: 'secret' } as const, + }, + }, { label: 'string-list with non-string items', config: { @@ -481,7 +503,12 @@ describe('WorkspaceChannelSettingsStore', () => { mode: 'safe', senderPolicy: 'open', groupPolicy: 'pairing', + sessionScope: 'chat_thread', allowedUsers: ['user-1'], + groups: { + '*': { requireMention: false }, + 'group-1': { dispatchMode: 'collect', groupHistoryLimit: 25 }, + }, groupHistoryLimit: 25, blockStreaming: 'on', identity: { id: 'ops', displayName: 'Ops' }, @@ -503,7 +530,12 @@ describe('WorkspaceChannelSettingsStore', () => { mode: 'safe', senderPolicy: 'open', groupPolicy: 'pairing', + sessionScope: 'chat_thread', allowedUsers: ['user-1'], + groups: { + '*': { requireMention: false }, + 'group-1': { dispatchMode: 'collect', groupHistoryLimit: 25 }, + }, groupHistoryLimit: 25, blockStreaming: 'on', identity: { id: 'ops', displayName: 'Ops' }, @@ -622,6 +654,191 @@ describe('WorkspaceChannelSettingsStore', () => { expect(fs.readFileSync(settingsPath, 'utf8')).toBe(beforeRejectedWrite); }); + it('preserves unchanged legacy group settings while editing another field', async () => { + writeWorkspaceSettings(`{ + "$version": 4, + "channels": { "bot": { + "type": "management-validation-test", + "clientId": "client-id", + "clientSecret": "existing-secret", + "groups": { "group-1": { "mentionKeywords": ["@bot"] } } + } } +}\n`); + const store = new WorkspaceChannelSettingsStore(workspace); + + const next = await store.upsert('bot', { + expectedRevision: store.snapshot().revision, + config: { + type: 'management-validation-test', + clientId: 'updated-id', + groups: { 'group-1': { mentionKeywords: ['@bot'] } }, + }, + secrets: { clientSecret: { operation: 'preserve' } }, + }); + + expect(next.channels['bot']).toMatchObject({ + clientId: 'updated-id', + groups: { 'group-1': { mentionKeywords: ['@bot'] } }, + }); + }); + + it.each([ + { stored: null, changed: [] }, + { stored: [], changed: null }, + ])( + 'preserves unchanged non-record groups but rejects a changed value', + async ({ stored, changed }) => { + writeWorkspaceSettings( + JSON.stringify({ + $version: 4, + channels: { + bot: { + type: 'management-validation-test', + clientId: 'client-id', + clientSecret: 'existing-secret', + groups: stored, + }, + }, + }), + ); + const store = new WorkspaceChannelSettingsStore(workspace); + + const next = await store.upsert('bot', { + expectedRevision: store.snapshot().revision, + config: { + type: 'management-validation-test', + clientId: 'updated-id', + groups: stored, + }, + secrets: { clientSecret: { operation: 'preserve' } }, + }); + + expect(next.channels['bot']).toMatchObject({ + clientId: 'updated-id', + groups: stored, + }); + + await expect( + store.upsert('bot', { + expectedRevision: next.revision, + config: { + type: 'management-validation-test', + clientId: 'updated-id', + groups: changed, + }, + secrets: { clientSecret: { operation: 'preserve' } }, + }), + ).rejects.toMatchObject({ code: 'channel_settings_invalid_config' }); + }, + ); + + it('rejects unchanged non-record groups containing an unsafe key', async () => { + const groups = [JSON.parse('{"__proto__":{"polluted":true}}') as unknown]; + writeWorkspaceSettings( + JSON.stringify({ + $version: 4, + channels: { + bot: { + type: 'management-validation-test', + clientId: 'client-id', + clientSecret: 'existing-secret', + groups, + }, + }, + }), + ); + const store = new WorkspaceChannelSettingsStore(workspace); + + await expect( + store.upsert('bot', { + expectedRevision: store.snapshot().revision, + config: { + type: 'management-validation-test', + clientId: 'updated-id', + groups, + }, + secrets: { clientSecret: { operation: 'preserve' } }, + }), + ).rejects.toMatchObject({ code: 'channel_settings_invalid_config' }); + }); + + it('preserves unchanged legacy values in known group fields', async () => { + writeWorkspaceSettings(`{ + "$version": 4, + "channels": { "bot": { + "type": "management-validation-test", + "clientId": "client-id", + "clientSecret": "existing-secret", + "groups": { + "*": { "requireMention": "yes", "dispatchMode": "collect" } + } + } } +}\n`); + const store = new WorkspaceChannelSettingsStore(workspace); + + const next = await store.upsert('bot', { + expectedRevision: store.snapshot().revision, + config: { + type: 'management-validation-test', + clientId: 'updated-id', + groups: { '*': { requireMention: 'yes', dispatchMode: 'steer' } }, + }, + secrets: { clientSecret: { operation: 'preserve' } }, + }); + + expect(next.channels['bot']).toMatchObject({ + clientId: 'updated-id', + groups: { '*': { requireMention: 'yes', dispatchMode: 'steer' } }, + }); + + await expect( + store.upsert('bot', { + expectedRevision: next.revision, + config: { + type: 'management-validation-test', + clientId: 'updated-id', + groups: { '*': { requireMention: 'no', dispatchMode: 'steer' } }, + }, + secrets: { clientSecret: { operation: 'preserve' } }, + }), + ).rejects.toMatchObject({ code: 'channel_settings_invalid_config' }); + }); + + it('rejects reserved keys inside unchanged known group fields', async () => { + writeWorkspaceSettings(`{ + "$version": 4, + "channels": { "bot": { + "type": "management-validation-test", + "clientId": "client-id", + "clientSecret": "existing-secret", + "groups": { + "*": { "requireMention": { "__proto__": { "legacy": true } } } + } + } } +}\n`); + const store = new WorkspaceChannelSettingsStore(workspace); + const before = fs.readFileSync(settingsPath, 'utf8'); + + await expect( + store.upsert('bot', { + expectedRevision: store.snapshot().revision, + config: { + type: 'management-validation-test', + clientId: 'updated-id', + groups: { + '*': { + requireMention: JSON.parse( + '{"__proto__":{"legacy":true}}', + ) as unknown, + }, + }, + }, + secrets: { clientSecret: { operation: 'preserve' } }, + }), + ).rejects.toMatchObject({ code: 'channel_settings_invalid_config' }); + expect(fs.readFileSync(settingsPath, 'utf8')).toBe(before); + }); + it('re-validates an unchanged stored scalar instead of preserving it', async () => { writeWorkspaceSettings(`{ "$version": 4, diff --git a/packages/cli/src/serve/channel-settings-store.ts b/packages/cli/src/serve/channel-settings-store.ts index 6d80ea1afe2..e9d91e6a449 100644 --- a/packages/cli/src/serve/channel-settings-store.ts +++ b/packages/cli/src/serve/channel-settings-store.ts @@ -139,7 +139,11 @@ function assertNumberRecord( } } -function assertSharedField(key: string, value: unknown): boolean { +function assertSharedField( + key: string, + value: unknown, + previous?: unknown, +): boolean { const enumValues: Record> = { senderPolicy: new Set(['allowlist', 'pairing', 'open']), dmPolicy: new Set(['open', 'disabled']), @@ -169,6 +173,64 @@ function assertSharedField(key: string, value: unknown): boolean { } return true; } + if (key === 'groups') { + if (!isRecord(value)) { + if ( + containsUnsafeObjectKey(value) || + !isDeepStrictEqual(previous, value) + ) { + throw invalidConfig(`Channel field "${key}" must be an object.`); + } + return true; + } + const previousGroups = isRecord(previous) ? previous : {}; + for (const [groupId, groupConfig] of Object.entries(value)) { + if (UNSAFE_OBJECT_KEYS.has(groupId) || !isRecord(groupConfig)) { + throw invalidConfig(`Channel field "${key}.${groupId}" is invalid.`); + } + const previousGroup = isRecord(previousGroups[groupId]) + ? previousGroups[groupId] + : {}; + for (const [nestedKey, nestedValue] of Object.entries(groupConfig)) { + const known = [ + 'requireMention', + 'dispatchMode', + 'groupHistoryLimit', + ].includes(nestedKey); + const valid = + (nestedKey === 'requireMention' && + typeof nestedValue === 'boolean') || + (nestedKey === 'dispatchMode' && + typeof nestedValue === 'string' && + ['collect', 'steer', 'followup'].includes(nestedValue)) || + (nestedKey === 'groupHistoryLimit' && + typeof nestedValue === 'number' && + Number.isFinite(nestedValue)); + if ( + known && + !valid && + !( + Object.hasOwn(previousGroup, nestedKey) && + isDeepStrictEqual(previousGroup[nestedKey], nestedValue) && + !containsUnsafeObjectKey(nestedValue) + ) + ) { + throw invalidConfig( + `Channel field "${key}.${groupId}.${nestedKey}" is invalid.`, + ); + } + if (!known) { + assertPreservedUnknownField( + `${key}.${groupId}`, + nestedKey, + nestedValue, + previousGroup, + ); + } + } + } + return true; + } if (key === 'groupHistoryLimit') { if (typeof value !== 'number' || !Number.isFinite(value)) { throw invalidConfig(`Channel field "${key}" must be a number.`); @@ -345,7 +407,15 @@ function assertManagedConfig( ); continue; } - if (assertSharedField(key, value)) continue; + if ( + assertSharedField( + key, + value, + Object.hasOwn(previous, key) ? previous[key] : undefined, + ) + ) { + continue; + } assertPreservedUnknownField(undefined, key, value, previous); } assertRequiredFields(fields, config); diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.module.css b/packages/web-shell/client/components/channels/ChannelEditorDialog.module.css index 8849b351304..972e4244c04 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.module.css +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.module.css @@ -59,6 +59,37 @@ content: ''; } +.settingsPanel { + display: flex; + flex-direction: column; + gap: 14px; + padding: 16px; + border: 1px solid var(--border); + border-radius: calc(var(--radius-lg) + 2px); + background: color-mix(in srgb, var(--card) 94%, var(--muted)); +} + +.settingsPanelHeader { + display: flex; + flex-direction: column; + gap: 3px; +} + +.settingsPanelTitle { + margin: 0; + color: var(--foreground); + font-size: 14px; + font-weight: 700; + letter-spacing: -0.01em; +} + +.settingsPanelDescription { + margin: 0; + color: var(--muted-foreground); + font-size: 11px; + line-height: 1.5; +} + .field { display: flex; flex-direction: column; @@ -176,14 +207,104 @@ line-height: 1.45; } +.sessionScopeField { + display: flex; + flex-direction: column; + gap: 9px; +} + +.sessionScopeLabel { + color: var(--muted-foreground); + font-size: 11px; + font-weight: 650; +} + +.sessionScopeControl { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 3px; + padding: 3px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--muted); +} + +.sessionScopeOption { + position: relative; + display: flex; + min-width: 0; + min-height: 36px; + cursor: pointer; + align-items: center; + justify-content: center; + padding: 7px 8px; + border: 1px solid transparent; + border-radius: calc(var(--radius-lg) - 3px); + color: var(--muted-foreground); + font-size: 12px; + font-weight: 650; + line-height: 1.25; + text-align: center; + transition: + color 120ms ease, + border-color 120ms ease, + background-color 120ms ease, + box-shadow 120ms ease; +} + +.sessionScopeOption:hover { + color: var(--foreground); +} + +.sessionScopeOption[data-selected='true'] { + border-color: var(--border); + background: var(--background); + color: var(--foreground); + box-shadow: 0 1px 3px color-mix(in srgb, var(--foreground) 12%, transparent); +} + +.sessionScopeOption:has(.sessionScopeRadio:focus-visible) { + outline: 2px solid var(--ring); + outline-offset: 1px; +} + +.sessionScopeRadio { + position: absolute; + z-index: 1; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + border: 0; + margin: 0; + cursor: pointer; + opacity: 0; +} + +.sessionScopeDescription { + min-height: 18px; + margin: 0; + color: var(--muted-foreground); + font-size: 12px; + line-height: 1.5; +} + @media (max-width: 520px) { .policyGrid { grid-template-columns: 1fr; } + + .sessionScopeControl { + grid-template-columns: 1fr; + } } @media (prefers-reduced-motion: reduce) { .policyCard { transition: none; } + + .sessionScopeOption { + transition: none; + } } diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx index a578adefd20..041efbeaf5b 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx @@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { DaemonChannelInstanceSnapshot, DaemonChannelTypeDescriptor, + DaemonWorkspaceCapability, } from '@qwen-code/sdk/daemon'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); @@ -58,6 +59,43 @@ const DINGTALK: DaemonChannelTypeDescriptor = { ], }; +const DINGTALK_WITH_ACCESS: DaemonChannelTypeDescriptor = { + ...DINGTALK, + fields: [ + ...DINGTALK.fields, + { + key: 'senderPolicy', + label: 'Sender Policy', + kind: 'enum', + required: true, + default: 'allowlist', + options: [ + { value: 'pairing', label: 'Pairing' }, + { value: 'allowlist', label: 'Allowlist' }, + { value: 'open', label: 'Open' }, + ], + }, + { + key: 'allowedUsers', + label: 'Allowed Users', + kind: 'string-list', + }, + { + key: 'groupPolicy', + label: 'Group Policy', + kind: 'enum', + required: true, + default: 'disabled', + options: [ + { value: 'disabled', label: 'Disabled' }, + { value: 'pairing', label: 'Pairing' }, + { value: 'allowlist', label: 'Allowlist' }, + { value: 'open', label: 'Open' }, + ], + }, + ], +}; + const OPTIONAL_SECRET: DaemonChannelTypeDescriptor = { ...DINGTALK, fields: DINGTALK.fields.map((field) => @@ -141,6 +179,23 @@ const PAIRING_INSTANCE: DaemonChannelInstanceSnapshot = { }, }; +const WORKSPACES: DaemonWorkspaceCapability[] = [ + { + id: 'primary', + cwd: '/workspace/main', + displayName: 'Main repo', + primary: true, + trusted: true, + }, + { + id: 'secondary', + cwd: '/workspace/secondary', + displayName: 'Secondary repo', + primary: false, + trusted: true, + }, +]; + const { ChannelEditorDialog } = await import('./ChannelEditorDialog'); const { I18nProvider } = await import('../../i18n'); @@ -158,6 +213,9 @@ async function renderDialog( descriptor={DINGTALK} expectedRevision="revision-1" existingNames={[]} + workspaces={WORKSPACES} + workspaceCwd="/workspace/main" + onWorkspaceChange={vi.fn()} onOpenChange={vi.fn()} onSave={vi.fn().mockResolvedValue(undefined)} onReload={vi.fn().mockResolvedValue(undefined)} @@ -179,6 +237,39 @@ function inputByLabel(label: string): HTMLInputElement | null { return id ? document.querySelector(`#${id}`) : null; } +function fieldByLabel(label: string): HTMLElement | null { + const labels = Array.from(document.querySelectorAll('label')); + const match = labels.find((item) => item.textContent?.includes(label)); + return match?.htmlFor + ? document.querySelector(`#${match.htmlFor}`) + : null; +} + +async function selectOption(label: string, optionLabel: string) { + const trigger = fieldByLabel(label); + expect(trigger).not.toBeNull(); + await act(async () => { + trigger!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + const option = Array.from( + document.querySelectorAll('[role="option"]'), + ).find((item) => item.textContent?.trim() === optionLabel); + expect(option).toBeDefined(); + await act(async () => { + option!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); +} + +async function chooseRadioOption(optionLabel: string) { + const option = Array.from(document.querySelectorAll('label')).find( + (label) => label.textContent?.trim() === optionLabel, + ); + expect(option).not.toBeNull(); + await act(async () => { + option!.click(); + }); +} + function setInputValue(input: HTMLInputElement, value: string) { Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, @@ -200,6 +291,117 @@ afterEach(() => { }); describe('ChannelEditorDialog', () => { + it('defaults to the primary workspace and allows a registered workspace', async () => { + const onWorkspaceChange = vi.fn(); + await renderDialog({ onWorkspaceChange }); + + expect(fieldByLabel('Workspace')?.textContent).toContain('Main repo'); + expect(fieldByLabel('Workspace')?.textContent).toContain('Primary'); + + await selectOption('Workspace', 'Secondary repo'); + + expect(onWorkspaceChange).toHaveBeenCalledWith('/workspace/secondary'); + }); + + it('offers the legacy thread scope only for an existing legacy Channel', async () => { + await renderDialog(); + + expect(document.body.textContent).not.toContain('By thread (legacy)'); + + await renderDialog({ instance: INSTANCE }); + + expect(document.body.textContent).not.toContain('By thread (legacy)'); + + await renderDialog({ + instance: { + ...INSTANCE, + config: { ...INSTANCE.config, sessionScope: 'thread' }, + }, + }); + + expect(document.body.textContent).toContain('By thread (legacy)'); + expect( + document + .querySelector('[role="radio"][value="thread"]') + ?.getAttribute('data-state'), + ).toBe('checked'); + + const defaultThreadDescriptor: DaemonChannelTypeDescriptor = { + ...DINGTALK, + fields: DINGTALK.fields.map((field) => + field.key === 'sessionScope' ? { ...field, default: 'thread' } : field, + ), + }; + const defaultThreadInstance: DaemonChannelInstanceSnapshot = { + ...INSTANCE, + config: { ...INSTANCE.config }, + }; + delete defaultThreadInstance.config.sessionScope; + await renderDialog({ + descriptor: defaultThreadDescriptor, + instance: defaultThreadInstance, + }); + + expect(document.body.textContent).toContain('By thread (legacy)'); + expect( + document + .querySelector('[role="radio"][value="thread"]') + ?.getAttribute('data-state'), + ).toBe('checked'); + }); + + it('clears validation errors when switching workspaces', async () => { + await renderDialog({ existingNames: ['duplicate'] }); + + await act(async () => { + setInputValue(inputByLabel('Instance name')!, 'duplicate'); + }); + const save = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Save', + ); + await act(async () => save?.click()); + expect(document.body.textContent).toContain( + 'A Channel with this name already exists.', + ); + expect(inputByLabel('Instance name')?.getAttribute('aria-invalid')).toBe( + 'true', + ); + + await renderDialog({ + existingNames: [], + workspaceCwd: '/workspace/secondary', + }); + + expect(document.body.textContent).not.toContain( + 'A Channel with this name already exists.', + ); + expect(inputByLabel('Instance name')?.getAttribute('aria-invalid')).toBe( + 'false', + ); + }); + + it('clears submit errors when switching workspaces', async () => { + const onSave = vi.fn().mockRejectedValue(new Error('Revision conflict.')); + await renderDialog({ onSave }); + + await act(async () => { + setInputValue(inputByLabel('Instance name')!, 'release-bot'); + setInputValue(inputByLabel('Client ID')!, 'ding-client-id'); + setInputValue(inputByLabel('Client Secret')!, 'ding-client-secret'); + }); + const save = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Save', + ); + await act(async () => save?.click()); + expect(document.body.textContent).toContain('Revision conflict.'); + expect(document.body.textContent).toContain('Reload latest'); + + await renderDialog({ workspaceCwd: '/workspace/secondary' }); + + expect(document.body.textContent).not.toContain('Revision conflict.'); + expect(document.body.textContent).not.toContain('Reload latest'); + }); + it('does not render object metadata as a text field', async () => { await renderDialog(); @@ -213,6 +415,7 @@ describe('ChannelEditorDialog', () => { expect(document.body.textContent).toContain('Stored in environment'); expect(document.body.textContent).not.toContain('Clear'); expect(inputByLabel('Client Secret')).toBeNull(); + expect(fieldByLabel('Workspace')).toHaveProperty('disabled', true); const replace = Array.from(document.querySelectorAll('button')).find( (button) => button.textContent?.trim() === 'Replace', @@ -236,9 +439,9 @@ describe('ChannelEditorDialog', () => { it('shows the effective session scope in its own section', async () => { await renderDialog({ instance: INSTANCE }); - expect(document.body.textContent).toContain('Session'); - expect(document.body.textContent).toContain('Session scope'); - expect(document.body.textContent).toContain('Per user and chat'); + expect(document.body.textContent).toContain('Conversation management'); + expect(document.body.textContent).toContain('Conversation isolation'); + expect(document.body.textContent).toContain('By user'); }); it('submits a new instance with typed fields and the current revision', async () => { @@ -282,6 +485,148 @@ describe('ChannelEditorDialog', () => { }); }); + it('can be dismissed while a save finishes in the background', async () => { + let finishSave!: () => void; + const onSave = vi.fn( + () => + new Promise((resolve) => { + finishSave = resolve; + }), + ); + const onOpenChange = vi.fn(); + await renderDialog({ onSave, onOpenChange }); + + await act(async () => { + setInputValue(inputByLabel('Instance name')!, 'release-bot'); + setInputValue(inputByLabel('Client ID')!, 'ding-client-id'); + setInputValue(inputByLabel('Client Secret')!, 'ding-client-secret'); + }); + const save = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Save', + ); + await act(async () => { + save?.click(); + }); + expect((fieldByLabel('Workspace') as HTMLButtonElement).disabled).toBe( + true, + ); + const cancel = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Cancel', + ); + + expect(cancel?.disabled).toBe(false); + await act(async () => { + cancel?.click(); + }); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + + await act(async () => finishSave()); + expect(onOpenChange).toHaveBeenCalledTimes(1); + }); + + it('explains reserved group IDs under the allowlist field', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + await renderDialog({ descriptor: DINGTALK_WITH_ACCESS, onSave }); + + await act(async () => { + setInputValue(inputByLabel('Instance name')!, 'release-bot'); + setInputValue(inputByLabel('Client ID')!, 'ding-client-id'); + setInputValue(inputByLabel('Client Secret')!, 'ding-client-secret'); + }); + await selectOption('Group policy', 'Allowlist'); + await act(async () => { + setInputValue(inputByLabel('Allowed group IDs')!, '__proto__'); + }); + + const save = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Save', + ); + await act(async () => { + save?.click(); + }); + + expect(onSave).not.toHaveBeenCalled(); + expect( + inputByLabel('Allowed group IDs')?.getAttribute('aria-invalid'), + ).toBe('true'); + expect(document.body.textContent).toContain( + 'Enter a group ID other than __proto__, constructor, or prototype.', + ); + expect(document.body.textContent).not.toContain( + 'Choose a different instance name.', + ); + }); + + it('submits sender and group allowlists in their runtime config shapes', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + await renderDialog({ descriptor: DINGTALK_WITH_ACCESS, onSave }); + + const name = inputByLabel('Instance name'); + const clientId = inputByLabel('Client ID'); + const clientSecret = inputByLabel('Client Secret'); + await act(async () => { + setInputValue(name!, 'release-bot'); + setInputValue(clientId!, 'ding-client-id'); + setInputValue(clientSecret!, 'ding-client-secret'); + }); + + const allowedUsers = inputByLabel('Allowed user IDs'); + expect(allowedUsers).not.toBeNull(); + await selectOption('Direct message policy', 'Pairing'); + expect(inputByLabel('Allowed user IDs')).toBeNull(); + await selectOption('Direct message policy', 'Allowlist'); + await act(async () => { + setInputValue(inputByLabel('Allowed user IDs')!, 'staff-a, staff-b'); + }); + + await selectOption('Group policy', 'Allowlist'); + const allowedGroups = inputByLabel('Allowed group IDs'); + expect(allowedGroups).not.toBeNull(); + await act(async () => { + setInputValue(allowedGroups!, 'group-a, group-b'); + }); + + const dialogText = document.body.textContent ?? ''; + expect(dialogText).toContain('Conversation management'); + expect(dialogText.indexOf('Conversation management')).toBeLessThan( + dialogText.indexOf('Access control'), + ); + expect(dialogText).toContain( + "The same user's messages continue in one conversation; users stay isolated from each other.", + ); + await chooseRadioOption('By chat or thread'); + expect(document.body.textContent).toContain( + 'Messages in the same group or topic share one conversation; best for collaboration.', + ); + + const save = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Save', + ); + await act(async () => { + save?.click(); + }); + + expect(onSave).toHaveBeenCalledWith('release-bot', { + expectedRevision: 'revision-1', + config: { + type: 'dingtalk', + clientId: 'ding-client-id', + senderPolicy: 'allowlist', + allowedUsers: ['staff-a', 'staff-b'], + groupPolicy: 'allowlist', + sessionScope: 'chat_thread', + groups: { 'group-a': {}, 'group-b': {} }, + }, + secrets: { + clientSecret: { + operation: 'replace', + value: 'ding-client-secret', + }, + }, + }); + }); + it('explains that pairing requests appear after a new Channel is saved', async () => { await renderDialog(); diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx index 10961af8c3f..802c16a4274 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx @@ -7,6 +7,7 @@ import { useEffect, useId, + useRef, useState, type FormEvent, type ReactNode, @@ -22,9 +23,11 @@ import type { DaemonChannelPairingRevocationResult, DaemonChannelTypeDescriptor, DaemonChannelUpsertRequest, + DaemonWorkspaceCapability, } from '@qwen-code/sdk/daemon'; import { useI18n } from '../../i18n'; import { extractErrorDetail } from '../../utils/errorDetail'; +import { workspaceLabel } from '../../utils/workspace'; import { Alert, AlertDescription, AlertTitle } from '../ui/alert'; import { Button } from '../ui/button'; import { @@ -52,6 +55,7 @@ import { ChannelPairingRequests } from './ChannelPairingRequests'; import { buildChannelUpsertRequest, createChannelEditorDraft, + hasDescriptorGroupPolicy, hasDescriptorSenderPolicy, validateChannelEditorDraft, type ChannelEditorDraft, @@ -93,8 +97,18 @@ const FIELD_LABEL_KEYS: Record> = { }, }; -const COMMON_FIELD_LABEL_KEYS: Record = { - sessionScope: 'channels.editor.field.sessionScope', +const SHARED_ACCESS_FIELD_KEYS = new Set([ + 'senderPolicy', + 'allowedUsers', + 'groupPolicy', +]); +const SHARED_SESSION_FIELD_KEYS = new Set(['sessionScope']); + +const SHARED_FIELD_LABEL_KEYS: Record = { + senderPolicy: 'channels.editor.field.shared.senderPolicy', + allowedUsers: 'channels.editor.field.shared.allowedUsers', + groupPolicy: 'channels.editor.field.shared.groupPolicy', + sessionScope: 'channels.editor.field.shared.sessionScope', }; export interface ChannelEditorDialogProps { @@ -103,6 +117,10 @@ export interface ChannelEditorDialogProps { instance?: DaemonChannelInstanceSnapshot; expectedRevision: string; existingNames: readonly string[]; + workspaces: readonly DaemonWorkspaceCapability[]; + workspaceCwd: string; + workspaceLoading?: boolean; + onWorkspaceChange: (workspaceCwd: string) => void; onOpenChange: (open: boolean) => void; onSave: ( name: string, @@ -185,6 +203,10 @@ export function ChannelEditorDialog({ instance, expectedRevision, existingNames, + workspaces, + workspaceCwd, + workspaceLoading = false, + onWorkspaceChange, onOpenChange, onSave, onReload, @@ -202,25 +224,57 @@ export function ChannelEditorDialog({ const [submitError, setSubmitError] = useState(); const [saving, setSaving] = useState(false); const [reloading, setReloading] = useState(false); + const dismissedRef = useRef(false); + const accessFields = descriptor.fields.filter((field) => + SHARED_ACCESS_FIELD_KEYS.has(field.key), + ); + const sessionFields = descriptor.fields.filter((field) => + SHARED_SESSION_FIELD_KEYS.has(field.key), + ); + const sessionScopeField = sessionFields.find( + (field) => field.key === 'sessionScope' && field.kind === 'enum', + ); + const sessionScopeOptions = (sessionScopeField?.options ?? []).filter( + (option) => + option.value !== 'thread' || + instance?.config.sessionScope === 'thread' || + (instance !== undefined && + instance.config.sessionScope === undefined && + sessionScopeField?.default === 'thread'), + ); + const remainingSessionFields = sessionFields.filter( + (field) => field !== sessionScopeField, + ); + const credentialFields = descriptor.fields.filter( + (field) => + !SHARED_ACCESS_FIELD_KEYS.has(field.key) && + !SHARED_SESSION_FIELD_KEYS.has(field.key), + ); useEffect(() => { if (!open) return; + dismissedRef.current = false; setDraft(createChannelEditorDraft(descriptor, instance)); setErrors({}); setSubmitError(undefined); }, [descriptor, instance, open]); - const fieldLabelKey = (field: DaemonChannelConfigFieldDescriptor) => - FIELD_LABEL_KEYS[descriptor.type]?.[field.key] ?? - COMMON_FIELD_LABEL_KEYS[field.key]; + useEffect(() => { + setErrors({}); + setSubmitError(undefined); + }, [workspaceCwd]); const fieldLabel = (field: DaemonChannelConfigFieldDescriptor) => { - const key = fieldLabelKey(field); + const key = + FIELD_LABEL_KEYS[descriptor.type]?.[field.key] ?? + SHARED_FIELD_LABEL_KEYS[field.key]; return key ? t(key) : field.label; }; const fieldDescription = (field: DaemonChannelConfigFieldDescriptor) => { - const labelKey = fieldLabelKey(field); + const labelKey = + FIELD_LABEL_KEYS[descriptor.type]?.[field.key] ?? + SHARED_FIELD_LABEL_KEYS[field.key]; if (labelKey) { const descKey = `${labelKey}.description`; const translated = t(descKey); @@ -231,15 +285,19 @@ export function ChannelEditorDialog({ const fieldOptionLabel = ( field: DaemonChannelConfigFieldDescriptor, - option: { value: string; label: string }, + value: string, + fallback: string, ) => { - const labelKey = fieldLabelKey(field); - if (labelKey) { - const optionKey = `${labelKey}.option.${option.value}`; + const labelKeys = [ + FIELD_LABEL_KEYS[descriptor.type]?.[field.key], + SHARED_FIELD_LABEL_KEYS[field.key], + ].filter((key): key is string => Boolean(key)); + for (const labelKey of labelKeys) { + const optionKey = `${labelKey}.option.${value}`; const translated = t(optionKey); if (translated !== optionKey) return translated; } - return option.label; + return fallback; }; const validationMessage = ( @@ -250,6 +308,8 @@ export function ChannelEditorDialog({ if (code === 'credential') return t('channels.editor.validation.credential'); if (code === 'invalid') return t('channels.editor.validation.invalidName'); + if (code === 'invalidGroupId') + return t('channels.editor.validation.invalidGroupId'); if (code === 'invalidOption') return t('channels.editor.validation.invalidOption'); if (code === 'number') return t('channels.editor.validation.number'); @@ -298,7 +358,7 @@ export function ChannelEditorDialog({ instance, ), ); - onOpenChange(false); + if (!dismissedRef.current) onOpenChange(false); } catch (error) { setSubmitError(extractErrorDetail(error)); } finally { @@ -464,7 +524,7 @@ export function ChannelEditorDialog({ {field.options?.map((option) => ( - {fieldOptionLabel(field, option)} + {fieldOptionLabel(field, option.value, option.label)} ))} @@ -551,15 +611,14 @@ export function ChannelEditorDialog({ ); }; - const sessionScopeField = descriptor.fields.find( - (field) => field.key === 'sessionScope', - ); - const platformFields = descriptor.fields.filter( - (field) => field.key !== 'sessionScope' && field.kind !== 'object', - ); - return ( - + { + if (!nextOpen) dismissedRef.current = true; + onOpenChange(nextOpen); + }} + >
@@ -633,23 +692,121 @@ export function ChannelEditorDialog({ } /> + + + - {platformFields.length > 0 ? ( -
-

- {t('channels.editor.section.credentials')} -

- {platformFields.map(renderField)} -
- ) : null} +
+

+ {t('channels.editor.section.credentials')} +

+ {credentialFields.map(renderField)} +
- {sessionScopeField ? ( -
-

+ {sessionFields.length > 0 ? ( +
+

{t('channels.editor.section.session')}

- {renderField(sessionScopeField)} + {sessionScopeField ? ( +
+ + {t('channels.editor.session.isolation')} + + + setDraft((current) => ({ + ...current, + values: { + ...current.values, + [sessionScopeField.key]: value, + }, + })) + } + > + {sessionScopeOptions.map((option) => ( + + ))} + +

+ {t( + `channels.editor.field.shared.sessionScope.detail.${String( + draft.values[sessionScopeField.key] ?? 'user', + )}`, + )} +

+ {errors[sessionScopeField.key] ? ( +

+ {errors[sessionScopeField.key]} +

+ ) : null} +
+ ) : null} + {remainingSessionFields.map(renderField)}
) : null} @@ -659,21 +816,36 @@ export function ChannelEditorDialog({ ? String(draft.values['senderPolicy'] ?? '') : draft.senderPolicy; const showRadioGroup = !descriptorPolicy; - const descriptorGroupPolicy = descriptor.fields.some( - (field) => field.key === 'groupPolicy', - ); + const descriptorGroupPolicy = + hasDescriptorGroupPolicy(descriptor); const effectiveGroupPolicy = descriptorGroupPolicy ? String(draft.values['groupPolicy'] ?? '') : String(instance?.config.groupPolicy ?? ''); const showPairing = effectivePolicy === 'pairing' || effectiveGroupPolicy === 'pairing'; - if (!showRadioGroup && !showPairing) return null; + const visibleAccessFields = accessFields.filter( + (field) => + field.key !== 'allowedUsers' || + effectivePolicy === 'allowlist', + ); + if ( + !showRadioGroup && + visibleAccessFields.length === 0 && + !showPairing + ) { + return null; + } return ( -
-

- {t('channels.editor.section.access')} -

+
+
+

+ {t('channels.editor.section.access')} +

+

+ {t('channels.editor.section.access.description')} +

+
{showRadioGroup ? ( <> ) : null} + {visibleAccessFields.map(renderField)} + {effectiveGroupPolicy === 'allowlist' ? ( + + + setDraft((current) => ({ + ...current, + allowedGroupIds: event.target.value, + })) + } + /> + + ) : null} {showPairing ? ( instance?.config.senderPolicy === 'pairing' || instance?.config.groupPolicy === 'pairing' ? ( @@ -748,11 +946,19 @@ export function ChannelEditorDialog({ - diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.module.css b/packages/web-shell/client/components/channels/ChannelsManagerPage.module.css index 4e0e4385d6e..73736feb9fa 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.module.css +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.module.css @@ -1,48 +1,105 @@ .page { display: flex; - width: 100%; + min-height: 100%; flex-direction: column; - gap: 28px; - padding-block-end: 32px; + margin: -16px -20px 0; } .pageHeader { display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - padding-block-end: 18px; + min-height: 46px; + flex: 0 0 auto; + align-items: center; + gap: 10px; + padding: 10px 20px; border-block-end: 1px solid var(--border); } -.titleGroup { +.backButton { + width: 30px; + height: 30px; + flex: 0 0 30px; + color: var(--muted-foreground); +} + +.title { + min-width: 0; + color: var(--foreground); + font-size: 14px; + font-weight: 600; + line-height: 1.35; + outline: none; +} + +.pageBody { display: flex; min-width: 0; - align-items: flex-start; - gap: 8px; + flex-direction: column; + gap: 24px; + padding: 20px 24px 32px; } -.titleCopy { +.intro { + color: var(--muted-foreground); + font-size: 13px; + line-height: 1.5; +} + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.count { min-width: 0; + color: var(--foreground); + font-size: 13px; + font-weight: 600; + line-height: 1.4; } -.title { - outline: none; - font-size: 24px; - font-weight: 650; - letter-spacing: -0.025em; - line-height: 1.2; +.toolbarActions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; } -.summary { - overflow: hidden; - margin-block-start: 5px; +.workspacePicker { + display: flex; + align-items: center; + gap: 8px; +} + +.workspacePickerLabel { color: var(--muted-foreground); - font-size: 13px; - text-overflow: ellipsis; + font-size: 12px; + font-weight: 500; white-space: nowrap; } +.workspacePickerTrigger { + width: 220px; + min-height: 34px; + background: var(--background); +} + +.refreshButton { + min-height: 34px; +} + +.loadingState { + display: flex; + min-height: 120px; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--muted-foreground); + font-size: 13px; +} + .section { display: flex; min-width: 0; @@ -52,79 +109,148 @@ .sectionHeader { display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; + min-width: 0; + flex-direction: column; + gap: 4px; } .sectionTitle { + color: var(--foreground); font-size: 14px; - font-weight: 650; + font-weight: 600; + line-height: 1.4; +} + +.sectionDescription { + color: var(--muted-foreground); + font-size: 12px; + line-height: 1.5; +} + +.emptyState { + min-height: 140px; + border: 0; + background: transparent; } .channelGrid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + display: flex; + min-width: 0; + flex-direction: column; gap: 12px; } .channelCard { - position: relative; - overflow: hidden; -} - -.channelCard::before { - position: absolute; - inset-block: 0; - inset-inline-start: 0; - width: 3px; - background: var(--muted-foreground); - content: ''; + min-width: 0; + border-radius: 12px; + background: var(--background); } -.channelCard[data-runtime-state='connected']::before { - background: var(--success-color); +.channelHeader { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 14px 14px 4px; } -.channelCard[data-runtime-state='starting']::before, -.channelCard[data-runtime-state='partial']::before { - background: var(--warning-color); +.channelIdentity { + display: flex; + min-width: 0; + align-items: center; + gap: 11px; } -.channelCard[data-runtime-state='error']::before { - background: var(--destructive); +.channelIdentityCopy { + min-width: 0; } -.channelActions { +.channelNameRow { display: flex; + min-width: 0; flex-wrap: wrap; align-items: center; - justify-content: space-between; - gap: 10px; + gap: 7px; +} + +.channelName { + overflow: hidden; + min-width: 0; + color: var(--foreground); + font-size: 14px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; } -.lifecycleActions { +.channelMeta { display: flex; + min-width: 0; flex-wrap: wrap; - gap: 8px; + gap: 4px; + margin-block-start: 2px; + font-size: 11px; } -.startupControl { - display: inline-flex; +.runtimeBadge { + height: 19px; + padding-inline: 7px; + font-size: 10px; +} + +.runtimeBadge[data-runtime-state='connected'] { + background: var(--success-bg); + color: var(--success-color); +} + +.cardActionGroup { + display: flex; align-items: center; - gap: 8px; - color: var(--muted-foreground); - font-size: 12px; + gap: 2px; } .errorAlert [data-slot='alert-description'] { - display: -webkit-box; - overflow: hidden; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; overflow-wrap: anywhere; } +.channelFooter { + min-height: 58px; + padding: 11px 14px; + border-block-start: 1px solid var(--border); +} + +.startupControl { + display: flex; + width: 100%; + cursor: pointer; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.startupCopy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.startupLabel { + color: var(--foreground); + font-size: 12px; + font-weight: 500; + line-height: 1.35; +} + +.startupDescription { + color: var(--muted-foreground); + font-size: 11px; + line-height: 1.4; +} + +.platformSection { + padding-block-start: 4px; +} + .platformGrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -134,28 +260,26 @@ .platformCard { display: flex; min-width: 0; + min-height: 66px; cursor: pointer; align-items: center; gap: 11px; padding: 13px; border: 1px solid var(--border); - border-radius: var(--radius-lg); - background: var(--card); + border-radius: 12px; + background: var(--background); color: inherit; font: inherit; text-align: start; - transition: - border-color 120ms ease, - background-color 120ms ease; + transition: background-color 150ms ease; } .platformCard:hover:not(:disabled) { - border-color: color-mix(in srgb, var(--foreground) 38%, var(--border)); - background: color-mix(in srgb, var(--muted) 46%, var(--card)); + background: var(--muted); } .platformCard:focus-visible { - outline: 3px solid color-mix(in srgb, var(--ring) 50%, transparent); + outline: 2px solid var(--ring); outline-offset: 2px; } @@ -175,50 +299,95 @@ background: var(--muted); color: var(--foreground); font-size: 12px; - font-weight: 750; + font-weight: 700; letter-spacing: -0.03em; } .platformCopy { + display: flex; min-width: 0; + flex: 1 1 auto; + flex-direction: column; + gap: 2px; } .platformName { overflow: hidden; + color: var(--foreground); font-size: 13px; - font-weight: 650; + font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } .platformHint { - margin-block-start: 2px; color: var(--muted-foreground); font-size: 11px; } -@container panel-body (max-width: 620px) { +.platformAction { + display: inline-flex; + width: 26px; + height: 26px; + flex: 0 0 26px; + align-items: center; + justify-content: center; + color: var(--muted-foreground); +} + +.platformAction svg { + width: 14px; + height: 14px; +} + +@container panel-body (max-width: 760px) { .platformGrid { - grid-template-columns: 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); } } -@container panel-body (max-width: 440px) { - .pageHeader { +@container panel-body (max-width: 620px) { + .pageBody { + padding-inline: 20px; + } + + .toolbar { + align-items: stretch; + flex-direction: column; + } + + .toolbarActions { + align-items: stretch; + flex-wrap: wrap; + } + + .workspacePicker { + min-width: 0; + flex: 1 1 100%; + } + + .workspacePickerTrigger { + width: 100%; + } + + .channelHeader { + display: flex; + align-items: stretch; flex-direction: column; } - .channelGrid { + .cardActionGroup { + width: 100%; + justify-content: flex-end; + } + + .platformGrid { grid-template-columns: 1fr; } } @media (prefers-reduced-motion: reduce) { - .channelCard, - .channelCard *, .platformCard { transition-duration: 0.01ms !important; - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; } } diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx b/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx index 3c8dd2d38be..a7ba909a8ed 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx @@ -73,7 +73,17 @@ const { channelState, useChannelsMock, workspaceState } = vi.hoisted(() => ({ current: { workspaceCwd: '/workspace/demo', token: 'secret', - capabilities: { features: ['channel_management'] }, + capabilities: { + features: ['channel_management'], + workspaces: [] as Array<{ + id: string; + cwd: string; + displayName?: string; + primary: boolean; + trusted: boolean; + kind?: 'live'; + }>, + }, }, }, })); @@ -116,6 +126,24 @@ async function renderPage() { }); } +function setInputValue(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set; + setter?.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); +} + +function inputByLabel(label: string): HTMLInputElement | null { + const match = Array.from(document.querySelectorAll('label')).find((item) => + item.textContent?.includes(label), + ); + return match?.htmlFor + ? document.querySelector(`#${match.htmlFor}`) + : null; +} + beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); @@ -188,7 +216,7 @@ beforeEach(() => { workspaceState.current = { workspaceCwd: '/workspace/demo', token: 'secret', - capabilities: { features: ['channel_management'] }, + capabilities: { features: ['channel_management'], workspaces: [] }, }; }); @@ -202,6 +230,9 @@ describe('ChannelsManagerPage', () => { await renderPage(); expect(container.textContent).toContain('DingTalk Bot'); + expect(container.textContent).toContain( + 'Offline and not receiving messages.', + ); expect(container.textContent).not.toContain('Telegram Bot'); expect( container.querySelectorAll('[data-testid^="channel-platform-"]'), @@ -261,6 +292,159 @@ describe('ChannelsManagerPage', () => { expect(document.body.textContent).toContain('Client Secret (AppSecret)'); }); + it('manages the primary workspace by default and switches to a registered workspace', async () => { + workspaceState.current = { + ...workspaceState.current, + workspaceCwd: '/workspace/secondary', + capabilities: { + features: ['channel_management'], + workspaces: [ + { + id: 'primary', + cwd: '/workspace/main', + displayName: 'Main repo', + primary: true, + trusted: true, + }, + { + id: 'secondary', + cwd: '/workspace/secondary', + displayName: 'Secondary repo', + primary: false, + trusted: true, + }, + ], + }, + }; + await renderPage(); + + expect(useChannelsMock).toHaveBeenLastCalledWith({ + autoLoad: true, + enabled: true, + workspaceCwd: '/workspace/main', + }); + + const trigger = document.querySelector( + '[aria-label="Workspace"]', + ); + await act(async () => { + trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + const secondary = Array.from( + document.querySelectorAll('[role="option"]'), + ).find((item) => item.textContent?.trim() === 'Secondary repo'); + await act(async () => { + secondary?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(useChannelsMock).toHaveBeenLastCalledWith({ + autoLoad: true, + enabled: true, + workspaceCwd: '/workspace/secondary', + }); + }); + + it('keeps a newer workspace selection when a dismissed save finishes', async () => { + let finishSave!: () => void; + channelState.current.createOrUpdate.mockImplementationOnce( + () => + new Promise((resolve) => { + finishSave = resolve; + }), + ); + workspaceState.current = { + ...workspaceState.current, + capabilities: { + features: ['channel_management'], + workspaces: [ + { + id: 'primary', + cwd: '/workspace/main', + displayName: 'Main repo', + primary: true, + trusted: true, + }, + { + id: 'secondary', + cwd: '/workspace/secondary', + displayName: 'Secondary repo', + primary: false, + trusted: true, + }, + { + id: 'third', + cwd: '/workspace/third', + displayName: 'Third repo', + primary: false, + trusted: true, + }, + ], + }, + }; + await renderPage(); + + const platform = container.querySelector( + '[data-testid="channel-platform-dingtalk"]', + ); + await act(async () => platform?.click()); + const dialog = document.querySelector('[role="dialog"]'); + const workspaceLabel = Array.from( + dialog?.querySelectorAll('label') ?? [], + ).find((label) => label.textContent?.includes('Workspace')); + const workspaceTrigger = workspaceLabel?.htmlFor + ? document.getElementById(workspaceLabel.htmlFor) + : undefined; + await act(async () => { + workspaceTrigger?.dispatchEvent( + new MouseEvent('click', { bubbles: true }), + ); + }); + const secondary = Array.from( + document.querySelectorAll('[role="option"]'), + ).find((item) => item.textContent?.trim() === 'Secondary repo'); + await act(async () => secondary?.click()); + + await act(async () => { + setInputValue(inputByLabel('Instance name')!, 'release-bot'); + setInputValue(inputByLabel('Client ID')!, 'ding-client-id'); + setInputValue(inputByLabel('Client Secret')!, 'ding-client-secret'); + }); + const save = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Save', + ); + await act(async () => save?.click()); + expect(channelState.current.createOrUpdate).toHaveBeenCalledTimes(1); + + const cancel = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Cancel', + ); + await act(async () => cancel?.click()); + const toolbarWorkspace = document.querySelector( + '[aria-label="Workspace"]', + ); + await act(async () => { + toolbarWorkspace?.dispatchEvent( + new MouseEvent('click', { bubbles: true }), + ); + }); + const third = Array.from( + document.querySelectorAll('[role="option"]'), + ).find((item) => item.textContent?.trim() === 'Third repo'); + await act(async () => third?.click()); + expect(useChannelsMock).toHaveBeenLastCalledWith({ + autoLoad: true, + enabled: true, + workspaceCwd: '/workspace/third', + }); + + await act(async () => finishSave()); + expect(useChannelsMock).toHaveBeenLastCalledWith({ + autoLoad: true, + enabled: true, + workspaceCwd: '/workspace/third', + }); + }); + it('opens an existing Channel for editing', async () => { channelState.current.channels.ding = { ...channelState.current.channels.ding, @@ -306,9 +490,18 @@ describe('ChannelsManagerPage', () => { it('deletes a Channel with the current revision', async () => { await renderPage(); - const remove = Array.from(container.querySelectorAll('button')).find( - (button) => button.getAttribute('aria-label') === 'Delete DingTalk Bot', + const more = Array.from(container.querySelectorAll('button')).find( + (button) => + button.getAttribute('aria-label') === 'More actions for DingTalk Bot', ); + await act(async () => { + more?.dispatchEvent( + new MouseEvent('pointerdown', { bubbles: true, button: 0 }), + ); + }); + const remove = Array.from( + document.body.querySelectorAll('[role="menuitem"]'), + ).find((item) => item.getAttribute('aria-label') === 'Delete DingTalk Bot'); await act(async () => { remove?.click(); }); @@ -327,6 +520,183 @@ describe('ChannelsManagerPage', () => { }); }); + it('clears a stale lifecycle error after deleting the channel', async () => { + channelState.current.start.mockRejectedValueOnce( + new Error('stale start failure'), + ); + await renderPage(); + + const start = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Start', + ); + await act(async () => start?.click()); + expect(container.textContent).toContain('stale start failure'); + + const more = Array.from(container.querySelectorAll('button')).find( + (button) => + button.getAttribute('aria-label') === 'More actions for DingTalk Bot', + ); + await act(async () => { + more?.dispatchEvent( + new MouseEvent('pointerdown', { bubbles: true, button: 0 }), + ); + }); + const remove = Array.from( + document.body.querySelectorAll('[role="menuitem"]'), + ).find((item) => item.getAttribute('aria-label') === 'Delete DingTalk Bot'); + await act(async () => remove?.click()); + const confirm = Array.from( + document + .querySelector('[role="alertdialog"]') + ?.querySelectorAll('button') ?? [], + ).find((button) => button.textContent?.trim() === 'Delete'); + await act(async () => confirm?.click()); + + expect(container.textContent).not.toContain('stale start failure'); + }); + + it('allows an independent lifecycle action after switching workspaces', async () => { + let finishPrimaryStart!: () => void; + let finishSecondaryStart!: () => void; + channelState.current.start + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishPrimaryStart = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishSecondaryStart = resolve; + }), + ); + workspaceState.current = { + ...workspaceState.current, + capabilities: { + features: ['channel_management'], + workspaces: [ + { + id: 'primary', + cwd: '/workspace/main', + displayName: 'Main repo', + primary: true, + trusted: true, + }, + { + id: 'secondary', + cwd: '/workspace/secondary', + displayName: 'Secondary repo', + primary: false, + trusted: true, + }, + ], + }, + }; + await renderPage(); + + const start = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Start', + ); + await act(async () => start?.click()); + const workspaceTrigger = container.querySelector( + '[aria-label="Workspace"]', + ); + expect(workspaceTrigger?.disabled).toBe(false); + await act(async () => { + workspaceTrigger?.dispatchEvent( + new MouseEvent('click', { bubbles: true }), + ); + }); + const secondary = Array.from( + document.querySelectorAll('[role="option"]'), + ).find((item) => item.textContent?.trim() === 'Secondary repo'); + await act(async () => { + secondary?.click(); + }); + + expect(useChannelsMock).toHaveBeenLastCalledWith({ + autoLoad: true, + enabled: true, + workspaceCwd: '/workspace/secondary', + }); + const secondaryStart = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent?.trim() === 'Start'); + expect(secondaryStart?.disabled).toBe(false); + await act(async () => secondaryStart?.click()); + expect(channelState.current.start).toHaveBeenCalledTimes(2); + expect(secondaryStart?.disabled).toBe(true); + + await act(async () => { + workspaceTrigger?.dispatchEvent( + new MouseEvent('click', { bubbles: true }), + ); + }); + const primary = Array.from( + document.querySelectorAll('[role="option"]'), + ).find((item) => item.textContent?.includes('Main repo')); + expect(primary).toBeDefined(); + await act(async () => primary?.click()); + expect(useChannelsMock).toHaveBeenLastCalledWith({ + autoLoad: true, + enabled: true, + workspaceCwd: '/workspace/main', + }); + const primaryStart = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Start', + ); + expect(primaryStart?.disabled).toBe(true); + expect(primaryStart?.querySelector('[data-slot="spinner"]')).not.toBeNull(); + + await act(async () => { + workspaceTrigger?.dispatchEvent( + new MouseEvent('click', { bubbles: true }), + ); + }); + const secondaryAgain = Array.from( + document.querySelectorAll('[role="option"]'), + ).find((item) => item.textContent?.trim() === 'Secondary repo'); + await act(async () => secondaryAgain?.click()); + + await act(async () => finishPrimaryStart()); + expect(secondaryStart?.disabled).toBe(true); + + await act(async () => finishSecondaryStart()); + expect(secondaryStart?.disabled).toBe(false); + }); + + it('keeps restart in the overflow menu for a running Channel', async () => { + channelState.current.channels.ding = channel( + 'DingTalk Bot', + 'dingtalk', + 'connected', + ); + channelState.current.snapshot = { + revision: '1', + instances: channelState.current.channels, + }; + await renderPage(); + + const more = Array.from(container.querySelectorAll('button')).find( + (button) => + button.getAttribute('aria-label') === 'More actions for DingTalk Bot', + ); + await act(async () => { + more?.dispatchEvent( + new MouseEvent('pointerdown', { bubbles: true, button: 0 }), + ); + }); + const restart = Array.from( + document.body.querySelectorAll('[role="menuitem"]'), + ).find((item) => item.textContent?.trim() === 'Restart'); + await act(async () => { + restart?.click(); + }); + + expect(channelState.current.restart).toHaveBeenCalledWith('DingTalk Bot'); + }); + it('closes an editor when the selected workspace changes', async () => { await renderPage(); const platform = container.querySelector( @@ -368,7 +738,7 @@ describe('ChannelsManagerPage', () => { it('does not load Channel routes when the capability is unavailable', async () => { workspaceState.current = { ...workspaceState.current, - capabilities: { features: [] }, + capabilities: { features: [], workspaces: [] }, }; await renderPage(); @@ -378,6 +748,11 @@ describe('ChannelsManagerPage', () => { expect(useChannelsMock).toHaveBeenLastCalledWith({ autoLoad: false, enabled: false, + workspaceCwd: '/workspace/demo', }); + expect( + document.querySelector('[aria-label="Workspace"]') + ?.disabled, + ).toBe(true); }); }); diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx index 6a2c0bffcdc..b94f1a17cff 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx @@ -4,12 +4,22 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useCallback, useEffect, useMemo, useState, type Ref } from 'react'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type Ref, +} from 'react'; import { AlertCircleIcon, ArrowLeftIcon, + EllipsisVerticalIcon, PencilIcon, + PlusIcon, RadioTowerIcon, + RefreshCwIcon, RotateCwIcon, Trash2Icon, } from 'lucide-react'; @@ -18,6 +28,7 @@ import type { DaemonChannelRuntimeState, DaemonChannelTypeDescriptor, DaemonChannelUpsertRequest, + DaemonWorkspaceCapability, } from '@qwen-code/sdk/daemon'; import { useChannels, useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; import { useI18n } from '../../i18n'; @@ -43,6 +54,14 @@ import { CardHeader, CardTitle, } from '../ui/card'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '../ui/dropdown-menu'; import { Empty, EmptyDescription, @@ -51,7 +70,15 @@ import { EmptyTitle, } from '../ui/empty'; import { Spinner } from '../ui/spinner'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select'; import { Switch } from '../ui/switch'; +import { workspaceLabel } from '../../utils/workspace'; import { ChannelEditorDialog } from './ChannelEditorDialog'; import styles from './ChannelsManagerPage.module.css'; import { @@ -67,6 +94,10 @@ interface ChannelsManagerPageProps { type ChannelAction = 'start' | 'stop' | 'restart' | 'startup'; +function actionErrorKey(workspaceCwd: string | undefined, name: string) { + return `${workspaceCwd ?? ''}\0${name}`; +} + const STATUS_KEYS: Record = { stopped: 'channels.status.stopped', starting: 'channels.status.starting', @@ -75,6 +106,17 @@ const STATUS_KEYS: Record = { error: 'channels.status.error', }; +const STATUS_DESCRIPTION_KEYS: Record< + DaemonChannelRuntimeState['state'], + string +> = { + stopped: 'channels.statusDescription.stopped', + starting: 'channels.statusDescription.starting', + connected: 'channels.statusDescription.connected', + partial: 'channels.statusDescription.partial', + error: 'channels.statusDescription.error', +}; + function badgeVariant( state: DaemonChannelRuntimeState['state'], ): 'secondary' | 'outline' | 'destructive' { @@ -91,6 +133,46 @@ export function ChannelsManagerPage({ const workspace = useWorkspace(); const supportsManagement = workspace.capabilities?.features.includes('channel_management') === true; + const registeredWorkspaces = useMemo(() => { + const listed = (workspace.capabilities?.workspaces ?? []).filter( + (entry) => entry.kind !== 'live', + ); + if (listed.length > 0) return listed; + if (!workspace.workspaceCwd) return []; + return [ + { + id: 'primary', + cwd: workspace.workspaceCwd, + primary: true, + trusted: true, + }, + ]; + }, [workspace.capabilities?.workspaces, workspace.workspaceCwd]); + const defaultWorkspace = + registeredWorkspaces.find((entry) => entry.primary) ?? + registeredWorkspaces.find((entry) => entry.trusted) ?? + registeredWorkspaces[0]; + const [managementWorkspaceCwd, setManagementWorkspaceCwd] = useState< + string | undefined + >(); + const selectedManagementWorkspace = + registeredWorkspaces.find( + (entry) => entry.cwd === managementWorkspaceCwd, + ) ?? defaultWorkspace; + const [editor, setEditor] = useState<{ + workspaceCwd: string; + descriptor: DaemonChannelTypeDescriptor; + instance?: DaemonChannelInstanceSnapshot; + }>(); + const editorRef = useRef(editor); + useEffect(() => { + editorRef.current = editor; + }, [editor]); + const activeWorkspaceCwd = + editor?.workspaceCwd ?? selectedManagementWorkspace?.cwd; + const activeWorkspace = registeredWorkspaces.find( + (entry) => entry.cwd === activeWorkspaceCwd, + ); const { catalog, snapshot, @@ -108,18 +190,27 @@ export function ChannelsManagerPage({ } = useChannels({ autoLoad: supportsManagement, enabled: supportsManagement, + workspaceCwd: activeWorkspaceCwd, }); - const canManage = supportsManagement && Boolean(workspace.token); - const [busy, setBusy] = useState<{ - name: string; - action: ChannelAction; - } | null>(null); + const canManage = + supportsManagement && + Boolean(workspace.token) && + Boolean(activeWorkspaceCwd) && + activeWorkspace?.trusted === true; + const [busyByWorkspace, setBusyByWorkspace] = useState< + Record< + string, + { + workspaceCwd: string; + name: string; + action: ChannelAction; + } + > + >({}); + const busy = activeWorkspaceCwd + ? (busyByWorkspace[activeWorkspaceCwd] ?? null) + : null; const [actionErrors, setActionErrors] = useState>({}); - const [editor, setEditor] = useState<{ - workspaceCwd?: string; - descriptor: DaemonChannelTypeDescriptor; - instance?: DaemonChannelInstanceSnapshot; - }>(); const [deleteTarget, setDeleteTarget] = useState<{ workspaceCwd?: string; instance: DaemonChannelInstanceSnapshot; @@ -128,13 +219,27 @@ export function ChannelsManagerPage({ const [deleting, setDeleting] = useState(false); useEffect(() => { - setBusy(null); - setActionErrors({}); - setEditor(undefined); setDeleteTarget(undefined); setDeleteError(undefined); setDeleting(false); - }, [workspace.workspaceCwd]); + }, [activeWorkspaceCwd]); + + useEffect(() => { + if ( + managementWorkspaceCwd && + !registeredWorkspaces.some( + (entry) => entry.cwd === managementWorkspaceCwd, + ) + ) { + setManagementWorkspaceCwd(undefined); + } + if ( + editor && + !registeredWorkspaces.some((entry) => entry.cwd === editor.workspaceCwd) + ) { + setEditor(undefined); + } + }, [editor, managementWorkspaceCwd, registeredWorkspaces]); const availablePlatforms = useMemo( () => catalog.filter(isChannelPlatformAvailable), @@ -147,12 +252,9 @@ export function ChannelsManagerPage({ .sort((left, right) => left.name.localeCompare(right.name)), [channels], ); - const workspaceName = - workspace.workspaceCwd - ?.split(/[\\/]+/) - .filter(Boolean) - .at(-1) ?? t('channels.workspace.current'); - + const workspaceName = activeWorkspace + ? workspaceLabel(activeWorkspace) + : t('channels.workspace.current'); const channelTypeLabel = useCallback( (channel: DaemonChannelInstanceSnapshot) => { const type = String(channel.config.type); @@ -170,15 +272,21 @@ export function ChannelsManagerPage({ ); const saveChannel = useCallback( - (name: string, request: DaemonChannelUpsertRequest) => - createOrUpdate(name, request), - [createOrUpdate], + async (name: string, request: DaemonChannelUpsertRequest) => { + const workspaceCwd = editor?.workspaceCwd; + const result = await createOrUpdate(name, request); + if (workspaceCwd && editorRef.current?.workspaceCwd === workspaceCwd) { + setManagementWorkspaceCwd(workspaceCwd); + } + return result; + }, + [createOrUpdate, editor?.workspaceCwd], ); const deleteChannel = useCallback(async () => { if ( !deleteTarget || - deleteTarget.workspaceCwd !== workspace.workspaceCwd || + deleteTarget.workspaceCwd !== activeWorkspaceCwd || !snapshot || deleting ) { @@ -190,13 +298,20 @@ export function ChannelsManagerPage({ await remove(deleteTarget.instance.name, { expectedRevision: snapshot.revision, }); + setActionErrors((current) => { + const next = { ...current }; + delete next[ + actionErrorKey(deleteTarget.workspaceCwd, deleteTarget.instance.name) + ]; + return next; + }); setDeleteTarget(undefined); } catch (removeError) { setDeleteError(extractErrorDetail(removeError)); } finally { setDeleting(false); } - }, [deleteTarget, deleting, remove, snapshot, workspace.workspaceCwd]); + }, [activeWorkspaceCwd, deleteTarget, deleting, remove, snapshot]); const runAction = useCallback( async ( @@ -204,11 +319,16 @@ export function ChannelsManagerPage({ action: ChannelAction, operation: () => Promise, ) => { - if (!canManage || busy) return; - setBusy({ name: channel.name, action }); + if (!canManage || busy || !activeWorkspaceCwd) return; + const workspaceCwd = activeWorkspaceCwd; + const errorKey = actionErrorKey(workspaceCwd, channel.name); + setBusyByWorkspace((current) => ({ + ...current, + [workspaceCwd]: { workspaceCwd, name: channel.name, action }, + })); setActionErrors((current) => { const next = { ...current }; - delete next[channel.name]; + delete next[errorKey]; return next; }); try { @@ -216,13 +336,24 @@ export function ChannelsManagerPage({ } catch (actionError) { setActionErrors((current) => ({ ...current, - [channel.name]: extractErrorDetail(actionError), + [errorKey]: extractErrorDetail(actionError), })); } finally { - setBusy(null); + setBusyByWorkspace((current) => { + const workspaceBusy = current[workspaceCwd]; + if ( + workspaceBusy?.name !== channel.name || + workspaceBusy.action !== action + ) { + return current; + } + const next = { ...current }; + delete next[workspaceCwd]; + return next; + }); } }, - [busy, canManage], + [activeWorkspaceCwd, busy, canManage], ); const renderPrimaryAction = (channel: DaemonChannelInstanceSnapshot) => { @@ -236,7 +367,9 @@ export function ChannelsManagerPage({ void runAction(channel, 'start', () => start(channel.name)) } > - {busy?.name === channel.name && busy.action === 'start' ? ( + {busy?.workspaceCwd === activeWorkspaceCwd && + busy.name === channel.name && + busy.action === 'start' ? ( ) : null} {t('channels.action.start')} @@ -252,7 +385,9 @@ export function ChannelsManagerPage({ void runAction(channel, 'restart', () => restart(channel.name)) } > - {busy?.name === channel.name && busy.action === 'restart' ? ( + {busy?.workspaceCwd === activeWorkspaceCwd && + busy.name === channel.name && + busy.action === 'restart' ? ( ) : null} {t('channels.action.retry')} @@ -268,7 +403,9 @@ export function ChannelsManagerPage({ void runAction(channel, 'stop', () => stop(channel.name)) } > - {busy?.name === channel.name && busy.action === 'stop' ? ( + {busy?.workspaceCwd === activeWorkspaceCwd && + busy.name === channel.name && + busy.action === 'stop' ? ( ) : null} {t('channels.action.stop')} @@ -279,275 +416,407 @@ export function ChannelsManagerPage({ return (
-
- -
-

- {t('channels.title')} -

-

- {t('channels.summary', { - workspace: workspaceName, - count: instances.length, - })} -

-
-
+ +

+ {t('channels.title')} +

- {!supportsManagement ? ( - - - {t('channels.unsupported.title')} - - {t('channels.unsupported.description')} - - - ) : null} - - {supportsManagement && !workspace.token ? ( - - - {t('channels.readOnly.title')} - - {t('channels.readOnly.description')} - - - ) : null} +
+

{t('channels.description')}

- {loading && instances.length === 0 ? ( -
- - {t('channels.loading')} +
+

+ {t('channels.summary', { + workspace: workspaceName, + count: instances.length, + })} +

+
+ {registeredWorkspaces.length > 0 ? ( +
+ + {t('channels.workspace.label')} + + +
+ ) : null} + +
- ) : null} - {error ? ( - - - {t('channels.loadError.title')} - {extractErrorDetail(error)} - - - ) : null} + + {t('channels.loading')} +
+ ) : null} -
-
-

- {t('channels.configured')} -

- {instances.length} -
- {!loading && !error && instances.length === 0 ? ( - - - - - - {t('channels.empty.title')} - - {t('channels.empty.description')} - - - + {error ? ( + + + {t('channels.loadError.title')} + {extractErrorDetail(error)} + + ) : null} - {instances.length > 0 ? ( -
- {instances.map((channel) => { - const descriptor = descriptorFor(channel); - const runtimeError = - actionErrors[channel.name] ?? channel.runtime.lastError; - return ( - - -
- - {channel.name} - - {t(STATUS_KEYS[channel.runtime.state])} - - - - {channelTypeLabel(channel)} - -
- {renderPrimaryAction(channel)} -
- {runtimeError ? ( - - - - {t('channels.runtimeError')} - {runtimeError} - - - ) : null} - - -
- {channel.runtime.state !== 'stopped' && - channel.runtime.state !== 'error' ? ( - + ) : null} + + + + + + + {canRestart ? ( + + void runAction(channel, 'restart', () => + restart(channel.name), + ) + } + > + + {t('channels.action.restart')} + + ) : null} + {canRestart ? : null} + { + setDeleteError(undefined); + setDeleteTarget({ + workspaceCwd: activeWorkspaceCwd, + instance: channel, + }); + }} + > + + {t('channels.action.delete')} + + + + + + + {runtimeError ? ( + + - {busy?.name === channel.name && - busy.action === 'restart' ? ( - - ) : ( - - )} - {t('channels.action.restart')} - - ) : null} - {descriptor ? ( - - ) : null} - -
-
-
- ); - })} -
- ) : null} -
- - {availablePlatforms.length > 0 ? ( -
-
-

- {t('channels.availablePlatforms')} -

-

- {t('channels.availablePlatforms.description')} -

-
-
- {availablePlatforms.map((platform) => ( - - ))} -
+ /> + + + + ); + })} +
+ ) : null}
- ) : null} - {editor && editor.workspaceCwd === workspace.workspaceCwd && snapshot ? ( + {availablePlatforms.length > 0 ? ( +
+
+

+ {t('channels.availablePlatforms')} +

+

+ {t('channels.availablePlatforms.description')} +

+
+
+ {availablePlatforms.map((platform) => ( + + ))} +
+
+ ) : null} +

+ + {editor ? ( channel.name !== editor.instance?.name) .map((channel) => channel.name)} + workspaces={registeredWorkspaces} + workspaceCwd={editor.workspaceCwd} + workspaceLoading={loading} + onWorkspaceChange={(workspaceCwd) => + setEditor((current) => + current ? { ...current, workspaceCwd } : current, + ) + } onOpenChange={(open) => { if (!open) setEditor(undefined); }} @@ -562,7 +831,7 @@ export function ChannelsManagerPage({ { if (!open && !deleting) { diff --git a/packages/web-shell/client/components/channels/channel-editor-state.test.ts b/packages/web-shell/client/components/channels/channel-editor-state.test.ts index 1632e0b35d7..8cb3d742d2e 100644 --- a/packages/web-shell/client/components/channels/channel-editor-state.test.ts +++ b/packages/web-shell/client/components/channels/channel-editor-state.test.ts @@ -56,6 +56,43 @@ const DINGTALK: DaemonChannelTypeDescriptor = { ], }; +const DINGTALK_WITH_ACCESS: DaemonChannelTypeDescriptor = { + ...DINGTALK, + fields: [ + ...DINGTALK.fields, + { + key: 'senderPolicy', + label: 'Sender Policy', + kind: 'enum', + required: true, + default: 'pairing', + options: [ + { value: 'pairing', label: 'Pairing' }, + { value: 'allowlist', label: 'Allowlist' }, + { value: 'open', label: 'Open' }, + ], + }, + { + key: 'allowedUsers', + label: 'Allowed Users', + kind: 'string-list', + }, + { + key: 'groupPolicy', + label: 'Group Policy', + kind: 'enum', + required: true, + default: 'disabled', + options: [ + { value: 'disabled', label: 'Disabled' }, + { value: 'pairing', label: 'Pairing' }, + { value: 'allowlist', label: 'Allowlist' }, + { value: 'open', label: 'Open' }, + ], + }, + ], +}; + function configuredInstance(): DaemonChannelInstanceSnapshot { return { name: 'release-bot', @@ -131,11 +168,264 @@ describe('Channel editor state', () => { }); }); + it('serializes sender and group allowlists in the runtime config shapes', () => { + const draft = createChannelEditorDraft(DINGTALK_WITH_ACCESS) as ReturnType< + typeof createChannelEditorDraft + > & { allowedGroupIds?: string }; + draft.name = 'release-bot'; + draft.values.clientId = 'ding-client-id'; + draft.values.senderPolicy = 'allowlist'; + draft.values.allowedUsers = 'staff-a, staff-b'; + draft.values.groupPolicy = 'allowlist'; + draft.values.sessionScope = 'chat_thread'; + draft.allowedGroupIds = 'group-a, group-b'; + draft.secrets.clientSecret = { + operation: 'replace', + value: 'ding-client-secret', + }; + + expect( + buildChannelUpsertRequest(DINGTALK_WITH_ACCESS, draft, 'revision-access') + .config, + ).toEqual({ + type: 'dingtalk', + clientId: 'ding-client-id', + senderPolicy: 'allowlist', + allowedUsers: ['staff-a', 'staff-b'], + groupPolicy: 'allowlist', + sessionScope: 'chat_thread', + groups: { + 'group-a': {}, + 'group-b': {}, + }, + }); + }); + + it('preserves the deprecated thread scope when editing', () => { + const instance = configuredInstance(); + + const draft = createChannelEditorDraft(DINGTALK_WITH_ACCESS, instance); + + expect(draft.values.sessionScope).toBe('thread'); + expect( + buildChannelUpsertRequest( + DINGTALK_WITH_ACCESS, + draft, + 'revision-session-scope', + instance, + ).config.sessionScope, + ).toBe('thread'); + }); + + it('uses chat_thread for a new Channel whose plugin default is legacy thread', () => { + const descriptor: DaemonChannelTypeDescriptor = { + ...DINGTALK, + fields: DINGTALK.fields.map((field) => + field.key === 'sessionScope' ? { ...field, default: 'thread' } : field, + ), + }; + + const draft = createChannelEditorDraft(descriptor); + + expect(draft.values.sessionScope).toBe('chat_thread'); + }); + + it('uses a visible scope for a new Channel without chat_thread support', () => { + const descriptor: DaemonChannelTypeDescriptor = { + ...DINGTALK, + fields: DINGTALK.fields.map((field) => + field.key === 'sessionScope' + ? { + ...field, + default: 'thread', + options: field.options?.filter( + (option) => option.value !== 'chat_thread', + ), + } + : field, + ), + }; + + const draft = createChannelEditorDraft(descriptor); + + expect(draft.values.sessionScope).toBe('user'); + }); + + it('preserves an inherited legacy thread default when editing', () => { + const descriptor: DaemonChannelTypeDescriptor = { + ...DINGTALK, + fields: DINGTALK.fields.map((field) => + field.key === 'sessionScope' ? { ...field, default: 'thread' } : field, + ), + }; + const instance = configuredInstance(); + delete instance.config.sessionScope; + + const draft = createChannelEditorDraft(descriptor, instance); + + expect(draft.values.sessionScope).toBe('thread'); + expect( + buildChannelUpsertRequest( + descriptor, + draft, + 'revision-session-scope', + instance, + ).config.sessionScope, + ).toBe('thread'); + }); + + it('fills safe policy defaults when editing a legacy instance', () => { + const instance = configuredInstance(); + delete instance.config.senderPolicy; + + const draft = createChannelEditorDraft(DINGTALK_WITH_ACCESS, instance); + + expect(draft.values.senderPolicy).toBe('allowlist'); + expect(draft.values.groupPolicy).toBe('disabled'); + expect(validateChannelEditorDraft(DINGTALK_WITH_ACCESS, draft, [])).toEqual( + {}, + ); + }); + + it('changes group allowlist membership without losing wildcard or retained group settings', () => { + const instance: DaemonChannelInstanceSnapshot = { + ...configuredInstance(), + config: { + ...configuredInstance().config, + senderPolicy: 'allowlist', + allowedUsers: ['staff-a'], + groupPolicy: 'allowlist', + groups: { + '*': { requireMention: false }, + 'group-a': { dispatchMode: 'collect' }, + 'group-removed': { requireMention: true }, + }, + }, + }; + const draft = createChannelEditorDraft( + DINGTALK_WITH_ACCESS, + instance, + ) as ReturnType & { + allowedGroupIds?: string; + }; + + expect(draft.allowedGroupIds).toBe('group-a, group-removed'); + draft.allowedGroupIds = 'group-a, group-new'; + + expect( + buildChannelUpsertRequest( + DINGTALK_WITH_ACCESS, + draft, + 'revision-groups', + instance, + ).config.groups, + ).toEqual({ + '*': { requireMention: false }, + 'group-a': { dispatchMode: 'collect' }, + 'group-new': {}, + }); + }); + + it('rejects unsafe group allowlist keys before building the request', () => { + const draft = createChannelEditorDraft(DINGTALK_WITH_ACCESS); + draft.name = 'release-bot'; + draft.values.clientId = 'ding-client-id'; + draft.values.senderPolicy = 'allowlist'; + draft.values.groupPolicy = 'allowlist'; + draft.allowedGroupIds = '__proto__'; + draft.secrets.clientSecret = { + operation: 'replace', + value: 'ding-client-secret', + }; + + expect( + validateChannelEditorDraft(DINGTALK_WITH_ACCESS, draft, []), + ).toMatchObject({ allowedGroupIds: 'invalidGroupId' }); + }); + + it('ignores a hidden group allowlist outside allowlist policy', () => { + const draft = createChannelEditorDraft(DINGTALK_WITH_ACCESS); + draft.name = 'release-bot'; + draft.values.clientId = 'ding-client-id'; + draft.values.senderPolicy = 'allowlist'; + draft.values.groupPolicy = 'open'; + draft.allowedGroupIds = '__proto__'; + draft.secrets.clientSecret = { + operation: 'replace', + value: 'ding-client-secret', + }; + + expect(validateChannelEditorDraft(DINGTALK_WITH_ACCESS, draft, [])).toEqual( + {}, + ); + expect( + buildChannelUpsertRequest(DINGTALK_WITH_ACCESS, draft, 'revision-open') + .config, + ).not.toHaveProperty('groups'); + }); + + it('removes allowlist-only groups but keeps behavior settings when leaving allowlist', () => { + const instance: DaemonChannelInstanceSnapshot = { + ...configuredInstance(), + config: { + ...configuredInstance().config, + groupPolicy: 'allowlist', + groups: { + '*': { requireMention: false }, + 'group-a': {}, + 'group-b': { requireMention: true }, + 'group-c': { dispatchMode: 'collect', groupHistoryLimit: 25 }, + }, + }, + }; + const draft = createChannelEditorDraft(DINGTALK_WITH_ACCESS, instance); + draft.values.groupPolicy = 'open'; + + expect( + buildChannelUpsertRequest( + DINGTALK_WITH_ACCESS, + draft, + 'revision-open', + instance, + ).config.groups, + ).toEqual({ + '*': { requireMention: false }, + 'group-b': { requireMention: true }, + 'group-c': { dispatchMode: 'collect', groupHistoryLimit: 25 }, + }); + }); + + it('preserves stored group settings for an unchanged non-allowlist policy', () => { + const groups = { + '*': { requireMention: false }, + 'group-a': { dispatchMode: 'collect', groupHistoryLimit: 25 }, + }; + const instance: DaemonChannelInstanceSnapshot = { + ...configuredInstance(), + config: { + ...configuredInstance().config, + groupPolicy: 'pairing', + groups, + }, + }; + const draft = createChannelEditorDraft(DINGTALK_WITH_ACCESS, instance); + draft.values.clientId = 'updated-id'; + + expect( + buildChannelUpsertRequest( + DINGTALK_WITH_ACCESS, + draft, + 'revision-pairing', + instance, + ).config.groups, + ).toEqual(groups); + }); + it('shows the effective scope default for a legacy instance', () => { const instance = configuredInstance(); delete instance.config.sessionScope; - const draft = createChannelEditorDraft(DINGTALK, instance); + const draft = createChannelEditorDraft(DINGTALK_WITH_ACCESS, instance); expect(draft.values.sessionScope).toBe('user'); }); @@ -322,6 +612,7 @@ const GITHUB: DaemonChannelTypeDescriptor = { label: 'Group Policy', kind: 'enum', required: true, + default: 'open', options: [ { value: 'open', label: 'Open' }, { value: 'allowlist', label: 'Allowlist' }, @@ -333,6 +624,7 @@ const GITHUB: DaemonChannelTypeDescriptor = { label: 'Sender Policy', kind: 'enum', required: true, + default: 'allowlist', options: [ { value: 'allowlist', label: 'Allowlist' }, { value: 'pairing', label: 'Pairing' }, @@ -374,7 +666,7 @@ describe('Descriptor-driven senderPolicy', () => { expect(draft.values.allowedUsers).toBe('alice, bob'); }); - it('leaves enum fields empty when editing an instance that lacks them', () => { + it('uses runtime policy fallbacks when editing an instance that lacks them', () => { const instance: DaemonChannelInstanceSnapshot = { name: 'legacy-bot', config: { type: 'github' }, @@ -383,8 +675,8 @@ describe('Descriptor-driven senderPolicy', () => { runtime: { state: 'stopped' }, }; const draft = createChannelEditorDraft(GITHUB, instance); - expect(draft.values.groupPolicy).toBe(''); - expect(draft.values.senderPolicy).toBe(''); + expect(draft.values.groupPolicy).toBe('disabled'); + expect(draft.values.senderPolicy).toBe('allowlist'); }); it('writes senderPolicy via descriptor fields, not the hardcoded path', () => { diff --git a/packages/web-shell/client/components/channels/channel-editor-state.ts b/packages/web-shell/client/components/channels/channel-editor-state.ts index a8b1e58ef8e..ba5562c04c7 100644 --- a/packages/web-shell/client/components/channels/channel-editor-state.ts +++ b/packages/web-shell/client/components/channels/channel-editor-state.ts @@ -24,6 +24,7 @@ export interface ChannelEditorDraft { values: Record; secrets: Record; senderPolicy: ChannelSenderPolicy; + allowedGroupIds: string; } export type ChannelEditorValidationCode = @@ -31,6 +32,7 @@ export type ChannelEditorValidationCode = | 'credential' | 'duplicate' | 'invalid' + | 'invalidGroupId' | 'invalidOption' | 'number' | 'outOfRange' @@ -49,10 +51,24 @@ export function hasDescriptorSenderPolicy( return descriptor.fields.some((f) => f.key === 'senderPolicy'); } +export function hasDescriptorGroupPolicy( + descriptor: DaemonChannelTypeDescriptor, +): boolean { + return descriptor.fields.some((f) => f.key === 'groupPolicy'); +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function configuredGroupIds(instance?: DaemonChannelInstanceSnapshot): string { + const groups = instance?.config['groups']; + if (!isRecord(groups)) return ''; + return Object.keys(groups) + .filter((groupId) => groupId !== '*') + .join(', '); +} + function initialFieldValue( field: DaemonChannelConfigFieldDescriptor, instance?: DaemonChannelInstanceSnapshot, @@ -75,9 +91,21 @@ function initialFieldValue( } if (field.kind === 'enum') { if (typeof value === 'string' && value) return value; - return instance && field.key !== 'sessionScope' - ? '' - : (field.default ?? field.options?.[0]?.value ?? ''); + if (instance) { + if (field.key === 'senderPolicy') return 'allowlist'; + if (field.key === 'groupPolicy') return 'disabled'; + if (field.key !== 'sessionScope') return ''; + } + if (field.key === 'sessionScope' && field.default === 'thread') { + if (instance) return 'thread'; + return ( + field.options?.find((option) => option.value === 'chat_thread') + ?.value ?? + field.options?.find((option) => option.value !== 'thread')?.value ?? + field.default + ); + } + return field.default ?? field.options?.[0]?.value ?? ''; } return typeof value === 'string' ? value : ''; } @@ -111,6 +139,7 @@ export function createChannelEditorDraft( : instance ? '' : 'pairing', + allowedGroupIds: configuredGroupIds(instance), }; } @@ -197,6 +226,14 @@ export function validateChannelEditorDraft( if (!draft.senderPolicy && !hasDescriptorSenderPolicy(descriptor)) { errors['senderPolicy'] = 'policy'; } + if ( + String(draft.values['groupPolicy'] ?? '') === 'allowlist' && + splitList(draft.allowedGroupIds).some((groupId) => + UNSAFE_OBJECT_KEYS.includes(groupId), + ) + ) { + errors['allowedGroupIds'] = 'invalidGroupId'; + } return errors; } @@ -246,6 +283,61 @@ function assignField( } } +function splitList(value: string): string[] { + return [ + ...new Set( + value + .split(',') + .map((item) => item.trim()) + .filter(Boolean), + ), + ]; +} + +function assignGroups( + config: Record, + allowedGroupIds: string, + instance?: DaemonChannelInstanceSnapshot, +): void { + const previous = instance?.config['groups']; + const previousGroups = isRecord(previous) ? previous : {}; + const groups: Record = {}; + if (isRecord(previousGroups['*'])) { + groups['*'] = previousGroups['*']; + } + for (const groupId of splitList(allowedGroupIds)) { + if (groupId === '*') continue; + groups[groupId] = isRecord(previousGroups[groupId]) + ? previousGroups[groupId] + : {}; + } + if (Object.keys(groups).length > 0) { + config['groups'] = groups; + } else { + delete config['groups']; + } +} + +function removeGroupAllowlistMembership( + config: Record, + instance: DaemonChannelInstanceSnapshot, +): void { + const previous = instance.config['groups']; + const previousGroups = isRecord(previous) ? previous : {}; + const groups = Object.fromEntries( + Object.entries(previousGroups).filter( + ([groupId, groupConfig]) => + isRecord(groupConfig) && + (groupId === '*' || Object.keys(groupConfig).length > 0), + ), + ); + if (Object.keys(groups).length > 0) { + config['groups'] = groups; + } else { + delete config['groups']; + } +} + export function buildChannelUpsertRequest( descriptor: DaemonChannelTypeDescriptor, draft: ChannelEditorDraft, @@ -274,5 +366,12 @@ export function buildChannelUpsertRequest( if (!hasDescriptorSenderPolicy(descriptor)) { config['senderPolicy'] = draft.senderPolicy; } + if (hasDescriptorGroupPolicy(descriptor)) { + if (config['groupPolicy'] === 'allowlist') { + assignGroups(config, draft.allowedGroupIds, instance); + } else if (instance?.config['groupPolicy'] === 'allowlist') { + removeGroupAllowlistMembership(config, instance); + } + } return { expectedRevision, config, secrets }; } diff --git a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts index d3abff75e76..d0d611c9443 100644 --- a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts +++ b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts @@ -261,6 +261,51 @@ for (const theme of THEMES) { required: true, envResolvable: true, }, + { + key: 'senderPolicy', + label: 'Sender Policy', + kind: 'enum', + required: true, + default: 'allowlist', + options: [ + { value: 'pairing', label: 'Pairing' }, + { value: 'allowlist', label: 'Allowlist' }, + { value: 'open', label: 'Open' }, + ], + }, + { + key: 'allowedUsers', + label: 'Allowed Users', + kind: 'string-list', + }, + { + key: 'groupPolicy', + label: 'Group Policy', + kind: 'enum', + required: true, + default: 'disabled', + options: [ + { value: 'disabled', label: 'Disabled' }, + { value: 'pairing', label: 'Pairing' }, + { value: 'allowlist', label: 'Allowlist' }, + { value: 'open', label: 'Open' }, + ], + }, + { + key: 'sessionScope', + label: 'Session Scope', + kind: 'enum', + required: true, + default: 'user', + options: [ + { value: 'user', label: 'Per user and chat' }, + { + value: 'chat_thread', + label: 'Per chat and thread', + }, + { value: 'single', label: 'One shared session' }, + ], + }, ], }, { @@ -324,6 +369,8 @@ for (const theme of THEMES) { type: 'dingtalk', clientId: 'ding-visual-app', senderPolicy: 'pairing', + groupPolicy: 'disabled', + sessionScope: 'user', }, secrets: { clientSecret: { present: true, source: 'literal' }, diff --git a/packages/web-shell/client/e2e/web-shell.channels.spec.ts b/packages/web-shell/client/e2e/web-shell.channels.spec.ts index 783ecc7cb89..37508d71c36 100644 --- a/packages/web-shell/client/e2e/web-shell.channels.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.channels.spec.ts @@ -176,6 +176,22 @@ test('creates and deletes a typed Channel configuration', async ({ 'workspace_voice', 'channel_management', ], + workspaces: [ + { + id: 'primary', + cwd: '/tmp/qwen-web-shell-e2e', + displayName: 'Main workspace', + primary: true, + trusted: true, + }, + { + id: 'secondary', + cwd: '/tmp/qwen-channel-secondary', + displayName: 'Release workspace', + primary: false, + trusted: true, + }, + ], }, channelTypes: [ { @@ -197,9 +213,39 @@ test('creates and deletes a typed Channel configuration', async ({ required: true, envResolvable: true, }, + { + key: 'senderPolicy', + label: 'Sender Policy', + kind: 'enum', + required: true, + default: 'pairing', + options: [ + { value: 'pairing', label: 'Pairing' }, + { value: 'allowlist', label: 'Allowlist' }, + { value: 'open', label: 'Open' }, + ], + }, + { + key: 'allowedUsers', + label: 'Allowed Users', + kind: 'string-list', + }, + { + key: 'groupPolicy', + label: 'Group Policy', + kind: 'enum', + required: true, + default: 'disabled', + options: [ + { value: 'disabled', label: 'Disabled' }, + { value: 'pairing', label: 'Pairing' }, + { value: 'allowlist', label: 'Allowlist' }, + { value: 'open', label: 'Open' }, + ], + }, { key: 'sessionScope', - label: 'Session scope', + label: 'Session Scope', kind: 'enum', required: true, default: 'user', @@ -263,11 +309,34 @@ test('creates and deletes a typed Channel configuration', async ({ await expect( page.getByRole('heading', { name: 'Configure DingTalk' }), ).toBeVisible(); + const editor = page.getByRole('dialog'); + await expect(editor.getByLabel('Workspace')).toContainText('Main workspace'); + await editor.getByLabel('Workspace').click(); + await page.getByRole('option', { name: 'Release workspace' }).click(); + await expect(editor.getByLabel('Workspace')).toContainText( + 'Release workspace', + ); await page.getByLabel('Instance name').fill('release-bot'); await page.getByLabel('Client ID (AppKey)').fill('ding-client-id'); await page.getByLabel('Client Secret (AppSecret)').fill('ding-client-secret'); - await page.getByLabel('Session scope').click(); - await page.getByRole('option', { name: 'Per thread' }).click(); + await expect(page.getByLabel('Direct message policy')).toContainText( + 'Pairing', + ); + await expect(page.getByLabel('Allowed user IDs')).toHaveCount(0); + await expect( + page.getByRole('heading', { name: 'Conversation management' }), + ).toBeVisible(); + await expect(page.getByText('By user', { exact: true })).toBeVisible(); + await expect( + page.getByText('By chat or thread', { exact: true }), + ).toBeVisible(); + await expect(page.getByText('Share all', { exact: true })).toBeVisible(); + await page.getByLabel('By chat or thread').click(); + await expect( + page.getByText( + 'Messages in the same group or topic share one conversation; best for collaboration.', + ), + ).toBeVisible(); await page.getByRole('button', { name: 'Save' }).click(); await expect( @@ -279,7 +348,8 @@ test('creates and deletes a typed Channel configuration', async ({ daemon.requests.filter( (request) => request.method === 'PUT' && - request.path.endsWith('/channels/release-bot'), + request.path === + '/workspaces/%2Ftmp%2Fqwen-channel-secondary/channels/release-bot', ), ) .toEqual([ @@ -289,8 +359,9 @@ test('creates and deletes a typed Channel configuration', async ({ config: { type: 'dingtalk', clientId: 'ding-client-id', - sessionScope: 'thread', senderPolicy: 'pairing', + groupPolicy: 'disabled', + sessionScope: 'chat_thread', }, secrets: { clientSecret: { @@ -306,7 +377,7 @@ test('creates and deletes a typed Channel configuration', async ({ await expect( page.getByRole('heading', { name: 'Edit DingTalk' }), ).toBeVisible(); - await expect(page.getByLabel('Session scope')).toHaveText('Per thread'); + await expect(page.getByLabel('By chat or thread')).toBeChecked(); await expect(page.getByText('Ada', { exact: true })).toBeVisible(); await expect(page.getByText('ABCD1234', { exact: true })).toBeVisible(); await page @@ -375,9 +446,54 @@ test('creates and deletes a typed Channel configuration', async ({ body: { senderId: 'user-42' }, }), ]); - await page.getByRole('button', { name: 'Close' }).click(); - await page.getByRole('button', { name: 'Delete release-bot' }).click(); + await page.getByLabel('Direct message policy').click(); + await page.getByRole('option', { name: 'Allowlist' }).click(); + await page.getByLabel('Allowed user IDs').fill('staff-a, staff-b'); + await page.getByLabel('Group policy').click(); + await page.getByRole('option', { name: 'Allowlist' }).click(); + await page.getByLabel('Allowed group IDs').fill('group-a, group-b'); + await page.getByRole('button', { name: 'Save' }).click(); + await expect( + page.getByRole('heading', { name: 'Edit DingTalk' }), + ).toHaveCount(0); + await expect + .poll(() => + daemon.requests.filter( + (request) => + request.method === 'PUT' && + request.path.endsWith('/channels/release-bot'), + ), + ) + .toHaveLength(2); + expect( + daemon.requests.filter( + (request) => + request.method === 'PUT' && + request.path.endsWith('/channels/release-bot'), + )[1], + ).toEqual( + expect.objectContaining({ + body: { + expectedRevision: '2', + config: { + type: 'dingtalk', + clientId: 'ding-client-id', + senderPolicy: 'allowlist', + allowedUsers: ['staff-a', 'staff-b'], + groupPolicy: 'allowlist', + sessionScope: 'chat_thread', + groups: { 'group-a': {}, 'group-b': {} }, + }, + secrets: { clientSecret: { operation: 'preserve' } }, + }, + }), + ); + + await page + .getByRole('button', { name: 'More actions for release-bot' }) + .click(); + await page.getByRole('menuitem', { name: 'Delete release-bot' }).click(); const confirmation = page.getByRole('alertdialog'); await confirmation.getByRole('button', { name: 'Delete' }).click(); await expect(page.getByText('release-bot', { exact: true })).toHaveCount(0); @@ -391,7 +507,7 @@ test('creates and deletes a typed Channel configuration', async ({ ) .toEqual([ expect.objectContaining({ - body: { expectedRevision: '2' }, + body: { expectedRevision: '3' }, }), ]); }); diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 825f01aee60..feff7907c07 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2554,16 +2554,23 @@ const EN: Messages = { 'splitView.composerPlaceholder': 'Message this session…', 'settings.title': 'Settings', 'channels.title': 'Channels', + 'channels.description': + 'Connect Qwen Code to the places where your team already works.', 'channels.summary': (v) => `${v?.workspace ?? ''} · ${v?.count ?? 0} configured`, 'channels.workspace.current': 'Current workspace', + 'channels.workspace.label': 'Workspace', + 'channels.workspace.primary': 'Primary', 'channels.loading': 'Loading channels', 'channels.configured': 'Configured channels', + 'channels.configured.description': + 'Manage the bots that receive and deliver messages for this workspace.', 'channels.availablePlatforms': 'Available platforms', 'channels.availablePlatforms.description': - 'Channel management is currently available for these platforms only.', + 'Choose a platform to add another connection to this workspace.', 'channels.platform.available': 'Available', 'channels.platform.configure': 'Configure', + 'channels.platform.add': 'Add connection', 'channels.platform.configureNamed': (v) => `Configure ${v?.platform ?? 'Channel'}`, 'channels.status.stopped': 'Stopped', @@ -2571,7 +2578,16 @@ const EN: Messages = { 'channels.status.connected': 'Connected', 'channels.status.partial': 'Partially connected', 'channels.status.error': 'Error', - 'channels.startsWithServe': 'Start with serve', + 'channels.statusDescription.stopped': 'Offline and not receiving messages.', + 'channels.statusDescription.starting': 'Connecting to the platform now.', + 'channels.statusDescription.connected': + 'Online and ready to receive messages.', + 'channels.statusDescription.partial': + 'Connected, but some capabilities are unavailable.', + 'channels.statusDescription.error': 'Needs attention before it can connect.', + 'channels.startsWithServe': 'Connect when Qwen Code starts', + 'channels.startsWithServe.description': + 'Automatically bring this Channel online after Qwen Code starts.', 'channels.unsupported.title': 'Channel management is not supported', 'channels.unsupported.description': 'Update Qwen Code to a version that supports Channel management.', @@ -2584,12 +2600,15 @@ const EN: Messages = { 'Configure DingTalk, WeCom, Feishu, GitHub, or GitLab to receive messages in this workspace.', 'channels.runtimeError': 'Channel runtime error', 'channels.action.back': 'Back', + 'channels.action.refresh': 'Refresh', 'channels.action.start': 'Start', 'channels.action.stop': 'Stop', 'channels.action.restart': 'Restart', 'channels.action.retry': 'Retry', 'channels.action.edit': 'Edit', 'channels.action.editNamed': (v) => `Edit ${v?.name ?? 'Channel'}`, + 'channels.action.moreNamed': (v) => + `More actions for ${v?.name ?? 'Channel'}`, 'channels.action.delete': 'Delete', 'channels.action.deleteNamed': (v) => `Delete ${v?.name ?? 'Channel'}`, 'channels.action.startWithServeNamed': (v) => @@ -2601,15 +2620,23 @@ const EN: Messages = { 'channels.editor.addTitle': (v) => `Configure ${v?.platform ?? 'Channel'}`, 'channels.editor.editTitle': (v) => `Edit ${v?.platform ?? 'Channel'}`, 'channels.editor.addDescription': - 'Connect this workspace with an existing platform application.', + 'Connect a registered workspace with an existing platform application.', 'channels.editor.editDescription': 'Update public settings or explicitly change stored credentials.', 'channels.editor.section.identity': 'Identity', 'channels.editor.section.credentials': 'Credentials', - 'channels.editor.section.session': 'Session', - 'channels.editor.section.access': 'Access policy', + 'channels.editor.section.session': 'Conversation management', + 'channels.editor.section.access': 'Access control', + 'channels.editor.section.access.description': + 'Configure direct-message and group access separately.', + 'channels.editor.session.isolation': 'Conversation isolation', 'channels.editor.instanceName': 'Instance name', 'channels.editor.instanceNamePlaceholder': 'e.g. release-bot', + 'channels.editor.workspace': 'Workspace', + 'channels.editor.workspace.description': + 'Messages, sessions, and Channel settings belong to this workspace. The primary workspace is selected by default.', + 'channels.editor.workspace.lockedDescription': + 'A configured Channel stays bound to its workspace. Create another instance to use a different workspace.', 'channels.editor.environmentReference': '$ENV_VAR supported', 'channels.editor.field.sessionScope': 'Session scope', 'channels.editor.field.sessionScope.description': @@ -2694,6 +2721,44 @@ const EN: Messages = { 'channels.editor.secret.placeholder': (v) => `Enter ${v?.label ?? 'secret'}`, 'channels.editor.secret.clearHint': 'This credential will be removed when you save.', + 'channels.editor.field.shared.senderPolicy': 'Direct message policy', + 'channels.editor.field.shared.senderPolicy.description': + 'Choose who can start a direct conversation with this Channel.', + 'channels.editor.field.shared.senderPolicy.option.pairing': 'Pairing', + 'channels.editor.field.shared.senderPolicy.option.allowlist': 'Allowlist', + 'channels.editor.field.shared.senderPolicy.option.open': 'Open', + 'channels.editor.field.shared.allowedUsers': 'Allowed user IDs', + 'channels.editor.field.shared.allowedUsers.description': + 'Comma-separated stable user IDs that can access the Channel without pairing.', + 'channels.editor.field.shared.groupPolicy': 'Group policy', + 'channels.editor.field.shared.groupPolicy.description': + 'Choose which group conversations can use this Channel.', + 'channels.editor.field.shared.groupPolicy.option.disabled': 'Disabled', + 'channels.editor.field.shared.groupPolicy.option.pairing': 'Pairing', + 'channels.editor.field.shared.groupPolicy.option.allowlist': 'Allowlist', + 'channels.editor.field.shared.groupPolicy.option.open': 'Open', + 'channels.editor.field.shared.allowedGroupIds': 'Allowed group IDs', + 'channels.editor.field.shared.allowedGroupIds.description': + 'Comma-separated stable chat or repository IDs allowed to use this Channel.', + 'channels.editor.field.shared.allowedGroupIds.placeholder': + 'group-a, group-b', + 'channels.editor.field.shared.sessionScope': 'Conversation isolation', + 'channels.editor.field.shared.sessionScope.description': + 'Choose how conversations share persistent agent context.', + 'channels.editor.field.shared.sessionScope.option.user': 'By user', + 'channels.editor.field.shared.sessionScope.option.thread': + 'By thread (legacy)', + 'channels.editor.field.shared.sessionScope.option.chat_thread': + 'By chat or thread', + 'channels.editor.field.shared.sessionScope.option.single': 'Share all', + 'channels.editor.field.shared.sessionScope.detail.user': + "The same user's messages continue in one conversation; users stay isolated from each other.", + 'channels.editor.field.shared.sessionScope.detail.thread': + 'Preserves the legacy thread routing used by existing Channel sessions.', + 'channels.editor.field.shared.sessionScope.detail.chat_thread': + 'Messages in the same group or topic share one conversation; best for collaboration.', + 'channels.editor.field.shared.sessionScope.detail.single': + 'Every message shares one conversation; best for a single-bot duty channel.', 'channels.editor.policy.pairing.title': 'Pairing', 'channels.editor.policy.pairing.description': 'People receive a pairing code and can chat after you approve them.', @@ -2754,6 +2819,8 @@ const EN: Messages = { 'channels.editor.validation.duplicate': 'A Channel with this name already exists.', 'channels.editor.validation.invalidName': 'Choose a different instance name.', + 'channels.editor.validation.invalidGroupId': + 'Enter a group ID other than __proto__, constructor, or prototype.', 'channels.editor.validation.invalidOption': "Remove values that aren't in the allowed list.", 'channels.editor.validation.number': 'Enter a valid number.', @@ -5262,22 +5329,34 @@ const ZH: Messages = { 'splitView.composerPlaceholder': '给这个会话发消息…', 'settings.title': '设置', 'channels.title': '频道', + 'channels.description': '让 Qwen Code 在团队日常使用的平台中收发消息。', 'channels.summary': (v) => `${v?.workspace ?? ''} · 已配置 ${v?.count ?? 0} 个`, 'channels.workspace.current': '当前工作区', + 'channels.workspace.label': '工作区', + 'channels.workspace.primary': '主工作区', 'channels.loading': '正在加载频道', 'channels.configured': '已配置频道', + 'channels.configured.description': '管理当前工作区中负责收发消息的机器人。', 'channels.availablePlatforms': '可连接平台', - 'channels.availablePlatforms.description': '频道管理目前仅开放以下平台。', + 'channels.availablePlatforms.description': + '选择一个平台,为当前工作区添加新的连接。', 'channels.platform.available': '已开放', 'channels.platform.configure': '配置', + 'channels.platform.add': '添加连接', 'channels.platform.configureNamed': (v) => `配置${v?.platform ?? '频道'}`, 'channels.status.stopped': '已停止', 'channels.status.starting': '启动中', 'channels.status.connected': '已连接', 'channels.status.partial': '部分连接', 'channels.status.error': '错误', - 'channels.startsWithServe': '随服务启动', + 'channels.statusDescription.stopped': '当前离线,不会接收新消息。', + 'channels.statusDescription.starting': '正在连接平台。', + 'channels.statusDescription.connected': '在线,可以正常接收消息。', + 'channels.statusDescription.partial': '已经连接,但部分能力暂不可用。', + 'channels.statusDescription.error': '需要处理问题后才能重新连接。', + 'channels.startsWithServe': 'Qwen Code 启动时自动连接', + 'channels.startsWithServe.description': 'Qwen Code 启动后自动让该频道上线。', 'channels.unsupported.title': '当前版本不支持频道管理', 'channels.unsupported.description': '请升级 Qwen Code 到支持频道管理的版本。', 'channels.readOnly.title': '频道管理为只读模式', @@ -5289,12 +5368,14 @@ const ZH: Messages = { '配置钉钉、企业微信、飞书、GitHub 或 GitLab,让当前工作区接收消息。', 'channels.runtimeError': '频道运行时错误', 'channels.action.back': '返回', + 'channels.action.refresh': '刷新', 'channels.action.start': '启动', 'channels.action.stop': '停止', 'channels.action.restart': '重启', 'channels.action.retry': '重试', 'channels.action.edit': '编辑', 'channels.action.editNamed': (v) => `编辑${v?.name ?? '频道'}`, + 'channels.action.moreNamed': (v) => `${v?.name ?? '频道'}的更多操作`, 'channels.action.delete': '删除', 'channels.action.deleteNamed': (v) => `删除${v?.name ?? '频道'}`, 'channels.action.startWithServeNamed': (v) => @@ -5305,14 +5386,22 @@ const ZH: Messages = { 'channels.delete.error': '未能删除频道', 'channels.editor.addTitle': (v) => `配置${v?.platform ?? '频道'}`, 'channels.editor.editTitle': (v) => `编辑${v?.platform ?? '频道'}`, - 'channels.editor.addDescription': '连接当前工作区与已有的平台应用。', + 'channels.editor.addDescription': '将已注册的工作区连接到已有的平台应用。', 'channels.editor.editDescription': '更新公开配置,或明确更改已保存的凭据。', 'channels.editor.section.identity': '频道标识', 'channels.editor.section.credentials': '应用凭据', - 'channels.editor.section.session': '会话', - 'channels.editor.section.access': '准入策略', + 'channels.editor.section.session': '会话管理', + 'channels.editor.section.access': '访问控制', + 'channels.editor.section.access.description': + '分别设置谁可以私聊,以及哪些群聊可以使用此频道。', + 'channels.editor.session.isolation': '会话隔离方式', 'channels.editor.instanceName': '实例名称', 'channels.editor.instanceNamePlaceholder': '例如 release-bot', + 'channels.editor.workspace': '工作区', + 'channels.editor.workspace.description': + '机器人消息、会话和频道配置都归属此工作区;默认选择主工作区。', + 'channels.editor.workspace.lockedDescription': + '已配置的频道会固定归属当前工作区;如需更换,请新建一个频道实例。', 'channels.editor.environmentReference': '支持 $ENV_VAR', 'channels.editor.field.sessionScope': '会话作用域', 'channels.editor.field.sessionScope.description': @@ -5393,6 +5482,44 @@ const ZH: Messages = { 'channels.editor.secret.clear': '清除', 'channels.editor.secret.placeholder': (v) => `请输入${v?.label ?? '密钥'}`, 'channels.editor.secret.clearHint': '保存后将移除此凭据。', + 'channels.editor.field.shared.senderPolicy': '私聊策略', + 'channels.editor.field.shared.senderPolicy.description': + '选择哪些用户可以通过私聊使用此频道。', + 'channels.editor.field.shared.senderPolicy.option.pairing': '配对', + 'channels.editor.field.shared.senderPolicy.option.allowlist': '白名单', + 'channels.editor.field.shared.senderPolicy.option.open': '开放', + 'channels.editor.field.shared.allowedUsers': '允许的用户 ID', + 'channels.editor.field.shared.allowedUsers.description': + '用英文逗号分隔稳定用户 ID;这些用户无需配对即可访问频道。', + 'channels.editor.field.shared.groupPolicy': '群聊策略', + 'channels.editor.field.shared.groupPolicy.description': + '选择哪些群聊可以使用此频道。', + 'channels.editor.field.shared.groupPolicy.option.disabled': '禁用', + 'channels.editor.field.shared.groupPolicy.option.pairing': '配对', + 'channels.editor.field.shared.groupPolicy.option.allowlist': '白名单', + 'channels.editor.field.shared.groupPolicy.option.open': '开放', + 'channels.editor.field.shared.allowedGroupIds': '允许的群聊 ID', + 'channels.editor.field.shared.allowedGroupIds.description': + '用英文逗号分隔允许使用此频道的稳定群聊或代码仓库 ID。', + 'channels.editor.field.shared.allowedGroupIds.placeholder': + 'group-a, group-b', + 'channels.editor.field.shared.sessionScope': '会话隔离方式', + 'channels.editor.field.shared.sessionScope.description': + '选择不同对话如何共享持久化的智能体上下文。', + 'channels.editor.field.shared.sessionScope.option.user': '按用户隔离', + 'channels.editor.field.shared.sessionScope.option.thread': + '按话题隔离(旧版)', + 'channels.editor.field.shared.sessionScope.option.chat_thread': + '按群/话题隔离', + 'channels.editor.field.shared.sessionScope.option.single': '全部共享', + 'channels.editor.field.shared.sessionScope.detail.user': + '同一用户的消息进入同一个对话,不同用户互不影响。', + 'channels.editor.field.shared.sessionScope.detail.thread': + '保留已有频道会话使用的旧版话题路由。', + 'channels.editor.field.shared.sessionScope.detail.chat_thread': + '同一群聊或话题进入同一个对话,适合群内协作。', + 'channels.editor.field.shared.sessionScope.detail.single': + '所有消息共用一个对话,适合单一机器人值守场景。', 'channels.editor.policy.pairing.title': '配对模式', 'channels.editor.policy.pairing.description': '用户会收到配对码,经您批准后才能开始对话。', @@ -5450,6 +5577,8 @@ const ZH: Messages = { '请输入令牌,或开启本地 GitHub CLI 认证。', 'channels.editor.validation.duplicate': '已存在同名频道。', 'channels.editor.validation.invalidName': '请使用其他实例名称。', + 'channels.editor.validation.invalidGroupId': + '群聊 ID 不能是 __proto__、constructor 或 prototype。', 'channels.editor.validation.invalidOption': '请移除不在允许列表中的值。', 'channels.editor.validation.number': '请输入有效数字。', 'channels.editor.validation.outOfRange': (v) => diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonChannels.test.tsx b/packages/webui/src/daemon/workspace/hooks/useDaemonChannels.test.tsx index 1280adc2905..e283c27ec86 100644 --- a/packages/webui/src/daemon/workspace/hooks/useDaemonChannels.test.tsx +++ b/packages/webui/src/daemon/workspace/hooks/useDaemonChannels.test.tsx @@ -10,28 +10,50 @@ import { act, type ReactNode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { actions, context } = vi.hoisted(() => ({ - actions: { - loadChannels: vi.fn(), - upsertChannel: vi.fn(), - removeChannel: vi.fn(), - setChannelStartup: vi.fn(), - startChannel: vi.fn(), - stopChannel: vi.fn(), - restartChannel: vi.fn(), - channelPairing: { - list: vi.fn(), - approve: vi.fn(), - approvals: vi.fn(), - revoke: vi.fn(), +const { actions, client, context, qualifiedWorkspace } = vi.hoisted(() => { + const qualifiedWorkspace = { + workspaceChannelTypes: vi.fn(), + workspaceChannels: vi.fn(), + upsertWorkspaceChannel: vi.fn(), + deleteWorkspaceChannel: vi.fn(), + setWorkspaceChannelStartup: vi.fn(), + startWorkspaceChannel: vi.fn(), + stopWorkspaceChannel: vi.fn(), + restartWorkspaceChannel: vi.fn(), + workspaceChannelPairingRequests: vi.fn(), + approveWorkspaceChannelPairing: vi.fn(), + workspaceChannelPairingApprovals: vi.fn(), + revokeWorkspaceChannelPairingApproval: vi.fn(), + }; + const client = { + workspaceByCwd: vi.fn(() => qualifiedWorkspace), + }; + return { + actions: { + loadChannels: vi.fn(), + upsertChannel: vi.fn(), + removeChannel: vi.fn(), + setChannelStartup: vi.fn(), + startChannel: vi.fn(), + stopChannel: vi.fn(), + restartChannel: vi.fn(), + channelPairing: { + list: vi.fn(), + approve: vi.fn(), + approvals: vi.fn(), + revoke: vi.fn(), + }, }, - }, - context: { - current: { - workspaceCwd: '/workspace-a' as string | undefined, + context: { + current: { + workspaceCwd: '/workspace-a' as string | undefined, + client, + }, }, - }, -})); + client, + qualifiedWorkspace, + }; +}); vi.mock('../DaemonWorkspaceProvider.js', () => ({ useDaemonWorkspace: () => ({ @@ -78,7 +100,8 @@ describe('useDaemonChannels', () => { container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); - context.current = { workspaceCwd: '/workspace-a' }; + context.current = { workspaceCwd: '/workspace-a', client }; + client.workspaceByCwd.mockClear(); for (const action of [ actions.loadChannels, actions.upsertChannel, @@ -91,6 +114,18 @@ describe('useDaemonChannels', () => { actions.channelPairing.approve, actions.channelPairing.approvals, actions.channelPairing.revoke, + qualifiedWorkspace.workspaceChannelTypes, + qualifiedWorkspace.workspaceChannels, + qualifiedWorkspace.upsertWorkspaceChannel, + qualifiedWorkspace.deleteWorkspaceChannel, + qualifiedWorkspace.setWorkspaceChannelStartup, + qualifiedWorkspace.startWorkspaceChannel, + qualifiedWorkspace.stopWorkspaceChannel, + qualifiedWorkspace.restartWorkspaceChannel, + qualifiedWorkspace.workspaceChannelPairingRequests, + qualifiedWorkspace.approveWorkspaceChannelPairing, + qualifiedWorkspace.workspaceChannelPairingApprovals, + qualifiedWorkspace.revokeWorkspaceChannelPairingApproval, ]) { action.mockReset(); } @@ -118,6 +153,45 @@ describe('useDaemonChannels', () => { expect(result?.snapshot?.revision).toBe('1'); }); + it('loads and mutates an explicitly selected registered workspace', async () => { + const data = channelData('bot-b'); + qualifiedWorkspace.workspaceChannelTypes.mockResolvedValue(data.catalog); + qualifiedWorkspace.workspaceChannels.mockResolvedValue(data.snapshot); + qualifiedWorkspace.upsertWorkspaceChannel.mockResolvedValue({ + snapshot: data.snapshot, + instance: data.snapshot.instances['bot-b'], + }); + let result: ReturnType | undefined; + + function TestComponent() { + result = useDaemonChannels({ + autoLoad: true, + workspaceCwd: '/workspace-b', + }); + return null; + } + + await act(async () => root.render(() as ReactNode)); + await act(async () => { + await result?.createOrUpdate('bot-b', { + expectedRevision: '1', + config: { type: 'dingtalk' }, + }); + }); + + expect(client.workspaceByCwd).toHaveBeenCalledWith('/workspace-b'); + expect(actions.loadChannels).not.toHaveBeenCalled(); + expect(qualifiedWorkspace.upsertWorkspaceChannel).toHaveBeenCalledWith( + 'bot-b', + { + expectedRevision: '1', + config: { type: 'dingtalk' }, + }, + ); + expect(qualifiedWorkspace.workspaceChannels).toHaveBeenCalledTimes(2); + expect(Object.keys(result?.channels ?? {})).toEqual(['bot-b']); + }); + it('reports errors when loading Channel data fails', async () => { actions.loadChannels.mockRejectedValue(new Error('network down')); let result: ReturnType | undefined; @@ -237,7 +311,7 @@ describe('useDaemonChannels', () => { await act(async () => root.render(() as ReactNode)); expect(Object.keys(result?.channels ?? {})).toEqual(['bot-a']); - context.current = { workspaceCwd: '/workspace-b' }; + context.current = { workspaceCwd: '/workspace-b', client }; await act(async () => root.render(() as ReactNode)); expect(result?.channels).toEqual({}); @@ -263,7 +337,7 @@ describe('useDaemonChannels', () => { await result?.reload(); }); - context.current = { workspaceCwd: '/workspace-b' }; + context.current = { workspaceCwd: '/workspace-b', client }; await act(async () => root.render(() as ReactNode)); expect(actions.loadChannels).toHaveBeenCalledTimes(2); @@ -288,7 +362,7 @@ describe('useDaemonChannels', () => { }); enabled = false; - context.current = { workspaceCwd: '/workspace-b' }; + context.current = { workspaceCwd: '/workspace-b', client }; await act(async () => root.render(() as ReactNode)); enabled = true; await act(async () => root.render(() as ReactNode)); diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonChannels.ts b/packages/webui/src/daemon/workspace/hooks/useDaemonChannels.ts index ac01319b0ad..695e59477ee 100644 --- a/packages/webui/src/daemon/workspace/hooks/useDaemonChannels.ts +++ b/packages/webui/src/daemon/workspace/hooks/useDaemonChannels.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import type { DaemonChannelStartupRequest, DaemonChannelUpsertRequest, @@ -12,6 +12,7 @@ import type { } from '@qwen-code/sdk/daemon'; import { useDaemonWorkspace } from '../DaemonWorkspaceProvider.js'; import type { + DaemonChannelPairingActions, DaemonChannelsResource, DaemonResourceOptions, } from '../types.js'; @@ -21,20 +22,45 @@ interface WorkspaceChannelsResource extends DaemonChannelsResource { workspaceCwd: string; } -export function useDaemonChannels(options: DaemonResourceOptions = {}) { - const { actions, workspaceCwd } = useDaemonWorkspace(); - const enabled = options.enabled !== false && workspaceCwd !== undefined; +interface DaemonChannelsOptions extends DaemonResourceOptions { + workspaceCwd?: string; +} + +export function useDaemonChannels(options: DaemonChannelsOptions = {}) { + const { + actions, + client, + workspaceCwd: providerWorkspaceCwd, + } = useDaemonWorkspace(); + const { workspaceCwd: requestedWorkspaceCwd, ...resourceOptions } = options; + const workspaceCwd = requestedWorkspaceCwd ?? providerWorkspaceCwd; + const usesProviderWorkspace = workspaceCwd === providerWorkspaceCwd; + const workspaceClient = useMemo( + () => + workspaceCwd && !usesProviderWorkspace + ? client.workspaceByCwd(workspaceCwd) + : undefined, + [client, usesProviderWorkspace, workspaceCwd], + ); + const enabled = + resourceOptions.enabled !== false && workspaceCwd !== undefined; const load = useCallback(async (): Promise => { if (!workspaceCwd) { throw new Error('Channel management requires a workspace.'); } + const data = workspaceClient + ? await Promise.all([ + workspaceClient.workspaceChannelTypes(), + workspaceClient.workspaceChannels(), + ]).then(([catalog, snapshot]) => ({ catalog, snapshot })) + : await actions.loadChannels(); return { - ...(await actions.loadChannels()), + ...data, workspaceCwd, }; - }, [actions, workspaceCwd]); + }, [actions, workspaceClient, workspaceCwd]); const resource = useDaemonResource(load, { - ...options, + ...resourceOptions, autoLoad: false, enabled, }); @@ -52,13 +78,14 @@ export function useDaemonChannels(options: DaemonResourceOptions = {}) { const workspaceChanged = previousWorkspaceRef.current !== workspaceCwd; if ( !enabled || - (options.autoLoad !== true && !(workspaceChanged && requestedRef.current)) + (resourceOptions.autoLoad !== true && + !(workspaceChanged && requestedRef.current)) ) { return; } previousWorkspaceRef.current = workspaceCwd; void reload(); - }, [enabled, options.autoLoad, reload, workspaceCwd]); + }, [enabled, reload, resourceOptions.autoLoad, workspaceCwd]); const mutate = useCallback( async (operation: () => Promise): Promise => { @@ -70,33 +97,79 @@ export function useDaemonChannels(options: DaemonResourceOptions = {}) { ); const createOrUpdate = useCallback( (name: string, request: DaemonChannelUpsertRequest) => - mutate(() => actions.upsertChannel(name, request)), - [actions, mutate], + mutate(() => + workspaceClient + ? workspaceClient.upsertWorkspaceChannel(name, request) + : actions.upsertChannel(name, request), + ), + [actions, mutate, workspaceClient], ); const remove = useCallback( (name: string, request: DaemonRevisionRequest) => - mutate(() => actions.removeChannel(name, request)), - [actions, mutate], + mutate(() => + workspaceClient + ? workspaceClient.deleteWorkspaceChannel(name, request) + : actions.removeChannel(name, request), + ), + [actions, mutate, workspaceClient], ); const setStartup = useCallback( (name: string, request: DaemonChannelStartupRequest) => - mutate(() => actions.setChannelStartup(name, request)), - [actions, mutate], + mutate(() => + workspaceClient + ? workspaceClient.setWorkspaceChannelStartup(name, request) + : actions.setChannelStartup(name, request), + ), + [actions, mutate, workspaceClient], ); const start = useCallback( - (name: string) => mutate(() => actions.startChannel(name)), - [actions, mutate], + (name: string) => + mutate(() => + workspaceClient + ? workspaceClient.startWorkspaceChannel(name) + : actions.startChannel(name), + ), + [actions, mutate, workspaceClient], ); const stop = useCallback( - (name: string) => mutate(() => actions.stopChannel(name)), - [actions, mutate], + (name: string) => + mutate(() => + workspaceClient + ? workspaceClient.stopWorkspaceChannel(name) + : actions.stopChannel(name), + ), + [actions, mutate, workspaceClient], ); const restart = useCallback( - (name: string) => mutate(() => actions.restartChannel(name)), - [actions, mutate], + (name: string) => + mutate(() => + workspaceClient + ? workspaceClient.restartWorkspaceChannel(name) + : actions.restartChannel(name), + ), + [actions, mutate, workspaceClient], ); const current = resource.data?.workspaceCwd === workspaceCwd ? resource.data : undefined; + const pairing = useMemo( + () => + workspaceClient + ? { + list: (name) => + workspaceClient.workspaceChannelPairingRequests(name), + approve: (name, code) => + workspaceClient.approveWorkspaceChannelPairing(name, { code }), + approvals: (name) => + workspaceClient.workspaceChannelPairingApprovals(name), + revoke: (name, request) => + workspaceClient.revokeWorkspaceChannelPairingApproval( + name, + request, + ), + } + : actions.channelPairing, + [actions.channelPairing, workspaceClient], + ); return { data: current @@ -114,6 +187,6 @@ export function useDaemonChannels(options: DaemonResourceOptions = {}) { start, stop, restart, - pairing: actions.channelPairing, + pairing, }; }