From dcac8c5fd5f2a55b179e97684fe4b76d1fe4ce14 Mon Sep 17 00:00:00 2001 From: OrbitZore Date: Sat, 1 Aug 2026 18:14:04 +0800 Subject: [PATCH 01/13] feat(channels): add Web Shell management support for GitHub and GitLab --- packages/channels/github/src/index.ts | 17 +++++++++++++++++ packages/channels/gitlab/src/index.ts | 17 +++++++++++++++++ .../channels/channel-platform.test.ts | 9 +++++++-- .../components/channels/channel-platform.ts | 10 ++++++++-- 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/packages/channels/github/src/index.ts b/packages/channels/github/src/index.ts index 1f872aeae69..8aa0a270dcc 100644 --- a/packages/channels/github/src/index.ts +++ b/packages/channels/github/src/index.ts @@ -9,6 +9,23 @@ export const plugin: ChannelPlugin = { requiredConfigFields: ['token'], envResolvableConfigFields: ['baseUrl'], defaultSessionScope: 'chat_thread', + management: { + fields: [ + { + key: 'token', + label: 'Personal Access Token', + kind: 'secret', + required: true, + envResolvable: true, + }, + { + key: 'baseUrl', + label: 'Base URL', + kind: 'string', + envResolvable: true, + }, + ], + }, createChannel: (name, config, bridge, options) => new GithubChannel(name, config, bridge, options), }; diff --git a/packages/channels/gitlab/src/index.ts b/packages/channels/gitlab/src/index.ts index fe48f7b3342..02b581780d9 100644 --- a/packages/channels/gitlab/src/index.ts +++ b/packages/channels/gitlab/src/index.ts @@ -9,6 +9,23 @@ export const plugin: ChannelPlugin = { requiredConfigFields: ['token'], envResolvableConfigFields: ['baseUrl'], defaultSessionScope: 'chat_thread', + management: { + fields: [ + { + key: 'token', + label: 'Personal Access Token', + kind: 'secret', + required: true, + envResolvable: true, + }, + { + key: 'baseUrl', + label: 'Base URL', + kind: 'string', + envResolvable: true, + }, + ], + }, createChannel: (name, config, bridge, options) => new GitlabChannel(name, config, bridge, options), }; diff --git a/packages/web-shell/client/components/channels/channel-platform.test.ts b/packages/web-shell/client/components/channels/channel-platform.test.ts index 7f825f2da06..b636c828cdc 100644 --- a/packages/web-shell/client/components/channels/channel-platform.test.ts +++ b/packages/web-shell/client/components/channels/channel-platform.test.ts @@ -19,25 +19,30 @@ function descriptor( } describe('Channel platform availability', () => { - it('only exposes manageable DingTalk, WeCom, and Feishu channels', () => { + it('only exposes manageable DingTalk, WeCom, Feishu, GitHub, and GitLab channels', () => { expect( [ descriptor('dingtalk'), descriptor('wecom'), descriptor('feishu'), + descriptor('github'), + descriptor('gitlab'), descriptor('telegram'), descriptor('weixin'), descriptor('dingtalk', false), + descriptor('github', false), ] .filter(isChannelPlatformAvailable) .map((item) => item.type), - ).toEqual(['dingtalk', 'wecom', 'feishu']); + ).toEqual(['dingtalk', 'wecom', 'feishu', 'github', 'gitlab']); }); it('uses the same allowlist for configured Channel instances', () => { expect(isSupportedChannelType('dingtalk')).toBe(true); expect(isSupportedChannelType('wecom')).toBe(true); expect(isSupportedChannelType('feishu')).toBe(true); + expect(isSupportedChannelType('github')).toBe(true); + expect(isSupportedChannelType('gitlab')).toBe(true); expect(isSupportedChannelType('telegram')).toBe(false); expect(isSupportedChannelType(undefined)).toBe(false); }); diff --git a/packages/web-shell/client/components/channels/channel-platform.ts b/packages/web-shell/client/components/channels/channel-platform.ts index b7be47081e9..a12abfa971e 100644 --- a/packages/web-shell/client/components/channels/channel-platform.ts +++ b/packages/web-shell/client/components/channels/channel-platform.ts @@ -6,11 +6,17 @@ import type { DaemonChannelTypeDescriptor } from '@qwen-code/sdk/daemon'; -const SUPPORTED_CHANNEL_TYPES = new Set(['dingtalk', 'wecom', 'feishu']); +const SUPPORTED_CHANNEL_TYPES = new Set([ + 'dingtalk', + 'wecom', + 'feishu', + 'github', + 'gitlab', +]); export function isSupportedChannelType( type: unknown, -): type is 'dingtalk' | 'wecom' | 'feishu' { +): type is 'dingtalk' | 'wecom' | 'feishu' | 'github' | 'gitlab' { return typeof type === 'string' && SUPPORTED_CHANNEL_TYPES.has(type); } From 8b37f3afe874833ce5d248858a97418bda0e7465 Mon Sep 17 00:00:00 2001 From: OrbitZore Date: Sat, 1 Aug 2026 18:17:26 +0800 Subject: [PATCH 02/13] test(channels): update channel-registry manageable expectation for GitHub and GitLab --- packages/cli/src/commands/channel/channel-registry.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/channel/channel-registry.test.ts b/packages/cli/src/commands/channel/channel-registry.test.ts index fd72c9ee821..d74915900a9 100644 --- a/packages/cli/src/commands/channel/channel-registry.test.ts +++ b/packages/cli/src/commands/channel/channel-registry.test.ts @@ -16,7 +16,7 @@ describe('channel registry', () => { ]); expect( catalog.filter((entry) => entry.manageable).map((entry) => entry.type), - ).toEqual(['dingtalk', 'wecom', 'feishu']); + ).toEqual(['dingtalk', 'wecom', 'feishu', 'github', 'gitlab']); expect( catalog.find((entry) => entry.type === 'dingtalk')?.fields, ).toContainEqual( From 07cfd2f838fbfaf455406e6d2ab45994a242eadb Mon Sep 17 00:00:00 2001 From: OrbitZore Date: Sat, 1 Aug 2026 18:20:14 +0800 Subject: [PATCH 03/13] test(web-shell): add gitlab manageable:false negative case for symmetry --- .../client/components/channels/channel-platform.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web-shell/client/components/channels/channel-platform.test.ts b/packages/web-shell/client/components/channels/channel-platform.test.ts index b636c828cdc..ad627302a1a 100644 --- a/packages/web-shell/client/components/channels/channel-platform.test.ts +++ b/packages/web-shell/client/components/channels/channel-platform.test.ts @@ -31,6 +31,7 @@ describe('Channel platform availability', () => { descriptor('weixin'), descriptor('dingtalk', false), descriptor('github', false), + descriptor('gitlab', false), ] .filter(isChannelPlatformAvailable) .map((item) => item.type), From af525b924792182fdbdfc7452552f74d5ec0feb9 Mon Sep 17 00:00:00 2001 From: OrbitZore Date: Sat, 1 Aug 2026 19:45:08 +0800 Subject: [PATCH 04/13] fix(web-shell): add GitHub/GitLab platform marks, i18n labels, and empty-state copy --- .../components/channels/ChannelEditorDialog.tsx | 10 ++++++++++ .../components/channels/ChannelsManagerPage.tsx | 4 +++- packages/web-shell/client/i18n.tsx | 12 ++++++++++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx index 303118678a0..7a2a773672f 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx @@ -60,6 +60,8 @@ const PLATFORM_MARKS: Record = { dingtalk: 'D', wecom: 'W', feishu: 'F', + github: 'GH', + gitlab: 'GL', }; const FIELD_LABEL_KEYS: Record> = { @@ -76,6 +78,14 @@ const FIELD_LABEL_KEYS: Record> = { clientId: 'channels.editor.field.feishu.clientId', clientSecret: 'channels.editor.field.feishu.clientSecret', }, + github: { + token: 'channels.editor.field.github.token', + baseUrl: 'channels.editor.field.github.baseUrl', + }, + gitlab: { + token: 'channels.editor.field.gitlab.token', + baseUrl: 'channels.editor.field.gitlab.baseUrl', + }, }; export interface ChannelEditorDialogProps { diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx index 1c5ad307829..0dbbc68e119 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx @@ -78,6 +78,8 @@ const PLATFORM_MARKS: Record = { dingtalk: 'D', wecom: 'W', feishu: 'F', + github: 'GH', + gitlab: 'GL', }; function badgeVariant( @@ -528,7 +530,7 @@ export function ChannelsManagerPage({ } >

{platform.displayName}

diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 74795242c6a..cbb0536cebb 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2397,7 +2397,7 @@ const EN: Messages = { 'channels.loadError.title': 'Channels could not be loaded', 'channels.empty.title': 'No supported channels configured', 'channels.empty.description': - 'Configure DingTalk, WeCom, or Feishu to receive messages in this workspace.', + 'Configure DingTalk, WeCom, Feishu, GitHub, or GitLab to receive messages in this workspace.', 'channels.runtimeError': 'Channel runtime error', 'channels.action.back': 'Back', 'channels.action.start': 'Start', @@ -2433,6 +2433,10 @@ const EN: Messages = { 'channels.editor.field.wecom.wsUrl': 'WebSocket URL', 'channels.editor.field.feishu.clientId': 'App ID', 'channels.editor.field.feishu.clientSecret': 'App Secret', + 'channels.editor.field.github.token': 'Personal Access Token', + 'channels.editor.field.github.baseUrl': 'Base URL', + 'channels.editor.field.gitlab.token': 'Personal Access Token', + 'channels.editor.field.gitlab.baseUrl': 'Base URL', 'channels.editor.secret.environment': 'Stored in environment', 'channels.editor.secret.stored': 'Stored securely', 'channels.editor.secret.preserve': 'Keep', @@ -4845,7 +4849,7 @@ const ZH: Messages = { 'channels.loadError.title': '无法加载频道', 'channels.empty.title': '尚未配置支持的频道', 'channels.empty.description': - '配置钉钉、企业微信或飞书,让当前工作区接收消息。', + '配置钉钉、企业微信、飞书、GitHub 或 GitLab,让当前工作区接收消息。', 'channels.runtimeError': '频道运行时错误', 'channels.action.back': '返回', 'channels.action.start': '启动', @@ -4880,6 +4884,10 @@ const ZH: Messages = { 'channels.editor.field.wecom.wsUrl': 'WebSocket URL', 'channels.editor.field.feishu.clientId': 'App ID', 'channels.editor.field.feishu.clientSecret': 'App Secret', + 'channels.editor.field.github.token': '个人访问令牌', + 'channels.editor.field.github.baseUrl': '基础 URL', + 'channels.editor.field.gitlab.token': '个人访问令牌', + 'channels.editor.field.gitlab.baseUrl': '基础 URL', 'channels.editor.secret.environment': '已保存在环境变量中', 'channels.editor.secret.stored': '已安全保存', 'channels.editor.secret.preserve': '保留', From 63c32c530d64e55cb5a8f9c07cfaa2ea053f566e Mon Sep 17 00:00:00 2001 From: OrbitZore Date: Sun, 2 Aug 2026 01:51:32 +0800 Subject: [PATCH 05/13] feat(web-shell): descriptor-driven groupPolicy, senderPolicy, allowedUsers for GitHub/GitLab channels --- packages/channels/base/src/types.ts | 3 +- packages/channels/github/src/index.ts | 27 +++++ packages/channels/gitlab/src/index.ts | 27 +++++ .../commands/channel/channel-registry.test.ts | 30 +++++ packages/sdk-typescript/src/daemon/types.ts | 3 +- .../channels/ChannelEditorDialog.tsx | 93 ++++++++-------- .../channels/ChannelsManagerPage.tsx | 9 +- .../channels/channel-editor-state.test.ts | 103 ++++++++++++++++++ .../channels/channel-editor-state.ts | 35 +++++- .../components/channels/channel-platform.ts | 8 ++ packages/web-shell/client/i18n.tsx | 22 +++- 11 files changed, 298 insertions(+), 62 deletions(-) diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index ebf2bb0b23b..b3e42eab68e 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -384,7 +384,8 @@ export type ChannelConfigFieldKind = | 'secret' | 'boolean' | 'number' - | 'enum'; + | 'enum' + | 'string-list'; export interface ChannelConfigFieldDescriptor { key: string; diff --git a/packages/channels/github/src/index.ts b/packages/channels/github/src/index.ts index 8aa0a270dcc..26c16d93374 100644 --- a/packages/channels/github/src/index.ts +++ b/packages/channels/github/src/index.ts @@ -24,6 +24,33 @@ export const plugin: ChannelPlugin = { kind: 'string', envResolvable: true, }, + { + key: 'groupPolicy', + label: 'Group Policy', + kind: 'enum', + required: true, + options: [ + { value: 'open', label: 'Open' }, + { value: 'allowlist', label: 'Allowlist' }, + { value: 'disabled', label: 'Disabled' }, + ], + }, + { + key: 'senderPolicy', + label: 'Sender Policy', + kind: 'enum', + required: true, + options: [ + { value: 'allowlist', label: 'Allowlist' }, + { value: 'pairing', label: 'Pairing' }, + { value: 'open', label: 'Open' }, + ], + }, + { + key: 'allowedUsers', + label: 'Allowed Users', + kind: 'string-list', + }, ], }, createChannel: (name, config, bridge, options) => diff --git a/packages/channels/gitlab/src/index.ts b/packages/channels/gitlab/src/index.ts index 02b581780d9..786f874f6f4 100644 --- a/packages/channels/gitlab/src/index.ts +++ b/packages/channels/gitlab/src/index.ts @@ -24,6 +24,33 @@ export const plugin: ChannelPlugin = { kind: 'string', envResolvable: true, }, + { + key: 'groupPolicy', + label: 'Group Policy', + kind: 'enum', + required: true, + options: [ + { value: 'open', label: 'Open' }, + { value: 'allowlist', label: 'Allowlist' }, + { value: 'disabled', label: 'Disabled' }, + ], + }, + { + key: 'senderPolicy', + label: 'Sender Policy', + kind: 'enum', + required: true, + options: [ + { value: 'allowlist', label: 'Allowlist' }, + { value: 'pairing', label: 'Pairing' }, + { value: 'open', label: 'Open' }, + ], + }, + { + key: 'allowedUsers', + label: 'Allowed Users', + kind: 'string-list', + }, ], }, createChannel: (name, config, bridge, options) => diff --git a/packages/cli/src/commands/channel/channel-registry.test.ts b/packages/cli/src/commands/channel/channel-registry.test.ts index d74915900a9..5b68d249a8e 100644 --- a/packages/cli/src/commands/channel/channel-registry.test.ts +++ b/packages/cli/src/commands/channel/channel-registry.test.ts @@ -26,6 +26,36 @@ describe('channel registry', () => { required: true, }), ); + for (const type of ['github', 'gitlab'] as const) { + const fields = catalog.find((entry) => entry.type === type)?.fields; + expect(fields).toContainEqual( + expect.objectContaining({ + key: 'token', + kind: 'secret', + required: true, + }), + ); + expect(fields).toContainEqual( + expect.objectContaining({ + key: 'groupPolicy', + kind: 'enum', + required: true, + }), + ); + expect(fields).toContainEqual( + expect.objectContaining({ + key: 'senderPolicy', + kind: 'enum', + required: true, + }), + ); + expect(fields).toContainEqual( + expect.objectContaining({ + key: 'allowedUsers', + kind: 'string-list', + }), + ); + } expect(JSON.stringify(catalog)).not.toContain('createChannel'); }); }); diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index db61813c335..194ec1774b6 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -2924,7 +2924,8 @@ export type DaemonChannelConfigFieldKind = | 'secret' | 'boolean' | 'number' - | 'enum'; + | 'enum' + | 'string-list'; export interface DaemonChannelConfigFieldDescriptor { key: string; diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx index 7a2a773672f..4336fe662f3 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx @@ -55,14 +55,7 @@ import { type ChannelEditorDraft, type ChannelEditorValidationCode, } from './channel-editor-state'; - -const PLATFORM_MARKS: Record = { - dingtalk: 'D', - wecom: 'W', - feishu: 'F', - github: 'GH', - gitlab: 'GL', -}; +import { PLATFORM_MARKS } from './channel-platform'; const FIELD_LABEL_KEYS: Record> = { dingtalk: { @@ -81,10 +74,16 @@ const FIELD_LABEL_KEYS: Record> = { github: { token: 'channels.editor.field.github.token', baseUrl: 'channels.editor.field.github.baseUrl', + groupPolicy: 'channels.editor.field.github.groupPolicy', + senderPolicy: 'channels.editor.field.github.senderPolicy', + allowedUsers: 'channels.editor.field.github.allowedUsers', }, gitlab: { token: 'channels.editor.field.gitlab.token', baseUrl: 'channels.editor.field.gitlab.baseUrl', + groupPolicy: 'channels.editor.field.gitlab.groupPolicy', + senderPolicy: 'channels.editor.field.gitlab.senderPolicy', + allowedUsers: 'channels.editor.field.gitlab.allowedUsers', }, }; @@ -524,42 +523,50 @@ export function ChannelEditorDialog({

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

- - setDraft((current) => ({ - ...current, - senderPolicy: - value === 'pairing' || value === 'open' ? value : '', - })) - } - > - {(['pairing', 'open'] as const).map((policy) => ( - - ))} - - {errors['senderPolicy'] ? ( -

- {errors['senderPolicy']} -

- ) : null} - {draft.senderPolicy === 'pairing' ? ( + {(['pairing', 'open'] as const).map((policy) => ( + + ))} + + {errors['senderPolicy'] ? ( +

+ {errors['senderPolicy']} +

+ ) : null} + + )} + {(descriptor.fields.some((f) => f.key === 'senderPolicy') + ? String(draft.values['senderPolicy'] ?? '') + : draft.senderPolicy) === 'pairing' ? ( instance?.config.senderPolicy === 'pairing' ? ( = { error: 'channels.status.error', }; -const PLATFORM_MARKS: Record = { - dingtalk: 'D', - wecom: 'W', - feishu: 'F', - github: 'GH', - gitlab: 'GL', -}; - function badgeVariant( state: DaemonChannelRuntimeState['state'], ): 'secondary' | 'outline' | 'destructive' { 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 554dc642595..1ad1feca881 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 @@ -171,3 +171,106 @@ describe('Channel editor state', () => { }); }); }); + +const GITHUB: DaemonChannelTypeDescriptor = { + type: 'github', + displayName: 'GitHub', + manageable: true, + fields: [ + { + key: 'token', + label: 'Personal Access Token', + kind: 'secret', + required: true, + }, + { + key: 'groupPolicy', + label: 'Group Policy', + kind: 'enum', + required: true, + options: [ + { value: 'open', label: 'Open' }, + { value: 'allowlist', label: 'Allowlist' }, + { value: 'disabled', label: 'Disabled' }, + ], + }, + { + key: 'senderPolicy', + label: 'Sender Policy', + kind: 'enum', + required: true, + options: [ + { value: 'allowlist', label: 'Allowlist' }, + { value: 'pairing', label: 'Pairing' }, + { value: 'open', label: 'Open' }, + ], + }, + { + key: 'allowedUsers', + label: 'Allowed Users', + kind: 'string-list', + }, + ], +}; + +describe('Descriptor-driven senderPolicy', () => { + it('defaults enum fields to the first option for new channels', () => { + const draft = createChannelEditorDraft(GITHUB); + expect(draft.values.groupPolicy).toBe('open'); + expect(draft.values.senderPolicy).toBe('allowlist'); + expect(draft.senderPolicy).toBe(''); + }); + + it('reads stored enum and string-list values when editing', () => { + const instance: DaemonChannelInstanceSnapshot = { + name: 'my-bot', + config: { + type: 'github', + groupPolicy: 'allowlist', + senderPolicy: 'pairing', + allowedUsers: ['alice', 'bob'], + }, + secrets: { token: { present: true, source: 'stored' } }, + startsWithServe: false, + runtime: { state: 'stopped' }, + }; + const draft = createChannelEditorDraft(GITHUB, instance); + expect(draft.values.groupPolicy).toBe('allowlist'); + expect(draft.values.senderPolicy).toBe('pairing'); + expect(draft.values.allowedUsers).toBe('alice, bob'); + }); + + it('writes senderPolicy via descriptor fields, not the hardcoded path', () => { + const draft = createChannelEditorDraft(GITHUB); + draft.name = 'my-bot'; + draft.secrets.token = { operation: 'replace', value: 'ghp_test' }; + draft.values.allowedUsers = 'alice, bob'; + + const request = buildChannelUpsertRequest(GITHUB, draft, 'rev-1'); + expect(request.config).toEqual({ + type: 'github', + groupPolicy: 'open', + senderPolicy: 'allowlist', + allowedUsers: ['alice', 'bob'], + }); + }); + + it('skips senderPolicy validation when descriptor declares it', () => { + const draft = createChannelEditorDraft(GITHUB); + draft.name = 'my-bot'; + draft.secrets.token = { operation: 'replace', value: 'ghp_test' }; + + const errors = validateChannelEditorDraft(GITHUB, draft, []); + expect(errors).toEqual({}); + }); + + it('omits empty string-list fields from the upsert config', () => { + const draft = createChannelEditorDraft(GITHUB); + draft.name = 'my-bot'; + draft.secrets.token = { operation: 'replace', value: 'ghp_test' }; + draft.values.allowedUsers = ''; + + const request = buildChannelUpsertRequest(GITHUB, draft, 'rev-1'); + expect(request.config).not.toHaveProperty('allowedUsers'); + }); +}); 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 7db001612c5..166cacf5a51 100644 --- a/packages/web-shell/client/components/channels/channel-editor-state.ts +++ b/packages/web-shell/client/components/channels/channel-editor-state.ts @@ -49,6 +49,13 @@ function initialFieldValue( if (field.kind === 'number') { return typeof value === 'number' ? String(value) : ''; } + if (field.kind === 'string-list') { + return Array.isArray(value) ? value.join(', ') : ''; + } + if (field.kind === 'enum') { + if (typeof value === 'string' && value) return value; + return field.options?.[0]?.value ?? ''; + } return typeof value === 'string' ? value : ''; } @@ -67,13 +74,17 @@ export function createChannelEditorDraft( } values[field.key] = initialFieldValue(field, instance); } + const hasDescriptorPolicy = descriptor.fields.some( + (f) => f.key === 'senderPolicy', + ); const configuredPolicy = instance?.config['senderPolicy']; return { name: instance?.name ?? '', values, secrets, - senderPolicy: - configuredPolicy === 'pairing' || configuredPolicy === 'open' + senderPolicy: hasDescriptorPolicy + ? '' + : configuredPolicy === 'pairing' || configuredPolicy === 'open' ? configuredPolicy : instance ? '' @@ -124,7 +135,10 @@ export function validateChannelEditorDraft( errors[field.key] = 'number'; } } - if (!draft.senderPolicy) { + if ( + !draft.senderPolicy && + !descriptor.fields.some((f) => f.key === 'senderPolicy') + ) { errors['senderPolicy'] = 'policy'; } return errors; @@ -144,7 +158,16 @@ function assignField( delete config[field.key]; return; } - config[field.key] = field.kind === 'number' ? Number(value) : value; + if (field.kind === 'number') { + config[field.key] = Number(value); + } else if (field.kind === 'string-list') { + config[field.key] = value + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + } else { + config[field.key] = value; + } } export function buildChannelUpsertRequest( @@ -169,6 +192,8 @@ export function buildChannelUpsertRequest( } assignField(config, field, draft.values[field.key]); } - config['senderPolicy'] = draft.senderPolicy; + if (!descriptor.fields.some((f) => f.key === 'senderPolicy')) { + config['senderPolicy'] = draft.senderPolicy; + } return { expectedRevision, config, secrets }; } diff --git a/packages/web-shell/client/components/channels/channel-platform.ts b/packages/web-shell/client/components/channels/channel-platform.ts index a12abfa971e..38369522d46 100644 --- a/packages/web-shell/client/components/channels/channel-platform.ts +++ b/packages/web-shell/client/components/channels/channel-platform.ts @@ -6,6 +6,14 @@ import type { DaemonChannelTypeDescriptor } from '@qwen-code/sdk/daemon'; +export const PLATFORM_MARKS: Record = { + dingtalk: 'D', + wecom: 'W', + feishu: 'F', + github: 'GH', + gitlab: 'GL', +}; + const SUPPORTED_CHANNEL_TYPES = new Set([ 'dingtalk', 'wecom', diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index cbb0536cebb..a693634aee8 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2434,9 +2434,17 @@ const EN: Messages = { 'channels.editor.field.feishu.clientId': 'App ID', 'channels.editor.field.feishu.clientSecret': 'App Secret', 'channels.editor.field.github.token': 'Personal Access Token', - 'channels.editor.field.github.baseUrl': 'Base URL', + 'channels.editor.field.github.baseUrl': 'API Base URL', + 'channels.editor.field.github.groupPolicy': 'Group Policy', + 'channels.editor.field.github.senderPolicy': 'Sender Policy', + 'channels.editor.field.github.allowedUsers': + 'Allowed Users (comma-separated)', 'channels.editor.field.gitlab.token': 'Personal Access Token', - 'channels.editor.field.gitlab.baseUrl': 'Base URL', + 'channels.editor.field.gitlab.baseUrl': 'Instance URL', + 'channels.editor.field.gitlab.groupPolicy': 'Group Policy', + 'channels.editor.field.gitlab.senderPolicy': 'Sender Policy', + 'channels.editor.field.gitlab.allowedUsers': + 'Allowed Users (comma-separated)', 'channels.editor.secret.environment': 'Stored in environment', 'channels.editor.secret.stored': 'Stored securely', 'channels.editor.secret.preserve': 'Keep', @@ -4885,9 +4893,15 @@ const ZH: Messages = { 'channels.editor.field.feishu.clientId': 'App ID', 'channels.editor.field.feishu.clientSecret': 'App Secret', 'channels.editor.field.github.token': '个人访问令牌', - 'channels.editor.field.github.baseUrl': '基础 URL', + 'channels.editor.field.github.baseUrl': 'API 基础 URL', + 'channels.editor.field.github.groupPolicy': '群组策略', + 'channels.editor.field.github.senderPolicy': '发送者策略', + 'channels.editor.field.github.allowedUsers': '允许的用户(逗号分隔)', 'channels.editor.field.gitlab.token': '个人访问令牌', - 'channels.editor.field.gitlab.baseUrl': '基础 URL', + 'channels.editor.field.gitlab.baseUrl': '实例 URL', + 'channels.editor.field.gitlab.groupPolicy': '群组策略', + 'channels.editor.field.gitlab.senderPolicy': '发送者策略', + 'channels.editor.field.gitlab.allowedUsers': '允许的用户(逗号分隔)', 'channels.editor.secret.environment': '已保存在环境变量中', 'channels.editor.secret.stored': '已安全保存', 'channels.editor.secret.preserve': '保留', From ac558ab0f138f281adb84504a1348414665a36bc Mon Sep 17 00:00:00 2001 From: OrbitZore Date: Sun, 2 Aug 2026 02:00:43 +0800 Subject: [PATCH 06/13] feat(web-shell): render field descriptions below inputs in channel editor --- packages/channels/github/src/index.ts | 6 +++ packages/channels/gitlab/src/index.ts | 6 +++ .../channels/ChannelEditorDialog.module.css | 7 ++++ .../channels/ChannelEditorDialog.tsx | 19 +++++++++ packages/web-shell/client/i18n.tsx | 40 +++++++++++++++++++ 5 files changed, 78 insertions(+) diff --git a/packages/channels/github/src/index.ts b/packages/channels/github/src/index.ts index 26c16d93374..ee83e417282 100644 --- a/packages/channels/github/src/index.ts +++ b/packages/channels/github/src/index.ts @@ -17,18 +17,22 @@ export const plugin: ChannelPlugin = { kind: 'secret', required: true, envResolvable: true, + description: 'Classic PAT with "notifications" scope', }, { key: 'baseUrl', label: 'Base URL', kind: 'string', envResolvable: true, + description: + 'GitHub Enterprise API root (e.g. https://ghe.example.com/api/v3). Leave empty for github.com', }, { key: 'groupPolicy', label: 'Group Policy', kind: 'enum', required: true, + description: 'Must be "Open" for notifications to flow', options: [ { value: 'open', label: 'Open' }, { value: 'allowlist', label: 'Allowlist' }, @@ -40,6 +44,7 @@ export const plugin: ChannelPlugin = { label: 'Sender Policy', kind: 'enum', required: true, + description: 'Use "Allowlist" with allowed users on public repos', options: [ { value: 'allowlist', label: 'Allowlist' }, { value: 'pairing', label: 'Pairing' }, @@ -50,6 +55,7 @@ export const plugin: ChannelPlugin = { key: 'allowedUsers', label: 'Allowed Users', kind: 'string-list', + description: 'GitHub usernames, used by Allowlist and Pairing policies', }, ], }, diff --git a/packages/channels/gitlab/src/index.ts b/packages/channels/gitlab/src/index.ts index 786f874f6f4..b20769dd08f 100644 --- a/packages/channels/gitlab/src/index.ts +++ b/packages/channels/gitlab/src/index.ts @@ -17,18 +17,22 @@ export const plugin: ChannelPlugin = { kind: 'secret', required: true, envResolvable: true, + description: 'PAT with "read_api" + "api" scopes', }, { key: 'baseUrl', label: 'Base URL', kind: 'string', envResolvable: true, + description: + 'Self-hosted instance URL (e.g. https://gitlab.example.com). Leave empty for gitlab.com', }, { key: 'groupPolicy', label: 'Group Policy', kind: 'enum', required: true, + description: 'Must be "Open" or "Allowlist" for todos to be processed', options: [ { value: 'open', label: 'Open' }, { value: 'allowlist', label: 'Allowlist' }, @@ -40,6 +44,7 @@ export const plugin: ChannelPlugin = { label: 'Sender Policy', kind: 'enum', required: true, + description: 'Use "Allowlist" with allowed users on public projects', options: [ { value: 'allowlist', label: 'Allowlist' }, { value: 'pairing', label: 'Pairing' }, @@ -50,6 +55,7 @@ export const plugin: ChannelPlugin = { key: 'allowedUsers', label: 'Allowed Users', kind: 'string-list', + description: 'GitLab usernames, used by Allowlist and Pairing policies', }, ], }, diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.module.css b/packages/web-shell/client/components/channels/ChannelEditorDialog.module.css index 45f5a497037..aff243c8e08 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.module.css +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.module.css @@ -82,6 +82,13 @@ font-size: 11px; } +.fieldDescription { + margin: 0; + color: var(--muted-foreground); + font-size: 11px; + line-height: 1.5; +} + .secretState { display: flex; flex-wrap: wrap; diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx index 4336fe662f3..3545d4b92db 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx @@ -127,6 +127,7 @@ function FieldShell({ label, required, hint, + description, error, children, }: { @@ -134,6 +135,7 @@ function FieldShell({ label: string; required?: boolean; hint?: string; + description?: string; error?: string; children: ReactNode; }) { @@ -151,6 +153,9 @@ function FieldShell({ {hint ? {hint} : null}
{children} + {description ? ( +

{description}

+ ) : null} {error ? (

{error} @@ -196,6 +201,16 @@ export function ChannelEditorDialog({ return key ? t(key) : field.label; }; + const fieldDescription = (field: DaemonChannelConfigFieldDescriptor) => { + const labelKey = FIELD_LABEL_KEYS[descriptor.type]?.[field.key]; + if (labelKey) { + const descKey = `${labelKey}.description`; + const translated = t(descKey); + if (translated !== descKey) return translated; + } + return field.description; + }; + const validationMessage = ( field: DaemonChannelConfigFieldDescriptor | undefined, code: ChannelEditorValidationCode, @@ -280,6 +295,7 @@ export function ChannelEditorDialog({ id={id} label={fieldLabel(field)} required={field.required} + description={fieldDescription(field)} hint={ field.envResolvable ? t('channels.editor.environmentReference') @@ -373,6 +389,7 @@ export function ChannelEditorDialog({ id={id} label={fieldLabel(field)} required={field.required} + description={fieldDescription(field)} error={error} > + updateRecord(option.value, event.target.value) + } + /> + + ); + })} + + + ); + } return ( ; + return Object.values(parsed).every((v) => !v.trim()); + } catch { + return true; + } + } return typeof value === 'string' ? value.trim().length === 0 : false; } @@ -165,6 +180,22 @@ function assignField( .split(',') .map((s) => s.trim()) .filter(Boolean); + } else if (field.kind === 'record') { + try { + const parsed = JSON.parse(value) as Record; + const filtered = Object.fromEntries( + Object.entries(parsed).filter( + ([, v]) => typeof v === 'string' && v.trim(), + ), + ); + if (Object.keys(filtered).length > 0) { + config[field.key] = filtered; + } else { + delete config[field.key]; + } + } catch { + delete config[field.key]; + } } else { config[field.key] = value; } diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 7c782eaa71b..86029ad4a6d 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2458,6 +2458,9 @@ const EN: Messages = { 'Allowed Users (comma-separated)', 'channels.editor.field.github.allowedUsers.description': 'GitHub usernames, used by Allowlist and Pairing policies', + 'channels.editor.field.github.reasonFilter': 'Reason Filter', + 'channels.editor.field.github.reasonFilter.description': + 'Optional. Comma-separated notification reasons to process. Valid values: mention, review_requested, assign, author, comment, ci_activity, manual, state_change, subscribed, team_mention, security_alert, approval_requested, invitation, member_feature_requested, security_advisory_credit. Leave empty to process all.', 'channels.editor.field.gitlab.token': 'Personal Access Token', 'channels.editor.field.gitlab.token.description': 'PAT with "read_api" + "api" scopes', @@ -2474,6 +2477,27 @@ const EN: Messages = { 'Allowed Users (comma-separated)', 'channels.editor.field.gitlab.allowedUsers.description': 'GitLab usernames, used by Allowlist and Pairing policies', + 'channels.editor.field.gitlab.action_prompt_template': 'Action Templates', + 'channels.editor.field.gitlab.action_prompt_template.description': + 'Only actions with a template are processed; others are skipped. Template variables: %project%, %project_url%, %author%, %target_type%, %iid%, %title%, %description%, %todo_id%. Use %% for a literal %. Example for "mentioned": Project: %project% | Author: %author% | Title: %title%', + 'channels.editor.field.gitlab.action_prompt_template.option.mentioned': + 'Mentioned — @bot in a comment or description', + 'channels.editor.field.gitlab.action_prompt_template.option.directly_addressed': + 'Directly Addressed — comment starts with @bot', + 'channels.editor.field.gitlab.action_prompt_template.option.assigned': + 'Assigned — bot assigned to an issue or MR', + 'channels.editor.field.gitlab.action_prompt_template.option.review_requested': + 'Review Requested — bot requested as MR reviewer', + 'channels.editor.field.gitlab.action_prompt_template.option.approval_required': + 'Approval Required — MR needs bot approval', + 'channels.editor.field.gitlab.action_prompt_template.option.marked': + "Marked — someone stars bot's comment/issue/MR", + 'channels.editor.field.gitlab.action_prompt_template.option.build_failed': + 'Build Failed — CI/CD pipeline fails on bot branch/MR', + 'channels.editor.field.gitlab.action_prompt_template.option.unmergeable': + 'Unmergeable — MR becomes unmergeable (conflicts)', + 'channels.editor.field.gitlab.action_prompt_template.option.merge_train_removed': + 'Merge Train Removed — MR removed from merge train', 'channels.editor.secret.environment': 'Stored in environment', 'channels.editor.secret.stored': 'Stored securely', 'channels.editor.secret.preserve': 'Keep', @@ -4948,6 +4972,9 @@ const ZH: Messages = { 'channels.editor.field.github.allowedUsers': '允许的用户(逗号分隔)', 'channels.editor.field.github.allowedUsers.description': 'GitHub 用户名,用于 Allowlist 和 Pairing 策略', + 'channels.editor.field.github.reasonFilter': '通知原因过滤', + 'channels.editor.field.github.reasonFilter.description': + '可选。逗号分隔的通知原因。有效值:mention、review_requested、assign、author、comment、ci_activity、manual、state_change、subscribed、team_mention、security_alert、approval_requested、invitation、member_feature_requested、security_advisory_credit。留空则处理全部。', 'channels.editor.field.gitlab.token': '个人访问令牌', 'channels.editor.field.gitlab.token.description': '需要 "read_api" + "api" 权限的 PAT', @@ -4963,6 +4990,27 @@ const ZH: Messages = { 'channels.editor.field.gitlab.allowedUsers': '允许的用户(逗号分隔)', 'channels.editor.field.gitlab.allowedUsers.description': 'GitLab 用户名,用于 Allowlist 和 Pairing 策略', + 'channels.editor.field.gitlab.action_prompt_template': '动作模板', + 'channels.editor.field.gitlab.action_prompt_template.description': + '仅配置了模板的动作会被处理,其余跳过。模板变量:%project%、%project_url%、%author%、%target_type%、%iid%、%title%、%description%、%todo_id%。用 %% 表示字面 %。示例(mentioned):Project: %project% | Author: %author% | Title: %title%', + 'channels.editor.field.gitlab.action_prompt_template.option.mentioned': + '被提及 — 评论或描述中 @bot', + 'channels.editor.field.gitlab.action_prompt_template.option.directly_addressed': + '直接对话 — 评论以 @bot 开头', + 'channels.editor.field.gitlab.action_prompt_template.option.assigned': + '被指派 — bot 被指派到 issue 或 MR', + 'channels.editor.field.gitlab.action_prompt_template.option.review_requested': + '请求审查 — bot 被请求为 MR 审查者', + 'channels.editor.field.gitlab.action_prompt_template.option.approval_required': + '需要批准 — MR 需要 bot 批准', + 'channels.editor.field.gitlab.action_prompt_template.option.marked': + '被标记 — 有人星标 bot 的评论/issue/MR', + 'channels.editor.field.gitlab.action_prompt_template.option.build_failed': + '构建失败 — CI/CD 流水线在 bot 分支/MR 上失败', + 'channels.editor.field.gitlab.action_prompt_template.option.unmergeable': + '不可合并 — MR 变为不可合并(冲突)', + 'channels.editor.field.gitlab.action_prompt_template.option.merge_train_removed': + '合并队列移除 — MR 从合并队列中移除', 'channels.editor.secret.environment': '已保存在环境变量中', 'channels.editor.secret.stored': '已安全保存', 'channels.editor.secret.preserve': '保留', From a62f738f463bf7b195aade664f904f400b165d48 Mon Sep 17 00:00:00 2001 From: OrbitZore Date: Sun, 2 Aug 2026 10:40:49 +0800 Subject: [PATCH 08/13] fix(cli): validate string-list and record kinds in assertDescriptorValue The daemon-side store validation only accepted string, secret, boolean, number, and enum field kinds. Channels declaring string-list or record fields in their management descriptor (GitHub allowedUsers/reasonFilter, GitLab action_prompt_template) could never be saved through the Web Shell. Teach assertDescriptorValue the two new kinds and add store-level tests covering both acceptance and rejection paths. --- .../src/serve/channel-settings-store.test.ts | 76 +++++++++++++++++++ .../cli/src/serve/channel-settings-store.ts | 12 ++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/serve/channel-settings-store.test.ts b/packages/cli/src/serve/channel-settings-store.test.ts index 5ec5b6c9e01..bf89b9c5f45 100644 --- a/packages/cli/src/serve/channel-settings-store.test.ts +++ b/packages/cli/src/serve/channel-settings-store.test.ts @@ -66,6 +66,16 @@ describe('WorkspaceChannelSettingsStore', () => { ], }, { key: 'literalOnly', label: 'Literal only', kind: 'string' }, + { key: 'tags', label: 'Tags', kind: 'string-list' }, + { + key: 'templates', + label: 'Templates', + kind: 'record', + options: [ + { value: 'greeting', label: 'Greeting' }, + { value: 'farewell', label: 'Farewell' }, + ], + }, ], }, createChannel() { @@ -356,6 +366,50 @@ describe('WorkspaceChannelSettingsStore', () => { clientSecret: { operation: 'replace', value: 'secret' } as const, }, }, + { + label: 'string-list with non-string items', + config: { + type: 'management-validation-test', + clientId: 'client-id', + tags: [1, 2], + }, + secrets: { + clientSecret: { operation: 'replace', value: 'secret' } as const, + }, + }, + { + label: 'string-list not an array', + config: { + type: 'management-validation-test', + clientId: 'client-id', + tags: 'single', + }, + secrets: { + clientSecret: { operation: 'replace', value: 'secret' } as const, + }, + }, + { + label: 'record with non-string value', + config: { + type: 'management-validation-test', + clientId: 'client-id', + templates: { greeting: 123 }, + }, + secrets: { + clientSecret: { operation: 'replace', value: 'secret' } as const, + }, + }, + { + label: 'record with undeclared key', + config: { + type: 'management-validation-test', + clientId: 'client-id', + templates: { unknown: 'hello' }, + }, + secrets: { + clientSecret: { operation: 'replace', value: 'secret' } as const, + }, + }, ])('rejects $label without writing', async ({ config, secrets }) => { const store = new WorkspaceChannelSettingsStore(workspace); const before = fs.readFileSync(settingsPath, 'utf8'); @@ -410,6 +464,28 @@ describe('WorkspaceChannelSettingsStore', () => { }); }); + it('accepts string-list and record descriptor fields', async () => { + const store = new WorkspaceChannelSettingsStore(workspace); + + const next = await store.upsert('bot', { + expectedRevision: store.snapshot().revision, + config: { + type: 'management-validation-test', + clientId: 'client-id', + tags: ['alpha', 'beta'], + templates: { greeting: 'hi %user%', farewell: 'bye' }, + }, + secrets: { + clientSecret: { operation: 'replace', value: 'secret' }, + }, + }); + + expect(next.channels['bot']).toMatchObject({ + tags: ['alpha', 'beta'], + templates: { greeting: 'hi %user%', farewell: 'bye' }, + }); + }); + it('rejects clearing an existing required secret without writing', 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 6f1268f8f5a..ed31eaa9daf 100644 --- a/packages/cli/src/serve/channel-settings-store.ts +++ b/packages/cli/src/serve/channel-settings-store.ts @@ -230,7 +230,17 @@ function assertDescriptorValue( Number.isFinite(value)) || (field.kind === 'enum' && typeof value === 'string' && - field.options?.some((option) => option.value === value) === true); + field.options?.some((option) => option.value === value) === true) || + (field.kind === 'string-list' && + Array.isArray(value) && + value.every((item) => typeof item === 'string')) || + (field.kind === 'record' && + isRecord(value) && + Object.values(value).every((v) => typeof v === 'string') && + (field.options === undefined || + Object.keys(value).every((k) => + field.options!.some((opt) => opt.value === k), + ))); if (!valid) { throw invalidConfig(`Channel field "${field.key}" has an invalid value.`); } From 50b8642a9c71a932b8b8ae7f26b958da05054f09 Mon Sep 17 00:00:00 2001 From: OrbitZore Date: Sun, 2 Aug 2026 10:46:29 +0800 Subject: [PATCH 09/13] fix(web-shell): prevent silent config rewrite and record crash in editor - initialFieldValue: for existing instances with an absent enum field, return empty string instead of the first option. This forces the user to explicitly choose rather than silently writing a new value on save. - isMissingField: guard Object.values().every() with typeof check so hand-edited configs with non-string record values show a validation error instead of throwing TypeError. --- .../channels/channel-editor-state.test.ts | 13 +++++++++++++ .../components/channels/channel-editor-state.ts | 6 ++++-- 2 files changed, 17 insertions(+), 2 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 1ad1feca881..cbfdb4a4c07 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 @@ -240,6 +240,19 @@ describe('Descriptor-driven senderPolicy', () => { expect(draft.values.allowedUsers).toBe('alice, bob'); }); + it('leaves enum fields empty when editing an instance that lacks them', () => { + const instance: DaemonChannelInstanceSnapshot = { + name: 'legacy-bot', + config: { type: 'github' }, + secrets: { token: { present: true, source: 'stored' } }, + startsWithServe: false, + runtime: { state: 'stopped' }, + }; + const draft = createChannelEditorDraft(GITHUB, instance); + expect(draft.values.groupPolicy).toBe(''); + expect(draft.values.senderPolicy).toBe(''); + }); + it('writes senderPolicy via descriptor fields, not the hardcoded path', () => { const draft = createChannelEditorDraft(GITHUB); draft.name = 'my-bot'; 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 aab0618e7d0..db513d16969 100644 --- a/packages/web-shell/client/components/channels/channel-editor-state.ts +++ b/packages/web-shell/client/components/channels/channel-editor-state.ts @@ -60,7 +60,7 @@ function initialFieldValue( } if (field.kind === 'enum') { if (typeof value === 'string' && value) return value; - return field.options?.[0]?.value ?? ''; + return instance ? '' : (field.options?.[0]?.value ?? ''); } return typeof value === 'string' ? value : ''; } @@ -113,7 +113,9 @@ function isMissingField( if (typeof value !== 'string' || !value.trim()) return true; try { const parsed = JSON.parse(value) as Record; - return Object.values(parsed).every((v) => !v.trim()); + return Object.values(parsed).every( + (v) => typeof v !== 'string' || !v.trim(), + ); } catch { return true; } From da16a1957f7145702984fdfca3f42748561a6632 Mon Sep 17 00:00:00 2001 From: OrbitZore Date: Sun, 2 Aug 2026 10:49:50 +0800 Subject: [PATCH 10/13] refactor(web-shell): extract hasDescriptorSenderPolicy helper, hide empty Access section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract repeated descriptor.fields.some(f => f.key === 'senderPolicy') into a shared hasDescriptorSenderPolicy() helper (was inline ×4). - Conditionally render the Access section: for descriptor-driven types with a non-pairing policy the section would show only a bare heading with no content beneath it; now it is omitted entirely. --- .../channels/ChannelEditorDialog.tsx | 148 ++++++++++-------- .../channels/channel-editor-state.ts | 17 +- 2 files changed, 89 insertions(+), 76 deletions(-) diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx index 7a41c37b1ad..ee64df6c511 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx @@ -51,6 +51,7 @@ import { ChannelPairingRequests } from './ChannelPairingRequests'; import { buildChannelUpsertRequest, createChannelEditorDraft, + hasDescriptorSenderPolicy, validateChannelEditorDraft, type ChannelEditorDraft, type ChannelEditorValidationCode, @@ -591,76 +592,87 @@ export function ChannelEditorDialog({ {descriptor.fields.map(renderField)} -

-

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

- {descriptor.fields.some( - (f) => f.key === 'senderPolicy', - ) ? null : ( - <> - - setDraft((current) => ({ - ...current, - senderPolicy: - value === 'pairing' || value === 'open' ? value : '', - })) - } - > - {(['pairing', 'open'] as const).map((policy) => ( -
+ ); + })()}