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 ? (
+
+
+
+
+ {t('channels.editor.pairing.empty.title')}
+
+
+ {t('channels.editor.pairing.empty.description')}
+
+
+
+ ) : null}
+
+ {requests.length > 0 ? (
+
+ ) : 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':
'所有能够访问机器人的用户均可直接开始对话。',