diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx index 635875bd239..f08df1a5b67 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx @@ -77,6 +77,8 @@ async function renderDialog( onOpenChange={vi.fn()} onSave={vi.fn().mockResolvedValue(undefined)} onReload={vi.fn().mockResolvedValue(undefined)} + listPairingRequests={vi.fn().mockResolvedValue({ requests: [] })} + approvePairingRequest={vi.fn()} {...props} /> , @@ -179,6 +181,15 @@ describe('ChannelEditorDialog', () => { }); }); + it('explains that pairing requests appear after a new Channel is saved', async () => { + await renderDialog(); + + expect(document.body.textContent).toContain('Save pairing mode first'); + expect(document.body.textContent).toContain( + 'Pending requests will appear here after this Channel is saved in pairing mode.', + ); + }); + it('keeps the dialog open and offers a reload after a stale write', async () => { const onSave = vi .fn() diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx index b736c4cb57d..8a83416b157 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx @@ -15,6 +15,8 @@ import { CheckCircle2Icon, KeyRoundIcon } from 'lucide-react'; import type { DaemonChannelConfigFieldDescriptor, DaemonChannelInstanceSnapshot, + DaemonChannelPairingApprovalResult, + DaemonChannelPairingRequestsSnapshot, DaemonChannelTypeDescriptor, DaemonChannelUpsertRequest, } from '@qwen-code/sdk/daemon'; @@ -43,6 +45,7 @@ import { import { Spinner } from '../ui/spinner'; import { Switch } from '../ui/switch'; import styles from './ChannelEditorDialog.module.css'; +import { ChannelPairingRequests } from './ChannelPairingRequests'; import { buildChannelUpsertRequest, createChannelEditorDraft, @@ -85,6 +88,13 @@ export interface ChannelEditorDialogProps { request: DaemonChannelUpsertRequest, ) => Promise; onReload: () => Promise; + listPairingRequests: ( + name: string, + ) => Promise; + approvePairingRequest: ( + name: string, + code: string, + ) => Promise; } function FieldShell({ @@ -134,6 +144,8 @@ export function ChannelEditorDialog({ onOpenChange, onSave, onReload, + listPairingRequests, + approvePairingRequest, }: ChannelEditorDialogProps) { const { t } = useI18n(); const formId = useId(); @@ -519,6 +531,25 @@ export function ChannelEditorDialog({ {errors['senderPolicy']}

) : null} + {draft.senderPolicy === 'pairing' ? ( + instance?.config.senderPolicy === 'pairing' ? ( + + ) : ( + + + + {t('channels.editor.pairing.saveFirst.title')} + + + {t('channels.editor.pairing.saveFirst.description')} + + + ) + ) : null} diff --git a/packages/web-shell/client/components/channels/ChannelPairingRequests.module.css b/packages/web-shell/client/components/channels/ChannelPairingRequests.module.css new file mode 100644 index 00000000000..81a668fb3fb --- /dev/null +++ b/packages/web-shell/client/components/channels/ChannelPairingRequests.module.css @@ -0,0 +1,136 @@ +.panel { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--muted) 34%, transparent); +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.title { + font-size: 12px; + font-weight: 700; +} + +.description, +.emptyDescription { + margin-top: 2px; + color: var(--muted-foreground); + font-size: 11px; + line-height: 1.45; +} + +.headerActions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 4px; +} + +.success { + display: flex; + align-items: center; + gap: 7px; + padding: 8px 10px; + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--primary) 8%, transparent); + color: var(--foreground); + font-size: 12px; +} + +.success svg { + width: 14px; + height: 14px; + color: var(--primary); +} + +.empty { + display: flex; + align-items: center; + gap: 10px; + padding: 12px; + border: 1px dashed var(--border); + border-radius: var(--radius-md); + background: var(--card); +} + +.empty > svg { + width: 18px; + height: 18px; + flex: 0 0 auto; + color: var(--muted-foreground); +} + +.emptyTitle { + font-size: 12px; + font-weight: 650; +} + +.list { + display: flex; + flex-direction: column; + gap: 7px; +} + +.request { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 10px; + padding: 10px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--card); +} + +.requestIdentity { + display: flex; + min-width: 0; + flex-direction: column; +} + +.senderName { + overflow: hidden; + font-size: 12px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.senderId, +.requestTime { + overflow: hidden; + color: var(--muted-foreground); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.code { + padding: 5px 7px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--muted); + font-family: var(--font-mono); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; +} + +@media (max-width: 520px) { + .request { + grid-template-columns: minmax(0, 1fr) auto; + } + + .requestIdentity { + grid-column: 1 / -1; + } +} diff --git a/packages/web-shell/client/components/channels/ChannelPairingRequests.test.tsx b/packages/web-shell/client/components/channels/ChannelPairingRequests.test.tsx new file mode 100644 index 00000000000..63b7cd3255b --- /dev/null +++ b/packages/web-shell/client/components/channels/ChannelPairingRequests.test.tsx @@ -0,0 +1,201 @@ +/** + * @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 { + DaemonChannelPairingApprovalResult, + DaemonChannelPairingRequestsSnapshot, +} from '@qwen-code/sdk/daemon'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const { ChannelPairingRequests } = await import('./ChannelPairingRequests'); +const { I18nProvider } = await import('../../i18n'); + +const PENDING: DaemonChannelPairingRequestsSnapshot = { + requests: [ + { + senderId: 'user-42', + senderName: 'Ada', + code: 'ABCD1234', + createdAt: Date.parse('2026-07-28T00:00:00.000Z'), + }, + ], +}; + +let container: HTMLDivElement; +let root: Root; + +async function renderRequests({ + channelName = 'release-bot', + list = vi.fn().mockResolvedValue(PENDING), + approve = vi.fn(), +}: { + channelName?: string; + list?: (name: string) => Promise; + approve?: ( + name: string, + code: string, + ) => Promise; +} = {}) { + await act(async () => { + root.render( + + + , + ); + }); + return { list, approve }; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-28T00:05:00.000Z')); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); +}); + +describe('ChannelPairingRequests', () => { + it('loads and displays pending requests for the selected Channel', async () => { + const { list } = await renderRequests(); + + expect(list).toHaveBeenCalledWith('release-bot'); + expect(container.textContent).toContain('Pending requests'); + expect(container.textContent).toContain('Ada'); + expect(container.textContent).toContain('user-42'); + expect(container.textContent).toContain('ABCD1234'); + expect(container.textContent).toContain('5 min ago'); + }); + + it('approves a request and replaces the list with the daemon response', async () => { + const approval: DaemonChannelPairingApprovalResult = { + approved: PENDING.requests[0], + requests: [], + }; + const approve = vi.fn().mockResolvedValue(approval); + await renderRequests({ approve }); + + const button = Array.from(container.querySelectorAll('button')).find( + (item) => item.textContent?.trim() === 'Approve', + ); + await act(async () => { + button?.click(); + }); + + expect(approve).toHaveBeenCalledWith('release-bot', 'ABCD1234'); + expect(container.textContent).toContain('Ada can now use this Channel.'); + expect(container.textContent).toContain('No pending requests'); + expect(container.textContent).not.toContain('ABCD1234'); + }); + + it('keeps a request visible when approval fails', async () => { + const approve = vi.fn().mockRejectedValue(new Error('Approval failed.')); + await renderRequests({ approve }); + + const button = Array.from(container.querySelectorAll('button')).find( + (item) => item.textContent?.trim() === 'Approve', + ); + await act(async () => { + button?.click(); + }); + + expect(container.textContent).toContain('Approval failed.'); + expect(container.textContent).toContain('ABCD1234'); + }); + + it('retries after loading requests fails', async () => { + const list = vi + .fn() + .mockRejectedValueOnce(new Error('Pairing list unavailable.')) + .mockResolvedValueOnce(PENDING); + await renderRequests({ list }); + + expect(container.textContent).toContain('Pairing list unavailable.'); + const retry = Array.from(container.querySelectorAll('button')).find( + (item) => item.textContent?.trim() === 'Try again', + ); + await act(async () => { + retry?.click(); + }); + + expect(list).toHaveBeenCalledTimes(2); + expect(container.textContent).toContain('ABCD1234'); + }); + + it('ignores an approval response after the selected Channel changes', async () => { + let resolveApproval: + | ((result: DaemonChannelPairingApprovalResult) => void) + | undefined; + const approve = vi.fn( + () => + new Promise((resolve) => { + resolveApproval = resolve; + }), + ); + const nextRequest = { + senderId: 'user-91', + senderName: 'Lin', + code: 'WXYZ5678', + createdAt: Date.now(), + }; + const list = vi + .fn() + .mockResolvedValueOnce(PENDING) + .mockResolvedValueOnce({ requests: [nextRequest] }); + await renderRequests({ list, approve }); + + const approveButton = Array.from(container.querySelectorAll('button')).find( + (item) => item.textContent?.trim() === 'Approve', + ); + await act(async () => { + approveButton?.click(); + }); + await renderRequests({ channelName: 'other-bot', list, approve }); + expect(container.textContent).toContain('WXYZ5678'); + + await act(async () => { + resolveApproval?.({ + approved: PENDING.requests[0], + requests: [], + }); + }); + + expect(container.textContent).toContain('WXYZ5678'); + expect(container.textContent).not.toContain( + 'Ada can now use this Channel.', + ); + }); + + it('does not show requests from the previous Channel while loading', async () => { + const list = vi + .fn() + .mockResolvedValueOnce(PENDING) + .mockReturnValueOnce( + new Promise(() => undefined), + ); + await renderRequests({ list }); + expect(container.textContent).toContain('ABCD1234'); + + await renderRequests({ channelName: 'other-bot', list }); + + expect(container.textContent).not.toContain('ABCD1234'); + }); +}); diff --git a/packages/web-shell/client/components/channels/ChannelPairingRequests.tsx b/packages/web-shell/client/components/channels/ChannelPairingRequests.tsx new file mode 100644 index 00000000000..bc190544e75 --- /dev/null +++ b/packages/web-shell/client/components/channels/ChannelPairingRequests.tsx @@ -0,0 +1,221 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useId, useRef, useState } from 'react'; +import { + AlertCircleIcon, + CheckIcon, + RefreshCwIcon, + UsersRoundIcon, +} from 'lucide-react'; +import type { + DaemonChannelPairingApprovalResult, + DaemonChannelPairingRequest, + DaemonChannelPairingRequestsSnapshot, +} from '@qwen-code/sdk/daemon'; +import { useI18n } from '../../i18n'; +import { extractErrorDetail } from '../../utils/errorDetail'; +import { formatRelativeTime } from '../../utils/formatRelativeTime'; +import { Alert, AlertDescription, AlertTitle } from '../ui/alert'; +import { Badge } from '../ui/badge'; +import { Button } from '../ui/button'; +import { Spinner } from '../ui/spinner'; +import styles from './ChannelPairingRequests.module.css'; + +export interface ChannelPairingRequestsProps { + channelName: string; + listRequests: (name: string) => Promise; + approveRequest: ( + name: string, + code: string, + ) => Promise; +} + +function senderLabel(request: DaemonChannelPairingRequest): string { + return request.senderName.trim() || request.senderId; +} + +export function ChannelPairingRequests({ + channelName, + listRequests, + approveRequest, +}: ChannelPairingRequestsProps) { + const { t } = useI18n(); + const headingId = useId(); + const mounted = useRef(false); + const currentChannelName = useRef(channelName); + currentChannelName.current = channelName; + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); + const [reloadToken, setReloadToken] = useState(0); + const [approvingCode, setApprovingCode] = useState(); + const [error, setError] = useState(); + const [success, setSuccess] = useState(); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + useEffect(() => { + let active = true; + setLoading(true); + setRequests([]); + setError(undefined); + setSuccess(undefined); + setApprovingCode(undefined); + void listRequests(channelName).then( + (snapshot) => { + if (!active) return; + setRequests(snapshot.requests); + setLoading(false); + }, + (loadError: unknown) => { + if (!active) return; + setError(extractErrorDetail(loadError)); + setLoading(false); + }, + ); + return () => { + active = false; + }; + }, [channelName, listRequests, reloadToken]); + + const approve = async (request: DaemonChannelPairingRequest) => { + if (approvingCode) return; + const approvalChannel = channelName; + setApprovingCode(request.code); + setError(undefined); + setSuccess(undefined); + try { + const result = await approveRequest(channelName, request.code); + if (!mounted.current || currentChannelName.current !== approvalChannel) { + return; + } + setRequests(result.requests); + setSuccess( + t('channels.editor.pairing.approved', { + sender: senderLabel(result.approved), + }), + ); + } catch (approvalError) { + if (!mounted.current || currentChannelName.current !== approvalChannel) { + return; + } + setError(extractErrorDetail(approvalError)); + } finally { + if (mounted.current && currentChannelName.current === approvalChannel) { + setApprovingCode(undefined); + } + } + }; + + return ( +
+
+
+

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

+

+ {t('channels.editor.pairing.description')} +

+
+
+ {requests.length} + +
+
+ + {error ? ( + + + {t('channels.editor.pairing.error')} + {error} + + + ) : null} + + {success ? ( +
+ + {success} +
+ ) : null} + + {!loading && !error && requests.length === 0 ? ( +
+
+ ) : null} + + {requests.length > 0 ? ( +
    + {requests.map((request) => { + const label = senderLabel(request); + return ( +
  • +
    + {label} + {label !== request.senderId ? ( + {request.senderId} + ) : null} + + {formatRelativeTime( + new Date(request.createdAt).toISOString(), + t, + )} + +
    + {request.code} + +
  • + ); + })} +
+ ) : null} +
+ ); +} diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx b/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx index 9203363c354..3d6e089f70c 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx @@ -60,6 +60,10 @@ const { channelState, useChannelsMock, workspaceState } = vi.hoisted(() => ({ start: vi.fn(), stop: vi.fn(), restart: vi.fn(), + pairing: { + list: vi.fn(), + approve: vi.fn(), + }, }, }, useChannelsMock: vi.fn(), @@ -171,6 +175,10 @@ beforeEach(() => { channelState.current.start.mockReset().mockResolvedValue(undefined); channelState.current.stop.mockReset().mockResolvedValue(undefined); channelState.current.restart.mockReset().mockResolvedValue(undefined); + channelState.current.pairing.list + .mockReset() + .mockResolvedValue({ requests: [] }); + channelState.current.pairing.approve.mockReset(); workspaceState.current = { workspaceCwd: '/workspace/demo', token: 'secret', @@ -259,6 +267,16 @@ describe('ChannelsManagerPage', () => { clientSecret: { present: true, source: 'literal' }, }, }; + channelState.current.pairing.list.mockResolvedValue({ + requests: [ + { + senderId: 'user-42', + senderName: 'Ada', + code: 'ABCD1234', + createdAt: Date.now(), + }, + ], + }); await renderPage(); const edit = Array.from(container.querySelectorAll('button')).find( @@ -273,6 +291,10 @@ describe('ChannelsManagerPage', () => { (input) => input.value === 'DingTalk Bot', ); expect(name?.disabled).toBe(true); + expect(channelState.current.pairing.list).toHaveBeenCalledWith( + 'DingTalk Bot', + ); + expect(document.body.textContent).toContain('ABCD1234'); }); it('deletes a Channel with the current revision', async () => { diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx index 9c450ae4bd9..6647f986c62 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.tsx @@ -109,6 +109,7 @@ export function ChannelsManagerPage({ start, stop, restart, + pairing, } = useChannels({ autoLoad: supportsManagement, enabled: supportsManagement, @@ -555,6 +556,8 @@ export function ChannelsManagerPage({ }} onSave={saveChannel} onReload={reload} + listPairingRequests={pairing.list} + approvePairingRequest={pairing.approve} /> ) : null} diff --git a/packages/web-shell/client/e2e/utils/mockDaemon.ts b/packages/web-shell/client/e2e/utils/mockDaemon.ts index 69c7c85bff1..681d5072a7a 100644 --- a/packages/web-shell/client/e2e/utils/mockDaemon.ts +++ b/packages/web-shell/client/e2e/utils/mockDaemon.ts @@ -4,6 +4,7 @@ import { type DaemonApprovalMode, type DaemonCapabilities, type DaemonChannelsSnapshot, + type DaemonChannelPairingRequest, type DaemonChannelTypeCatalog, type DaemonEvent, type DaemonRestoredSession, @@ -54,6 +55,7 @@ export interface WebShellDaemonScenario { extensionUpdateCheck: ExtensionUpdateCheckResponse; channelTypes: DaemonChannelTypeCatalog; channels: DaemonChannelsSnapshot; + pairingRequests: Record; sessions: DaemonSessionSummary[]; sessionGroups: DaemonSessionGroup[]; events: DaemonEvent[]; @@ -94,6 +96,7 @@ type ScenarioOverrides = Partial< | 'extensionUpdateCheck' | 'channelTypes' | 'channels' + | 'pairingRequests' | 'sessions' | 'sessionGroups' | 'state' @@ -109,6 +112,7 @@ type ScenarioOverrides = Partial< extensionUpdateCheck?: Partial; channelTypes?: DaemonChannelTypeCatalog; channels?: DaemonChannelsSnapshot; + pairingRequests?: Record; sessions?: DaemonSessionSummary[]; sessionGroups?: DaemonSessionGroup[]; state?: Partial; @@ -322,6 +326,7 @@ export function createWebShellDaemonScenario( extensionUpdateCheck, channelTypes: overrides.channelTypes ?? [], channels: overrides.channels ?? { revision: '1', instances: {} }, + pairingRequests: overrides.pairingRequests ?? {}, sessions, sessionGroups: overrides.sessionGroups ?? [], events: overrides.events ?? [], @@ -525,6 +530,9 @@ function isDaemonPath(path: string): boolean { /^\/workspace\/mcp\/[^/]+\/resources\/?$/.test(path) || /^\/workspaces\/[^/]+\/channel-types\/?$/.test(path) || /^\/workspaces\/[^/]+\/channels\/?$/.test(path) || + /^\/workspaces\/[^/]+\/channels\/[^/]+\/pairing-requests(?:\/approve)?\/?$/.test( + path, + ) || /^\/workspaces\/[^/]+\/channels\/[^/]+\/?$/.test(path) || /^\/workspace\/.+\/sessions\/?$/.test(path) || /^\/workspace\/.+\/session-groups\/?$/.test(path) || @@ -601,6 +609,14 @@ function isDaemonRoute(method: string, path: string): boolean { ) { return true; } + if ( + (method === 'GET' || method === 'POST') && + /^\/workspaces\/[^/]+\/channels\/[^/]+\/pairing-requests(?:\/approve)?\/?$/.test( + path, + ) + ) { + return true; + } if (method === 'POST' && path === '/session') return true; if (method === 'POST' && /^\/permission\/[^/]+\/?$/.test(path)) return true; if ( @@ -767,6 +783,32 @@ async function handleDaemonRoute( await json(route, scenario.channels); return; } + const pairingMatch = path.match( + /^\/workspaces\/[^/]+\/channels\/([^/]+)\/pairing-requests(\/approve)?\/?$/, + ); + if (pairingMatch) { + const name = decodeURIComponent(pairingMatch[1]); + const requests = scenario.pairingRequests[name] ?? []; + if (method === 'GET' && !pairingMatch[2]) { + await json(route, { requests }); + return; + } + if (method === 'POST' && pairingMatch[2]) { + const code = String(getRecordValue(body, 'code') ?? '').toUpperCase(); + const approved = requests.find((request) => request.code === code); + if (!approved) { + await json(route, { error: 'Pairing request not found.' }, 404); + return; + } + const remaining = requests.filter((request) => request.code !== code); + scenario.pairingRequests = { + ...scenario.pairingRequests, + [name]: remaining, + }; + await json(route, { approved, requests: remaining }); + return; + } + } const channelMutationMatch = path.match( /^\/workspaces\/[^/]+\/channels\/([^/]+)\/?$/, ); diff --git a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts index 4ac113a7dfe..ddf131a69fa 100644 --- a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts +++ b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts @@ -288,6 +288,22 @@ for (const theme of THEMES) { }, }, }, + pairingRequests: { + dingtalk: [ + { + senderId: 'user-42', + senderName: 'Ada', + code: 'ABCD1234', + createdAt: Date.parse('2026-07-28T00:00:00.000Z'), + }, + { + senderId: 'user-91', + senderName: 'Lin', + code: 'WXYZ5678', + createdAt: Date.parse('2026-07-28T00:04:00.000Z'), + }, + ], + }, }); await page.addInitScript(() => { window.sessionStorage.setItem('qwen-daemon-token', 'visual-token'); @@ -323,6 +339,7 @@ for (const theme of THEMES) { name: 'Edit DingTalk', }); await expect(editHeading).toBeVisible(); + await expect(page.getByText('ABCD1234', { exact: true })).toBeVisible(); await editHeading.click(); await captureScreenshot(page, `channel-editor-existing-${theme}`); }); diff --git a/packages/web-shell/client/e2e/web-shell.channels.spec.ts b/packages/web-shell/client/e2e/web-shell.channels.spec.ts index ea7940dafd2..231ae65b9ca 100644 --- a/packages/web-shell/client/e2e/web-shell.channels.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.channels.spec.ts @@ -62,6 +62,16 @@ test('creates and deletes a typed Channel configuration', async ({ fields: [], }, ], + pairingRequests: { + 'release-bot': [ + { + senderId: 'user-42', + senderName: 'Ada', + code: 'ABCD1234', + createdAt: Date.parse('2026-07-28T00:00:00.000Z'), + }, + ], + }, }); await page.addInitScript(() => { window.sessionStorage.setItem('qwen-daemon-token', 'e2e-token'); @@ -119,6 +129,31 @@ test('creates and deletes a typed Channel configuration', async ({ }), ]); + await page.getByRole('button', { name: 'Edit release-bot' }).click(); + await expect( + page.getByRole('heading', { name: 'Edit DingTalk' }), + ).toBeVisible(); + await expect(page.getByText('Ada', { exact: true })).toBeVisible(); + await expect(page.getByText('ABCD1234', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Approve' }).click(); + await expect(page.getByText('No pending requests')).toBeVisible(); + await expect + .poll(() => + daemon.requests.filter( + (request) => + request.method === 'POST' && + request.path.endsWith( + '/channels/release-bot/pairing-requests/approve', + ), + ), + ) + .toEqual([ + expect.objectContaining({ + body: { code: 'ABCD1234' }, + }), + ]); + await page.getByRole('button', { name: 'Close' }).click(); + await page.getByRole('button', { name: 'Delete release-bot' }).click(); const confirmation = page.getByRole('alertdialog'); await confirmation.getByRole('button', { name: 'Delete' }).click(); diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 91c0ae2ac80..a9302a95b89 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2335,6 +2335,21 @@ const EN: Messages = { '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.pairing.title': 'Pending requests', + 'channels.editor.pairing.description': + 'Match the code shared by the person before approving access.', + 'channels.editor.pairing.refresh': 'Refresh pairing requests', + 'channels.editor.pairing.approve': 'Approve', + 'channels.editor.pairing.approved': (v) => + `${v?.sender ?? 'This person'} can now use this Channel.`, + 'channels.editor.pairing.error': 'Pairing requests were not updated', + 'channels.editor.pairing.retry': 'Try again', + 'channels.editor.pairing.empty.title': 'No pending requests', + 'channels.editor.pairing.empty.description': + 'New requests appear here after someone messages the bot.', + 'channels.editor.pairing.saveFirst.title': 'Save pairing mode first', + 'channels.editor.pairing.saveFirst.description': + 'Pending requests will appear here after this Channel is saved in pairing mode.', 'channels.editor.policy.open.title': 'Open', 'channels.editor.policy.open.description': 'Anyone who can reach the bot can start a conversation.', @@ -4634,6 +4649,21 @@ const ZH: Messages = { 'channels.editor.policy.pairing.title': '配对模式', 'channels.editor.policy.pairing.description': '用户会收到配对码,经您批准后才能开始对话。', + 'channels.editor.pairing.title': '待处理的配对请求', + 'channels.editor.pairing.description': + '批准前,请核对用户提供的配对码是否一致。', + 'channels.editor.pairing.refresh': '刷新配对请求', + 'channels.editor.pairing.approve': '允许', + 'channels.editor.pairing.approved': (v) => + `${v?.sender ?? '该用户'}现在可以使用此频道。`, + 'channels.editor.pairing.error': '未能更新配对请求', + 'channels.editor.pairing.retry': '重试', + 'channels.editor.pairing.empty.title': '暂无待处理请求', + 'channels.editor.pairing.empty.description': + '用户向机器人发送消息后,新的配对请求会显示在这里。', + 'channels.editor.pairing.saveFirst.title': '请先保存配对模式', + 'channels.editor.pairing.saveFirst.description': + '频道以配对模式保存后,待处理请求会显示在这里。', 'channels.editor.policy.open.title': '开放模式', 'channels.editor.policy.open.description': '所有能够访问机器人的用户均可直接开始对话。',