fix(ui): refresh device labels after rename (#126432)

* fix(ui): refresh device labels after rename

* fix(ci): classify device label refresh event for mobile

* chore(protocol): regenerate Android gateway events
This commit is contained in:
Peter Steinberger
2026-08-19 14:18:50 -07:00
committed by GitHub
parent 513ec6218c
commit a3ca8466ee
11 changed files with 94 additions and 37 deletions
@@ -669,6 +669,7 @@ enum class GatewayEvent(
NodeInvokeCancel("node.invoke.cancel"),
NodeInvokeInput("node.invoke.input"),
NodeInvokeRequest("node.invoke.request"),
DevicePairChanged("device.pair.changed"),
DevicePairRequested("device.pair.requested"),
DevicePairResolved("device.pair.resolved"),
DevicePairSetupCompleted("device.pair.setup.completed"),
@@ -3,6 +3,7 @@
"ios": {
"controlUi.sessionPullRequests.changed": "Sidebar PR indicators are a Control UI surface; native apps do not render session PR chips.",
"cron": "Cron run activity is not surfaced in the iOS app.",
"device.pair.changed": "Device label projection refresh is a Control UI surface; iOS does not render the Gateway Devices page.",
"device.pair.requested": "Device pairing flows poll via device.pair.* methods on iOS.",
"device.pair.resolved": "Device pairing flows poll via device.pair.* methods on iOS.",
"device.pair.setup.completed": "Generated setup lifecycle is a Control UI surface; iOS direct Watch setup completes through Watch connectivity.",
@@ -36,6 +37,7 @@
"android": {
"controlUi.sessionPullRequests.changed": "Sidebar PR indicators are a Control UI surface; native apps do not render session PR chips.",
"cron": "Cron run activity is not surfaced in the Android app.",
"device.pair.changed": "Device label projection refresh is a Control UI surface; Android does not render the Gateway Devices page.",
"device.pair.requested": "Device pairing flows poll via device.pair.* methods on Android.",
"device.pair.resolved": "Device pairing flows poll via device.pair.* methods on Android.",
"device.pair.setup.completed": "Generated setup lifecycle is a Control UI surface; Android consumes setup codes but does not issue or track them.",
+3
View File
@@ -6,6 +6,9 @@ import type {
import { roleScopesAllow } from "../shared/operator-scope-compat.js";
import { READ_SCOPE } from "./operator-scopes.js";
/** Event name emitted when the paired-device projection changes. */
export const GATEWAY_EVENT_DEVICE_PAIR_CHANGED = "device.pair.changed" as const;
/** Event name emitted when a node's private runner declaration changes. */
export const GATEWAY_EVENT_NODE_RUNNER_INVENTORY_CHANGED = "node.runnerInventory.changed" as const;
+10 -17
View File
@@ -530,42 +530,35 @@ describe("gateway broadcaster", () => {
});
it("filters approval and pairing events by scope", () => {
const approvalsSocket: TestSocket = {
bufferedAmount: 0,
send: vi.fn(),
close: vi.fn(),
};
const pairingSocket: TestSocket = {
bufferedAmount: 0,
send: vi.fn(),
close: vi.fn(),
};
const readSocket: TestSocket = {
bufferedAmount: 0,
send: vi.fn(),
close: vi.fn(),
};
const approvalsSocket = makeRecordingSocket();
const pairingSocket = makeRecordingSocket();
const readSocket = makeRecordingSocket();
const adminSocket = makeRecordingSocket();
const clients = new Set<GatewayWsClient>([
makeOperatorWsClient("c-approvals", approvalsSocket, ["operator.approvals"]),
makeOperatorWsClient("c-pairing", pairingSocket, ["operator.pairing"]),
makeOperatorWsClient("c-read", readSocket, ["operator.read"]),
makeOperatorWsClient("c-admin", adminSocket, ["operator.admin"]),
]);
const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ clients });
broadcast("exec.approval.requested", { id: "1" });
broadcast("device.pair.requested", { requestId: "r1" });
broadcast("device.pair.changed", {});
expect(approvalsSocket.send).toHaveBeenCalledTimes(1);
expect(pairingSocket.send).toHaveBeenCalledTimes(1);
expect(pairingSocket.send).toHaveBeenCalledTimes(2);
expect(readSocket.send).toHaveBeenCalledTimes(0);
expect(adminSocket.send).toHaveBeenCalledTimes(3);
broadcastToConnIds("tick", { ts: 1 }, new Set(["c-read"]));
broadcastToConnIds("talk.event", { type: "session.ready" }, new Set(["c-read"]));
expect(readSocket.send).toHaveBeenCalledTimes(2);
expect(approvalsSocket.send).toHaveBeenCalledTimes(1);
expect(pairingSocket.send).toHaveBeenCalledTimes(1);
expect(pairingSocket.send).toHaveBeenCalledTimes(2);
expect(adminSocket.send).toHaveBeenCalledTimes(3);
});
it("requires operator.read for chat-class broadcast events", () => {
+5 -1
View File
@@ -9,7 +9,10 @@ import { logRejectedLargePayload } from "../logging/diagnostic-payload.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { queuePluginSessionsChanged } from "../plugins/gateway-events.js";
import { isBrowserCopilotClient } from "../utils/message-channel.js";
import { GATEWAY_EVENT_NODE_RUNNER_INVENTORY_CHANGED } from "./events.js";
import {
GATEWAY_EVENT_DEVICE_PAIR_CHANGED,
GATEWAY_EVENT_NODE_RUNNER_INVENTORY_CHANGED,
} from "./events.js";
import {
ADMIN_SCOPE,
APPROVALS_SCOPE,
@@ -69,6 +72,7 @@ const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
"skills.changed": [READ_SCOPE],
"voicewake.changed": [READ_SCOPE],
"voicewake.routing.changed": [READ_SCOPE],
[GATEWAY_EVENT_DEVICE_PAIR_CHANGED]: [PAIRING_SCOPE],
"device.pair.requested": [PAIRING_SCOPE],
"device.pair.resolved": [PAIRING_SCOPE],
"device.pair.setup.completed": [PAIRING_SCOPE],
+1
View File
@@ -21,6 +21,7 @@ describe("GATEWAY_EVENTS", () => {
it("advertises node topology updates", () => {
expect(GATEWAY_EVENTS).toContain("node.presence");
expect(GATEWAY_EVENTS).toContain("device.pair.setup.completed");
expect(GATEWAY_EVENTS).toContain("device.pair.changed");
expect(GATEWAY_EVENTS).toContain("node.runnerInventory.changed");
});
+2
View File
@@ -2,6 +2,7 @@
// Lists advertised core, auxiliary, channel plugin methods, and websocket events.
import { listLoadedChannelPlugins } from "../channels/plugins/registry-loaded.js";
import {
GATEWAY_EVENT_DEVICE_PAIR_CHANGED,
GATEWAY_EVENT_NODE_RUNNER_INVENTORY_CHANGED,
GATEWAY_EVENT_UPDATE_AVAILABLE,
} from "./events.js";
@@ -71,6 +72,7 @@ export const GATEWAY_EVENTS = [
"node.invoke.cancel",
"node.invoke.input",
"node.invoke.request",
GATEWAY_EVENT_DEVICE_PAIR_CHANGED,
"device.pair.requested",
"device.pair.resolved",
"device.pair.setup.completed",
@@ -1657,6 +1657,11 @@ describe("deviceHandlers", () => {
expect(opts.context.logGateway.info).toHaveBeenCalledWith(
"device pairing renamed device=device-1 label=Kitchen Mac",
);
expect(opts.context.broadcast).toHaveBeenCalledWith(
"device.pair.changed",
{},
{ dropIfSlow: true },
);
});
it("rejects renaming another device from a non-admin device session", async () => {
@@ -1690,6 +1695,7 @@ describe("deviceHandlers", () => {
expect(updatePairedDeviceMetadataMock).toHaveBeenCalledWith("missing-device", {
operatorLabel: "Ghost",
});
expect(opts.context.broadcast).not.toHaveBeenCalled();
expectRespondedErrorMessage(opts, "unknown deviceId");
});
+2
View File
@@ -32,6 +32,7 @@ import {
} from "../../infra/device-pairing.js";
import type { DiagnosticSecurityEventInput } from "../../infra/diagnostic-events.js";
import { reconcileRevokedDeviceWorker } from "../device-worker-revocation.js";
import { GATEWAY_EVENT_DEVICE_PAIR_CHANGED } from "../events.js";
import { clearRemovedNodeRuntimeState } from "../node-runtime-state.js";
import { invalidateNodeWakeState } from "../node-wake-state.js";
import {
@@ -551,6 +552,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
targetDeviceId: deviceId,
controlId: "device.pair.rename",
});
context.broadcast(GATEWAY_EVENT_DEVICE_PAIR_CHANGED, {}, { dropIfSlow: true });
respond(true, { deviceId, label: trimmed }, undefined);
},
"device.token.rotate": async ({ params, respond, context, client }) => {
+56 -18
View File
@@ -6,7 +6,6 @@ import type { PresenceEntry } from "../../api/types.ts";
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
import { t } from "../../i18n/index.ts";
import {
approveDevicePairing,
createInitialDevicesState,
loadDevices,
loadNodes,
@@ -266,12 +265,15 @@ describe("DevicesPage gateway lifecycle", () => {
page.remove();
});
it("coalesces a device refresh requested while an older list is loading", async () => {
it("refetches a changed device label after an older list response", async () => {
const stale = deferred<{
paired: [];
pending: Array<{ requestId: string; deviceId: string }>;
paired: Array<{ deviceId: string; displayName: string }>;
pending: [];
}>();
const refreshed = deferred<{
paired: Array<{ deviceId: string; displayName: string; operatorLabel: string }>;
pending: [];
}>();
const refreshed = deferred<{ paired: []; pending: [] }>();
let listCalls = 0;
const request = vi.fn((method: string) => {
if (method === "device.pair.list") {
@@ -281,23 +283,55 @@ describe("DevicesPage gateway lifecycle", () => {
return Promise.resolve({});
});
const client = { request } as unknown as GatewayBrowserClient;
const state = createInitialDevicesState({ client, connected: true });
const snapshot = {
...gatewaySnapshot(client, true),
hello: {
type: "hello-ok",
protocol: 1,
auth: { role: "operator", scopes: ["operator.pairing"] },
features: { methods: ["device.pair.list"] },
},
} as ApplicationGatewaySnapshot;
let onEvent: ((event: { event: string; payload?: unknown }) => void) | undefined;
const currentGateway = gateway(client, snapshot);
currentGateway.subscribeEvents = vi.fn((listener) => {
onEvent = listener as typeof onEvent;
return () => undefined;
});
const page = document.createElement("openclaw-devices-page") as TestDevicesPage;
page.context = {
gateway: currentGateway,
runtimeConfig: {
state: { configSnapshot: {}, configLoading: false },
subscribe: vi.fn(() => () => undefined),
},
} as unknown as ApplicationContext;
page.pageState = createInitialDevicesState({ client, connected: true });
document.body.append(page);
await vi.waitFor(() => expect(onEvent).toBeDefined());
const initialLoad = loadDevices(state);
const initialLoad = loadDevices(page.pageState);
await vi.waitFor(() => expect(request).toHaveBeenCalledWith("device.pair.list", {}));
const approval = approveDevicePairing(state, "request-1");
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith("device.pair.approve", { requestId: "request-1" }),
);
onEvent?.({ event: "device.pair.changed", payload: {} });
stale.resolve({ paired: [], pending: [{ requestId: "request-1", deviceId: "device-1" }] });
stale.resolve({
paired: [{ deviceId: "device-1", displayName: "Kitchen Mac" }],
pending: [],
});
await vi.waitFor(() => expect(listCalls).toBe(2));
expect(state.devicesLoading).toBe(true);
expect(page.pageState.devicesLoading).toBe(true);
refreshed.resolve({ paired: [], pending: [] });
await Promise.all([initialLoad, approval]);
expect(state.devicesList).toEqual({ paired: [], pending: [] });
expect(state.devicesLoading).toBe(false);
refreshed.resolve({
paired: [{ deviceId: "device-1", displayName: "Kitchen Mac", operatorLabel: "Studio Mac" }],
pending: [],
});
await initialLoad;
expect(page.pageState.devicesList).toEqual({
paired: [{ deviceId: "device-1", displayName: "Kitchen Mac", operatorLabel: "Studio Mac" }],
pending: [],
});
expect(page.pageState.devicesLoading).toBe(false);
page.remove();
});
it("coalesces a node refresh requested while an older list is loading", async () => {
@@ -357,7 +391,7 @@ describe("DevicesPage gateway lifecycle", () => {
expect(request.mock.calls.map(([method]) => method)).not.toContain("exec.approvals.get");
});
it("keeps presence-driven device reloads gated on pairing access", async () => {
it("keeps event-driven device reloads gated on pairing access", async () => {
const request = vi.fn(async (method: string) => (method === "node.list" ? { nodes: [] } : {}));
const client = { request } as unknown as GatewayBrowserClient;
const snapshot = {
@@ -398,6 +432,10 @@ describe("DevicesPage gateway lifecycle", () => {
).toBeGreaterThan(nodeListCallsBefore.length),
);
expect(request.mock.calls.map(([method]) => method)).not.toContain("device.pair.list");
onEvent?.({ event: "device.pair.changed", payload: {} });
await Promise.resolve();
expect(request.mock.calls.map(([method]) => method)).not.toContain("device.pair.list");
page.remove();
});
+6 -1
View File
@@ -2,6 +2,7 @@ import { consume } from "@lit/context";
import { initialState, Task } from "@lit/task";
import { html, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import { GATEWAY_EVENT_DEVICE_PAIR_CHANGED } from "../../../../src/gateway/events.js";
import type { PresenceEntry } from "../../api/types.ts";
import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts";
import {
@@ -166,7 +167,11 @@ class DevicesPage extends OpenClawLightDomElement {
void this.runPageTask((pageState) => loadNodes(pageState, { quiet: true }));
}
}
if (event.event === "device.pair.requested" || event.event === "device.pair.resolved") {
if (
event.event === GATEWAY_EVENT_DEVICE_PAIR_CHANGED ||
event.event === "device.pair.requested" ||
event.event === "device.pair.resolved"
) {
if (this.canManagePairing) {
void this.runPageTask((pageState) => loadDevices(pageState, { quiet: true }));
}