From b008fb182123604867d7896a15cebaa694e485ac Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot <269191875+qwen-code-dev-bot@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:02:43 +0800 Subject: [PATCH 1/2] feat(web-shell): add Channel configuration flows --- .../channels/ChannelEditorDialog.module.css | 164 ++++++ .../channels/ChannelEditorDialog.test.tsx | 212 +++++++ .../channels/ChannelEditorDialog.tsx | 538 ++++++++++++++++++ .../channels/ChannelsManagerPage.module.css | 25 +- .../channels/ChannelsManagerPage.test.tsx | 124 +++- .../channels/ChannelsManagerPage.tsx | 223 +++++++- .../channels/channel-editor-state.test.ts | 173 ++++++ .../channels/channel-editor-state.ts | 174 ++++++ .../web-shell/client/e2e/utils/mockDaemon.ts | 73 +++ .../client/e2e/visuals/screenshots.spec.ts | 75 ++- .../client/e2e/web-shell.channels.spec.ts | 139 +++++ packages/web-shell/client/i18n.tsx | 106 ++++ 12 files changed, 2008 insertions(+), 18 deletions(-) create mode 100644 packages/web-shell/client/components/channels/ChannelEditorDialog.module.css create mode 100644 packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx create mode 100644 packages/web-shell/client/components/channels/ChannelEditorDialog.tsx create mode 100644 packages/web-shell/client/components/channels/channel-editor-state.test.ts create mode 100644 packages/web-shell/client/components/channels/channel-editor-state.ts create mode 100644 packages/web-shell/client/e2e/web-shell.channels.spec.ts diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.module.css b/packages/web-shell/client/components/channels/ChannelEditorDialog.module.css new file mode 100644 index 00000000000..45f5a497037 --- /dev/null +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.module.css @@ -0,0 +1,164 @@ +.platformHeader { + display: flex; + align-items: center; + gap: 12px; +} + +.platformMark { + display: inline-flex; + width: 42px; + height: 42px; + flex: 0 0 42px; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: 13px; + background: var(--muted); + font-size: 13px; + font-weight: 750; +} + +.form { + display: flex; + min-height: 0; + flex-direction: column; + gap: 0; +} + +.body { + display: flex; + max-height: min(66vh, 650px); + flex-direction: column; + gap: 22px; + overflow-y: auto; + padding-block: 4px 8px; + padding-inline: 1px; +} + +.section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.sectionHeading { + display: flex; + align-items: center; + gap: 10px; + color: var(--muted-foreground); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.sectionHeading::after { + height: 1px; + flex: 1; + background: var(--border); + content: ''; +} + +.field { + display: flex; + flex-direction: column; + gap: 7px; +} + +.fieldHeader { + display: flex; + min-height: 20px; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.required { + color: var(--destructive); +} + +.hint { + color: var(--muted-foreground); + font-size: 11px; +} + +.secretState { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 11px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--muted) 52%, transparent); +} + +.secretStatus { + display: flex; + align-items: center; + gap: 7px; + color: var(--muted-foreground); + font-size: 12px; +} + +.secretActions { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.policyGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 9px; +} + +.policyCard { + display: flex; + min-height: 76px; + cursor: pointer; + align-items: flex-start; + gap: 10px; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--card); + transition: + border-color 120ms ease, + background-color 120ms ease; +} + +.policyCard[data-selected='true'] { + border-color: var(--foreground); + background: color-mix(in srgb, var(--muted) 62%, transparent); +} + +.policyCopy { + display: flex; + flex-direction: column; + gap: 4px; +} + +.policyTitle { + font-size: 13px; + font-weight: 650; +} + +.policyDescription { + color: var(--muted-foreground); + font-size: 11px; + line-height: 1.45; +} + +@media (max-width: 520px) { + .policyGrid { + grid-template-columns: 1fr; + } +} + +@media (prefers-reduced-motion: reduce) { + .policyCard { + transition: none; + } +} diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx new file mode 100644 index 00000000000..d7eb4a695db --- /dev/null +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx @@ -0,0 +1,212 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// @vitest-environment jsdom + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + DaemonChannelInstanceSnapshot, + DaemonChannelTypeDescriptor, +} from '@qwen-code/sdk/daemon'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const DINGTALK: DaemonChannelTypeDescriptor = { + type: 'dingtalk', + displayName: 'DingTalk', + manageable: true, + fields: [ + { + key: 'clientId', + label: 'Client ID', + kind: 'string', + required: true, + }, + { + key: 'clientSecret', + label: 'Client Secret', + kind: 'secret', + required: true, + }, + ], +}; + +const INSTANCE: DaemonChannelInstanceSnapshot = { + name: 'release-bot', + config: { + type: 'dingtalk', + clientId: 'stored-id', + senderPolicy: 'open', + }, + secrets: { + clientSecret: { present: true, source: 'environment' }, + }, + startsWithServe: false, + runtime: { state: 'stopped' }, +}; + +const { ChannelEditorDialog } = await import('./ChannelEditorDialog'); +const { I18nProvider } = await import('../../i18n'); + +let container: HTMLDivElement; +let root: Root; + +async function renderDialog( + props: Partial> = {}, +) { + await act(async () => { + root.render( + + + , + ); + }); +} + +function inputByLabel(label: string): HTMLInputElement | null { + const labels = Array.from(document.querySelectorAll('label')); + const match = labels.find((item) => item.textContent?.includes(label)); + const id = match?.htmlFor; + return id ? document.querySelector(`#${id}`) : null; +} + +function setInputValue(input: HTMLInputElement, value: string) { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set?.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); +} + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + document.body.innerHTML = ''; +}); + +describe('ChannelEditorDialog', () => { + it('preserves a stored secret until Replace is explicitly selected', async () => { + await renderDialog({ instance: INSTANCE }); + + expect(document.body.textContent).toContain('Edit DingTalk'); + expect(document.body.textContent).toContain('Stored in environment'); + expect(inputByLabel('Client Secret')).toBeNull(); + + const replace = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Replace', + ); + await act(async () => { + replace?.click(); + }); + + expect(inputByLabel('Client Secret')).not.toBeNull(); + }); + + it('submits a new instance with typed fields and the current revision', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + await renderDialog({ onSave }); + + const name = inputByLabel('Instance name'); + const clientId = inputByLabel('Client ID'); + const clientSecret = inputByLabel('Client Secret'); + expect(name).not.toBeNull(); + expect(clientId).not.toBeNull(); + expect(clientSecret).not.toBeNull(); + + await act(async () => { + setInputValue(name!, 'release-bot'); + setInputValue(clientId!, 'ding-client-id'); + setInputValue(clientSecret!, 'ding-client-secret'); + }); + + 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: 'pairing', + }, + secrets: { + clientSecret: { + operation: 'replace', + value: 'ding-client-secret', + }, + }, + }); + }); + + it('keeps the dialog open and offers a reload after a stale write', async () => { + const onSave = vi + .fn() + .mockRejectedValue( + new Error('Channel settings changed; reload before trying again.'), + ); + await renderDialog({ instance: INSTANCE, onSave }); + + const save = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Save', + ); + await act(async () => { + save?.click(); + }); + + expect(document.body.textContent).toContain( + 'Channel settings changed; reload before trying again.', + ); + expect(document.body.textContent).toContain('Reload latest'); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + }); + + it('keeps the dialog open when reloading the latest configuration fails', async () => { + const onSave = vi.fn().mockRejectedValue(new Error('Revision conflict.')); + const onReload = vi + .fn() + .mockRejectedValue(new Error('Reload is temporarily unavailable.')); + await renderDialog({ instance: INSTANCE, onSave, onReload }); + + const save = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Save', + ); + await act(async () => { + save?.click(); + }); + const reload = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Reload latest', + ); + await act(async () => { + reload?.click(); + }); + + expect(document.body.textContent).toContain( + 'Reload is temporarily unavailable.', + ); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + }); +}); diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx new file mode 100644 index 00000000000..fb1c1630b11 --- /dev/null +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx @@ -0,0 +1,538 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + useEffect, + useId, + useState, + type FormEvent, + type ReactNode, +} from 'react'; +import { CheckCircle2Icon, KeyRoundIcon } from 'lucide-react'; +import type { + DaemonChannelConfigFieldDescriptor, + DaemonChannelInstanceSnapshot, + DaemonChannelTypeDescriptor, + DaemonChannelUpsertRequest, +} from '@qwen-code/sdk/daemon'; +import { useI18n } from '../../i18n'; +import { extractErrorDetail } from '../../utils/errorDetail'; +import { Alert, AlertDescription, AlertTitle } from '../ui/alert'; +import { Button } from '../ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../ui/dialog'; +import { Input } from '../ui/input'; +import { Label } from '../ui/label'; +import { RadioGroup, RadioGroupItem } from '../ui/radio-group'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select'; +import { Spinner } from '../ui/spinner'; +import { Switch } from '../ui/switch'; +import styles from './ChannelEditorDialog.module.css'; +import { + buildChannelUpsertRequest, + createChannelEditorDraft, + validateChannelEditorDraft, + type ChannelEditorDraft, + type ChannelEditorValidationCode, +} from './channel-editor-state'; + +const PLATFORM_MARKS: Record = { + dingtalk: 'D', + wecom: 'W', + feishu: 'F', +}; + +const FIELD_LABEL_KEYS: Record> = { + dingtalk: { + clientId: 'channels.editor.field.dingtalk.clientId', + clientSecret: 'channels.editor.field.dingtalk.clientSecret', + }, + wecom: { + botId: 'channels.editor.field.wecom.botId', + secret: 'channels.editor.field.wecom.secret', + wsUrl: 'channels.editor.field.wecom.wsUrl', + }, + feishu: { + clientId: 'channels.editor.field.feishu.clientId', + clientSecret: 'channels.editor.field.feishu.clientSecret', + }, +}; + +export interface ChannelEditorDialogProps { + open: boolean; + descriptor: DaemonChannelTypeDescriptor; + instance?: DaemonChannelInstanceSnapshot; + expectedRevision: string; + existingNames: readonly string[]; + onOpenChange: (open: boolean) => void; + onSave: ( + name: string, + request: DaemonChannelUpsertRequest, + ) => Promise; + onReload: () => Promise; +} + +function FieldShell({ + id, + label, + required, + hint, + error, + children, +}: { + id: string; + label: string; + required?: boolean; + hint?: string; + error?: string; + children: ReactNode; +}) { + return ( +
+
+ + {hint ? {hint} : null} +
+ {children} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} + +export function ChannelEditorDialog({ + open, + descriptor, + instance, + expectedRevision, + existingNames, + onOpenChange, + onSave, + onReload, +}: ChannelEditorDialogProps) { + const { t } = useI18n(); + const formId = useId(); + const [draft, setDraft] = useState(() => + createChannelEditorDraft(descriptor, instance), + ); + const [errors, setErrors] = useState>({}); + const [submitError, setSubmitError] = useState(); + const [saving, setSaving] = useState(false); + const [reloading, setReloading] = useState(false); + + useEffect(() => { + if (!open) return; + setDraft(createChannelEditorDraft(descriptor, instance)); + setErrors({}); + setSubmitError(undefined); + }, [descriptor, instance, open]); + + const fieldLabel = (field: DaemonChannelConfigFieldDescriptor) => { + const key = FIELD_LABEL_KEYS[descriptor.type]?.[field.key]; + return key ? t(key) : field.label; + }; + + const validationMessage = ( + field: DaemonChannelConfigFieldDescriptor | undefined, + code: ChannelEditorValidationCode, + ) => { + if (code === 'duplicate') return t('channels.editor.validation.duplicate'); + if (code === 'invalid') return t('channels.editor.validation.invalidName'); + if (code === 'number') return t('channels.editor.validation.number'); + if (code === 'policy') return t('channels.editor.validation.policy'); + return t('channels.editor.validation.required', { + label: field ? fieldLabel(field) : t('channels.editor.instanceName'), + }); + }; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + const validation = validateChannelEditorDraft( + descriptor, + draft, + existingNames, + ); + if (Object.keys(validation).length > 0) { + setErrors( + Object.fromEntries( + Object.entries(validation).map(([key, code]) => [ + key, + validationMessage( + descriptor.fields.find((field) => field.key === key), + code, + ), + ]), + ), + ); + return; + } + setSaving(true); + setSubmitError(undefined); + try { + await onSave( + draft.name.trim(), + buildChannelUpsertRequest( + descriptor, + draft, + expectedRevision, + instance, + ), + ); + onOpenChange(false); + } catch (error) { + setSubmitError(extractErrorDetail(error)); + } finally { + setSaving(false); + } + }; + + const reloadLatest = async () => { + setReloading(true); + try { + await onReload(); + onOpenChange(false); + } catch (error) { + setSubmitError(extractErrorDetail(error)); + } finally { + setReloading(false); + } + }; + + const renderSecret = (field: DaemonChannelConfigFieldDescriptor) => { + const id = `${formId}-${field.key}`; + const stored = instance?.secrets[field.key]; + const secret = draft.secrets[field.key] ?? { + operation: 'replace' as const, + value: '', + }; + const error = errors[field.key]; + const showInput = secret.operation === 'replace'; + return ( + + {stored?.present ? ( +
+ + + {stored.source === 'environment' + ? t('channels.editor.secret.environment') + : t('channels.editor.secret.stored')} + +
+ {(['preserve', 'replace', 'clear'] as const).map((operation) => ( + + ))} +
+
+ ) : null} + {showInput ? ( + + setDraft((current) => ({ + ...current, + secrets: { + ...current.secrets, + [field.key]: { + operation: 'replace', + value: event.target.value, + }, + }, + })) + } + /> + ) : null} + {secret.operation === 'clear' ? ( +

{t('channels.editor.secret.clearHint')}

+ ) : null} +
+ ); + }; + + const renderField = (field: DaemonChannelConfigFieldDescriptor) => { + if (field.kind === 'secret') return renderSecret(field); + const id = `${formId}-${field.key}`; + const value = draft.values[field.key]; + const error = errors[field.key]; + const update = (next: string | boolean) => + setDraft((current) => ({ + ...current, + values: { ...current.values, [field.key]: next }, + })); + if (field.kind === 'boolean') { + return ( + + update(checked)} + /> + + ); + } + if (field.kind === 'enum') { + return ( + + + + ); + } + return ( + + update(event.target.value)} + /> + + ); + }; + + return ( + + + +
+ +
+ + {t( + instance + ? 'channels.editor.editTitle' + : 'channels.editor.addTitle', + { platform: descriptor.displayName }, + )} + + + {t( + instance + ? 'channels.editor.editDescription' + : 'channels.editor.addDescription', + )} + +
+
+
+
+
+ {submitError ? ( + + + {t('channels.editor.saveError')} + {submitError} + + + ) : null} + +
+

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

+ + + setDraft((current) => ({ + ...current, + name: event.target.value, + })) + } + /> + +
+ +
+

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

+ {descriptor.fields.map(renderField)} +
+ +
+

+ {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} +
+
+ + + + +
+
+
+ ); +} diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.module.css b/packages/web-shell/client/components/channels/ChannelsManagerPage.module.css index a6133f707aa..4e0e4385d6e 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.module.css +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.module.css @@ -134,12 +134,34 @@ .platformCard { display: flex; min-width: 0; + cursor: pointer; align-items: center; gap: 11px; padding: 13px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--card); + color: inherit; + font: inherit; + text-align: start; + transition: + border-color 120ms ease, + background-color 120ms ease; +} + +.platformCard:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--foreground) 38%, var(--border)); + background: color-mix(in srgb, var(--muted) 46%, var(--card)); +} + +.platformCard:focus-visible { + outline: 3px solid color-mix(in srgb, var(--ring) 50%, transparent); + outline-offset: 2px; +} + +.platformCard:disabled { + cursor: not-allowed; + opacity: 0.55; } .platformMark { @@ -193,7 +215,8 @@ @media (prefers-reduced-motion: reduce) { .channelCard, - .channelCard * { + .channelCard *, + .platformCard { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx b/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx index 6c21b4766a8..9203363c354 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx @@ -19,14 +19,22 @@ const { channelState, useChannelsMock, workspaceState } = vi.hoisted(() => ({ type: string; displayName: string; manageable: boolean; - fields: never[]; + fields: Array<{ + key: string; + label: string; + kind: 'string' | 'secret'; + required?: boolean; + }>; }>, channels: {} as Record< string, { name: string; config: { type: string }; - secrets: Record; + secrets: Record< + string, + { present: boolean; source?: 'literal' | 'environment' } + >; startsWithServe: boolean; runtime: { state: 'stopped' | 'starting' | 'connected' | 'partial' | 'error'; @@ -46,6 +54,8 @@ const { channelState, useChannelsMock, workspaceState } = vi.hoisted(() => ({ loading: false, error: undefined as Error | undefined, reload: vi.fn(), + createOrUpdate: vi.fn(), + remove: vi.fn(), setStartup: vi.fn(), start: vi.fn(), stop: vi.fn(), @@ -109,7 +119,20 @@ beforeEach(() => { type: 'dingtalk', displayName: 'DingTalk', manageable: true, - fields: [], + fields: [ + { + key: 'clientId', + label: 'Client ID', + kind: 'string', + required: true, + }, + { + key: 'clientSecret', + label: 'Client Secret', + kind: 'secret', + required: true, + }, + ], }, { type: 'wecom', @@ -141,6 +164,9 @@ beforeEach(() => { channelState.current.loading = false; channelState.current.error = undefined; useChannelsMock.mockReset(); + channelState.current.reload.mockReset().mockResolvedValue(undefined); + channelState.current.createOrUpdate.mockReset().mockResolvedValue(undefined); + channelState.current.remove.mockReset().mockResolvedValue(undefined); channelState.current.setStartup.mockReset().mockResolvedValue(undefined); channelState.current.start.mockReset().mockResolvedValue(undefined); channelState.current.stop.mockReset().mockResolvedValue(undefined); @@ -205,6 +231,98 @@ describe('ChannelsManagerPage', () => { ); }); + it('opens the typed editor from an available platform', async () => { + await renderPage(); + + const platform = container.querySelector( + '[data-testid="channel-platform-dingtalk"]', + ); + expect(platform?.tagName).toBe('BUTTON'); + await act(async () => { + platform?.click(); + }); + + expect(document.body.textContent).toContain('Configure DingTalk'); + expect(document.body.textContent).toContain('Client ID (AppKey)'); + expect(document.body.textContent).toContain('Client Secret (AppSecret)'); + }); + + it('opens an existing Channel for editing', async () => { + channelState.current.channels.ding = { + ...channelState.current.channels.ding, + config: { + type: 'dingtalk', + clientId: 'stored-id', + senderPolicy: 'pairing', + }, + secrets: { + clientSecret: { present: true, source: 'literal' }, + }, + }; + await renderPage(); + + const edit = Array.from(container.querySelectorAll('button')).find( + (button) => button.getAttribute('aria-label') === 'Edit DingTalk Bot', + ); + await act(async () => { + edit?.click(); + }); + + expect(document.body.textContent).toContain('Edit DingTalk'); + const name = Array.from(document.querySelectorAll('input')).find( + (input) => input.value === 'DingTalk Bot', + ); + expect(name?.disabled).toBe(true); + }); + + 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', + ); + await act(async () => { + remove?.click(); + }); + expect(document.body.textContent).toContain('Delete DingTalk Bot?'); + + const dialog = document.querySelector('[role="alertdialog"]'); + const confirm = Array.from(dialog?.querySelectorAll('button') ?? []).find( + (button) => button.textContent?.trim() === 'Delete', + ); + await act(async () => { + confirm?.click(); + }); + + expect(channelState.current.remove).toHaveBeenCalledWith('DingTalk Bot', { + expectedRevision: '1', + }); + }); + + it('closes an editor when the selected workspace changes', async () => { + await renderPage(); + const platform = container.querySelector( + '[data-testid="channel-platform-dingtalk"]', + ); + await act(async () => { + platform?.click(); + }); + expect(document.body.textContent).toContain('Configure DingTalk'); + + workspaceState.current = { + ...workspaceState.current, + workspaceCwd: '/workspace/other', + }; + channelState.current.snapshot = { + revision: 'other-1', + instances: {}, + }; + channelState.current.channels = {}; + await renderPage(); + + expect(document.querySelector('[role="dialog"]')).toBeNull(); + }); + it('disables lifecycle controls without a bearer token', async () => { workspaceState.current = { ...workspaceState.current, diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx index ebfed3fd579..9c450ae4bd9 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx @@ -4,21 +4,34 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useCallback, useMemo, useState, type Ref } from 'react'; +import { useCallback, useEffect, useMemo, useState, type Ref } from 'react'; import { AlertCircleIcon, ArrowLeftIcon, + PencilIcon, RadioTowerIcon, RotateCwIcon, + Trash2Icon, } from 'lucide-react'; import type { DaemonChannelInstanceSnapshot, DaemonChannelRuntimeState, + DaemonChannelTypeDescriptor, + DaemonChannelUpsertRequest, } from '@qwen-code/sdk/daemon'; import { useChannels, useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { extractErrorDetail } from '../../utils/errorDetail'; import { Alert, AlertDescription, AlertTitle } from '../ui/alert'; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '../ui/alert-dialog'; import { Badge } from '../ui/badge'; import { Button } from '../ui/button'; import { @@ -39,6 +52,7 @@ import { } from '../ui/empty'; import { Spinner } from '../ui/spinner'; import { Switch } from '../ui/switch'; +import { ChannelEditorDialog } from './ChannelEditorDialog'; import styles from './ChannelsManagerPage.module.css'; import { isChannelPlatformAvailable, @@ -89,6 +103,8 @@ export function ChannelsManagerPage({ loading, error, reload, + createOrUpdate, + remove, setStartup, start, stop, @@ -103,6 +119,27 @@ export function ChannelsManagerPage({ action: ChannelAction; } | null>(null); const [actionErrors, setActionErrors] = useState>({}); + const [editor, setEditor] = useState<{ + workspaceCwd?: string; + descriptor: DaemonChannelTypeDescriptor; + instance?: DaemonChannelInstanceSnapshot; + }>(); + const [deleteTarget, setDeleteTarget] = useState<{ + workspaceCwd?: string; + instance: DaemonChannelInstanceSnapshot; + }>(); + const [deleteError, setDeleteError] = useState(); + const [deleting, setDeleting] = useState(false); + + useEffect(() => { + setBusy(null); + setActionErrors({}); + setEditor(undefined); + setDeleteTarget(undefined); + setDeleteError(undefined); + setDeleting(false); + }, [workspace.workspaceCwd]); + const availablePlatforms = useMemo( () => catalog.filter(isChannelPlatformAvailable), [catalog], @@ -128,6 +165,43 @@ export function ChannelsManagerPage({ [catalog], ); + const descriptorFor = useCallback( + (channel: DaemonChannelInstanceSnapshot) => + availablePlatforms.find( + (descriptor) => descriptor.type === channel.config.type, + ), + [availablePlatforms], + ); + + const saveChannel = useCallback( + (name: string, request: DaemonChannelUpsertRequest) => + createOrUpdate(name, request), + [createOrUpdate], + ); + + const deleteChannel = useCallback(async () => { + if ( + !deleteTarget || + deleteTarget.workspaceCwd !== workspace.workspaceCwd || + !snapshot || + deleting + ) { + return; + } + setDeleting(true); + setDeleteError(undefined); + try { + await remove(deleteTarget.instance.name, { + expectedRevision: snapshot.revision, + }); + setDeleteTarget(undefined); + } catch (removeError) { + setDeleteError(extractErrorDetail(removeError)); + } finally { + setDeleting(false); + } + }, [deleteTarget, deleting, remove, snapshot, workspace.workspaceCwd]); + const runAction = useCallback( async ( channel: DaemonChannelInstanceSnapshot, @@ -298,6 +372,7 @@ export function ChannelsManagerPage({ {instances.length > 0 ? (
{instances.map((channel) => { + const descriptor = descriptorFor(channel); const runtimeError = actionErrors[channel.name] ?? channel.runtime.lastError; return ( @@ -353,9 +428,9 @@ export function ChannelsManagerPage({ /> {t('channels.startsWithServe')} - {channel.runtime.state !== 'stopped' && - channel.runtime.state !== 'error' ? ( -
+
+ {channel.runtime.state !== 'stopped' && + channel.runtime.state !== 'error' ? (
- ) : null} + ) : null} + {descriptor ? ( + + ) : null} + +
); @@ -396,10 +510,21 @@ export function ChannelsManagerPage({
{availablePlatforms.map((platform) => ( -
+ setEditor({ + workspaceCwd: workspace.workspaceCwd, + descriptor: platform, + }) + } >
+ ))}
) : null} + + {editor && editor.workspaceCwd === workspace.workspaceCwd && snapshot ? ( + channel.name !== editor.instance?.name) + .map((channel) => channel.name)} + onOpenChange={(open) => { + if (!open) setEditor(undefined); + }} + onSave={saveChannel} + onReload={reload} + /> + ) : null} + + { + if (!open && !deleting) { + setDeleteTarget(undefined); + setDeleteError(undefined); + } + }} + > + + + + {t('channels.delete.title', { + name: deleteTarget?.instance.name ?? '', + })} + + + {t('channels.delete.description')} + + + {deleteError ? ( + + + {t('channels.delete.error')} + {deleteError} + + + ) : null} + + + {t('channels.editor.cancel')} + + + + + ); } 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 new file mode 100644 index 00000000000..554dc642595 --- /dev/null +++ b/packages/web-shell/client/components/channels/channel-editor-state.test.ts @@ -0,0 +1,173 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import type { + DaemonChannelInstanceSnapshot, + DaemonChannelTypeDescriptor, +} from '@qwen-code/sdk/daemon'; +import { + buildChannelUpsertRequest, + createChannelEditorDraft, + validateChannelEditorDraft, +} from './channel-editor-state'; + +const DINGTALK: DaemonChannelTypeDescriptor = { + type: 'dingtalk', + displayName: 'DingTalk', + manageable: true, + fields: [ + { + key: 'clientId', + label: 'Client ID', + kind: 'string', + required: true, + envResolvable: true, + }, + { + key: 'clientSecret', + label: 'Client Secret', + kind: 'secret', + required: true, + envResolvable: true, + }, + ], +}; + +function configuredInstance(): DaemonChannelInstanceSnapshot { + return { + name: 'release-bot', + config: { + type: 'dingtalk', + clientId: 'stored-id', + senderPolicy: 'open', + sessionScope: 'thread', + model: 'qwen3-coder-plus', + }, + secrets: { + clientSecret: { present: true, source: 'environment' }, + }, + startsWithServe: false, + runtime: { state: 'stopped' }, + }; +} + +describe('Channel editor state', () => { + it('builds a new typed configuration with an explicit secret replacement', () => { + const draft = createChannelEditorDraft(DINGTALK); + draft.name = 'release-bot'; + draft.values.clientId = 'ding-client-id'; + draft.secrets.clientSecret = { + operation: 'replace', + value: 'ding-client-secret', + }; + + expect(buildChannelUpsertRequest(DINGTALK, draft, 'revision-1')).toEqual({ + expectedRevision: 'revision-1', + config: { + type: 'dingtalk', + clientId: 'ding-client-id', + senderPolicy: 'pairing', + }, + secrets: { + clientSecret: { + operation: 'replace', + value: 'ding-client-secret', + }, + }, + }); + }); + + it('preserves hidden public settings and stored secrets when editing', () => { + const instance = configuredInstance(); + const draft = createChannelEditorDraft(DINGTALK, instance); + draft.values.clientId = 'updated-id'; + + expect( + buildChannelUpsertRequest(DINGTALK, draft, 'revision-2', instance), + ).toEqual({ + expectedRevision: 'revision-2', + config: { + type: 'dingtalk', + clientId: 'updated-id', + senderPolicy: 'open', + sessionScope: 'thread', + model: 'qwen3-coder-plus', + }, + secrets: { + clientSecret: { operation: 'preserve' }, + }, + }); + }); + + it('supports explicitly clearing a stored secret', () => { + const instance = configuredInstance(); + const draft = createChannelEditorDraft(DINGTALK, instance); + draft.secrets.clientSecret = { operation: 'clear' }; + + expect( + buildChannelUpsertRequest(DINGTALK, draft, 'revision-3', instance) + .secrets, + ).toEqual({ + clientSecret: { operation: 'clear' }, + }); + }); + + it('does not change whitespace in a replacement secret', () => { + const draft = createChannelEditorDraft(DINGTALK); + draft.name = 'release-bot'; + draft.values.clientId = 'ding-client-id'; + draft.secrets.clientSecret = { + operation: 'replace', + value: ' exact-secret ', + }; + + expect( + buildChannelUpsertRequest(DINGTALK, draft, 'revision-4').secrets, + ).toEqual({ + clientSecret: { + operation: 'replace', + value: ' exact-secret ', + }, + }); + }); + + it('requires a unique name, required fields, a replacement secret, and an access policy', () => { + const draft = createChannelEditorDraft(DINGTALK); + draft.name = 'existing'; + draft.senderPolicy = ''; + + expect(validateChannelEditorDraft(DINGTALK, draft, ['existing'])).toEqual({ + name: 'duplicate', + clientId: 'required', + clientSecret: 'required', + senderPolicy: 'policy', + }); + }); + + it('rejects a non-numeric value for a number field', () => { + const descriptor: DaemonChannelTypeDescriptor = { + type: 'example', + displayName: 'Example', + manageable: true, + fields: [ + { + key: 'port', + label: 'Port', + kind: 'number', + required: false, + }, + ], + }; + const draft = createChannelEditorDraft(descriptor); + draft.name = 'example'; + draft.values.port = 'not-a-number'; + + expect(validateChannelEditorDraft(descriptor, draft, [])).toEqual({ + port: 'number', + }); + }); +}); diff --git a/packages/web-shell/client/components/channels/channel-editor-state.ts b/packages/web-shell/client/components/channels/channel-editor-state.ts new file mode 100644 index 00000000000..7db001612c5 --- /dev/null +++ b/packages/web-shell/client/components/channels/channel-editor-state.ts @@ -0,0 +1,174 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + DaemonChannelConfigFieldDescriptor, + DaemonChannelInstanceSnapshot, + DaemonChannelSecretUpdate, + DaemonChannelTypeDescriptor, + DaemonChannelUpsertRequest, +} from '@qwen-code/sdk/daemon'; + +export type ChannelSenderPolicy = 'pairing' | 'open' | ''; + +export interface ChannelSecretDraft { + operation: DaemonChannelSecretUpdate['operation']; + value?: string; +} + +export interface ChannelEditorDraft { + name: string; + values: Record; + secrets: Record; + senderPolicy: ChannelSenderPolicy; +} + +export type ChannelEditorValidationCode = + | 'required' + | 'duplicate' + | 'invalid' + | 'number' + | 'policy'; + +export type ChannelEditorValidationErrors = Record< + string, + ChannelEditorValidationCode +>; + +function initialFieldValue( + field: DaemonChannelConfigFieldDescriptor, + instance?: DaemonChannelInstanceSnapshot, +): string | boolean { + const value = instance?.config[field.key]; + if (field.kind === 'boolean') { + return typeof value === 'boolean' ? value : false; + } + if (field.kind === 'number') { + return typeof value === 'number' ? String(value) : ''; + } + return typeof value === 'string' ? value : ''; +} + +export function createChannelEditorDraft( + descriptor: DaemonChannelTypeDescriptor, + instance?: DaemonChannelInstanceSnapshot, +): ChannelEditorDraft { + const values: Record = {}; + const secrets: Record = {}; + for (const field of descriptor.fields) { + if (field.kind === 'secret') { + secrets[field.key] = instance?.secrets[field.key]?.present + ? { operation: 'preserve' } + : { operation: 'replace', value: '' }; + continue; + } + values[field.key] = initialFieldValue(field, instance); + } + const configuredPolicy = instance?.config['senderPolicy']; + return { + name: instance?.name ?? '', + values, + secrets, + senderPolicy: + configuredPolicy === 'pairing' || configuredPolicy === 'open' + ? configuredPolicy + : instance + ? '' + : 'pairing', + }; +} + +function isMissingField( + field: DaemonChannelConfigFieldDescriptor, + draft: ChannelEditorDraft, +): boolean { + if (field.kind === 'secret') { + const secret = draft.secrets[field.key]; + if (secret?.operation === 'preserve') return false; + if (secret?.operation === 'clear') return true; + return !secret?.value?.trim(); + } + const value = draft.values[field.key]; + return typeof value === 'string' ? value.trim().length === 0 : false; +} + +export function validateChannelEditorDraft( + descriptor: DaemonChannelTypeDescriptor, + draft: ChannelEditorDraft, + existingNames: readonly string[], +): ChannelEditorValidationErrors { + const errors: ChannelEditorValidationErrors = {}; + const name = draft.name.trim(); + if (!name) { + errors['name'] = 'required'; + } else if ( + name === 'all' || + ['__proto__', 'constructor', 'prototype'].includes(name) + ) { + errors['name'] = 'invalid'; + } else if (existingNames.includes(name)) { + errors['name'] = 'duplicate'; + } + for (const field of descriptor.fields) { + if (field.required && isMissingField(field, draft)) { + errors[field.key] = 'required'; + } else if ( + field.kind === 'number' && + typeof draft.values[field.key] === 'string' && + draft.values[field.key] !== '' && + !Number.isFinite(Number(draft.values[field.key])) + ) { + errors[field.key] = 'number'; + } + } + if (!draft.senderPolicy) { + errors['senderPolicy'] = 'policy'; + } + return errors; +} + +function assignField( + config: Record, + field: DaemonChannelConfigFieldDescriptor, + rawValue: string | boolean | undefined, +): void { + if (field.kind === 'boolean') { + config[field.key] = rawValue === true; + return; + } + const value = typeof rawValue === 'string' ? rawValue.trim() : ''; + if (!value) { + delete config[field.key]; + return; + } + config[field.key] = field.kind === 'number' ? Number(value) : value; +} + +export function buildChannelUpsertRequest( + descriptor: DaemonChannelTypeDescriptor, + draft: ChannelEditorDraft, + expectedRevision: string, + instance?: DaemonChannelInstanceSnapshot, +): DaemonChannelUpsertRequest { + const config: Record & { type: string } = { + ...(instance?.config ?? {}), + type: descriptor.type, + }; + const secrets: Record = {}; + for (const field of descriptor.fields) { + if (field.kind === 'secret') { + const secret = draft.secrets[field.key] ?? { operation: 'preserve' }; + secrets[field.key] = + secret.operation === 'replace' + ? { operation: 'replace', value: secret.value ?? '' } + : { operation: secret.operation }; + continue; + } + assignField(config, field, draft.values[field.key]); + } + config['senderPolicy'] = draft.senderPolicy; + return { expectedRevision, config, secrets }; +} diff --git a/packages/web-shell/client/e2e/utils/mockDaemon.ts b/packages/web-shell/client/e2e/utils/mockDaemon.ts index 26b87391888..69c7c85bff1 100644 --- a/packages/web-shell/client/e2e/utils/mockDaemon.ts +++ b/packages/web-shell/client/e2e/utils/mockDaemon.ts @@ -525,6 +525,7 @@ function isDaemonPath(path: string): boolean { /^\/workspace\/mcp\/[^/]+\/resources\/?$/.test(path) || /^\/workspaces\/[^/]+\/channel-types\/?$/.test(path) || /^\/workspaces\/[^/]+\/channels\/?$/.test(path) || + /^\/workspaces\/[^/]+\/channels\/[^/]+\/?$/.test(path) || /^\/workspace\/.+\/sessions\/?$/.test(path) || /^\/workspace\/.+\/session-groups\/?$/.test(path) || /^\/workspaces\/.+\/git\/?$/.test(path) || @@ -594,6 +595,12 @@ function isDaemonRoute(method: string, path: string): boolean { ) { return true; } + if ( + (method === 'PUT' || method === 'DELETE') && + /^\/workspaces\/[^/]+\/channels\/[^/]+\/?$/.test(path) + ) { + return true; + } if (method === 'POST' && path === '/session') return true; if (method === 'POST' && /^\/permission\/[^/]+\/?$/.test(path)) return true; if ( @@ -760,6 +767,65 @@ async function handleDaemonRoute( await json(route, scenario.channels); return; } + const channelMutationMatch = path.match( + /^\/workspaces\/[^/]+\/channels\/([^/]+)\/?$/, + ); + if (channelMutationMatch && (method === 'PUT' || method === 'DELETE')) { + const name = decodeURIComponent(channelMutationMatch[1]); + if ( + !isRecord(body) || + body['expectedRevision'] !== scenario.channels.revision + ) { + await json(route, { error: 'Channel settings changed.' }, 409); + return; + } + const revision = nextRevision(scenario.channels.revision); + if (method === 'DELETE') { + const instances = { ...scenario.channels.instances }; + delete instances[name]; + scenario.channels = { revision, instances }; + await json(route, { + snapshot: scenario.channels, + instance: { + name, + config: {}, + secrets: {}, + startsWithServe: false, + runtime: { state: 'stopped' }, + }, + }); + return; + } + if (!isRecord(body['config'])) { + await badRequest(route, 'Invalid Channel configuration.'); + return; + } + const previous = scenario.channels.instances[name]; + const secrets = { ...(previous?.secrets ?? {}) }; + if (isRecord(body['secrets'])) { + for (const [key, update] of Object.entries(body['secrets'])) { + if (!isRecord(update)) continue; + if (update['operation'] === 'clear') { + delete secrets[key]; + } else if (update['operation'] === 'replace') { + secrets[key] = { present: true, source: 'literal' }; + } + } + } + const instance = { + name, + config: body['config'], + secrets, + startsWithServe: previous?.startsWithServe ?? false, + runtime: previous?.runtime ?? ({ state: 'stopped' } as const), + }; + scenario.channels = { + revision, + instances: { ...scenario.channels.instances, [name]: instance }, + }; + await json(route, { snapshot: scenario.channels, instance }); + return; + } if (method === 'GET' && /^\/workspaces\/.+\/git\/?$/.test(path)) { await json( route, @@ -982,6 +1048,13 @@ function readStringField(body: unknown, key: string): string | undefined { : undefined; } +function nextRevision(revision: string): string { + const numeric = Number(revision); + return Number.isSafeInteger(numeric) && numeric >= 0 + ? String(numeric + 1) + : `${revision}-next`; +} + function isApprovalMode(mode: string): mode is DaemonApprovalMode { const modes: readonly string[] = DAEMON_APPROVAL_MODES; return modes.includes(mode); diff --git a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts index 3d71b73c44b..4ac113a7dfe 100644 --- a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts +++ b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts @@ -182,19 +182,67 @@ for (const theme of THEMES) { type: 'dingtalk', displayName: 'DingTalk', manageable: true, - fields: [], + fields: [ + { + key: 'clientId', + label: 'Client ID', + kind: 'string', + required: true, + envResolvable: true, + }, + { + key: 'clientSecret', + label: 'Client Secret', + kind: 'secret', + required: true, + envResolvable: true, + }, + ], }, { type: 'wecom', displayName: 'WeCom', manageable: true, - fields: [], + fields: [ + { + key: 'botId', + label: 'Bot ID', + kind: 'string', + required: true, + }, + { + key: 'secret', + label: 'Bot Secret', + kind: 'secret', + required: true, + }, + { + key: 'wsUrl', + label: 'WebSocket URL', + kind: 'string', + required: false, + envResolvable: true, + }, + ], }, { type: 'feishu', displayName: 'Feishu', manageable: true, - fields: [], + fields: [ + { + key: 'clientId', + label: 'App ID', + kind: 'string', + required: true, + }, + { + key: 'clientSecret', + label: 'App Secret', + kind: 'secret', + required: true, + }, + ], }, { type: 'telegram', @@ -208,7 +256,11 @@ for (const theme of THEMES) { instances: { dingtalk: { name: 'dingtalk', - config: { type: 'dingtalk' }, + config: { + type: 'dingtalk', + clientId: 'ding-visual-app', + senderPolicy: 'pairing', + }, secrets: { clientSecret: { present: true, source: 'literal' }, }, @@ -219,7 +271,7 @@ for (const theme of THEMES) { name: 'release-notifier', config: { type: 'feishu' }, secrets: { - appSecret: { present: true, source: 'environment' }, + clientSecret: { present: true, source: 'environment' }, }, startsWithServe: false, runtime: { @@ -260,6 +312,19 @@ for (const theme of THEMES) { ).toBeVisible(); await expect(page.getByText('hidden-telegram')).toHaveCount(0); await captureScreenshot(page, `channel-manager-${theme}`); + await page.getByRole('button', { name: 'Configure DingTalk' }).click(); + await expect( + page.getByRole('heading', { name: 'Configure DingTalk' }), + ).toBeVisible(); + await captureScreenshot(page, `channel-editor-${theme}`); + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: 'Edit dingtalk' }).click(); + const editHeading = page.getByRole('heading', { + name: 'Edit DingTalk', + }); + await expect(editHeading).toBeVisible(); + await editHeading.click(); + await captureScreenshot(page, `channel-editor-existing-${theme}`); }); test(`mermaid diagram`, async ({ page }, testInfo) => { diff --git a/packages/web-shell/client/e2e/web-shell.channels.spec.ts b/packages/web-shell/client/e2e/web-shell.channels.spec.ts new file mode 100644 index 00000000000..ea7940dafd2 --- /dev/null +++ b/packages/web-shell/client/e2e/web-shell.channels.spec.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { expect, test } from '@playwright/test'; +import { + createWebShellDaemonScenario, + installMockDaemon, + replayCompleteEvent, +} from './utils/mockDaemon'; + +test('creates and deletes a typed Channel configuration', async ({ + page, +}, testInfo) => { + const scenario = createWebShellDaemonScenario({ + capabilities: { + features: [ + 'session_events', + 'permission_vote', + 'session_permission_vote', + 'session_scope_override', + 'session_source_metadata', + 'workspace_settings', + 'workspace_voice', + 'channel_management', + ], + }, + channelTypes: [ + { + type: 'dingtalk', + displayName: 'DingTalk', + manageable: true, + fields: [ + { + key: 'clientId', + label: 'Client ID', + kind: 'string', + required: true, + envResolvable: true, + }, + { + key: 'clientSecret', + label: 'Client Secret', + kind: 'secret', + required: true, + envResolvable: true, + }, + ], + }, + { + type: 'wecom', + displayName: 'WeCom', + manageable: true, + fields: [], + }, + { + type: 'feishu', + displayName: 'Feishu', + manageable: true, + fields: [], + }, + ], + }); + await page.addInitScript(() => { + window.sessionStorage.setItem('qwen-daemon-token', 'e2e-token'); + }); + const daemon = await installMockDaemon(page, scenario, { + baseURL: String(testInfo.project.use.baseURL), + }); + + await page.goto(`/session/${encodeURIComponent(scenario.sessionId)}`); + await expect(page.locator('[data-web-shell-root]')).toBeVisible(); + const connection = await daemon.sse.waitForConnection(scenario.sessionId); + await daemon.sendEvent( + replayCompleteEvent({ sessionId: connection.sessionId }), + ); + await expect(page.getByText('Loading...')).toHaveCount(0); + + await page.getByRole('button', { name: 'Channels' }).click(); + await page.getByRole('button', { name: 'Configure DingTalk' }).click(); + await expect( + page.getByRole('heading', { name: 'Configure DingTalk' }), + ).toBeVisible(); + 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.getByRole('button', { name: 'Save' }).click(); + + await expect( + page.getByRole('heading', { name: 'Configure DingTalk' }), + ).toHaveCount(0); + await expect(page.getByText('release-bot', { exact: true })).toBeVisible(); + await expect + .poll(() => + daemon.requests.filter( + (request) => + request.method === 'PUT' && + request.path.endsWith('/channels/release-bot'), + ), + ) + .toEqual([ + expect.objectContaining({ + body: { + expectedRevision: '1', + config: { + type: 'dingtalk', + clientId: 'ding-client-id', + senderPolicy: 'pairing', + }, + secrets: { + clientSecret: { + operation: 'replace', + value: 'ding-client-secret', + }, + }, + }, + }), + ]); + + await page.getByRole('button', { 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); + await expect + .poll(() => + daemon.requests.filter( + (request) => + request.method === 'DELETE' && + request.path.endsWith('/channels/release-bot'), + ), + ) + .toEqual([ + expect.objectContaining({ + body: { expectedRevision: '2' }, + }), + ]); +}); diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 66d298ad02a..658823ae401 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2267,6 +2267,9 @@ const EN: Messages = { 'channels.availablePlatforms.description': 'Channel management is currently available for these platforms only.', 'channels.platform.available': 'Available', + 'channels.platform.configure': 'Configure', + 'channels.platform.configureNamed': (v) => + `Configure ${v?.platform ?? 'Channel'}`, 'channels.status.stopped': 'Stopped', 'channels.status.starting': 'Starting', 'channels.status.connected': 'Connected', @@ -2289,8 +2292,60 @@ const EN: Messages = { '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.delete': 'Delete', + 'channels.action.deleteNamed': (v) => `Delete ${v?.name ?? 'Channel'}`, 'channels.action.startWithServeNamed': (v) => `Start ${v?.name ?? ''} with serve`, + 'channels.delete.title': (v) => `Delete ${v?.name ?? 'Channel'}?`, + 'channels.delete.description': + 'The Channel will be stopped and removed from this workspace. This action cannot be undone.', + 'channels.delete.error': 'Channel was not deleted', + '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.', + 'channels.editor.editDescription': + 'Update public settings or explicitly change stored credentials.', + 'channels.editor.section.identity': 'Identity', + 'channels.editor.section.credentials': 'Credentials', + 'channels.editor.section.access': 'Access policy', + 'channels.editor.instanceName': 'Instance name', + 'channels.editor.instanceNamePlaceholder': 'e.g. release-bot', + 'channels.editor.environmentReference': '$ENV_VAR supported', + 'channels.editor.field.dingtalk.clientId': 'Client ID (AppKey)', + 'channels.editor.field.dingtalk.clientSecret': 'Client Secret (AppSecret)', + 'channels.editor.field.wecom.botId': 'Bot ID', + 'channels.editor.field.wecom.secret': 'Bot Secret', + 'channels.editor.field.wecom.wsUrl': 'WebSocket URL', + 'channels.editor.field.feishu.clientId': 'App ID', + 'channels.editor.field.feishu.clientSecret': 'App Secret', + 'channels.editor.secret.environment': 'Stored in environment', + 'channels.editor.secret.stored': 'Stored securely', + 'channels.editor.secret.preserve': 'Keep', + 'channels.editor.secret.replace': 'Replace', + 'channels.editor.secret.clear': 'Clear', + 'channels.editor.secret.placeholder': (v) => `Enter ${v?.label ?? 'secret'}`, + 'channels.editor.secret.clearHint': + 'This credential will be removed when you save.', + 'channels.editor.policy.pairing.title': 'Pairing', + 'channels.editor.policy.pairing.description': + 'People receive a pairing code and can chat after you approve them.', + 'channels.editor.policy.open.title': 'Open', + 'channels.editor.policy.open.description': + 'Anyone who can reach the bot can start a conversation.', + 'channels.editor.validation.required': (v) => + `${v?.label ?? 'This field'} is required.`, + 'channels.editor.validation.duplicate': + 'A Channel with this name already exists.', + 'channels.editor.validation.invalidName': 'Choose a different instance name.', + 'channels.editor.validation.number': 'Enter a valid number.', + 'channels.editor.validation.policy': 'Choose an access policy.', + 'channels.editor.saveError': 'Changes were not saved', + 'channels.editor.reloadLatest': 'Reload latest', + 'channels.editor.cancel': 'Cancel', + 'channels.editor.save': 'Save', 'settings.loading': 'Loading settings...', 'settings.empty': 'No settings available.', 'settings.footer': @@ -4512,6 +4567,8 @@ const ZH: Messages = { 'channels.availablePlatforms': '可连接平台', 'channels.availablePlatforms.description': '频道管理目前仅开放以下平台。', 'channels.platform.available': '已开放', + 'channels.platform.configure': '配置', + 'channels.platform.configureNamed': (v) => `配置${v?.platform ?? '频道'}`, 'channels.status.stopped': '已停止', 'channels.status.starting': '启动中', 'channels.status.connected': '已连接', @@ -4533,8 +4590,57 @@ const ZH: Messages = { 'channels.action.stop': '停止', 'channels.action.restart': '重启', 'channels.action.retry': '重试', + 'channels.action.edit': '编辑', + 'channels.action.editNamed': (v) => `编辑${v?.name ?? '频道'}`, + 'channels.action.delete': '删除', + 'channels.action.deleteNamed': (v) => `删除${v?.name ?? '频道'}`, 'channels.action.startWithServeNamed': (v) => `让 ${v?.name ?? ''} 随服务启动`, + 'channels.delete.title': (v) => `删除${v?.name ?? '频道'}?`, + 'channels.delete.description': + '该频道将停止运行,并从当前工作区移除。此操作无法撤销。', + 'channels.delete.error': '未能删除频道', + 'channels.editor.addTitle': (v) => `配置${v?.platform ?? '频道'}`, + 'channels.editor.editTitle': (v) => `编辑${v?.platform ?? '频道'}`, + 'channels.editor.addDescription': '连接当前工作区与已有的平台应用。', + 'channels.editor.editDescription': '更新公开配置,或明确更改已保存的凭据。', + 'channels.editor.section.identity': '频道标识', + 'channels.editor.section.credentials': '应用凭据', + 'channels.editor.section.access': '准入策略', + 'channels.editor.instanceName': '实例名称', + 'channels.editor.instanceNamePlaceholder': '例如 release-bot', + 'channels.editor.environmentReference': '支持 $ENV_VAR', + 'channels.editor.field.dingtalk.clientId': 'Client ID(原 AppKey)', + 'channels.editor.field.dingtalk.clientSecret': + 'Client Secret(原 AppSecret)', + 'channels.editor.field.wecom.botId': 'Bot ID', + 'channels.editor.field.wecom.secret': 'Bot Secret', + 'channels.editor.field.wecom.wsUrl': 'WebSocket URL', + 'channels.editor.field.feishu.clientId': 'App ID', + 'channels.editor.field.feishu.clientSecret': 'App Secret', + 'channels.editor.secret.environment': '已保存在环境变量中', + 'channels.editor.secret.stored': '已安全保存', + 'channels.editor.secret.preserve': '保留', + 'channels.editor.secret.replace': '替换', + 'channels.editor.secret.clear': '清除', + 'channels.editor.secret.placeholder': (v) => `请输入${v?.label ?? '密钥'}`, + 'channels.editor.secret.clearHint': '保存后将移除此凭据。', + 'channels.editor.policy.pairing.title': '配对模式', + 'channels.editor.policy.pairing.description': + '用户会收到配对码,经您批准后才能开始对话。', + 'channels.editor.policy.open.title': '开放模式', + 'channels.editor.policy.open.description': + '所有能够访问机器人的用户均可直接开始对话。', + 'channels.editor.validation.required': (v) => + `${v?.label ?? '此字段'}为必填项。`, + 'channels.editor.validation.duplicate': '已存在同名频道。', + 'channels.editor.validation.invalidName': '请使用其他实例名称。', + 'channels.editor.validation.number': '请输入有效数字。', + 'channels.editor.validation.policy': '请选择准入策略。', + 'channels.editor.saveError': '未能保存更改', + 'channels.editor.reloadLatest': '加载最新配置', + 'channels.editor.cancel': '取消', + 'channels.editor.save': '保存', 'settings.loading': '正在加载设置...', 'settings.empty': '暂无可用设置。', 'settings.footer': '↑↓ 导航 Enter 切换 Tab 切换作用域 r 刷新 ESC 关闭', From 5712fc1f6fee4444c77dbffdfcdae7536dec5f8e Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot <269191875+qwen-code-dev-bot@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:12:43 +0800 Subject: [PATCH 2/2] fix(web-shell): hide invalid secret clear action --- .../channels/ChannelEditorDialog.test.tsx | 17 +++++++++++++++++ .../components/channels/ChannelEditorDialog.tsx | 5 ++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx index d7eb4a695db..635875bd239 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx @@ -36,6 +36,13 @@ const DINGTALK: DaemonChannelTypeDescriptor = { ], }; +const OPTIONAL_SECRET: DaemonChannelTypeDescriptor = { + ...DINGTALK, + fields: DINGTALK.fields.map((field) => + field.key === 'clientSecret' ? { ...field, required: false } : field, + ), +}; + const INSTANCE: DaemonChannelInstanceSnapshot = { name: 'release-bot', config: { @@ -110,6 +117,7 @@ describe('ChannelEditorDialog', () => { expect(document.body.textContent).toContain('Edit DingTalk'); expect(document.body.textContent).toContain('Stored in environment'); + expect(document.body.textContent).not.toContain('Clear'); expect(inputByLabel('Client Secret')).toBeNull(); const replace = Array.from(document.querySelectorAll('button')).find( @@ -122,6 +130,15 @@ describe('ChannelEditorDialog', () => { expect(inputByLabel('Client Secret')).not.toBeNull(); }); + it('offers Clear for an optional stored secret', async () => { + await renderDialog({ descriptor: OPTIONAL_SECRET, instance: INSTANCE }); + + const clear = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Clear', + ); + expect(clear).toBeDefined(); + }); + it('submits a new instance with typed fields and the current revision', async () => { const onSave = vi.fn().mockResolvedValue(undefined); await renderDialog({ onSave }); diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx index fb1c1630b11..b736c4cb57d 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx @@ -232,6 +232,9 @@ export function ChannelEditorDialog({ }; const error = errors[field.key]; const showInput = secret.operation === 'replace'; + const operations = field.required + ? (['preserve', 'replace'] as const) + : (['preserve', 'replace', 'clear'] as const); return (
- {(['preserve', 'replace', 'clear'] as const).map((operation) => ( + {operations.map((operation) => (