From f6f74a2f0f00886b25dc33489a2814d562b44aef Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:42:10 +0800 Subject: [PATCH 01/21] feat(web-shell): expose channel access policies --- docs/developers/daemon/15-channel-adapters.md | 18 +-- docs/users/features/channels/overview.md | 2 +- docs/users/features/channels/plugins.md | 22 +-- packages/channels/base/README.md | 2 +- packages/channels/base/src/types.ts | 6 +- .../channels/wecom/src/WeComAdapter.test.ts | 24 +++ packages/channels/wecom/src/WeComAdapter.ts | 4 + .../commands/channel/channel-registry.test.ts | 58 ++++++++ .../src/commands/channel/channel-registry.ts | 83 ++++++++++- .../src/commands/channel/config-utils.test.ts | 13 +- .../cli/src/commands/channel/config-utils.ts | 10 +- .../src/serve/channel-settings-store.test.ts | 21 +++ .../cli/src/serve/channel-settings-store.ts | 28 +++- .../channels/ChannelEditorDialog.test.tsx | 129 +++++++++++++++++ .../channels/ChannelEditorDialog.tsx | 107 +++++++++++++- .../channels/channel-editor-state.test.ts | 137 ++++++++++++++++++ .../channels/channel-editor-state.ts | 61 +++++++- .../client/e2e/visuals/screenshots.spec.ts | 47 ++++++ .../client/e2e/web-shell.channels.spec.ts | 92 +++++++++++- packages/web-shell/client/i18n.tsx | 59 ++++++++ 20 files changed, 881 insertions(+), 42 deletions(-) diff --git a/docs/developers/daemon/15-channel-adapters.md b/docs/developers/daemon/15-channel-adapters.md index d221713372a..df69e7196c1 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` is deprecated and normalized to `chat_thread`; 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 accepted but normalized to `'chat_thread'`. | +| `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..8d431447826 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` is accepted but normalized to `chat_thread` | | `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..931df4498c0 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` is deprecated | +| `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 5da16a1934f..e531390a8db 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 deprecated here; use `chat_thread`. */ sessionScope: SessionScope; cwd: string; approvalMode?: string; @@ -514,7 +515,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 deprecated here; use `chat_thread`. + */ 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.test.ts b/packages/cli/src/commands/channel/channel-registry.test.ts index 84de79466c3..36597971785 100644 --- a/packages/cli/src/commands/channel/channel-registry.test.ts +++ b/packages/cli/src/commands/channel/channel-registry.test.ts @@ -654,6 +654,7 @@ describe('channel registry', () => { const plugin: ChannelPlugin = { channelType: 'valid-optional-required-object', displayName: 'valid-optional-required-object', + defaultSessionScope: 'thread', management: { fields: [ { @@ -686,6 +687,16 @@ 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: 'chat_thread', + options: [ + { value: 'user' }, + { value: 'chat_thread' }, + { value: 'single' }, + ], + }); }); it('only marks the manually configurable built-in types as manageable', async () => { @@ -718,8 +729,45 @@ describe('channel registry', () => { required: true, }), ); + 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).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: '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', @@ -745,6 +793,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 === 'dingtalk')?.fields, diff --git a/packages/cli/src/commands/channel/channel-registry.ts b/packages/cli/src/commands/channel/channel-registry.ts index 784559f8d3b..5fef75c0aa0 100644 --- a/packages/cli/src/commands/channel/channel-registry.ts +++ b/packages/cli/src/commands/channel/channel-registry.ts @@ -2,6 +2,7 @@ import type { ChannelConfigFieldDescriptor, ChannelConfigFieldKind, ChannelPlugin, + SessionScope, } from '@qwen-code/channel-base'; export interface ChannelTypeDescriptor { @@ -30,6 +31,79 @@ 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: '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)); + return [ + ...fields, + ...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 === 'thread' + ? 'chat_thread' + : defaultSessionScope, + description: + 'Controls how conversations share persistent agent sessions', + options: SESSION_SCOPE_OPTIONS, + }, + ]), + ]; +} + function assertManagementFields( fields: readonly ChannelConfigFieldDescriptor[], parentPath?: string, @@ -273,11 +347,16 @@ export async function supportedChannelCatalog(): Promise< > { await ensureBuiltins(); return [...registry.values()].map( - ({ channelType, displayName, management }) => ({ + ({ channelType, displayName, management, defaultSessionScope }) => ({ type: channelType, displayName, manageable: management !== undefined, - fields: management?.fields ?? [], + 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..f1e385d35a1 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('normalizes the deprecated thread scope to chat_thread', async () => { + const result = await parseChannelConfig('bot', { + type: 'bare', + sessionScope: 'thread', + }); + + expect(result.sessionScope).toBe('chat_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..805155c5248 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, @@ -463,9 +467,9 @@ export async function parseChannelConfig( 'allowlist', allowedUsers: (rawConfig['allowedUsers'] as string[]) || [], sessionScope: - (rawConfig['sessionScope'] as ChannelConfig['sessionScope']) || - plugin?.defaultSessionScope || - 'user', + configuredSessionScope === 'thread' + ? 'chat_thread' + : 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 f0888e08b37..d90fe468b6a 100644 --- a/packages/cli/src/serve/channel-settings-store.test.ts +++ b/packages/cli/src/serve/channel-settings-store.test.ts @@ -397,6 +397,17 @@ 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: 'string-list with non-string items', config: { @@ -459,7 +470,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' }, @@ -481,7 +497,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' }, diff --git a/packages/cli/src/serve/channel-settings-store.ts b/packages/cli/src/serve/channel-settings-store.ts index a87a7075047..c6193f4cd91 100644 --- a/packages/cli/src/serve/channel-settings-store.ts +++ b/packages/cli/src/serve/channel-settings-store.ts @@ -144,7 +144,7 @@ function assertSharedField(key: string, value: unknown): boolean { senderPolicy: new Set(['allowlist', 'pairing', 'open']), dmPolicy: new Set(['open', 'disabled']), groupPolicy: new Set(['disabled', 'allowlist', 'pairing', 'open']), - sessionScope: new Set(['user', 'thread', 'single']), + sessionScope: new Set(['user', 'thread', 'chat_thread', 'single']), dispatchMode: new Set(['steer', 'followup', 'collect']), blockStreaming: new Set(['on', 'off']), }; @@ -169,6 +169,32 @@ function assertSharedField(key: string, value: unknown): boolean { } return true; } + if (key === 'groups') { + if (!isRecord(value)) { + throw invalidConfig(`Channel field "${key}" must be an object.`); + } + for (const [groupId, groupConfig] of Object.entries(value)) { + if (UNSAFE_OBJECT_KEYS.has(groupId) || !isRecord(groupConfig)) { + throw invalidConfig(`Channel field "${key}.${groupId}" is invalid.`); + } + for (const [nestedKey, nestedValue] of Object.entries(groupConfig)) { + const valid = + (nestedKey === 'requireMention' && + typeof nestedValue === 'boolean') || + (nestedKey === 'dispatchMode' && + ['collect', 'steer', 'followup'].includes(String(nestedValue))) || + (nestedKey === 'groupHistoryLimit' && + typeof nestedValue === 'number' && + Number.isFinite(nestedValue)); + if (!valid) { + throw invalidConfig( + `Channel field "${key}.${groupId}.${nestedKey}" is invalid.`, + ); + } + } + } + return true; + } if (key === 'groupHistoryLimit') { if (typeof value !== 'number' || !Number.isFinite(value)) { throw invalidConfig(`Channel field "${key}" must be a number.`); diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx index aeee30855b1..2eb083976d1 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx @@ -42,6 +42,55 @@ 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' }, + ], + }, + { + 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' }, + ], + }, + ], +}; + const OPTIONAL_SECRET: DaemonChannelTypeDescriptor = { ...DINGTALK, fields: DINGTALK.fields.map((field) => @@ -163,6 +212,29 @@ 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 })); + }); +} + function setInputValue(input: HTMLInputElement, value: string) { Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, @@ -257,6 +329,63 @@ describe('ChannelEditorDialog', () => { }); }); + 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'); + }); + + await selectOption('Direct message policy', 'Allowlist'); + const allowedUsers = inputByLabel('Allowed user IDs'); + expect(allowedUsers).not.toBeNull(); + await act(async () => { + setInputValue(allowedUsers!, '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'); + }); + + expect(document.body.textContent).toContain('Session'); + await selectOption('Session scope', 'Per chat and thread'); + + 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 a1c386fa022..7fef23ce2a5 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx @@ -52,6 +52,7 @@ import { ChannelPairingRequests } from './ChannelPairingRequests'; import { buildChannelUpsertRequest, createChannelEditorDraft, + hasDescriptorGroupPolicy, hasDescriptorSenderPolicy, validateChannelEditorDraft, type ChannelEditorDraft, @@ -93,6 +94,20 @@ const FIELD_LABEL_KEYS: Record> = { }, }; +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 { open: boolean; descriptor: DaemonChannelTypeDescriptor; @@ -198,6 +213,17 @@ export function ChannelEditorDialog({ const [submitError, setSubmitError] = useState(); const [saving, setSaving] = useState(false); const [reloading, setReloading] = useState(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 credentialFields = descriptor.fields.filter( + (field) => + !SHARED_ACCESS_FIELD_KEYS.has(field.key) && + !SHARED_SESSION_FIELD_KEYS.has(field.key), + ); useEffect(() => { if (!open) return; @@ -207,12 +233,16 @@ export function ChannelEditorDialog({ }, [descriptor, instance, open]); const fieldLabel = (field: DaemonChannelConfigFieldDescriptor) => { - const key = FIELD_LABEL_KEYS[descriptor.type]?.[field.key]; + 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 = FIELD_LABEL_KEYS[descriptor.type]?.[field.key]; + 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); @@ -221,6 +251,23 @@ export function ChannelEditorDialog({ return field.description; }; + const fieldOptionLabel = ( + field: DaemonChannelConfigFieldDescriptor, + value: string, + fallback: string, + ) => { + 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 fallback; + }; + const validationMessage = ( field: DaemonChannelConfigFieldDescriptor | undefined, code: ChannelEditorValidationCode, @@ -443,7 +490,7 @@ export function ChannelEditorDialog({ {field.options?.map((option) => ( - {option.label} + {fieldOptionLabel(field, option.value, option.label)} ))} @@ -611,25 +658,45 @@ export function ChannelEditorDialog({

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

- {descriptor.fields.map(renderField)} + {credentialFields.map(renderField)} + {sessionFields.length > 0 ? ( +
+

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

+ {sessionFields.map(renderField)} +
+ ) : null} + {(() => { const descriptorPolicy = hasDescriptorSenderPolicy(descriptor); const effectivePolicy = descriptorPolicy ? 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' || + effectivePolicy === 'pairing', + ); + if ( + !showRadioGroup && + visibleAccessFields.length === 0 && + !showPairing + ) { + return null; + } return (

@@ -678,6 +745,30 @@ export function ChannelEditorDialog({ ) : null} ) : null} + {visibleAccessFields.map(renderField)} + {effectiveGroupPolicy === 'allowlist' ? ( + + + setDraft((current) => ({ + ...current, + allowedGroupIds: event.target.value, + })) + } + /> + + ) : null} {showPairing ? ( instance?.config.senderPolicy === 'pairing' || instance?.config.groupPolicy === 'pairing' ? ( 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 76335ff3efa..198a3d3319d 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 @@ -43,6 +43,55 @@ 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' }, + ], + }, + { + 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' }, + ], + }, + ], +}; + function configuredInstance(): DaemonChannelInstanceSnapshot { return { name: 'release-bot', @@ -117,6 +166,94 @@ 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('migrates the deprecated thread scope to chat_thread when editing', () => { + const instance = configuredInstance(); + + const draft = createChannelEditorDraft(DINGTALK_WITH_ACCESS, instance); + + expect(draft.values.sessionScope).toBe('chat_thread'); + expect( + buildChannelUpsertRequest( + DINGTALK_WITH_ACCESS, + draft, + 'revision-session-scope', + instance, + ).config.sessionScope, + ).toBe('chat_thread'); + }); + + 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('supports explicitly clearing a stored secret', () => { const instance = configuredInstance(); const draft = createChannelEditorDraft(DINGTALK, instance); 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 5ae74bf1fd6..e2af7953e4d 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 = @@ -49,10 +50,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, @@ -74,8 +89,13 @@ function initialFieldValue( return ''; } if (field.kind === 'enum') { + if (field.key === 'sessionScope' && value === 'thread') { + return 'chat_thread'; + } if (typeof value === 'string' && value) return value; - return instance ? '' : (field.default ?? field.options?.[0]?.value ?? ''); + return instance && field.key !== 'sessionScope' + ? '' + : (field.default ?? field.options?.[0]?.value ?? ''); } return typeof value === 'string' ? value : ''; } @@ -109,6 +129,7 @@ export function createChannelEditorDraft( : instance ? '' : 'pairing', + allowedGroupIds: configuredGroupIds(instance), }; } @@ -244,6 +265,41 @@ 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']; + } +} + export function buildChannelUpsertRequest( descriptor: DaemonChannelTypeDescriptor, draft: ChannelEditorDraft, @@ -272,5 +328,8 @@ export function buildChannelUpsertRequest( if (!hasDescriptorSenderPolicy(descriptor)) { config['senderPolicy'] = draft.senderPolicy; } + if (hasDescriptorGroupPolicy(descriptor)) { + assignGroups(config, draft.allowedGroupIds, 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..5f43eb1211c 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: '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', + 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 b37d300fea7..7449649e315 100644 --- a/packages/web-shell/client/e2e/web-shell.channels.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.channels.spec.ts @@ -47,6 +47,48 @@ 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', + 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' }, + ], + }, ], }, { @@ -103,6 +145,8 @@ test('creates and deletes a typed Channel configuration', async ({ 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 chat and thread' }).click(); await page.getByRole('button', { name: 'Save' }).click(); await expect( @@ -125,6 +169,8 @@ test('creates and deletes a typed Channel configuration', async ({ type: 'dingtalk', clientId: 'ding-client-id', senderPolicy: 'pairing', + groupPolicy: 'disabled', + sessionScope: 'chat_thread', }, secrets: { clientSecret: { @@ -208,7 +254,49 @@ test('creates and deletes a typed Channel configuration', async ({ body: { senderId: 'user-42' }, }), ]); - await page.getByRole('button', { name: 'Close' }).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: 'Delete release-bot' }).click(); const confirmation = page.getByRole('alertdialog'); @@ -224,7 +312,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 7c7f1afb346..d3daa766404 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2585,6 +2585,7 @@ const EN: Messages = { '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.instanceName': 'Instance name', 'channels.editor.instanceNamePlaceholder': 'e.g. release-bot', @@ -2664,6 +2665,35 @@ 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': 'Session scope', + 'channels.editor.field.shared.sessionScope.description': + 'Choose how conversations share persistent agent context.', + 'channels.editor.field.shared.sessionScope.option.user': 'Per user and chat', + 'channels.editor.field.shared.sessionScope.option.chat_thread': + 'Per chat and thread', + 'channels.editor.field.shared.sessionScope.option.single': + 'One shared session', 'channels.editor.policy.pairing.title': 'Pairing', 'channels.editor.policy.pairing.description': 'People receive a pairing code and can chat after you approve them.', @@ -5258,6 +5288,7 @@ const ZH: Messages = { 'channels.editor.editDescription': '更新公开配置,或明确更改已保存的凭据。', 'channels.editor.section.identity': '频道标识', 'channels.editor.section.credentials': '应用凭据', + 'channels.editor.section.session': '会话策略', 'channels.editor.section.access': '准入策略', 'channels.editor.instanceName': '实例名称', 'channels.editor.instanceNamePlaceholder': '例如 release-bot', @@ -5334,6 +5365,34 @@ 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.chat_thread': + '按会话和话题', + 'channels.editor.field.shared.sessionScope.option.single': '整个频道共享', 'channels.editor.policy.pairing.title': '配对模式', 'channels.editor.policy.pairing.description': '用户会收到配对码,经您批准后才能开始对话。', From c197181847a2fbce4f21451aeb15c476d5742462 Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:02:48 +0800 Subject: [PATCH 02/21] test(cli): cover shared Channel management fields --- .../channel/channel-registry-builtins.test.ts | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) 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 4417c960287..1709da96a99 100644 --- a/packages/cli/src/commands/channel/channel-registry-builtins.test.ts +++ b/packages/cli/src/commands/channel/channel-registry-builtins.test.ts @@ -108,18 +108,23 @@ 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' }], - }, - ], }); + 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', + ]); }); }); From aed48dc9440e562874b623e9d73641e85b9deb64 Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:35:18 +0800 Subject: [PATCH 03/21] feat(web-shell): clarify channel policy controls --- .../channels/ChannelEditorDialog.module.css | 121 ++++++++++++++++++ .../channels/ChannelEditorDialog.test.tsx | 21 ++- .../channels/ChannelEditorDialog.tsx | 102 ++++++++++++--- .../client/e2e/web-shell.channels.spec.ts | 17 ++- packages/web-shell/client/i18n.tsx | 43 +++++-- 5 files changed, 272 insertions(+), 32 deletions(-) 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 2eb083976d1..1bfda48daff 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx @@ -235,6 +235,16 @@ async function selectOption(label: string, optionLabel: string) { }); } +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, @@ -342,6 +352,7 @@ describe('ChannelEditorDialog', () => { setInputValue(clientSecret!, 'ding-client-secret'); }); + expect(inputByLabel('Allowed user IDs')).toBeNull(); await selectOption('Direct message policy', 'Allowlist'); const allowedUsers = inputByLabel('Allowed user IDs'); expect(allowedUsers).not.toBeNull(); @@ -356,8 +367,14 @@ describe('ChannelEditorDialog', () => { setInputValue(allowedGroups!, 'group-a, group-b'); }); - expect(document.body.textContent).toContain('Session'); - await selectOption('Session scope', 'Per chat and thread'); + expect(document.body.textContent).toContain('Conversation management'); + expect(document.body.textContent).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', diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx index 7fef23ce2a5..e6540dc242f 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx @@ -219,6 +219,12 @@ export function ChannelEditorDialog({ 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 remainingSessionFields = sessionFields.filter( + (field) => field !== sessionScopeField, + ); const credentialFields = descriptor.fields.filter( (field) => !SHARED_ACCESS_FIELD_KEYS.has(field.key) && @@ -661,15 +667,6 @@ export function ChannelEditorDialog({ {credentialFields.map(renderField)}

- {sessionFields.length > 0 ? ( -
-

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

- {sessionFields.map(renderField)} -
- ) : null} - {(() => { const descriptorPolicy = hasDescriptorSenderPolicy(descriptor); const effectivePolicy = descriptorPolicy @@ -687,8 +684,7 @@ export function ChannelEditorDialog({ const visibleAccessFields = accessFields.filter( (field) => field.key !== 'allowedUsers' || - effectivePolicy === 'allowlist' || - effectivePolicy === 'pairing', + effectivePolicy === 'allowlist', ); if ( !showRadioGroup && @@ -698,10 +694,15 @@ export function ChannelEditorDialog({ return null; } return ( -
-

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

+
+
+

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

+

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

+
{showRadioGroup ? ( <> ); })()} + + {sessionFields.length > 0 ? ( +
+

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

+ {sessionScopeField ? ( +
+ + {t('channels.editor.session.isolation')} + + + setDraft((current) => ({ + ...current, + values: { + ...current.values, + [sessionScopeField.key]: value, + }, + })) + } + > + {(sessionScopeField.options ?? []).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}
@@ -876,7 +924,12 @@ export function ChannelEditorDialog({ > {t('channels.editor.cancel')} - diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.module.css b/packages/web-shell/client/components/channels/ChannelsManagerPage.module.css index 4e0e4385d6e..1f75b58e013 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.module.css +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.module.css @@ -43,6 +43,24 @@ white-space: nowrap; } +.workspacePicker { + display: flex; + min-width: 220px; + flex-direction: column; + gap: 5px; +} + +.workspacePickerLabel { + color: var(--muted-foreground); + font-size: 11px; + font-weight: 650; +} + +.workspacePickerTrigger { + width: 100%; + background: var(--background); +} + .section { display: flex; min-width: 0; @@ -208,6 +226,10 @@ flex-direction: column; } + .workspacePicker { + width: 100%; + } + .channelGrid { grid-template-columns: 1fr; } diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx b/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx index 3c8dd2d38be..f00a0d09999 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'; + }>, + }, }, }, })); @@ -188,7 +198,7 @@ beforeEach(() => { workspaceState.current = { workspaceCwd: '/workspace/demo', token: 'secret', - capabilities: { features: ['channel_management'] }, + capabilities: { features: ['channel_management'], workspaces: [] }, }; }); @@ -261,6 +271,58 @@ 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('opens an existing Channel for editing', async () => { channelState.current.channels.ding = { ...channelState.current.channels.ding, @@ -378,6 +440,7 @@ describe('ChannelsManagerPage', () => { expect(useChannelsMock).toHaveBeenLastCalledWith({ autoLoad: false, enabled: false, + workspaceCwd: '/workspace/demo', }); }); }); diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx index 6a2c0bffcdc..89ba65f6ba1 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx @@ -18,6 +18,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'; @@ -51,7 +52,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 { @@ -91,6 +100,42 @@ 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 activeWorkspaceCwd = + editor?.workspaceCwd ?? selectedManagementWorkspace?.cwd; + const activeWorkspace = registeredWorkspaces.find( + (entry) => entry.cwd === activeWorkspaceCwd, + ); const { catalog, snapshot, @@ -108,18 +153,18 @@ export function ChannelsManagerPage({ } = useChannels({ autoLoad: supportsManagement, enabled: supportsManagement, + workspaceCwd: activeWorkspaceCwd, }); - const canManage = supportsManagement && Boolean(workspace.token); + const canManage = + supportsManagement && + Boolean(workspace.token) && + Boolean(activeWorkspaceCwd) && + activeWorkspace?.trusted === true; const [busy, setBusy] = useState<{ name: string; action: ChannelAction; } | null>(null); const [actionErrors, setActionErrors] = useState>({}); - const [editor, setEditor] = useState<{ - workspaceCwd?: string; - descriptor: DaemonChannelTypeDescriptor; - instance?: DaemonChannelInstanceSnapshot; - }>(); const [deleteTarget, setDeleteTarget] = useState<{ workspaceCwd?: string; instance: DaemonChannelInstanceSnapshot; @@ -130,11 +175,27 @@ export function ChannelsManagerPage({ 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,11 +208,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) => { @@ -170,15 +229,20 @@ export function ChannelsManagerPage({ ); const saveChannel = useCallback( - (name: string, request: DaemonChannelUpsertRequest) => - createOrUpdate(name, request), - [createOrUpdate], + async (name: string, request: DaemonChannelUpsertRequest) => { + const result = await createOrUpdate(name, request); + if (editor?.workspaceCwd) { + setManagementWorkspaceCwd(editor.workspaceCwd); + } + return result; + }, + [createOrUpdate, editor?.workspaceCwd], ); const deleteChannel = useCallback(async () => { if ( !deleteTarget || - deleteTarget.workspaceCwd !== workspace.workspaceCwd || + deleteTarget.workspaceCwd !== activeWorkspaceCwd || !snapshot || deleting ) { @@ -196,7 +260,7 @@ export function ChannelsManagerPage({ } finally { setDeleting(false); } - }, [deleteTarget, deleting, remove, snapshot, workspace.workspaceCwd]); + }, [activeWorkspaceCwd, deleteTarget, deleting, remove, snapshot]); const runAction = useCallback( async ( @@ -300,6 +364,39 @@ export function ChannelsManagerPage({

+ {registeredWorkspaces.length > 0 ? ( +
+ + {t('channels.workspace.label')} + + +
+ ) : null} {!supportsManagement ? ( @@ -456,7 +553,7 @@ export function ChannelsManagerPage({ })} onClick={() => setEditor({ - workspaceCwd: workspace.workspaceCwd, + workspaceCwd: activeWorkspaceCwd!, descriptor, instance: channel, }) @@ -477,7 +574,7 @@ export function ChannelsManagerPage({ onClick={() => { setDeleteError(undefined); setDeleteTarget({ - workspaceCwd: workspace.workspaceCwd, + workspaceCwd: activeWorkspaceCwd, instance: channel, }); }} @@ -517,7 +614,7 @@ export function ChannelsManagerPage({ })} onClick={() => setEditor({ - workspaceCwd: workspace.workspaceCwd, + workspaceCwd: activeWorkspaceCwd!, descriptor: platform, }) } @@ -539,15 +636,23 @@ export function ChannelsManagerPage({
) : null} - {editor && editor.workspaceCwd === workspace.workspaceCwd && snapshot ? ( + {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 +667,7 @@ export function ChannelsManagerPage({ { if (!open && !deleting) { 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 f1b97a18820..d2b67fdd37c 100644 --- a/packages/web-shell/client/e2e/web-shell.channels.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.channels.spec.ts @@ -26,6 +26,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: [ { @@ -142,6 +158,13 @@ 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'); @@ -171,7 +194,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([ diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index b9366ff2494..3351e61129f 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2536,6 +2536,8 @@ const EN: Messages = { '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.availablePlatforms': 'Available platforms', @@ -2580,7 +2582,7 @@ 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', @@ -2592,6 +2594,11 @@ const EN: Messages = { '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.dingtalk.clientId': 'Client ID (AppKey)', 'channels.editor.field.dingtalk.clientSecret': 'Client Secret (AppSecret)', @@ -5252,6 +5259,8 @@ const ZH: Messages = { 'channels.summary': (v) => `${v?.workspace ?? ''} · 已配置 ${v?.count ?? 0} 个`, 'channels.workspace.current': '当前工作区', + 'channels.workspace.label': '工作区', + 'channels.workspace.primary': '主工作区', 'channels.loading': '正在加载频道', 'channels.configured': '已配置频道', 'channels.availablePlatforms': '可连接平台', @@ -5292,7 +5301,7 @@ 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': '应用凭据', @@ -5303,6 +5312,11 @@ const ZH: Messages = { '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.dingtalk.clientId': 'Client ID(原 AppKey)', 'channels.editor.field.dingtalk.clientSecret': 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, }; } From 111e17eb7eb750dc09892b66be1b64c43a3f12fb Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:58:51 +0800 Subject: [PATCH 05/21] feat(web-shell): redesign channel management --- .../channels/ChannelsManagerPage.module.css | 550 +++++++++++++++--- .../channels/ChannelsManagerPage.test.tsx | 47 +- .../channels/ChannelsManagerPage.tsx | 413 ++++++++----- .../client/e2e/web-shell.channels.spec.ts | 5 +- packages/web-shell/client/i18n.tsx | 37 +- 5 files changed, 827 insertions(+), 225 deletions(-) diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.module.css b/packages/web-shell/client/components/channels/ChannelsManagerPage.module.css index 1f75b58e013..c101a09d5fd 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.module.css +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.module.css @@ -1,25 +1,63 @@ .page { display: flex; width: 100%; + max-width: 1180px; flex-direction: column; - gap: 28px; - padding-block-end: 32px; + gap: 32px; + margin-inline: auto; + padding: 4px 4px 40px; } .pageHeader { + position: relative; display: flex; - align-items: flex-start; + overflow: hidden; + align-items: center; justify-content: space-between; - gap: 16px; - padding-block-end: 18px; - border-block-end: 1px solid var(--border); + gap: 24px; + padding: 20px; + border: 1px solid var(--border); + border-radius: 18px; + background: + radial-gradient( + circle at 92% -30%, + color-mix(in srgb, var(--foreground) 7%, transparent), + transparent 48% + ), + var(--card); + box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 5%, transparent); } .titleGroup { display: flex; min-width: 0; - align-items: flex-start; - gap: 8px; + align-items: center; + gap: 12px; +} + +.backButton { + margin-inline-start: -8px; + color: var(--muted-foreground); +} + +.titleMark { + display: inline-flex; + width: 48px; + height: 48px; + flex: 0 0 48px; + align-items: center; + justify-content: center; + border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent); + border-radius: 15px; + background: color-mix(in srgb, var(--muted) 68%, var(--card)); + color: var(--foreground); + box-shadow: inset 0 1px 0 color-mix(in srgb, white 8%, transparent); +} + +.titleMark svg { + width: 22px; + height: 22px; + stroke-width: 1.65; } .titleCopy { @@ -28,111 +66,269 @@ .title { outline: none; - font-size: 24px; - font-weight: 650; - letter-spacing: -0.025em; - line-height: 1.2; + font-size: 26px; + font-weight: 680; + letter-spacing: -0.035em; + line-height: 1.15; } .summary { - overflow: hidden; - margin-block-start: 5px; + max-width: 560px; + margin-block-start: 6px; color: var(--muted-foreground); font-size: 13px; - text-overflow: ellipsis; - white-space: nowrap; + line-height: 1.5; +} + +.headerControls { + display: flex; + flex: 0 0 auto; + align-items: flex-end; + gap: 10px; } .workspacePicker { display: flex; - min-width: 220px; + min-width: 240px; flex-direction: column; - gap: 5px; + gap: 6px; } .workspacePickerLabel { + display: flex; + align-items: center; + gap: 5px; color: var(--muted-foreground); font-size: 11px; font-weight: 650; + line-height: 1; +} + +.workspacePickerLabel svg { + width: 12px; + height: 12px; } .workspacePickerTrigger { width: 100%; - background: var(--background); + min-height: 36px; + background: color-mix(in srgb, var(--background) 82%, transparent); +} + +.refreshButton { + min-height: 36px; +} + +.loadingState { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.loadingCard { + display: flex; + align-items: center; + gap: 12px; + min-height: 108px; + padding: 18px; + border: 1px solid var(--border); + border-radius: 16px; + background: var(--card); } .section { display: flex; min-width: 0; flex-direction: column; - gap: 12px; + gap: 16px; } .sectionHeader { display: flex; - align-items: center; + align-items: flex-end; justify-content: space-between; - gap: 12px; + gap: 16px; +} + +.sectionHeadingCopy { + min-width: 0; +} + +.sectionTitleRow { + display: flex; + align-items: center; + gap: 8px; } .sectionTitle { - font-size: 14px; - font-weight: 650; + font-size: 16px; + font-weight: 680; + letter-spacing: -0.015em; + line-height: 1.35; +} + +.sectionCount { + min-width: 24px; + padding-inline: 7px; + color: var(--muted-foreground); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.sectionDescription { + margin-block-start: 4px; + color: var(--muted-foreground); + font-size: 12px; + line-height: 1.5; +} + +.emptyState { + min-height: 190px; + border: 1px dashed color-mix(in srgb, var(--foreground) 18%, var(--border)); + background: color-mix(in srgb, var(--muted) 34%, transparent); } .channelGrid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 12px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; } .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: 1px solid transparent; + background: color-mix(in srgb, var(--card) 96%, var(--muted)); + box-shadow: + 0 1px 2px color-mix(in srgb, var(--foreground) 5%, transparent), + 0 8px 24px color-mix(in srgb, var(--foreground) 3%, transparent); } -.channelCard[data-runtime-state='connected']::before { - background: var(--success-color); +.channelHeader { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + padding: 16px 16px 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: 12px; } -.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: 680; + text-overflow: ellipsis; + white-space: nowrap; +} + +.channelType { + margin-block-start: 3px; + font-size: 11px; +} + +.runtimeBadge { + height: 19px; + gap: 5px; + padding-inline: 7px; + border-color: color-mix(in srgb, currentColor 24%, transparent); + font-size: 10px; +} + +.runtimeBadge[data-runtime-state='connected'] { + background: var(--success-bg); + color: var(--success-color); +} + +.runtimeBadge[data-runtime-state='starting'], +.runtimeBadge[data-runtime-state='partial'] { + border-color: var(--warning-border); + background: var(--warning-bg); + color: var(--warning-color); +} + +.runtimeBadge[data-runtime-state='error'] { + border-color: var(--error-border); + background: var(--error-bg); + color: var(--error-color); +} + +.runtimeBadge[data-runtime-state='stopped'] { + background: color-mix(in srgb, var(--muted) 72%, transparent); + color: var(--muted-foreground); } -.lifecycleActions { +.runtimeDot { + width: 5px; + height: 5px; + flex: 0 0 5px; + border-radius: 50%; + background: currentColor; +} + +.cardActionGroup { display: flex; - flex-wrap: wrap; - gap: 8px; + align-items: center; + gap: 2px; } -.startupControl { - display: inline-flex; +.runtimeSummary { + display: flex; align-items: center; gap: 8px; + min-height: 36px; + padding: 8px 16px 10px; color: var(--muted-foreground); - font-size: 12px; + font-size: 11px; + line-height: 1.4; +} + +.runtimeSignal { + width: 7px; + height: 7px; + flex: 0 0 7px; + border: 2px solid color-mix(in srgb, var(--muted-foreground) 45%, transparent); + border-radius: 50%; + background: transparent; +} + +.runtimeSignal[data-runtime-state='connected'] { + border-color: var(--success-color); + background: var(--success-color); + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--success-color) 12%, transparent); +} + +.runtimeSignal[data-runtime-state='starting'], +.runtimeSignal[data-runtime-state='partial'] { + border-color: var(--warning-color); + background: var(--warning-color); + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--warning-color) 12%, transparent); +} + +.runtimeSignal[data-runtime-state='error'] { + border-color: var(--error-color); + background: var(--error-color); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--error-color) 12%, transparent); } .errorAlert [data-slot='alert-description'] { @@ -143,33 +339,101 @@ overflow-wrap: anywhere; } +.channelFooter { + min-height: 64px; + margin-block-start: auto; + padding: 12px 16px; + background: color-mix(in srgb, var(--muted) 42%, transparent); +} + +.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: 620; + line-height: 1.35; +} + +.startupDescription { + color: var(--muted-foreground); + font-size: 10.5px; + line-height: 1.4; +} + +.platformSection { + padding-block-start: 6px; +} + .platformGrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; + gap: 12px; } .platformCard { + --channel-platform-accent: var(--foreground); + + position: relative; display: flex; + overflow: hidden; min-width: 0; + min-height: 82px; cursor: pointer; align-items: center; - gap: 11px; - padding: 13px; + gap: 12px; + padding: 15px; border: 1px solid var(--border); - border-radius: var(--radius-lg); + border-radius: 15px; background: var(--card); color: inherit; font: inherit; text-align: start; + box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 4%, transparent); transition: - border-color 120ms ease, - background-color 120ms ease; + border-color 140ms ease, + box-shadow 140ms ease, + transform 140ms ease; +} + +.platformCard::before { + position: absolute; + inset: 0; + background: linear-gradient( + 125deg, + color-mix(in srgb, var(--channel-platform-accent) 8%, transparent), + transparent 52% + ); + content: ''; + opacity: 0.72; + pointer-events: none; } .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)); + border-color: color-mix( + in srgb, + var(--channel-platform-accent) 42%, + var(--border) + ); + box-shadow: + 0 2px 5px color-mix(in srgb, var(--foreground) 6%, transparent), + 0 10px 24px + color-mix(in srgb, var(--channel-platform-accent) 8%, transparent); + transform: translateY(-2px); } .platformCard:focus-visible { @@ -179,58 +443,191 @@ .platformCard:disabled { cursor: not-allowed; - opacity: 0.55; + opacity: 0.5; +} + +.platformCard[data-platform='dingtalk'], +.platformMark[data-platform='dingtalk'] { + --channel-platform-accent: #1677ff; +} + +.platformCard[data-platform='wecom'], +.platformMark[data-platform='wecom'] { + --channel-platform-accent: #07a958; +} + +.platformCard[data-platform='feishu'], +.platformMark[data-platform='feishu'] { + --channel-platform-accent: #515cff; +} + +.platformCard[data-platform='github'], +.platformMark[data-platform='github'] { + --channel-platform-accent: color-mix(in srgb, var(--foreground) 82%, #6e7781); +} + +.platformCard[data-platform='gitlab'], +.platformMark[data-platform='gitlab'] { + --channel-platform-accent: #e5532d; } .platformMark { + position: relative; + z-index: 1; display: inline-flex; - width: 36px; - height: 36px; - flex: 0 0 36px; + width: 42px; + height: 42px; + flex: 0 0 42px; align-items: center; justify-content: center; - border-radius: 11px; - background: var(--muted); - color: var(--foreground); + border: 1px solid + color-mix(in srgb, var(--channel-platform-accent) 20%, transparent); + border-radius: 13px; + background: color-mix( + in srgb, + var(--channel-platform-accent) 11%, + var(--card) + ); + color: var(--channel-platform-accent); font-size: 12px; - font-weight: 750; - letter-spacing: -0.03em; + font-weight: 760; + letter-spacing: -0.035em; } .platformCopy { + position: relative; + z-index: 1; + display: flex; min-width: 0; + flex: 1 1 auto; + flex-direction: column; + gap: 3px; } .platformName { overflow: hidden; + color: var(--foreground); font-size: 13px; - font-weight: 650; + font-weight: 680; text-overflow: ellipsis; white-space: nowrap; } .platformHint { - margin-block-start: 2px; color: var(--muted-foreground); font-size: 11px; } -@container panel-body (max-width: 620px) { - .platformGrid { +.platformAction { + position: relative; + z-index: 1; + display: inline-flex; + width: 29px; + height: 29px; + flex: 0 0 29px; + align-items: center; + justify-content: center; + border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--muted) 78%, transparent); + color: var(--muted-foreground); + transition: + background-color 140ms ease, + color 140ms ease, + transform 140ms ease; +} + +.platformAction svg { + width: 14px; + height: 14px; +} + +.platformCard:hover:not(:disabled) .platformAction { + background: color-mix( + in srgb, + var(--channel-platform-accent) 12%, + var(--card) + ); + color: var(--channel-platform-accent); + transform: scale(1.06); +} + +@container panel-body (max-width: 860px) { + .pageHeader { + align-items: stretch; + flex-direction: column; + } + + .headerControls { + width: 100%; + } + + .workspacePicker { + flex: 1 1 auto; + } + + .channelGrid, + .loadingState { grid-template-columns: 1fr; } + + .platformGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } -@container panel-body (max-width: 440px) { +@container panel-body (max-width: 560px) { + .page { + gap: 26px; + padding-inline: 0; + } + .pageHeader { + padding: 17px; + border-radius: 16px; + } + + .titleGroup { + align-items: flex-start; + } + + .titleMark { + width: 42px; + height: 42px; + flex-basis: 42px; + border-radius: 13px; + } + + .title { + font-size: 23px; + } + + .headerControls { + align-items: stretch; flex-direction: column; } .workspacePicker { width: 100%; + min-width: 0; + } + + .refreshButton { + width: 100%; } - .channelGrid { + .channelHeader { + display: flex; + align-items: stretch; + flex-direction: column; + } + + .cardActionGroup { + width: 100%; + justify-content: flex-end; + } + + .platformGrid { grid-template-columns: 1fr; } } @@ -238,7 +635,8 @@ @media (prefers-reduced-motion: reduce) { .channelCard, .channelCard *, - .platformCard { + .platformCard, + .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 f00a0d09999..d666f3821eb 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx @@ -212,6 +212,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-"]'), @@ -368,9 +371,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(); }); @@ -389,6 +401,37 @@ describe('ChannelsManagerPage', () => { }); }); + 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( diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx index 89ba65f6ba1..df150cd3464 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx @@ -8,8 +8,12 @@ import { useCallback, useEffect, useMemo, useState, type Ref } from 'react'; import { AlertCircleIcon, ArrowLeftIcon, + EllipsisVerticalIcon, + FolderIcon, PencilIcon, + PlusIcon, RadioTowerIcon, + RefreshCwIcon, RotateCwIcon, Trash2Icon, } from 'lucide-react'; @@ -44,6 +48,14 @@ import { CardHeader, CardTitle, } from '../ui/card'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '../ui/dropdown-menu'; import { Empty, EmptyDescription, @@ -52,6 +64,7 @@ import { EmptyTitle, } from '../ui/empty'; import { Spinner } from '../ui/spinner'; +import { Skeleton } from '../ui/skeleton'; import { Select, SelectContent, @@ -84,6 +97,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' { @@ -208,10 +232,6 @@ export function ChannelsManagerPage({ .sort((left, right) => left.name.localeCompare(right.name)), [channels], ); - const workspaceName = activeWorkspace - ? workspaceLabel(activeWorkspace) - : t('channels.workspace.current'); - const channelTypeLabel = useCallback( (channel: DaemonChannelInstanceSnapshot) => { const type = String(channel.config.type); @@ -347,56 +367,75 @@ export function ChannelsManagerPage({ +

{t('channels.title')}

-

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

+

{t('channels.description')}

- {registeredWorkspaces.length > 0 ? ( -
- - {t('channels.workspace.label')} - - setManagementWorkspaceCwd(cwd)} > - - - - {registeredWorkspaces.map((entry) => ( - - {workspaceLabel(entry)} - {entry.primary - ? ` · ${t('channels.workspace.primary')}` - : ''} - - ))} - - -
- ) : null} + + + + + {registeredWorkspaces.map((entry) => ( + + {workspaceLabel(entry)} + {entry.primary + ? ` · ${t('channels.workspace.primary')}` + : ''} + + ))} + + + + ) : null} + + {!supportsManagement ? ( @@ -420,9 +459,21 @@ export function ChannelsManagerPage({ ) : null} {loading && instances.length === 0 ? ( -
- - {t('channels.loading')} +
+ {[0, 1].map((index) => ( +
+ +
+ + +
+ +
+ ))}
) : null} @@ -444,13 +495,22 @@ export function ChannelsManagerPage({
-

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

- {instances.length} +
+
+

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

+ + {instances.length} + +
+

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

+
{!loading && !error && instances.length === 0 ? ( - + @@ -468,27 +528,131 @@ export function ChannelsManagerPage({ const descriptor = descriptorFor(channel); const runtimeError = actionErrors[channel.name] ?? channel.runtime.lastError; + const canRestart = + channel.runtime.state !== 'stopped' && + channel.runtime.state !== 'error'; return ( - -
- - {channel.name} - - {t(STATUS_KEYS[channel.runtime.state])} - - - - {channelTypeLabel(channel)} - + +
+ +
+ + + {channel.name} + + + + {t(STATUS_KEYS[channel.runtime.state])} + + + + {channelTypeLabel(channel)} + +
- {renderPrimaryAction(channel)} + + {renderPrimaryAction(channel)} + {descriptor ? ( + + ) : 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 ? ( ) : null} - + -
- {channel.runtime.state !== 'stopped' && - channel.runtime.state !== 'error' ? ( - - ) : null} - {descriptor ? ( - - ) : null} - -
); @@ -592,14 +701,24 @@ export function ChannelsManagerPage({
{availablePlatforms.length > 0 ? ( -
-
-

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

-

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

+
+
+
+
+

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

+ + {availablePlatforms.length} + +
+

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

+
{availablePlatforms.map((platform) => ( @@ -607,6 +726,7 @@ export function ChannelsManagerPage({ key={platform.type} type="button" className={styles.platformCard} + data-platform={platform.type} data-testid={`channel-platform-${platform.type}`} disabled={!canManage || !snapshot} aria-label={t('channels.platform.configureNamed', { @@ -619,17 +739,26 @@ export function ChannelsManagerPage({ }) } > -
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 d2b67fdd37c..1d3c87a76c8 100644 --- a/packages/web-shell/client/e2e/web-shell.channels.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.channels.spec.ts @@ -335,7 +335,10 @@ test('creates and deletes a typed Channel configuration', async ({ }), ); - await page.getByRole('button', { name: 'Delete release-bot' }).click(); + 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); diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 3351e61129f..bf4437f306b 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2533,6 +2533,8 @@ 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', @@ -2540,11 +2542,14 @@ const EN: Messages = { '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', @@ -2552,7 +2557,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.', @@ -2565,12 +2579,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) => @@ -5256,6 +5273,7 @@ const ZH: Messages = { 'splitView.composerPlaceholder': '给这个会话发消息…', 'settings.title': '设置', 'channels.title': '频道', + 'channels.description': '让 Qwen Code 在团队日常使用的平台中收发消息。', 'channels.summary': (v) => `${v?.workspace ?? ''} · 已配置 ${v?.count ?? 0} 个`, 'channels.workspace.current': '当前工作区', @@ -5263,17 +5281,26 @@ const ZH: Messages = { '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': '频道管理为只读模式', @@ -5285,12 +5312,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) => From 2333a055521ffea46ba27f3a7380dd13ced1ab0e Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:44:18 +0800 Subject: [PATCH 06/21] fix(web-shell): align channel manager with shell tabs --- .../channels/ChannelsManagerPage.module.css | 491 +++--------- .../channels/ChannelsManagerPage.tsx | 719 +++++++++--------- 2 files changed, 468 insertions(+), 742 deletions(-) diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.module.css b/packages/web-shell/client/components/channels/ChannelsManagerPage.module.css index c101a09d5fd..73736feb9fa 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.module.css +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.module.css @@ -1,221 +1,163 @@ .page { display: flex; - width: 100%; - max-width: 1180px; + min-height: 100%; flex-direction: column; - gap: 32px; - margin-inline: auto; - padding: 4px 4px 40px; + margin: -16px -20px 0; } .pageHeader { - position: relative; display: flex; - overflow: hidden; - align-items: center; - justify-content: space-between; - gap: 24px; - padding: 20px; - border: 1px solid var(--border); - border-radius: 18px; - background: - radial-gradient( - circle at 92% -30%, - color-mix(in srgb, var(--foreground) 7%, transparent), - transparent 48% - ), - var(--card); - box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 5%, transparent); -} - -.titleGroup { - display: flex; - min-width: 0; + min-height: 46px; + flex: 0 0 auto; align-items: center; - gap: 12px; + gap: 10px; + padding: 10px 20px; + border-block-end: 1px solid var(--border); } .backButton { - margin-inline-start: -8px; + width: 30px; + height: 30px; + flex: 0 0 30px; color: var(--muted-foreground); } -.titleMark { - display: inline-flex; - width: 48px; - height: 48px; - flex: 0 0 48px; - align-items: center; - justify-content: center; - border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent); - border-radius: 15px; - background: color-mix(in srgb, var(--muted) 68%, var(--card)); +.title { + min-width: 0; color: var(--foreground); - box-shadow: inset 0 1px 0 color-mix(in srgb, white 8%, transparent); + font-size: 14px; + font-weight: 600; + line-height: 1.35; + outline: none; } -.titleMark svg { - width: 22px; - height: 22px; - stroke-width: 1.65; +.pageBody { + display: flex; + min-width: 0; + flex-direction: column; + gap: 24px; + padding: 20px 24px 32px; } -.titleCopy { - min-width: 0; +.intro { + color: var(--muted-foreground); + font-size: 13px; + line-height: 1.5; } -.title { - outline: none; - font-size: 26px; - font-weight: 680; - letter-spacing: -0.035em; - line-height: 1.15; +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; } -.summary { - max-width: 560px; - margin-block-start: 6px; - color: var(--muted-foreground); +.count { + min-width: 0; + color: var(--foreground); font-size: 13px; - line-height: 1.5; + font-weight: 600; + line-height: 1.4; } -.headerControls { +.toolbarActions { display: flex; flex: 0 0 auto; - align-items: flex-end; - gap: 10px; + align-items: center; + gap: 8px; } .workspacePicker { display: flex; - min-width: 240px; - flex-direction: column; - gap: 6px; + align-items: center; + gap: 8px; } .workspacePickerLabel { - display: flex; - align-items: center; - gap: 5px; color: var(--muted-foreground); - font-size: 11px; - font-weight: 650; - line-height: 1; -} - -.workspacePickerLabel svg { - width: 12px; - height: 12px; + font-size: 12px; + font-weight: 500; + white-space: nowrap; } .workspacePickerTrigger { - width: 100%; - min-height: 36px; - background: color-mix(in srgb, var(--background) 82%, transparent); + width: 220px; + min-height: 34px; + background: var(--background); } .refreshButton { - min-height: 36px; + min-height: 34px; } .loadingState { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; -} - -.loadingCard { display: flex; + min-height: 120px; align-items: center; - gap: 12px; - min-height: 108px; - padding: 18px; - border: 1px solid var(--border); - border-radius: 16px; - background: var(--card); + justify-content: center; + gap: 8px; + color: var(--muted-foreground); + font-size: 13px; } .section { display: flex; min-width: 0; flex-direction: column; - gap: 16px; + gap: 12px; } .sectionHeader { display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 16px; -} - -.sectionHeadingCopy { min-width: 0; -} - -.sectionTitleRow { - display: flex; - align-items: center; - gap: 8px; + flex-direction: column; + gap: 4px; } .sectionTitle { - font-size: 16px; - font-weight: 680; - letter-spacing: -0.015em; - line-height: 1.35; -} - -.sectionCount { - min-width: 24px; - padding-inline: 7px; - color: var(--muted-foreground); - font-size: 11px; - font-variant-numeric: tabular-nums; + color: var(--foreground); + font-size: 14px; + font-weight: 600; + line-height: 1.4; } .sectionDescription { - margin-block-start: 4px; color: var(--muted-foreground); font-size: 12px; line-height: 1.5; } .emptyState { - min-height: 190px; - border: 1px dashed color-mix(in srgb, var(--foreground) 18%, var(--border)); - background: color-mix(in srgb, var(--muted) 34%, transparent); + min-height: 140px; + border: 0; + background: transparent; } .channelGrid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; + display: flex; + min-width: 0; + flex-direction: column; + gap: 12px; } .channelCard { - position: relative; min-width: 0; - border: 1px solid transparent; - background: color-mix(in srgb, var(--card) 96%, var(--muted)); - box-shadow: - 0 1px 2px color-mix(in srgb, var(--foreground) 5%, transparent), - 0 8px 24px color-mix(in srgb, var(--foreground) 3%, transparent); + border-radius: 12px; + background: var(--background); } .channelHeader { grid-template-columns: minmax(0, 1fr) auto; align-items: center; - gap: 14px; - padding: 16px 16px 4px; + gap: 12px; + padding: 14px 14px 4px; } .channelIdentity { display: flex; min-width: 0; align-items: center; - gap: 12px; + gap: 11px; } .channelIdentityCopy { @@ -235,21 +177,23 @@ min-width: 0; color: var(--foreground); font-size: 14px; - font-weight: 680; + font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } -.channelType { - margin-block-start: 3px; +.channelMeta { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: 4px; + margin-block-start: 2px; font-size: 11px; } .runtimeBadge { height: 19px; - gap: 5px; padding-inline: 7px; - border-color: color-mix(in srgb, currentColor 24%, transparent); font-size: 10px; } @@ -258,92 +202,20 @@ color: var(--success-color); } -.runtimeBadge[data-runtime-state='starting'], -.runtimeBadge[data-runtime-state='partial'] { - border-color: var(--warning-border); - background: var(--warning-bg); - color: var(--warning-color); -} - -.runtimeBadge[data-runtime-state='error'] { - border-color: var(--error-border); - background: var(--error-bg); - color: var(--error-color); -} - -.runtimeBadge[data-runtime-state='stopped'] { - background: color-mix(in srgb, var(--muted) 72%, transparent); - color: var(--muted-foreground); -} - -.runtimeDot { - width: 5px; - height: 5px; - flex: 0 0 5px; - border-radius: 50%; - background: currentColor; -} - .cardActionGroup { display: flex; align-items: center; gap: 2px; } -.runtimeSummary { - display: flex; - align-items: center; - gap: 8px; - min-height: 36px; - padding: 8px 16px 10px; - color: var(--muted-foreground); - font-size: 11px; - line-height: 1.4; -} - -.runtimeSignal { - width: 7px; - height: 7px; - flex: 0 0 7px; - border: 2px solid color-mix(in srgb, var(--muted-foreground) 45%, transparent); - border-radius: 50%; - background: transparent; -} - -.runtimeSignal[data-runtime-state='connected'] { - border-color: var(--success-color); - background: var(--success-color); - box-shadow: 0 0 0 3px - color-mix(in srgb, var(--success-color) 12%, transparent); -} - -.runtimeSignal[data-runtime-state='starting'], -.runtimeSignal[data-runtime-state='partial'] { - border-color: var(--warning-color); - background: var(--warning-color); - box-shadow: 0 0 0 3px - color-mix(in srgb, var(--warning-color) 12%, transparent); -} - -.runtimeSignal[data-runtime-state='error'] { - border-color: var(--error-color); - background: var(--error-color); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--error-color) 12%, transparent); -} - .errorAlert [data-slot='alert-description'] { - display: -webkit-box; - overflow: hidden; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; overflow-wrap: anywhere; } .channelFooter { - min-height: 64px; - margin-block-start: auto; - padding: 12px 16px; - background: color-mix(in srgb, var(--muted) 42%, transparent); + min-height: 58px; + padding: 11px 14px; + border-block-start: 1px solid var(--border); } .startupControl { @@ -365,150 +237,85 @@ .startupLabel { color: var(--foreground); font-size: 12px; - font-weight: 620; + font-weight: 500; line-height: 1.35; } .startupDescription { color: var(--muted-foreground); - font-size: 10.5px; + font-size: 11px; line-height: 1.4; } .platformSection { - padding-block-start: 6px; + padding-block-start: 4px; } .platformGrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; + gap: 10px; } .platformCard { - --channel-platform-accent: var(--foreground); - - position: relative; display: flex; - overflow: hidden; min-width: 0; - min-height: 82px; + min-height: 66px; cursor: pointer; align-items: center; - gap: 12px; - padding: 15px; + gap: 11px; + padding: 13px; border: 1px solid var(--border); - border-radius: 15px; - background: var(--card); + border-radius: 12px; + background: var(--background); color: inherit; font: inherit; text-align: start; - box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 4%, transparent); - transition: - border-color 140ms ease, - box-shadow 140ms ease, - transform 140ms ease; -} - -.platformCard::before { - position: absolute; - inset: 0; - background: linear-gradient( - 125deg, - color-mix(in srgb, var(--channel-platform-accent) 8%, transparent), - transparent 52% - ); - content: ''; - opacity: 0.72; - pointer-events: none; + transition: background-color 150ms ease; } .platformCard:hover:not(:disabled) { - border-color: color-mix( - in srgb, - var(--channel-platform-accent) 42%, - var(--border) - ); - box-shadow: - 0 2px 5px color-mix(in srgb, var(--foreground) 6%, transparent), - 0 10px 24px - color-mix(in srgb, var(--channel-platform-accent) 8%, transparent); - transform: translateY(-2px); + 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; } .platformCard:disabled { cursor: not-allowed; - opacity: 0.5; -} - -.platformCard[data-platform='dingtalk'], -.platformMark[data-platform='dingtalk'] { - --channel-platform-accent: #1677ff; -} - -.platformCard[data-platform='wecom'], -.platformMark[data-platform='wecom'] { - --channel-platform-accent: #07a958; -} - -.platformCard[data-platform='feishu'], -.platformMark[data-platform='feishu'] { - --channel-platform-accent: #515cff; -} - -.platformCard[data-platform='github'], -.platformMark[data-platform='github'] { - --channel-platform-accent: color-mix(in srgb, var(--foreground) 82%, #6e7781); -} - -.platformCard[data-platform='gitlab'], -.platformMark[data-platform='gitlab'] { - --channel-platform-accent: #e5532d; + opacity: 0.55; } .platformMark { - position: relative; - z-index: 1; display: inline-flex; - width: 42px; - height: 42px; - flex: 0 0 42px; + width: 36px; + height: 36px; + flex: 0 0 36px; align-items: center; justify-content: center; - border: 1px solid - color-mix(in srgb, var(--channel-platform-accent) 20%, transparent); - border-radius: 13px; - background: color-mix( - in srgb, - var(--channel-platform-accent) 11%, - var(--card) - ); - color: var(--channel-platform-accent); + border-radius: 11px; + background: var(--muted); + color: var(--foreground); font-size: 12px; - font-weight: 760; - letter-spacing: -0.035em; + font-weight: 700; + letter-spacing: -0.03em; } .platformCopy { - position: relative; - z-index: 1; display: flex; min-width: 0; flex: 1 1 auto; flex-direction: column; - gap: 3px; + gap: 2px; } .platformName { overflow: hidden; color: var(--foreground); font-size: 13px; - font-weight: 680; + font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } @@ -519,22 +326,13 @@ } .platformAction { - position: relative; - z-index: 1; display: inline-flex; - width: 29px; - height: 29px; - flex: 0 0 29px; + width: 26px; + height: 26px; + flex: 0 0 26px; align-items: center; justify-content: center; - border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent); - border-radius: 10px; - background: color-mix(in srgb, var(--muted) 78%, transparent); color: var(--muted-foreground); - transition: - background-color 140ms ease, - color 140ms ease, - transform 140ms ease; } .platformAction svg { @@ -542,77 +340,33 @@ height: 14px; } -.platformCard:hover:not(:disabled) .platformAction { - background: color-mix( - in srgb, - var(--channel-platform-accent) 12%, - var(--card) - ); - color: var(--channel-platform-accent); - transform: scale(1.06); -} - -@container panel-body (max-width: 860px) { - .pageHeader { - align-items: stretch; - flex-direction: column; - } - - .headerControls { - width: 100%; - } - - .workspacePicker { - flex: 1 1 auto; - } - - .channelGrid, - .loadingState { - grid-template-columns: 1fr; - } - +@container panel-body (max-width: 760px) { .platformGrid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } -@container panel-body (max-width: 560px) { - .page { - gap: 26px; - padding-inline: 0; +@container panel-body (max-width: 620px) { + .pageBody { + padding-inline: 20px; } - .pageHeader { - padding: 17px; - border-radius: 16px; - } - - .titleGroup { - align-items: flex-start; - } - - .titleMark { - width: 42px; - height: 42px; - flex-basis: 42px; - border-radius: 13px; - } - - .title { - font-size: 23px; + .toolbar { + align-items: stretch; + flex-direction: column; } - .headerControls { + .toolbarActions { align-items: stretch; - flex-direction: column; + flex-wrap: wrap; } .workspacePicker { - width: 100%; min-width: 0; + flex: 1 1 100%; } - .refreshButton { + .workspacePickerTrigger { width: 100%; } @@ -633,12 +387,7 @@ } @media (prefers-reduced-motion: reduce) { - .channelCard, - .channelCard *, - .platformCard, - .platformCard * { + .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.tsx b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx index df150cd3464..64fd950113e 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx @@ -9,7 +9,6 @@ import { AlertCircleIcon, ArrowLeftIcon, EllipsisVerticalIcon, - FolderIcon, PencilIcon, PlusIcon, RadioTowerIcon, @@ -64,7 +63,6 @@ import { EmptyTitle, } from '../ui/empty'; import { Spinner } from '../ui/spinner'; -import { Skeleton } from '../ui/skeleton'; import { Select, SelectContent, @@ -232,6 +230,9 @@ export function ChannelsManagerPage({ .sort((left, right) => left.name.localeCompare(right.name)), [channels], ); + const workspaceName = activeWorkspace + ? workspaceLabel(activeWorkspace) + : t('channels.workspace.current'); const channelTypeLabel = useCallback( (channel: DaemonChannelInstanceSnapshot) => { const type = String(channel.config.type); @@ -363,407 +364,383 @@ export function ChannelsManagerPage({ return (
-
- - -
-

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

-

{t('channels.description')}

-
-
-
- {registeredWorkspaces.length > 0 ? ( -
-
-
- -
- ) : null} - -
+ +

+ {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 ? ( -
- {[0, 1].map((index) => ( -
- -
- - +
+

+ {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} - -
+ {error ? ( + + + {t('channels.loadError.title')} + {extractErrorDetail(error)} + + + ) : null} + +
+
+

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

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

-
- {!loading && !error && instances.length === 0 ? ( - - - - - - {t('channels.empty.title')} - - {t('channels.empty.description')} - - - - ) : null} - {instances.length > 0 ? ( -
- {instances.map((channel) => { - const descriptor = descriptorFor(channel); - const runtimeError = - actionErrors[channel.name] ?? channel.runtime.lastError; - const canRestart = - channel.runtime.state !== 'stopped' && - channel.runtime.state !== 'error'; - return ( - - -
- -
- - - {channel.name} - - - - {t(STATUS_KEYS[channel.runtime.state])} - - - - {channelTypeLabel(channel)} - -
-
- - {renderPrimaryAction(channel)} - {descriptor ? ( - - ) : null} - - + {PLATFORM_MARKS[String(channel.config.type)] ?? + channelTypeLabel(channel)[0]?.toUpperCase() ?? + '?'} + +
+ + + {channel.name} + + + {t(STATUS_KEYS[channel.runtime.state])} + + + + {channelTypeLabel(channel)} + + + {t( + STATUS_DESCRIPTION_KEYS[channel.runtime.state], + )} + + +
+
+ + {renderPrimaryAction(channel)} + {descriptor ? ( - - - - {canRestart ? ( - - void runAction(channel, 'restart', () => - restart(channel.name), - ) - } - > - - {t('channels.action.restart')} - - ) : null} - {canRestart ? : 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 ? ( + + + + {t('channels.runtimeError')} + {runtimeError} + + + ) : null} + + - - - ); - })} -
- ) : null} -
+ + void runAction(channel, 'startup', () => + setStartup(channel.name, { + expectedRevision: snapshot?.revision ?? '', + enabled, + }), + ) + } + /> + + + + ); + })} +
+ ) : null} +
- {availablePlatforms.length > 0 ? ( -
-
-
-
-

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

- - {availablePlatforms.length} - -
+ {availablePlatforms.length > 0 ? ( +
+
+

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

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

-
-
- {availablePlatforms.map((platform) => ( - - ))} -
-
- ) : null} + + + ))} +
+
+ ) : null} +
{editor ? ( Date: Mon, 10 Aug 2026 20:11:46 +0800 Subject: [PATCH 07/21] fix(web-shell): prioritize conversation settings --- .../channels/ChannelEditorDialog.test.tsx | 8 +- .../channels/ChannelEditorDialog.tsx | 142 +++++++++--------- 2 files changed, 77 insertions(+), 73 deletions(-) diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx index 981ef7cb40b..10da034c7ef 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx @@ -401,8 +401,12 @@ describe('ChannelEditorDialog', () => { setInputValue(allowedGroups!, 'group-a, group-b'); }); - expect(document.body.textContent).toContain('Conversation management'); - expect(document.body.textContent).toContain( + 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'); diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx index d42cc86e077..11c0cc6ffac 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx @@ -715,6 +715,77 @@ export function ChannelEditorDialog({ {credentialFields.map(renderField)}
+ {sessionFields.length > 0 ? ( +
+

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

+ {sessionScopeField ? ( +
+ + {t('channels.editor.session.isolation')} + + + setDraft((current) => ({ + ...current, + values: { + ...current.values, + [sessionScopeField.key]: value, + }, + })) + } + > + {(sessionScopeField.options ?? []).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} + {(() => { const descriptorPolicy = hasDescriptorSenderPolicy(descriptor); const effectivePolicy = descriptorPolicy @@ -844,77 +915,6 @@ export function ChannelEditorDialog({ ); })()} - - {sessionFields.length > 0 ? ( -
-

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

- {sessionScopeField ? ( -
- - {t('channels.editor.session.isolation')} - - - setDraft((current) => ({ - ...current, - values: { - ...current.values, - [sessionScopeField.key]: value, - }, - })) - } - > - {(sessionScopeField.options ?? []).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} 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 05b812e3b97..b367d318305 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 @@ -281,7 +281,28 @@ describe('Channel editor state', () => { expect( validateChannelEditorDraft(DINGTALK_WITH_ACCESS, draft, []), - ).toMatchObject({ allowedGroupIds: 'invalid' }); + ).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('supports explicitly clearing a stored secret', () => { 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 253b6cf404b..73d0d2bc7be 100644 --- a/packages/web-shell/client/components/channels/channel-editor-state.ts +++ b/packages/web-shell/client/components/channels/channel-editor-state.ts @@ -32,6 +32,7 @@ export type ChannelEditorValidationCode = | 'credential' | 'duplicate' | 'invalid' + | 'invalidGroupId' | 'invalidOption' | 'number' | 'outOfRange' @@ -220,11 +221,12 @@ export function validateChannelEditorDraft( errors['senderPolicy'] = 'policy'; } if ( + String(draft.values['groupPolicy'] ?? '') === 'allowlist' && splitList(draft.allowedGroupIds).some((groupId) => UNSAFE_OBJECT_KEYS.includes(groupId), ) ) { - errors['allowedGroupIds'] = 'invalid'; + errors['allowedGroupIds'] = 'invalidGroupId'; } return errors; } @@ -338,7 +340,10 @@ export function buildChannelUpsertRequest( if (!hasDescriptorSenderPolicy(descriptor)) { config['senderPolicy'] = draft.senderPolicy; } - if (hasDescriptorGroupPolicy(descriptor)) { + if ( + hasDescriptorGroupPolicy(descriptor) && + config['groupPolicy'] === 'allowlist' + ) { assignGroups(config, draft.allowedGroupIds, instance); } return { expectedRevision, config, secrets }; diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index bf4437f306b..dc1e23e0cb7 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2786,6 +2786,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.', @@ -5510,6 +5512,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) => From b6f26d0deeb3b1056d73271356f9bdbcbfee4deb Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:17:58 +0800 Subject: [PATCH 11/21] fix(channels): preserve workspace action and route state --- .../commands/channel/channel-registry.test.ts | 4 +- .../src/commands/channel/channel-registry.ts | 6 +- .../src/commands/channel/config-utils.test.ts | 4 +- .../cli/src/commands/channel/config-utils.ts | 5 +- .../channels/ChannelsManagerPage.test.tsx | 65 +++++++++++++++---- .../channels/ChannelsManagerPage.tsx | 42 ++++++++---- .../channels/channel-editor-state.test.ts | 7 +- .../channels/channel-editor-state.ts | 3 - packages/web-shell/client/i18n.tsx | 8 +++ 9 files changed, 103 insertions(+), 41 deletions(-) diff --git a/packages/cli/src/commands/channel/channel-registry.test.ts b/packages/cli/src/commands/channel/channel-registry.test.ts index 36597971785..d0c3ffe1ab9 100644 --- a/packages/cli/src/commands/channel/channel-registry.test.ts +++ b/packages/cli/src/commands/channel/channel-registry.test.ts @@ -690,9 +690,10 @@ describe('channel registry', () => { expect( entry?.fields.find((field) => field.key === 'sessionScope'), ).toMatchObject({ - default: 'chat_thread', + default: 'thread', options: [ { value: 'user' }, + { value: 'thread' }, { value: 'chat_thread' }, { value: 'single' }, ], @@ -755,6 +756,7 @@ describe('channel registry', () => { default: 'user', options: [ { value: 'user' }, + { value: 'thread' }, { value: 'chat_thread' }, { value: 'single' }, ], diff --git a/packages/cli/src/commands/channel/channel-registry.ts b/packages/cli/src/commands/channel/channel-registry.ts index 20faccaf2ca..e0afb2e1466 100644 --- a/packages/cli/src/commands/channel/channel-registry.ts +++ b/packages/cli/src/commands/channel/channel-registry.ts @@ -72,6 +72,7 @@ const SESSION_SCOPE_OPTIONS: ReadonlyArray<{ 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' }, ]; @@ -92,10 +93,7 @@ function managementFieldsWithSharedControls( label: 'Session Scope', kind: 'enum' as const, required: true, - default: - defaultSessionScope === 'thread' - ? 'chat_thread' - : defaultSessionScope, + default: defaultSessionScope, description: 'Controls how conversations share persistent agent sessions', options: SESSION_SCOPE_OPTIONS, diff --git a/packages/cli/src/commands/channel/config-utils.test.ts b/packages/cli/src/commands/channel/config-utils.test.ts index f1e385d35a1..68d1a29064e 100644 --- a/packages/cli/src/commands/channel/config-utils.test.ts +++ b/packages/cli/src/commands/channel/config-utils.test.ts @@ -358,13 +358,13 @@ describe('parseChannelConfig', () => { expect(result.groups).toEqual({ g1: { mentionKeywords: ['@bot'] } }); }); - it('normalizes the deprecated thread scope to chat_thread', async () => { + it('preserves the deprecated thread scope for existing routes', async () => { const result = await parseChannelConfig('bot', { type: 'bare', sessionScope: 'thread', }); - expect(result.sessionScope).toBe('chat_thread'); + expect(result.sessionScope).toBe('thread'); }); it('uses plugin defaultSessionScope when sessionScope is not configured', async () => { diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts index 805155c5248..68c55d66d4e 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -466,10 +466,7 @@ export async function parseChannelConfig( (rawConfig['senderPolicy'] as ChannelConfig['senderPolicy']) || 'allowlist', allowedUsers: (rawConfig['allowedUsers'] as string[]) || [], - sessionScope: - configuredSessionScope === 'thread' - ? 'chat_thread' - : configuredSessionScope, + sessionScope: configuredSessionScope, cwd: resolveChannelCwd(rawConfig['cwd'] as string | undefined, defaultCwd), approvalMode: parseApprovalModeConfig(name, rawConfig), instructions: rawConfig['instructions'] as string | undefined, diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx b/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx index f071390a93f..c48cc2f8db7 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx @@ -436,14 +436,22 @@ describe('ChannelsManagerPage', () => { expect(container.textContent).not.toContain('stale start failure'); }); - it('keeps a lifecycle action busy while a new editor changes workspace', async () => { - let finishStart!: () => void; - channelState.current.start.mockImplementationOnce( - () => - new Promise((resolve) => { - finishStart = resolve; - }), - ); + 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: { @@ -500,9 +508,44 @@ describe('ChannelsManagerPage', () => { enabled: true, workspaceCwd: '/workspace/secondary', }); - expect(start?.disabled).toBe(true); - expect(start?.querySelector('[data-slot="spinner"]')).toBeNull(); - await act(async () => finishStart()); + 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?.trim() === 'Main repo'); + await act(async () => primary?.click()); + 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 () => { diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx index d08b19731cc..416993fc973 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx @@ -186,11 +186,19 @@ export function ChannelsManagerPage({ Boolean(workspace.token) && Boolean(activeWorkspaceCwd) && activeWorkspace?.trusted === true; - const [busy, setBusy] = useState<{ - workspaceCwd: string; - name: string; - action: ChannelAction; - } | null>(null); + const [busyByWorkspace, setBusyByWorkspace] = useState< + Record< + string, + { + workspaceCwd: string; + name: string; + action: ChannelAction; + } + > + >({}); + const busy = activeWorkspaceCwd + ? (busyByWorkspace[activeWorkspaceCwd] ?? null) + : null; const [actionErrors, setActionErrors] = useState>({}); const [deleteTarget, setDeleteTarget] = useState<{ workspaceCwd?: string; @@ -302,7 +310,10 @@ export function ChannelsManagerPage({ if (!canManage || busy || !activeWorkspaceCwd) return; const workspaceCwd = activeWorkspaceCwd; const errorKey = actionErrorKey(workspaceCwd, channel.name); - setBusy({ workspaceCwd, name: channel.name, action }); + setBusyByWorkspace((current) => ({ + ...current, + [workspaceCwd]: { workspaceCwd, name: channel.name, action }, + })); setActionErrors((current) => { const next = { ...current }; delete next[errorKey]; @@ -316,13 +327,18 @@ export function ChannelsManagerPage({ [errorKey]: extractErrorDetail(actionError), })); } finally { - setBusy((current) => - current?.workspaceCwd === workspaceCwd && - current.name === channel.name && - current.action === action - ? null - : current, - ); + setBusyByWorkspace((current) => { + const workspaceBusy = current[workspaceCwd]; + if ( + workspaceBusy?.name !== channel.name || + workspaceBusy.action !== action + ) { + return current; + } + const next = { ...current }; + delete next[workspaceCwd]; + return next; + }); } }, [activeWorkspaceCwd, busy, canManage], 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 b367d318305..73d782aeb92 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 @@ -85,6 +85,7 @@ const DINGTALK_WITH_ACCESS: DaemonChannelTypeDescriptor = { default: 'user', options: [ { 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' }, ], @@ -199,12 +200,12 @@ describe('Channel editor state', () => { }); }); - it('migrates the deprecated thread scope to chat_thread when editing', () => { + it('preserves the deprecated thread scope when editing', () => { const instance = configuredInstance(); const draft = createChannelEditorDraft(DINGTALK_WITH_ACCESS, instance); - expect(draft.values.sessionScope).toBe('chat_thread'); + expect(draft.values.sessionScope).toBe('thread'); expect( buildChannelUpsertRequest( DINGTALK_WITH_ACCESS, @@ -212,7 +213,7 @@ describe('Channel editor state', () => { 'revision-session-scope', instance, ).config.sessionScope, - ).toBe('chat_thread'); + ).toBe('thread'); }); it('fills safe policy defaults when editing a legacy instance', () => { 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 73d0d2bc7be..6eb54a67077 100644 --- a/packages/web-shell/client/components/channels/channel-editor-state.ts +++ b/packages/web-shell/client/components/channels/channel-editor-state.ts @@ -90,9 +90,6 @@ function initialFieldValue( return ''; } if (field.kind === 'enum') { - if (field.key === 'sessionScope' && value === 'thread') { - return 'chat_thread'; - } if (typeof value === 'string' && value) return value; if (instance) { if (field.key === 'senderPolicy') return 'allowlist'; diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index dc1e23e0cb7..462c2a5cc47 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2717,11 +2717,15 @@ const EN: Messages = { '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': @@ -5446,11 +5450,15 @@ const ZH: Messages = { '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': From 49f7a2585d6d6a1c0f932fbbf2ac96c48b81ae28 Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:11:52 +0800 Subject: [PATCH 12/21] fix(web-shell): prevent stale channel editor state --- .../channels/ChannelsManagerPage.test.tsx | 119 ++++++++++++++++++ .../channels/ChannelsManagerPage.tsx | 18 ++- .../channels/channel-editor-state.test.ts | 25 ++++ .../channels/channel-editor-state.ts | 11 +- 4 files changed, 165 insertions(+), 8 deletions(-) diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx b/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx index c48cc2f8db7..b68125478d5 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx @@ -126,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); @@ -326,6 +344,107 @@ describe('ChannelsManagerPage', () => { }); }); + 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, diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx index 416993fc973..49866b8164f 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx @@ -4,7 +4,14 @@ * 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, @@ -157,6 +164,10 @@ export function ChannelsManagerPage({ descriptor: DaemonChannelTypeDescriptor; instance?: DaemonChannelInstanceSnapshot; }>(); + const editorRef = useRef(editor); + useEffect(() => { + editorRef.current = editor; + }, [editor]); const activeWorkspaceCwd = editor?.workspaceCwd ?? selectedManagementWorkspace?.cwd; const activeWorkspace = registeredWorkspaces.find( @@ -262,9 +273,10 @@ export function ChannelsManagerPage({ const saveChannel = useCallback( async (name: string, request: DaemonChannelUpsertRequest) => { + const workspaceCwd = editor?.workspaceCwd; const result = await createOrUpdate(name, request); - if (editor?.workspaceCwd) { - setManagementWorkspaceCwd(editor.workspaceCwd); + if (workspaceCwd && editorRef.current?.workspaceCwd === workspaceCwd) { + setManagementWorkspaceCwd(workspaceCwd); } return result; }, 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 73d782aeb92..c0e130bdf98 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 @@ -306,6 +306,31 @@ describe('Channel editor state', () => { ).not.toHaveProperty('groups'); }); + it('removes stored group settings when leaving allowlist policy', () => { + const instance: DaemonChannelInstanceSnapshot = { + ...configuredInstance(), + config: { + ...configuredInstance().config, + groupPolicy: 'allowlist', + groups: { + 'group-a': {}, + 'group-b': { requireMention: true }, + }, + }, + }; + const draft = createChannelEditorDraft(DINGTALK_WITH_ACCESS, instance); + draft.values.groupPolicy = 'open'; + + expect( + buildChannelUpsertRequest( + DINGTALK_WITH_ACCESS, + draft, + 'revision-open', + instance, + ).config, + ).not.toHaveProperty('groups'); + }); + it('supports explicitly clearing a stored secret', () => { const instance = configuredInstance(); const draft = createChannelEditorDraft(DINGTALK, instance); 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 6eb54a67077..211a41d810e 100644 --- a/packages/web-shell/client/components/channels/channel-editor-state.ts +++ b/packages/web-shell/client/components/channels/channel-editor-state.ts @@ -337,11 +337,12 @@ export function buildChannelUpsertRequest( if (!hasDescriptorSenderPolicy(descriptor)) { config['senderPolicy'] = draft.senderPolicy; } - if ( - hasDescriptorGroupPolicy(descriptor) && - config['groupPolicy'] === 'allowlist' - ) { - assignGroups(config, draft.allowedGroupIds, instance); + if (hasDescriptorGroupPolicy(descriptor)) { + if (config['groupPolicy'] === 'allowlist') { + assignGroups(config, draft.allowedGroupIds, instance); + } else { + delete config['groups']; + } } return { expectedRevision, config, secrets }; } From 844d833d80ef895b384f698697c81fcfd6cc4d2c Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:17:08 +0800 Subject: [PATCH 13/21] fix(channels): preserve stored group settings --- .../src/serve/channel-settings-store.test.ts | 42 +++++++++++++++++++ .../cli/src/serve/channel-settings-store.ts | 9 +++- .../channels/channel-editor-state.test.ts | 26 ++++++++++++ .../channels/channel-editor-state.ts | 2 +- 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/serve/channel-settings-store.test.ts b/packages/cli/src/serve/channel-settings-store.test.ts index a3952cf4f79..792c2e1dd30 100644 --- a/packages/cli/src/serve/channel-settings-store.test.ts +++ b/packages/cli/src/serve/channel-settings-store.test.ts @@ -660,6 +660,48 @@ describe('WorkspaceChannelSettingsStore', () => { }); }); + 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('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 385a04162be..d45b8dec118 100644 --- a/packages/cli/src/serve/channel-settings-store.ts +++ b/packages/cli/src/serve/channel-settings-store.ts @@ -200,7 +200,14 @@ function assertSharedField( (nestedKey === 'groupHistoryLimit' && typeof nestedValue === 'number' && Number.isFinite(nestedValue)); - if (known && !valid) { + if ( + known && + !valid && + !( + Object.hasOwn(previousGroup, nestedKey) && + isDeepStrictEqual(previousGroup[nestedKey], nestedValue) + ) + ) { throw invalidConfig( `Channel field "${key}.${groupId}.${nestedKey}" is invalid.`, ); 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 c0e130bdf98..d28ee9fcbb2 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 @@ -331,6 +331,32 @@ describe('Channel editor state', () => { ).not.toHaveProperty('groups'); }); + 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('supports explicitly clearing a stored secret', () => { const instance = configuredInstance(); const draft = createChannelEditorDraft(DINGTALK, instance); 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 211a41d810e..f56e43bfec8 100644 --- a/packages/web-shell/client/components/channels/channel-editor-state.ts +++ b/packages/web-shell/client/components/channels/channel-editor-state.ts @@ -340,7 +340,7 @@ export function buildChannelUpsertRequest( if (hasDescriptorGroupPolicy(descriptor)) { if (config['groupPolicy'] === 'allowlist') { assignGroups(config, draft.allowedGroupIds, instance); - } else { + } else if (instance?.config['groupPolicy'] === 'allowlist') { delete config['groups']; } } From 410543bb9bc06d57436355dc422102b4a339cc4b Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:56:26 +0800 Subject: [PATCH 14/21] fix(channels): preserve group behavior settings --- .../src/serve/channel-settings-store.test.ts | 35 +++++++++++++++++++ .../cli/src/serve/channel-settings-store.ts | 3 +- .../channels/channel-editor-state.test.ts | 12 +++++-- .../channels/channel-editor-state.ts | 22 +++++++++++- 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/serve/channel-settings-store.test.ts b/packages/cli/src/serve/channel-settings-store.test.ts index 792c2e1dd30..86d5fd0becc 100644 --- a/packages/cli/src/serve/channel-settings-store.test.ts +++ b/packages/cli/src/serve/channel-settings-store.test.ts @@ -702,6 +702,41 @@ describe('WorkspaceChannelSettingsStore', () => { ).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 d45b8dec118..bf99072e4fb 100644 --- a/packages/cli/src/serve/channel-settings-store.ts +++ b/packages/cli/src/serve/channel-settings-store.ts @@ -205,7 +205,8 @@ function assertSharedField( !valid && !( Object.hasOwn(previousGroup, nestedKey) && - isDeepStrictEqual(previousGroup[nestedKey], nestedValue) + isDeepStrictEqual(previousGroup[nestedKey], nestedValue) && + !containsUnsafeObjectKey(nestedValue) ) ) { throw invalidConfig( 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 d28ee9fcbb2..d6239aed62a 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 @@ -306,15 +306,17 @@ describe('Channel editor state', () => { ).not.toHaveProperty('groups'); }); - it('removes stored group settings when leaving allowlist policy', () => { + 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 }, }, }, }; @@ -327,8 +329,12 @@ describe('Channel editor state', () => { draft, 'revision-open', instance, - ).config, - ).not.toHaveProperty('groups'); + ).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', () => { 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 f56e43bfec8..f0d8ed7b24a 100644 --- a/packages/web-shell/client/components/channels/channel-editor-state.ts +++ b/packages/web-shell/client/components/channels/channel-editor-state.ts @@ -309,6 +309,26 @@ function assignGroups( } } +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, @@ -341,7 +361,7 @@ export function buildChannelUpsertRequest( if (config['groupPolicy'] === 'allowlist') { assignGroups(config, draft.allowedGroupIds, instance); } else if (instance?.config['groupPolicy'] === 'allowlist') { - delete config['groups']; + removeGroupAllowlistMembership(config, instance); } } return { expectedRevision, config, secrets }; From 7c5c12511358b29b12e9361364f7d878e88ccf95 Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:19:31 +0800 Subject: [PATCH 15/21] fix(web-shell): reset channel workspace UI state --- .../channels/ChannelEditorDialog.test.tsx | 52 +++++++++++++++++++ .../channels/ChannelEditorDialog.tsx | 5 ++ .../channels/ChannelsManagerPage.test.tsx | 12 ++++- .../channels/ChannelsManagerPage.tsx | 6 ++- 4 files changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx index ae3f7ea0d0a..3f9b465cffa 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx @@ -299,6 +299,58 @@ describe('ChannelEditorDialog', () => { expect(onWorkspaceChange).toHaveBeenCalledWith('/workspace/secondary'); }); + 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(); diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx index efa3e7361cc..7348fce1de3 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx @@ -251,6 +251,11 @@ export function ChannelEditorDialog({ setSubmitError(undefined); }, [descriptor, instance, open]); + useEffect(() => { + setErrors({}); + setSubmitError(undefined); + }, [workspaceCwd]); + const fieldLabel = (field: DaemonChannelConfigFieldDescriptor) => { const key = FIELD_LABEL_KEYS[descriptor.type]?.[field.key] ?? diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx b/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx index b68125478d5..480be11422b 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx @@ -642,8 +642,14 @@ describe('ChannelsManagerPage', () => { }); const primary = Array.from( document.querySelectorAll('[role="option"]'), - ).find((item) => item.textContent?.trim() === 'Main repo'); + ).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', ); @@ -751,5 +757,9 @@ describe('ChannelsManagerPage', () => { 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 49866b8164f..cfbe15047cf 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx @@ -449,7 +449,11 @@ export function ChannelsManagerPage({ - {(sessionScopeField.options ?? []).map((option) => ( + {sessionScopeOptions.map((option) => (