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')}
-
+ {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')}
-
-
+
+ ) : null}
+ void reload()}
+ >
+ {loading ? : }
+ {t('channels.action.refresh')}
+
+
{!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({
-
-
{instances.length}
+
+
+
+
+ {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)}
-
+
+
+
+ {PLATFORM_MARKS[String(channel.config.type)] ??
+ channelTypeLabel(channel)[0]?.toUpperCase() ??
+ '?'}
+
+
+
+
+ {channel.name}
+
+
+
+ {t(STATUS_KEYS[channel.runtime.state])}
+
+
+
+ {channelTypeLabel(channel)}
+
+
- {renderPrimaryAction(channel)}
+
+ {renderPrimaryAction(channel)}
+ {descriptor ? (
+
+ setEditor({
+ workspaceCwd: activeWorkspaceCwd!,
+ descriptor,
+ instance: channel,
+ })
+ }
+ >
+
+ {t('channels.action.edit')}
+
+ ) : null}
+
+
+
+ {busy?.name === channel.name &&
+ busy.action === 'restart' ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {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')}
+
+
+
+
+
+
+
+
+ {t(STATUS_DESCRIPTION_KEYS[channel.runtime.state])}
+
+
{runtimeError ? (
) : null}
-
+
-
- {channel.runtime.state !== 'stopped' &&
- channel.runtime.state !== 'error' ? (
-
- void runAction(channel, 'restart', () =>
- restart(channel.name),
- )
- }
- >
- {busy?.name === channel.name &&
- busy.action === 'restart' ? (
-
- ) : (
-
- )}
- {t('channels.action.restart')}
-
- ) : null}
- {descriptor ? (
-
- setEditor({
- workspaceCwd: activeWorkspaceCwd!,
- descriptor,
- instance: channel,
- })
- }
- >
-
- {t('channels.action.edit')}
-
- ) : null}
-
{
- setDeleteError(undefined);
- setDeleteTarget({
- workspaceCwd: activeWorkspaceCwd,
- instance: channel,
- });
- }}
- >
-
- {t('channels.action.delete')}
-
-
);
@@ -592,14 +701,24 @@ export function ChannelsManagerPage({
{availablePlatforms.length > 0 ? (
-
-
-
-
- {t('channels.availablePlatforms.description')}
-
+
+
+
+
+
+
+ {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({
})
}
>
-
+
{PLATFORM_MARKS[platform.type] ??
platform.displayName[0]?.toUpperCase() ??
'?'}
-
-
{platform.displayName}
-
- {t('channels.platform.configure')}
-
-
+
+
+ {platform.displayName}
+
+
+ {t('channels.platform.add')}
+
+
+
+
+
))}
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 (
- {!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')}
+
+ setManagementWorkspaceCwd(cwd)}
+ >
+
+
+
+
+ {registeredWorkspaces.map((entry) => (
+
+ {workspaceLabel(entry)}
+ {entry.primary
+ ? ` · ${t('channels.workspace.primary')}`
+ : ''}
+
+ ))}
+
+
-
-
- ))}
+ ) : null}
+
void reload()}
+ >
+ {loading ? : }
+ {t('channels.action.refresh')}
+
+
- ) : null}
- {error ? (
-
-
- {t('channels.loadError.title')}
- {extractErrorDetail(error)}
- void reload()}
+ {!supportsManagement ? (
+
+
+ {t('channels.unsupported.title')}
+
+ {t('channels.unsupported.description')}
+
+
+ ) : null}
+
+ {supportsManagement && !workspace.token ? (
+
+
+ {t('channels.readOnly.title')}
+
+ {t('channels.readOnly.description')}
+
+
+ ) : null}
+
+ {loading && instances.length === 0 ? (
+
- {t('channels.action.retry')}
-
-
- ) : null}
+
+ {t('channels.loading')}
+
+ ) : null}
-
-
-
-
-
-
- {instances.length}
-
-
+ {error ? (
+
+
+ {t('channels.loadError.title')}
+ {extractErrorDetail(error)}
+ void reload()}
+ >
+ {t('channels.action.retry')}
+
+
+ ) : null}
+
+
+
+
{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 (
-
-
-
-
- {PLATFORM_MARKS[String(channel.config.type)] ??
- channelTypeLabel(channel)[0]?.toUpperCase() ??
- '?'}
-
-
-
-
- {channel.name}
-
-
-
- {t(STATUS_KEYS[channel.runtime.state])}
-
-
-
- {channelTypeLabel(channel)}
-
-
-
-
- {renderPrimaryAction(channel)}
- {descriptor ? (
-
- setEditor({
- workspaceCwd: activeWorkspaceCwd!,
- descriptor,
- instance: channel,
- })
- }
+ {!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 (
+
+
+
+
-
- {t('channels.action.edit')}
-
- ) : 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 ? (
+ setEditor({
+ workspaceCwd: activeWorkspaceCwd!,
+ descriptor,
+ instance: channel,
+ })
+ }
>
- {busy?.name === channel.name &&
- busy.action === 'restart' ? (
-
- ) : (
-
- )}
+
+ {t('channels.action.edit')}
-
-
-
- {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')}
-
-
-
-
-
-
-
-
-
- {t(STATUS_DESCRIPTION_KEYS[channel.runtime.state])}
-
-
- {runtimeError ? (
-
-
-
- {t('channels.runtimeError')}
- {runtimeError}
-
-
- ) : null}
-
-
-
-
- );
- })}
-
- ) : null}
-
+
+ void runAction(channel, 'startup', () =>
+ setStartup(channel.name, {
+ expectedRevision: snapshot?.revision ?? '',
+ enabled,
+ }),
+ )
+ }
+ />
+
+
+
+ );
+ })}
+
+ ) : null}
+
- {availablePlatforms.length > 0 ? (
-
-
-
-
-
-
- {availablePlatforms.length}
-
-
+ {availablePlatforms.length > 0 ? (
+
+
+
{t('channels.availablePlatforms.description')}
-
-
- {availablePlatforms.map((platform) => (
-
- setEditor({
- workspaceCwd: activeWorkspaceCwd!,
- descriptor: platform,
- })
- }
- >
-
+ {availablePlatforms.map((platform) => (
+
+ setEditor({
+ workspaceCwd: activeWorkspaceCwd!,
+ descriptor: platform,
+ })
+ }
>
- {PLATFORM_MARKS[platform.type] ??
- platform.displayName[0]?.toUpperCase() ??
- '?'}
-
-
-
- {platform.displayName}
+
+ {PLATFORM_MARKS[platform.type] ??
+ platform.displayName[0]?.toUpperCase() ??
+ '?'}
-
- {t('channels.platform.add')}
+
+
+ {platform.displayName}
+
+
+ {t('channels.platform.add')}
+
-
-
-
-
-
- ))}
-
-
- ) : 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) => (
+
+
+
+ {fieldOptionLabel(
+ sessionScopeField,
+ option.value,
+ option.label,
+ )}
+
+
+ ))}
+
+
+ {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) => (
-
-
-
- {fieldOptionLabel(
- sessionScopeField,
- option.value,
- option.label,
- )}
-
-
- ))}
-
-
- {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}
Date: Tue, 11 Aug 2026 00:09:17 +0800
Subject: [PATCH 08/21] fix(web-shell): preserve legacy channel defaults
---
.../channels/channel-editor-state.test.ts | 13 +++++++++++++
.../components/channels/channel-editor-state.ts | 11 ++++++++---
2 files changed, 21 insertions(+), 3 deletions(-)
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 198a3d3319d..9cba38b259f 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
@@ -215,6 +215,19 @@ describe('Channel editor state', () => {
).toBe('chat_thread');
});
+ it('fills safe policy defaults when editing a legacy instance', () => {
+ const instance = configuredInstance();
+ delete instance.config.senderPolicy;
+
+ const draft = createChannelEditorDraft(DINGTALK_WITH_ACCESS, instance);
+
+ expect(draft.values.senderPolicy).toBe('pairing');
+ expect(draft.values.groupPolicy).toBe('disabled');
+ expect(validateChannelEditorDraft(DINGTALK_WITH_ACCESS, draft, [])).toEqual(
+ {},
+ );
+ });
+
it('changes group allowlist membership without losing wildcard or retained group settings', () => {
const instance: DaemonChannelInstanceSnapshot = {
...configuredInstance(),
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 e2af7953e4d..8f32d79fd08 100644
--- a/packages/web-shell/client/components/channels/channel-editor-state.ts
+++ b/packages/web-shell/client/components/channels/channel-editor-state.ts
@@ -93,9 +93,14 @@ function initialFieldValue(
return 'chat_thread';
}
if (typeof value === 'string' && value) return value;
- return instance && field.key !== 'sessionScope'
- ? ''
- : (field.default ?? field.options?.[0]?.value ?? '');
+ if (
+ instance &&
+ field.key !== 'sessionScope' &&
+ field.default === undefined
+ ) {
+ return '';
+ }
+ return field.default ?? field.options?.[0]?.value ?? '';
}
return typeof value === 'string' ? value : '';
}
From 907b46ae1aeef8dc526f8e141201ba6849448ae7 Mon Sep 17 00:00:00 2001
From: qqqys <266654365+qqqys@users.noreply.github.com>
Date: Tue, 11 Aug 2026 04:24:56 +0800
Subject: [PATCH 09/21] fix(channels): address management review blockers
---
.../channel/channel-registry-builtins.test.ts | 3 +
.../src/commands/channel/channel-registry.ts | 2 +-
.../src/serve/channel-settings-store.test.ts | 39 +++++++
.../cli/src/serve/channel-settings-store.ts | 38 ++++++-
.../channels/ChannelEditorDialog.test.tsx | 36 ++++++
.../channels/ChannelEditorDialog.tsx | 9 +-
.../channels/ChannelsManagerPage.test.tsx | 104 ++++++++++++++++++
.../channels/ChannelsManagerPage.tsx | 53 ++++++---
.../channels/channel-editor-state.test.ts | 27 ++++-
.../channels/channel-editor-state.ts | 17 ++-
10 files changed, 299 insertions(+), 29 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 1709da96a99..a37603e33f1 100644
--- a/packages/cli/src/commands/channel/channel-registry-builtins.test.ts
+++ b/packages/cli/src/commands/channel/channel-registry-builtins.test.ts
@@ -126,5 +126,8 @@ describe('built-in channel registry', () => {
'groupPolicy',
'sessionScope',
]);
+ expect(
+ entry?.fields.find((field) => field.key === 'senderPolicy'),
+ ).toMatchObject({ default: 'allowlist' });
});
});
diff --git a/packages/cli/src/commands/channel/channel-registry.ts b/packages/cli/src/commands/channel/channel-registry.ts
index 5fef75c0aa0..20faccaf2ca 100644
--- a/packages/cli/src/commands/channel/channel-registry.ts
+++ b/packages/cli/src/commands/channel/channel-registry.ts
@@ -37,7 +37,7 @@ const SHARED_ACCESS_FIELDS: readonly ChannelConfigFieldDescriptor[] = [
label: 'Sender Policy',
kind: 'enum',
required: true,
- default: 'pairing',
+ default: 'allowlist',
description: 'Controls who can start direct conversations',
options: [
{ value: 'pairing', label: 'Pairing' },
diff --git a/packages/cli/src/serve/channel-settings-store.test.ts b/packages/cli/src/serve/channel-settings-store.test.ts
index d90fe468b6a..a3952cf4f79 100644
--- a/packages/cli/src/serve/channel-settings-store.test.ts
+++ b/packages/cli/src/serve/channel-settings-store.test.ts
@@ -408,6 +408,17 @@ describe('WorkspaceChannelSettingsStore', () => {
clientSecret: { operation: 'replace', value: 'secret' } as const,
},
},
+ {
+ label: 'wrong nested dispatch mode kind',
+ config: {
+ type: 'management-validation-test',
+ clientId: 'client-id',
+ groups: { 'group-1': { dispatchMode: ['collect'] } },
+ },
+ secrets: {
+ clientSecret: { operation: 'replace', value: 'secret' } as const,
+ },
+ },
{
label: 'string-list with non-string items',
config: {
@@ -621,6 +632,34 @@ describe('WorkspaceChannelSettingsStore', () => {
expect(fs.readFileSync(settingsPath, 'utf8')).toBe(beforeRejectedWrite);
});
+ it('preserves unchanged legacy group settings while editing another field', async () => {
+ writeWorkspaceSettings(`{
+ "$version": 4,
+ "channels": { "bot": {
+ "type": "management-validation-test",
+ "clientId": "client-id",
+ "clientSecret": "existing-secret",
+ "groups": { "group-1": { "mentionKeywords": ["@bot"] } }
+ } }
+}\n`);
+ const store = new WorkspaceChannelSettingsStore(workspace);
+
+ const next = await store.upsert('bot', {
+ expectedRevision: store.snapshot().revision,
+ config: {
+ type: 'management-validation-test',
+ clientId: 'updated-id',
+ groups: { 'group-1': { mentionKeywords: ['@bot'] } },
+ },
+ secrets: { clientSecret: { operation: 'preserve' } },
+ });
+
+ expect(next.channels['bot']).toMatchObject({
+ clientId: 'updated-id',
+ groups: { 'group-1': { mentionKeywords: ['@bot'] } },
+ });
+ });
+
it('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 c6193f4cd91..385a04162be 100644
--- a/packages/cli/src/serve/channel-settings-store.ts
+++ b/packages/cli/src/serve/channel-settings-store.ts
@@ -139,7 +139,11 @@ function assertNumberRecord(
}
}
-function assertSharedField(key: string, value: unknown): boolean {
+function assertSharedField(
+ key: string,
+ value: unknown,
+ previous?: unknown,
+): boolean {
const enumValues: Record> = {
senderPolicy: new Set(['allowlist', 'pairing', 'open']),
dmPolicy: new Set(['open', 'disabled']),
@@ -173,24 +177,42 @@ function assertSharedField(key: string, value: unknown): boolean {
if (!isRecord(value)) {
throw invalidConfig(`Channel field "${key}" must be an object.`);
}
+ const previousGroups = isRecord(previous) ? previous : {};
for (const [groupId, groupConfig] of Object.entries(value)) {
if (UNSAFE_OBJECT_KEYS.has(groupId) || !isRecord(groupConfig)) {
throw invalidConfig(`Channel field "${key}.${groupId}" is invalid.`);
}
+ const previousGroup = isRecord(previousGroups[groupId])
+ ? previousGroups[groupId]
+ : {};
for (const [nestedKey, nestedValue] of Object.entries(groupConfig)) {
+ const known = [
+ 'requireMention',
+ 'dispatchMode',
+ 'groupHistoryLimit',
+ ].includes(nestedKey);
const valid =
(nestedKey === 'requireMention' &&
typeof nestedValue === 'boolean') ||
(nestedKey === 'dispatchMode' &&
- ['collect', 'steer', 'followup'].includes(String(nestedValue))) ||
+ typeof nestedValue === 'string' &&
+ ['collect', 'steer', 'followup'].includes(nestedValue)) ||
(nestedKey === 'groupHistoryLimit' &&
typeof nestedValue === 'number' &&
Number.isFinite(nestedValue));
- if (!valid) {
+ if (known && !valid) {
throw invalidConfig(
`Channel field "${key}.${groupId}.${nestedKey}" is invalid.`,
);
}
+ if (!known) {
+ assertPreservedUnknownField(
+ `${key}.${groupId}`,
+ nestedKey,
+ nestedValue,
+ previousGroup,
+ );
+ }
}
}
return true;
@@ -371,7 +393,15 @@ function assertManagedConfig(
);
continue;
}
- if (assertSharedField(key, value)) continue;
+ if (
+ assertSharedField(
+ key,
+ value,
+ Object.hasOwn(previous, key) ? previous[key] : undefined,
+ )
+ ) {
+ continue;
+ }
assertPreservedUnknownField(undefined, key, value, previous);
}
assertRequiredFields(fields, config);
diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx
index 10da034c7ef..df4893de479 100644
--- a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx
+++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx
@@ -373,6 +373,42 @@ describe('ChannelEditorDialog', () => {
});
});
+ it('cannot be dismissed while a save is in flight', async () => {
+ let finishSave!: () => void;
+ const onSave = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ finishSave = resolve;
+ }),
+ );
+ const onOpenChange = vi.fn();
+ await renderDialog({ onSave, onOpenChange });
+
+ await act(async () => {
+ setInputValue(inputByLabel('Instance name')!, 'release-bot');
+ setInputValue(inputByLabel('Client ID')!, 'ding-client-id');
+ setInputValue(inputByLabel('Client Secret')!, 'ding-client-secret');
+ });
+ const save = Array.from(document.querySelectorAll('button')).find(
+ (button) => button.textContent?.trim() === 'Save',
+ );
+ await act(async () => {
+ save?.click();
+ });
+ const cancel = Array.from(document.querySelectorAll('button')).find(
+ (button) => button.textContent?.trim() === 'Cancel',
+ );
+
+ expect(cancel?.disabled).toBe(true);
+ await act(async () => {
+ cancel?.click();
+ });
+ expect(onOpenChange).not.toHaveBeenCalled();
+
+ await act(async () => finishSave());
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
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 });
diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx
index 11c0cc6ffac..1792006a95c 100644
--- a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx
+++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx
@@ -594,7 +594,12 @@ export function ChannelEditorDialog({
};
return (
-