Skip to content

Commit 1d9fb27

Browse files
authored
feat(ui): surface mobile pairing shortcuts (#100157)
1 parent 5dff031 commit 1d9fb27

17 files changed

Lines changed: 415 additions & 147 deletions

ui/src/app/app-host.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ import {
2020
} from "../components/command-palette.ts";
2121
import type { ThemeModeChangeDetail } from "../components/theme-mode-toggle.ts";
2222
import { t } from "../i18n/index.ts";
23+
import { copyToClipboard } from "../lib/clipboard.ts";
2324
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
2425
import { searchForSession } from "../lib/sessions/index.ts";
2526
import { resolveAgentIdFromSessionKey } from "../lib/sessions/session-key.ts";
2627
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../lib/string-coerce.ts";
28+
import { renderDevicePairSetup } from "../pages/nodes/view-pairing.ts";
2729
import { bootstrapApplication, type ApplicationRuntime } from "./bootstrap.ts";
2830
import {
2931
applicationContext,
@@ -316,6 +318,11 @@ class OpenClawShell extends LitElement {
316318
approvalQueue: [],
317319
approvalBusy: false,
318320
approvalError: null,
321+
devicePairSetupOpen: false,
322+
devicePairSetupLoading: false,
323+
devicePairSetupError: null,
324+
devicePairSetup: null,
325+
devicePairPendingCount: 0,
319326
};
320327
@query("openclaw-command-palette") private commandPalette?: CommandPalette;
321328
private commandPaletteTarget?: CommandPaletteTargetDetail;
@@ -645,6 +652,8 @@ class OpenClawShell extends LitElement {
645652
.sessionKey=${this.activeSessionKey}
646653
.collapsed=${navCollapsed}
647654
.connected=${this.gatewayConnected}
655+
.canPairDevice=${this.gatewayConnected &&
656+
hasOperatorAdminAccess(context.gateway.snapshot.hello?.auth ?? null)}
648657
.navGroupsCollapsed=${this.navGroupsCollapsed}
649658
.recentSessionsCollapsed=${this.recentSessionsCollapsed}
650659
.themeMode=${context.theme.mode}
@@ -670,6 +679,7 @@ class OpenClawShell extends LitElement {
670679
context.navigation.update({
671680
recentSessionsCollapsed: !context.navigation.snapshot.recentSessionsCollapsed,
672681
})}
682+
.onPairMobile=${() => void context.overlays.openDevicePairSetup()}
673683
.onNavigate=${(routeId: string, options?: ApplicationNavigationOptions) =>
674684
this.navigate(routeId, options)}
675685
.onPreloadRoute=${(routeId: string) =>
@@ -712,6 +722,20 @@ class OpenClawShell extends LitElement {
712722
context.overlays.decideApproval(decision),
713723
}}
714724
></openclaw-exec-approval>
725+
${renderDevicePairSetup({
726+
open: this.overlaySnapshot.devicePairSetupOpen,
727+
loading: this.overlaySnapshot.devicePairSetupLoading,
728+
error: this.overlaySnapshot.devicePairSetupError,
729+
setup: this.overlaySnapshot.devicePairSetup,
730+
pendingCount: this.overlaySnapshot.devicePairPendingCount,
731+
onRefresh: () => void context.overlays.refreshDevicePairSetup(),
732+
onClose: () => context.overlays.closeDevicePairSetup(),
733+
onCopy: (setupCode) => void copyToClipboard(setupCode),
734+
onManageDevices: () => {
735+
context.overlays.closeDevicePairSetup();
736+
this.navigate("nodes");
737+
},
738+
})}
715739
</div>
716740
`;
717741
}

ui/src/app/overlays.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import {
44
} from "../../../src/gateway/events.js";
55
import type { GatewayEventFrame, GatewayHelloOk } from "../api/gateway.ts";
66
import type { UpdateAvailable } from "../api/types.ts";
7+
import {
8+
closeDevicePairSetup as closeDevicePairSetupState,
9+
openDevicePairSetup as openDevicePairSetupState,
10+
refreshDevicePairSetup as refreshDevicePairSetupState,
11+
type DevicePairSetup,
12+
type DevicePairSetupState,
13+
} from "../lib/device-pair-setup.ts";
714
import {
815
clearResolvedExecApprovalPrompt,
916
dismissExecApprovalPrompt,
@@ -31,6 +38,11 @@ export type ApplicationOverlaySnapshot = {
3138
approvalQueue: readonly ExecApprovalRequest[];
3239
approvalBusy: boolean;
3340
approvalError: string | null;
41+
devicePairSetupOpen: boolean;
42+
devicePairSetupLoading: boolean;
43+
devicePairSetupError: string | null;
44+
devicePairSetup: DevicePairSetup | null;
45+
devicePairPendingCount: number;
3446
};
3547

3648
export type ApplicationOverlays = {
@@ -39,6 +51,9 @@ export type ApplicationOverlays = {
3951
runUpdate: () => Promise<void>;
4052
dismissUpdate: () => void;
4153
decideApproval: (decision: ExecApprovalDecision) => Promise<void>;
54+
openDevicePairSetup: () => Promise<void>;
55+
refreshDevicePairSetup: () => Promise<void>;
56+
closeDevicePairSetup: () => void;
4257
dispose: () => void;
4358
};
4459

@@ -186,6 +201,11 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat
186201
approvalQueue: [],
187202
approvalBusy: false,
188203
approvalError: null,
204+
devicePairSetupOpen: false,
205+
devicePairSetupLoading: false,
206+
devicePairSetupError: null,
207+
devicePairSetup: null,
208+
devicePairPendingCount: 0,
189209
};
190210
const listeners = new Set<(next: ApplicationOverlaySnapshot) => void>();
191211
let disposed = false;
@@ -195,6 +215,16 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat
195215
let updateRunGeneration = 0;
196216
let updateVerificationGeneration = 0;
197217
let updateVerificationTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
218+
let devicePairPendingCountGeneration = 0;
219+
const devicePairSetupState: DevicePairSetupState & { pendingCount: number } = {
220+
client: gateway.snapshot.client,
221+
connected: gateway.snapshot.connected,
222+
devicePairSetupOpen: false,
223+
devicePairSetupLoading: false,
224+
devicePairSetupError: null,
225+
devicePairSetup: null,
226+
pendingCount: 0,
227+
};
198228
const promptState: ExecApprovalPromptState = {
199229
client: activeClient,
200230
execApprovalQueue: [],
@@ -211,13 +241,48 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat
211241
approvalQueue: promptState.execApprovalQueue,
212242
approvalBusy: promptState.execApprovalBusy,
213243
approvalError: promptState.execApprovalError,
244+
devicePairSetupOpen: devicePairSetupState.devicePairSetupOpen,
245+
devicePairSetupLoading: devicePairSetupState.devicePairSetupLoading,
246+
devicePairSetupError: devicePairSetupState.devicePairSetupError,
247+
devicePairSetup: devicePairSetupState.devicePairSetup,
248+
devicePairPendingCount: devicePairSetupState.pendingCount,
214249
};
215250
for (const listener of listeners) {
216251
listener(snapshot);
217252
}
218253
};
219254
promptState.execApprovalExpired = publish;
220255

256+
const refreshDevicePairPendingCount = async () => {
257+
const client = gateway.snapshot.client;
258+
if (
259+
!client ||
260+
!gateway.snapshot.connected ||
261+
disposed ||
262+
!devicePairSetupState.devicePairSetupOpen
263+
) {
264+
return;
265+
}
266+
const generation = ++devicePairPendingCountGeneration;
267+
let result: { pending?: unknown };
268+
try {
269+
result = await client.request<{ pending?: unknown }>("device.pair.list", {});
270+
} catch {
271+
return;
272+
}
273+
if (
274+
disposed ||
275+
generation !== devicePairPendingCountGeneration ||
276+
gateway.snapshot.client !== client ||
277+
!gateway.snapshot.connected ||
278+
!devicePairSetupState.devicePairSetupOpen
279+
) {
280+
return;
281+
}
282+
devicePairSetupState.pendingCount = Array.isArray(result.pending) ? result.pending.length : 0;
283+
publish();
284+
};
285+
221286
const refreshApprovals = async (client: NonNullable<typeof activeClient>) => {
222287
const applied = await refreshPendingApprovalQueue(promptState, {
223288
isCurrentClient: (requestClient) =>
@@ -343,6 +408,13 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat
343408
const previousClient = activeClient;
344409
activeClient = next.client;
345410
promptState.client = next.client;
411+
devicePairSetupState.client = next.client;
412+
devicePairSetupState.connected = next.connected;
413+
if (previousClient !== next.client || !next.connected) {
414+
devicePairPendingCountGeneration += 1;
415+
closeDevicePairSetupState(devicePairSetupState);
416+
devicePairSetupState.pendingCount = 0;
417+
}
346418
if (!next.connected || !next.client) {
347419
promptState.execApprovalQueue = [];
348420
promptState.execApprovalBusy = false;
@@ -370,6 +442,10 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat
370442
if (disposed || !isGatewayEvent(event)) {
371443
return;
372444
}
445+
if (event.event === "device.pair.requested" || event.event === "device.pair.resolved") {
446+
void refreshDevicePairPendingCount();
447+
return;
448+
}
373449
if (event.event === GATEWAY_EVENT_UPDATE_AVAILABLE) {
374450
const payload = event.payload as GatewayUpdateAvailableEventPayload | undefined;
375451
snapshot = { ...snapshot, updateAvailable: payload?.updateAvailable ?? null };
@@ -538,10 +614,42 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat
538614
publish();
539615
}
540616
},
617+
async openDevicePairSetup() {
618+
if (disposed) {
619+
return;
620+
}
621+
devicePairSetupState.pendingCount = 0;
622+
const setupOperation = openDevicePairSetupState(devicePairSetupState);
623+
const pendingCountOperation = refreshDevicePairPendingCount();
624+
publish();
625+
await Promise.all([setupOperation, pendingCountOperation]);
626+
if (!disposed) {
627+
publish();
628+
}
629+
},
630+
async refreshDevicePairSetup() {
631+
if (disposed) {
632+
return;
633+
}
634+
const operation = refreshDevicePairSetupState(devicePairSetupState);
635+
publish();
636+
await operation;
637+
if (!disposed) {
638+
publish();
639+
}
640+
},
641+
closeDevicePairSetup() {
642+
devicePairPendingCountGeneration += 1;
643+
closeDevicePairSetupState(devicePairSetupState);
644+
devicePairSetupState.pendingCount = 0;
645+
publish();
646+
},
541647
dispose() {
542648
disposed = true;
543649
updateRunGeneration += 1;
650+
devicePairPendingCountGeneration += 1;
544651
cancelUpdateVerification();
652+
closeDevicePairSetupState(devicePairSetupState);
545653
stopGateway();
546654
stopEvents();
547655
for (const timer of promptState.execApprovalExpiryTimers?.values() ?? []) {

ui/src/components/app-sidebar.ts

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,15 @@ export class AppSidebar extends LitElement {
8080
@property({ attribute: false }) enabledRouteIds?: readonly NavigationRouteId[];
8181
@property({ attribute: false }) collapsed = false;
8282
@property({ attribute: false }) connected = false;
83+
@property({ attribute: false }) canPairDevice = false;
8384
@property({ attribute: false }) sessionKey = "";
8485
@property({ attribute: false }) navGroupsCollapsed: Record<string, boolean> = {};
8586
@property({ attribute: false }) recentSessionsCollapsed = false;
8687
@property({ attribute: false }) themeMode: ThemeMode = "system";
8788
@property({ attribute: false }) onToggleCollapsed?: () => void;
8889
@property({ attribute: false }) onToggleGroup?: (label: string) => void;
8990
@property({ attribute: false }) onToggleRecentSessions?: () => void;
91+
@property({ attribute: false }) onPairMobile?: () => void;
9092
@property({ attribute: false })
9193
onNavigate?: (routeId: NavigationRouteId, options?: ApplicationNavigationOptions) => void;
9294
@property({ attribute: false }) onPreloadRoute?: (routeId: NavigationRouteId) => Promise<void>;
@@ -778,22 +780,39 @@ export class AppSidebar extends LitElement {
778780
<div class="sidebar-mode-switch">
779781
<openclaw-theme-mode-toggle .mode=${this.themeMode}></openclaw-theme-mode-toggle>
780782
</div>
781-
<div class="sidebar-status">
782-
<openclaw-tooltip .content=${gatewayStatus}>
783-
<span
784-
class="sidebar-status__dot ${this.connected
785-
? "sidebar-connection-status--online"
786-
: "sidebar-connection-status--offline"}"
787-
role="img"
788-
aria-live="polite"
789-
aria-label=${gatewayStatus}
790-
></span>
783+
<div class="sidebar-footer-row">
784+
<div class="sidebar-status">
785+
<openclaw-tooltip .content=${gatewayStatus}>
786+
<span
787+
class="sidebar-status__dot ${this.connected
788+
? "sidebar-connection-status--online"
789+
: "sidebar-connection-status--offline"}"
790+
role="img"
791+
aria-live="polite"
792+
aria-label=${gatewayStatus}
793+
></span>
794+
</openclaw-tooltip>
795+
${this.collapsed
796+
? nothing
797+
: html`<span class="sidebar-status__text"
798+
>${this.connected ? t("common.online") : t("common.offline")}</span
799+
>`}
800+
</div>
801+
<openclaw-tooltip
802+
.content=${this.canPairDevice
803+
? t("nodes.pairing.button")
804+
: t("nodes.pairing.adminRequired")}
805+
>
806+
<button
807+
class="sidebar-pair-mobile"
808+
type="button"
809+
aria-label=${t("nodes.pairing.button")}
810+
?disabled=${!this.canPairDevice}
811+
@click=${() => this.onPairMobile?.()}
812+
>
813+
<span aria-hidden="true">${icons.smartphone}</span>
814+
</button>
791815
</openclaw-tooltip>
792-
${this.collapsed
793-
? nothing
794-
: html`<span class="sidebar-status__text"
795-
>${this.connected ? t("common.online") : t("common.offline")}</span
796-
>`}
797816
</div>
798817
</div>
799818
</div>

ui/src/e2e/mobile-pairing.e2e.test.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ describeControlUiE2e("Control UI mobile pairing mocked Gateway E2E", () => {
3434
await server?.close();
3535
});
3636

37-
it("opens a scannable setup code and can mint a replacement", async () => {
37+
it("opens pairing from the app shell and Quick Settings", async () => {
3838
const setupCode = Buffer.from(
3939
JSON.stringify({
4040
url: "wss://gateway.example.test",
@@ -53,7 +53,10 @@ describeControlUiE2e("Control UI mobile pairing mocked Gateway E2E", () => {
5353
page.on("pageerror", (error) => pageErrors.push(String(error)));
5454
const gateway = await installMockGateway(page, {
5555
methodResponses: {
56-
"device.pair.list": { paired: [], pending: [] },
56+
"device.pair.list": {
57+
paired: [],
58+
pending: [{ deviceId: "mobile-1", requestId: "request-1" }],
59+
},
5760
"device.pair.setupCode": {
5861
auth: "token",
5962
gatewayUrl: "wss://gateway.example.test",
@@ -66,10 +69,13 @@ describeControlUiE2e("Control UI mobile pairing mocked Gateway E2E", () => {
6669
});
6770

6871
try {
69-
const response = await page.goto(`${server.baseUrl}nodes`);
72+
const response = await page.goto(`${server.baseUrl}chat`);
7073
expect(response?.status()).toBe(200);
7174

72-
await page.getByRole("button", { name: "Pair mobile device" }).click();
75+
const sidebarPairingButton = page.locator(".sidebar-pair-mobile");
76+
await sidebarPairingButton.waitFor();
77+
await expect.poll(async () => sidebarPairingButton.isEnabled()).toBe(true);
78+
await sidebarPairingButton.click();
7379

7480
const dialog = page.getByRole("dialog", { name: "OpenClaw mobile" });
7581
const qr = page.getByAltText("OpenClaw mobile pairing QR code");
@@ -82,15 +88,40 @@ describeControlUiE2e("Control UI mobile pairing mocked Gateway E2E", () => {
8288
);
8389
expect(
8490
await page.getByText("Official OpenClaw mobile apps connect automatically").isVisible(),
85-
).toBe(true);
91+
).toBe(false);
92+
expect(await page.getByText("Device requests waiting for review: 1").isVisible()).toBe(true);
8693

8794
const firstRequest = await gateway.waitForRequest("device.pair.setupCode");
8895
expect(firstRequest.params).toEqual({});
96+
await expect.poll(async () => (await gateway.getRequests("device.pair.list")).length).toBe(1);
97+
98+
await gateway.emitGatewayEvent("device.pair.requested", { requestId: "request-2" });
99+
await expect.poll(async () => (await gateway.getRequests("device.pair.list")).length).toBe(2);
100+
101+
await page.locator(".device-pair-setup__close").click();
102+
await dialog.waitFor({ state: "hidden" });
103+
104+
const settingsResponse = await page.goto(`${server.baseUrl}config`);
105+
expect(settingsResponse?.status()).toBe(200);
106+
const quickSettingsPairingButton = page
107+
.locator(".qs-card--security")
108+
.getByRole("button", { name: "Pair mobile device" });
109+
await quickSettingsPairingButton.waitFor();
110+
const setupRequestsBeforeQuickSettings = (await gateway.getRequests("device.pair.setupCode"))
111+
.length;
112+
await quickSettingsPairingButton.click();
113+
await dialog.waitFor();
114+
await expect
115+
.poll(async () => (await gateway.getRequests("device.pair.setupCode")).length)
116+
.toBe(setupRequestsBeforeQuickSettings + 1);
89117

90118
await page.getByRole("button", { name: "New code" }).click();
91119
await expect
92120
.poll(async () => (await gateway.getRequests("device.pair.setupCode")).length)
93-
.toBe(2);
121+
.toBe(setupRequestsBeforeQuickSettings + 2);
122+
123+
await page.getByRole("button", { name: "Manage devices" }).click();
124+
await expect.poll(() => new URL(page.url()).pathname).toBe("/nodes");
94125
expect(pageErrors).toEqual([]);
95126
} finally {
96127
await context.close();

0 commit comments

Comments
 (0)