fix(ui): make Control UI ownership and permissions reliable (#115364)

* fix(ui): unify connection-owned gateway event dispatch

* fix(qa): keep hosted UI smoke evidence truthful

* fix(ui): make mobile navigation drawer an accessible modal

* refactor(ui): centralize settings search destinations

* fix(ui): own chat media resource lifecycles

* fix(ui): unify sidebar session scope ownership

* refactor(ui): remove obsolete navigation backdrop styles

* refactor(ui): isolate dashboard provider lease capabilities

* fix(ui): centralize gateway operator permissions

* style(ui): format gateway event regressions

* fix(ui): render live dashboard permissions immediately

* fix(ui): enforce approval grant authority

* fix(gateway): fence retired sockets after event gaps

* fix(ui): isolate sidebar gateway fixtures across shared workers

* test(ui): isolate singleton-sensitive browser suites

* fix(ui): retire stale catalog pagination on gateway reconnect

* fix(ui): restore drawer focus after native dialog dismissal

* fix(ui): seed drawer threads and preserve modal focus ownership

* fix(ui): isolate nested native drawer overlay lifecycle

* test(ui): prove isolated dashboard lease authorization

* fix(ui): bound settled managed image resources

* fix(ui): type native modal focus and shared test helper

* fix(ui): correct approval mock types and trim dead export

* fix(ui): keep settings section metadata private

* fix(qa): keep hosted coverage regression within lint limit

* refactor(ui): keep catalog presence in its session data owner

* fix(browser): regenerate bundled copilot runtime

* test(ui): split board lease authorization regressions

* fix(ui): snapshot gateway listener fanout without redundant spread

* fix(ui): bind chat media subscriptions to image lifetime

* fix(ui): extract overlay access lifecycle

* fix(ui): restore guarded media after Lit reconnection

* test(ui): localize board lease fixture title

* fix(ui): reuse system approval fixture labels

* test(ui): narrow managed media lifecycle source

* fix(ui): prevent retired board gateway clients from rolling back leases

* fix(ui): expose pairing to pairing-scoped operators

* fix(ui): preserve legacy device pairing access

* fix(ui): preserve responsive sidebar ownership

* fix(ui): preserve responsive navigation and prove media ownership

* fix(ui): retire revoked catalog and refresh pairing grants

* test(ui): align typed catalog fixtures with main
This commit is contained in:
Peter Steinberger
2026-07-28 16:42:05 -04:00
committed by GitHub
parent 9e5e6f961e
commit d239a59d82
63 changed files with 5439 additions and 1015 deletions
File diff suppressed because one or more lines are too long
+27 -1
View File
@@ -97,7 +97,7 @@ describe("createQaSmokeCiPart", () => {
expect(new Set(scenarioIds)).toEqual(new Set(smokeProfileScenarioIds));
expect(
new Set(scenarioIds.map((scenarioId) => scenarioById.get(scenarioId)?.execution.kind)),
).toEqual(new Set(["flow", "playwright", "script"]));
).toEqual(new Set(smokeSelection.scenarios.map((scenario) => scenario.execution.kind)));
const selectedScenarioPaths = new Set(
scenarioIds.map((scenarioId) => scenarioById.get(scenarioId)?.sourcePath),
@@ -148,6 +148,32 @@ describe("createQaSmokeCiPart", () => {
expect(primaryScenarioIds.every((ids) => ids.length > 0)).toBe(true);
});
it("keeps real Gateway-hosted proof outside the Crabline smoke profile", () => {
const coverageId = "control-ui.gateway-hosted-ui-control";
const smokeSelection = resolveQaProfileScenarios({
profile: "smoke-ci",
providerMode: "mock-openai",
eligibleChannels: ["telegram", "matrix"],
});
const hostedScenario = expectDefined(
readQaScenarioPack().scenarios.find(
(scenario) => scenario.id === "control-ui-qa-channel-image-roundtrip",
),
"real Gateway-hosted Control UI scenario",
);
expect(smokeSelection.profile.channelDriver).toBe("crabline");
expect(smokeSelection.profile.coverageIds).not.toContain(coverageId);
expect(smokeSelection.scenarios.map((scenario) => scenario.id)).not.toContain(
hostedScenario.id,
);
expect(
smokeSelection.scenarios.flatMap((scenario) => scenario.coverage?.primary ?? []),
).not.toContain(coverageId);
expect(hostedScenario.execution).toMatchObject({ kind: "flow", channel: "qa-channel" });
expect(hostedScenario.coverage?.primary).toContain(coverageId);
});
it("rejects undeclared profile parts", () => {
expect(() => createQaSmokeCiPart("profile-5")).toThrow(
"unknown QA smoke CI profile part: profile-5",
+37 -8
View File
@@ -231,13 +231,31 @@ describe("qa coverage report", () => {
inventory.scorecardTaxonomy.categories.find(
(category) => category.id === TEST_BROWSER_CATEGORY_ID,
)?.inventoryRefs,
).toContainEqual({
coverageId: TEST_BROWSER_COVERAGE_ID,
kind: "playwright",
path: "ui/src/e2e/chat-flow.messaging.e2e.test.ts",
role: "primary",
scenarioRefs: ["qa/scenarios/ui/control-ui-chat-flow-playwright.yaml"],
});
).toEqual(
expect.arrayContaining([
{
coverageId: TEST_BROWSER_COVERAGE_ID,
kind: "qa-scenario",
path: null,
role: "primary",
scenarioRefs: ["qa/scenarios/ui/control-ui-qa-channel-image-roundtrip.yaml"],
},
{
coverageId: TEST_BROWSER_COVERAGE_ID,
kind: "playwright",
path: "ui/src/e2e/chat-flow.messaging.e2e.test.ts",
role: "secondary",
scenarioRefs: ["qa/scenarios/ui/control-ui-chat-flow-playwright.yaml"],
},
{
coverageId: TEST_BROWSER_COVERAGE_ID,
kind: "playwright",
path: "ui/src/e2e/plan-replay-reconnect.e2e.test.ts",
role: "secondary",
scenarioRefs: ["qa/scenarios/ui/control-ui-plan-replay-reconnect.yaml"],
},
]),
);
expect(
expectDefined(inventory.byTheme.memory, "memory QA theme").map((coverage) => coverage.id),
).toContain("session-memory.memory-recall");
@@ -371,8 +389,19 @@ describe("qa coverage report", () => {
"- tools.tool-invocation-and-execution (tools / Tool Invocation and Execution; partial): profiles: all, release; coverage IDs:",
);
expect(report).toContain(
"primary:playwright:ui/src/e2e/chat-flow.messaging.e2e.test.ts (control-ui.gateway-hosted-ui-control)",
"primary:qa-scenario:qa/scenarios/ui/control-ui-qa-channel-image-roundtrip.yaml (control-ui.gateway-hosted-ui-control)",
);
for (const executionPath of [
"ui/src/e2e/chat-flow.messaging.e2e.test.ts",
"ui/src/e2e/plan-replay-reconnect.e2e.test.ts",
]) {
expect(report).toContain(
`secondary:playwright:${executionPath} (${TEST_BROWSER_COVERAGE_ID})`,
);
expect(report).not.toContain(
`primary:playwright:${executionPath} (${TEST_BROWSER_COVERAGE_ID})`,
);
}
expect(report).not.toContain("### Unknown Scenario Coverage IDs");
});
+22 -1
View File
@@ -321,7 +321,7 @@ describe("qa scenario catalog", () => {
"sends a chat turn through the GUI and renders the final Gateway event",
);
expect(scenario.execution.flow).toBeUndefined();
expect(scenario.coverage?.primary).toContain(`${browserUi}.gateway-hosted-ui-control`);
expect(scenario.coverage?.secondary).toContain(`${browserUi}.gateway-hosted-ui-control`);
expect(otelSmoke.execution.kind).toBe("script");
if (otelSmoke.execution.kind !== "script") {
throw new Error(`expected script scenario, got ${otelSmoke.execution.kind}`);
@@ -335,6 +335,27 @@ describe("qa scenario catalog", () => {
expect(otelSmoke.coverage?.secondary).not.toContain(`${otel}.otlp-http-traces-qa-lab`);
});
it("reserves Gateway-hosted Control UI proof for the real Gateway flow", () => {
const coverageId = `${browserUi}.gateway-hosted-ui-control`;
const primaryOwnerIds = readQaScenarioPack()
.scenarios.filter((scenario) => scenario.coverage?.primary.includes(coverageId))
.map((scenario) => scenario.id);
expect(primaryOwnerIds).toStrictEqual(["control-ui-qa-channel-image-roundtrip"]);
for (const scenario of [
readQaScenarioById("control-ui-chat-flow-playwright"),
readQaScenarioById("control-ui-plan-replay-reconnect"),
]) {
expect(scenario.execution.kind, scenario.id).toBe("playwright");
expect(scenario.coverage?.primary, scenario.id).not.toContain(coverageId);
expect(scenario.coverage?.secondary, scenario.id).toContain(coverageId);
}
const hostedScenario = readQaScenarioById("control-ui-qa-channel-image-roundtrip");
expect(hostedScenario.execution).toMatchObject({ kind: "flow", channel: "qa-channel" });
expect(hostedScenario.coverage?.primary).toContain(coverageId);
});
it("loads helper-backed HTTP API scenarios as supporting taxonomy coverage", () => {
expect(readQaScenarioById("openai-compatible-chat-tools").coverage?.secondary).toStrictEqual([
"gateway.openai-compatible-apis",
@@ -1,6 +1,7 @@
// Gateway Client tests cover client.watchdog behavior.
import { createServer as createHttpsServer } from "node:https";
import { createServer } from "node:net";
import type { EventFrame } from "@openclaw/gateway-protocol";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WebSocket, WebSocketServer } from "ws";
import { GatewayClient } from "./client.js";
@@ -132,6 +133,8 @@ type SyntheticGatewayProtocolConnection = {
function createSyntheticGatewayProtocol(options?: {
retryOnClose?: boolean;
initialSocketFactoryFailures?: number;
onEvent?: (event: EventFrame) => void;
onGap?: (info: { expected: number; received: number }) => void;
}): {
client: GatewayProtocolClient<Record<string, never>>;
connections: SyntheticGatewayProtocolConnection[];
@@ -162,6 +165,8 @@ function createSyntheticGatewayProtocol(options?: {
buildConnectPlan: () => ({}),
buildConnectParams: (plan) => plan,
resolveClose: () => ({ retry: options?.retryOnClose ?? true, notify: true }),
onEvent: options?.onEvent,
onGap: options?.onGap,
handshake: { mode: "require-challenge", timeoutMs: 100 },
reconnect: { initialMs: 10, multiplier: 2, maxMs: 100 },
});
@@ -218,6 +223,109 @@ describe("GatewayClient", () => {
}
});
test.each([
{ recovery: "stops the socket", restart: false },
{ recovery: "replaces the socket", restart: true },
])("drops a gapped frame when recovery $recovery", ({ restart }) => {
const onEvent = vi.fn();
const listener = vi.fn();
const onGap = vi.fn(() => {
client.stop();
if (restart) {
client.start();
}
});
const { client, connections } = createSyntheticGatewayProtocol({ onEvent, onGap });
client.addEventListener(listener);
client.start();
const first = connections[0];
if (!first) {
throw new Error("synthetic protocol connection missing");
}
first.handlers.message(
JSON.stringify({ type: "event", event: "board.changed", payload: {}, seq: 1 }),
);
onEvent.mockClear();
listener.mockClear();
first.handlers.message(
JSON.stringify({
type: "event",
event: "board.command",
payload: { command: "stale" },
seq: 3,
}),
);
expect(onGap).toHaveBeenCalledExactlyOnceWith({ expected: 2, received: 3 });
expect(onEvent).not.toHaveBeenCalled();
expect(listener).not.toHaveBeenCalled();
expect(connections).toHaveLength(restart ? 2 : 1);
if (restart) {
const replacement = connections[1];
if (!replacement) {
throw new Error("synthetic replacement protocol connection missing");
}
const fresh = {
type: "event" as const,
event: "board.command",
payload: { command: "current" },
seq: 2,
};
replacement.handlers.message(JSON.stringify(fresh));
expect(onGap).toHaveBeenCalledOnce();
expect(onEvent).toHaveBeenCalledExactlyOnceWith(fresh);
expect(listener).toHaveBeenCalledExactlyOnceWith(fresh);
}
client.stop();
});
test("delivers a gapped frame when gap recovery retains the active socket", () => {
const onEvent = vi.fn();
const onGap = vi.fn();
const listener = vi.fn();
const { client, connections } = createSyntheticGatewayProtocol({ onEvent, onGap });
client.addEventListener(listener);
client.start();
const connection = connections[0];
if (!connection) {
throw new Error("synthetic protocol connection missing");
}
connection.handlers.message(
JSON.stringify({ type: "event", event: "board.changed", payload: {}, seq: 1 }),
);
onEvent.mockClear();
listener.mockClear();
const gapped = {
type: "event" as const,
event: "board.command",
payload: { command: "current" },
seq: 3,
};
connection.handlers.message(JSON.stringify(gapped));
expect(onGap).toHaveBeenCalledExactlyOnceWith({ expected: 2, received: 3 });
expect(onEvent).toHaveBeenCalledExactlyOnceWith(gapped);
expect(listener).toHaveBeenCalledExactlyOnceWith(gapped);
const next = {
type: "event" as const,
event: "board.changed",
payload: {},
seq: 4,
};
connection.handlers.message(JSON.stringify(next));
expect(onGap).toHaveBeenCalledOnce();
expect(onEvent).toHaveBeenLastCalledWith(next);
expect(listener).toHaveBeenLastCalledWith(next);
client.stop();
});
test("keeps one socket when the protocol is started twice during its handshake", () => {
const { client, connections } = createSyntheticGatewayProtocol();
@@ -554,6 +554,11 @@ export class GatewayProtocolClient<TPlan> {
if (this.lastSeq !== null && seq > this.lastSeq + 1) {
const expected = this.lastSeq + 1;
this.invoke("gap", () => this.opts.onGap?.({ expected, received: seq }));
// Gap recovery can retire this socket synchronously. Never advance a
// replacement's sequence or dispatch a frame from the retired owner.
if (!this.isActive(socket, generation)) {
return;
}
}
this.lastSeq = seq;
}
@@ -4,7 +4,7 @@ scenario:
id: control-ui-chat-flow-playwright
surface: control-ui
coverage:
primary:
secondary:
- control-ui.gateway-hosted-ui-control
objective: Smoke-test a Control UI chat turn through the hosted browser surface.
successCriteria:
@@ -4,7 +4,7 @@ scenario:
id: control-ui-plan-replay-reconnect
surface: control-ui
coverage:
primary:
secondary:
- control-ui.gateway-hosted-ui-control
objective: >-
Prove a client that connects or refreshes mid-run renders the active plan
-1
View File
@@ -19,7 +19,6 @@ profiles:
- agent-runtime.tool-task-followthrough
- channels.outbound-group-reply
- channels.resumed-final-reply
- control-ui.gateway-hosted-ui-control
- gateway.health-apis
- gateway.hello-ok-snapshot
- gateway.websocket-transport
+3
View File
@@ -2,6 +2,9 @@
// Tests in this list depend on module singletons or custom-element registration
// matching the current registry, so they need a fresh graph in the isolated lane.
export const uiIsolatedTestFiles = [
"ui/src/app/bootstrap.test.ts",
"ui/src/app/router-outlet.test.ts",
"ui/src/components/viewer-facepile.test.ts",
"ui/src/pages/chat/chat-pane-history.test.ts",
"ui/src/pages/chat/chat-pane-identity.test.ts",
"ui/src/pages/chat/chat-pane-lifecycle.test.ts",
+140
View File
@@ -0,0 +1,140 @@
/* @vitest-environment jsdom */
import { render, type TemplateResult } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { ApplicationRuntime } from "./bootstrap.ts";
import type { ApplicationContext, ApplicationGatewaySnapshot } from "./context.ts";
import "./app-host.ts";
type PairingShell = HTMLElement & {
runtime?: ApplicationRuntime;
render: () => TemplateResult;
};
type PairingSidebar = HTMLElement & {
canPairDevice: boolean;
onPairMobile?: () => void;
};
type PairingAuth = { role: string; scopes?: string[] };
function createPairingShell(params: { auth: PairingAuth | null; connected?: boolean }) {
const snapshot: ApplicationGatewaySnapshot = {
client: { request: vi.fn(async () => ({})) } as unknown as GatewayBrowserClient,
phase: params.connected === false ? "stopped" : "connected",
offlineStable: false,
canvasPluginSurfaceUrl: null,
hello: params.auth ? ({ auth: params.auth } as ApplicationGatewaySnapshot["hello"]) : null,
assistantAgentId: "main",
sessionKey: "main",
lastError: null,
lastErrorCode: null,
};
const openDevicePairSetup = vi.fn(async () => undefined);
const context = {
basePath: "",
gateway: {
snapshot,
connection: { gatewayUrl: "ws://gateway.test", token: "", password: "" },
},
navigation: {
snapshot: { navCollapsed: false, navWidth: 258, sidebarEntries: [], pinnedAgentIds: [] },
},
overlays: {
snapshot: {
approvalQueue: [],
approvalErrors: new Map(),
approvalNowMs: 0,
approvalBusy: false,
devicePairSetupOpen: false,
devicePairSetupLoading: false,
devicePairSetupError: null,
devicePairSetup: null,
devicePairSetupAccess: "full",
devicePairPendingCount: 0,
updateAvailable: null,
updateRunning: false,
updateStatusBanner: null,
controlUiRefreshRequired: false,
},
openDevicePairSetup,
},
config: { current: {} },
runtimeConfig: {
state: { configSnapshot: null, configForm: null, configSchema: null, configUiHints: {} },
},
agents: { state: { agentsList: null } },
agentSelection: { state: { selectedId: "main", scopeId: "main" } },
sessions: { state: { result: null } },
theme: { mode: "system" },
} as unknown as ApplicationContext;
const shell = document.createElement("openclaw-app-shell") as PairingShell;
shell.runtime = { context, router: {} } as ApplicationRuntime;
const container = document.createElement("div");
const renderSidebar = () => {
render(shell.render(), container);
const sidebar = container.querySelector<PairingSidebar>("openclaw-app-sidebar");
if (!sidebar) {
throw new Error("Expected the application shell to render its navigation sidebar");
}
return sidebar;
};
return { snapshot, openDevicePairSetup, renderSidebar };
}
afterEach(() => {
document.body.replaceChildren();
vi.restoreAllMocks();
});
describe("application shell pairing access", () => {
it.each([
{
name: "pairing-only",
auth: { role: "operator", scopes: ["operator.pairing"] },
canPair: true,
},
{
name: "administrator",
auth: { role: "operator", scopes: ["operator.admin"] },
canPair: true,
},
{ name: "legacy authenticated", auth: { role: "operator" }, canPair: true },
{ name: "legacy unadvertised", auth: null, canPair: true },
{ name: "read-only", auth: { role: "operator", scopes: ["operator.read"] }, canPair: false },
{ name: "write-only", auth: { role: "operator", scopes: ["operator.write"] }, canPair: false },
{ name: "explicitly ungranted", auth: { role: "operator", scopes: [] }, canPair: false },
])("gates the sidebar pairing entry for a $name operator", ({ auth, canPair }) => {
const { renderSidebar } = createPairingShell({ auth });
expect(renderSidebar().canPairDevice).toBe(canPair);
});
it("keeps the pairing entry accessible after admin becomes pairing-only", () => {
const { snapshot, openDevicePairSetup, renderSidebar } = createPairingShell({
auth: { role: "operator", scopes: ["operator.admin"] },
});
expect(renderSidebar().canPairDevice).toBe(true);
snapshot.hello = {
auth: { role: "operator", scopes: ["operator.pairing"] },
} as ApplicationGatewaySnapshot["hello"];
const sidebar = renderSidebar();
expect(sidebar.canPairDevice).toBe(true);
sidebar.onPairMobile?.();
expect(openDevicePairSetup).toHaveBeenCalledOnce();
});
it("keeps the pairing entry disabled while the gateway is disconnected", () => {
const { renderSidebar } = createPairingShell({
auth: { role: "operator", scopes: ["operator.pairing"] },
connected: false,
});
expect(renderSidebar().canPairDevice).toBe(false);
});
});
+68
View File
@@ -180,6 +180,15 @@ type ShellChromeEventState = {
disconnectedCallback: () => void;
};
type ShellNavDrawerCloseState = HTMLElement &
ShellChromeEventState & {
desktopNavigationExpanded: boolean;
navDrawerTrigger: HTMLElement | null;
closeNavDrawer: (options?: { restoreFocus?: boolean }) => void;
handleWindowResize: () => void;
toggleNavigationSurface: () => void;
};
function createDragEvent(type: "dragover" | "drop", types: string[]) {
const event = new Event(type, { bubbles: true, cancelable: true }) as DragEvent;
const dataTransfer = { dropEffect: "copy", types };
@@ -743,6 +752,65 @@ describe("OpenClaw shell keyboard shortcuts", () => {
}
});
it("suppresses modal focus restoration when the navigation drawer closes without restoring focus", () => {
const shell = document.createElement("openclaw-app-shell") as ShellNavDrawerCloseState;
const modal = document.createElement("openclaw-modal-dialog");
const setReturnFocusTarget = vi.fn();
modal.className = "drawer nav-drawer";
Object.defineProperty(modal, "setReturnFocusTarget", { value: setReturnFocusTarget });
shell.append(modal);
shell.navDrawerOpen = true;
shell.navDrawerTrigger = document.createElement("button");
shell.closeNavDrawer();
expect(setReturnFocusTarget).toHaveBeenCalledExactlyOnceWith(null);
expect(shell.navDrawerOpen).toBe(false);
expect(shell.navDrawerTrigger).toBeNull();
});
it("closes an open navigation drawer before moving its sidebar into desktop layout", () => {
vi.stubGlobal("matchMedia", () => ({ matches: false }));
const shell = document.createElement("openclaw-app-shell") as ShellNavDrawerCloseState;
const updateNavigation = vi.fn();
shell.runtime = {
context: {
navigation: {
snapshot: { navCollapsed: true },
update: updateNavigation,
},
} as unknown as ApplicationContext,
};
const sidebar = document.createElement("openclaw-app-sidebar");
const dismissTransientMenus = vi.fn(() => true);
Object.defineProperty(sidebar, "dismissTransientMenus", { value: dismissTransientMenus });
const modal = document.createElement("openclaw-modal-dialog");
const setReturnFocusTarget = vi.fn();
modal.className = "drawer nav-drawer";
Object.defineProperty(modal, "setReturnFocusTarget", { value: setReturnFocusTarget });
shell.append(sidebar, modal);
const trigger = document.body.appendChild(document.createElement("button"));
const restoreTriggerFocus = vi.spyOn(trigger, "focus");
const closeNavDrawer = vi.spyOn(shell, "closeNavDrawer");
shell.navDrawerOpen = true;
shell.navDrawerTrigger = trigger;
shell.handleWindowResize();
expect(closeNavDrawer).toHaveBeenCalledExactlyOnceWith({ restoreFocus: false });
expect(dismissTransientMenus).toHaveBeenCalledOnce();
expect(setReturnFocusTarget).toHaveBeenCalledExactlyOnceWith(null);
expect(restoreTriggerFocus).not.toHaveBeenCalled();
expect(shell.navDrawerOpen).toBe(false);
expect(shell.navDrawerTrigger).toBeNull();
expect(updateNavigation).not.toHaveBeenCalled();
expect(shell.desktopNavigationExpanded).toBe(true);
shell.toggleNavigationSurface();
expect(updateNavigation).toHaveBeenCalledExactlyOnceWith({ navCollapsed: true });
expect(shell.desktopNavigationExpanded).toBe(false);
trigger.remove();
});
it("handles merged header drawer and palette requests", () => {
vi.stubGlobal(
"matchMedia",
+138 -101
View File
@@ -13,17 +13,18 @@ import "../components/gateway-url-confirmation.ts";
import "../components/github-link-hovercard-registration.ts";
import "../components/login-gate.ts";
import "../components/macos-titlebar-controls.ts";
import "../components/modal-dialog.ts";
import {
formatDocumentTitle,
isSettingsNavigationRoute,
titleForRoute,
} from "../app-navigation.ts";
import "../components/onboarding-memory-import.ts";
import "../components/openclaw-mascot.ts";
import "../components/resizable-divider.ts";
import "../components/sidebar-update-card.ts";
import "../components/tooltip.ts";
import "../components/update-banner.ts";
import {
formatDocumentTitle,
isSettingsNavigationRoute,
titleForRoute,
} from "../app-navigation.ts";
import { isSessionRouteId, workboardBoardIdFromPath } from "../app-route-paths.ts";
import { APP_ROUTE_IDS, isRouteId, type RouteId } from "../app-routes.ts";
import {
@@ -42,6 +43,7 @@ import {
type ShellNavDrawerToggleDetail,
} from "../components/command-palette-contract.ts";
import { icons } from "../components/icons.ts";
import type { OpenClawModalDialog } from "../components/modal-dialog.ts";
import {
BROWSER_PANEL_TOGGLE_EVENT,
CUSTODIAN_PANEL_TOGGLE_EVENT,
@@ -112,7 +114,7 @@ import {
} from "./native-web-chrome.ts";
import { navigationSurfaceIsHidden, renderFloatingUpdateCard } from "./navigation-surface.ts";
import { resolveOnboardingMode } from "./onboarding-mode.ts";
import { hasOperatorAdminAccess } from "./operator-access.ts";
import { hasOperatorAdminAccess, readGatewayOperatorAccess } from "./operator-access.ts";
import { controlUiPublicAssetPath } from "./public-assets.ts";
import {
applyServerUiPrefs,
@@ -515,6 +517,7 @@ class OpenClawShell extends OpenClawLightDomElement {
@property({ attribute: false }) onboarding = false;
@state() private navDrawerOpen = false;
@state() private desktopNavigationExpanded = false;
@state() private activeSessionKey = "";
@state() private settingsSearchQuery = "";
@state() private routeState: ShellRouteState = {};
@@ -529,6 +532,12 @@ class OpenClawShell extends OpenClawLightDomElement {
private approvalOverlay?: HTMLElement & { show(): void };
private commandPaletteTarget?: CommandPaletteTargetDetail;
private navDrawerTrigger: HTMLElement | null = null;
// Desktop and modal navigation are two slots for the same live sidebar.
// Moving its element preserves session controllers and the resident pet
// instead of resetting their lifecycle at every responsive breakpoint.
private readonly navigationSidebar = document.createElement(
"openclaw-app-sidebar",
) as AppSidebarElement;
// Where "Back to app" / Escape leaves the settings takeover; falls back to
// chat (the app default route) when settings was the entry point.
private lastWorkspaceLocation: { routeId: RouteId; pathname: string; search: string } | null =
@@ -799,6 +808,7 @@ class OpenClawShell extends OpenClawLightDomElement {
private resetShellEpochState() {
this.navDrawerOpen = false;
this.desktopNavigationExpanded = false;
this.navDrawerTrigger = null;
this.lastWorkspaceLocation = null;
this.activeSessionKey = "";
@@ -891,6 +901,7 @@ class OpenClawShell extends OpenClawLightDomElement {
return;
}
if (command.kind === "sidebar") {
this.desktopNavigationExpanded = false;
context.navigation.update({ navCollapsed: !command.visible });
return;
}
@@ -1056,9 +1067,12 @@ class OpenClawShell extends OpenClawLightDomElement {
this.navDrawerOpen = true;
return;
}
// A drawer that survived a breakpoint change is visually expanded even
// when the persisted desktop preference says collapsed.
const nextNavCollapsed = this.navDrawerOpen || !context.navigation.snapshot.navCollapsed;
// A responsive drawer handoff can expand this shell without rewriting the
// stored desktop preference; toggle the surface the user actually sees.
const nextNavCollapsed =
this.navDrawerOpen ||
!(context.navigation.snapshot.navCollapsed && !this.desktopNavigationExpanded);
this.desktopNavigationExpanded = false;
if (nextNavCollapsed) {
this.dismissSidebarTransientMenus();
}
@@ -1095,6 +1109,15 @@ class OpenClawShell extends OpenClawLightDomElement {
this.dismissSidebarTransientMenus();
}
const trigger = options.restoreFocus ? this.navDrawerTrigger : null;
const returnFocusTarget =
options.restoreFocus && trigger?.isConnected && trigger.checkVisibility()
? trigger
: options.restoreFocus
? this.querySelector<HTMLElement>(".content")
: null;
this.querySelector<OpenClawModalDialog>(
"openclaw-modal-dialog.nav-drawer",
)?.setReturnFocusTarget(returnFocusTarget ?? null);
this.navDrawerOpen = false;
this.navDrawerTrigger = null;
if (!options.restoreFocus) {
@@ -1164,9 +1187,22 @@ class OpenClawShell extends OpenClawLightDomElement {
};
private readonly handleWindowResize = () => {
const mobileNavLayout = isMobileNavLayout();
// Clean up the old navigation surface before the shared sidebar moves;
// otherwise menus survive the breakpoint or the closed drawer reopens.
const dismissedSidebarMenus =
mobileNavLayout && !this.navDrawerOpen && this.dismissSidebarTransientMenus();
if (mobileNavLayout) {
this.desktopNavigationExpanded = false;
} else if (this.navDrawerOpen) {
this.closeNavDrawer({ restoreFocus: false });
// Keep the drawer visibly expanded in this shell without changing the
// user's persisted desktop-collapse preference.
this.desktopNavigationExpanded = this.context?.navigation.snapshot.navCollapsed ?? false;
}
this.requestUpdate();
void this.updateComplete.then(() => {
if (isMobileNavLayout() && !this.navDrawerOpen && this.dismissSidebarTransientMenus()) {
if (isMobileNavLayout() && !this.navDrawerOpen && dismissedSidebarMenus) {
requestAnimationFrame(() => {
this.restoreFocusTo(this.visibleNavDrawerToggle());
});
@@ -1203,14 +1239,6 @@ class OpenClawShell extends OpenClawLightDomElement {
);
}
private readonly handleShellKeydown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.key !== "Escape" || !this.navDrawerOpen) {
return;
}
event.preventDefault();
this.closeNavDrawer({ restoreFocus: true });
};
private readonly handleDocumentKeydown = (event: KeyboardEvent) => {
if (!this.commandPalette && isCommandPaletteShortcut(event)) {
event.preventDefault();
@@ -1408,7 +1436,9 @@ class OpenClawShell extends OpenClawLightDomElement {
this.onboardingMode ||
mobileNavLayout ||
(this.isSettingsTakeover() && !mobileNavLayout) ||
(!this.navDrawerOpen && (this.context?.navigation.snapshot.navCollapsed ?? false))
(!this.navDrawerOpen &&
!this.desktopNavigationExpanded &&
(this.context?.navigation.snapshot.navCollapsed ?? false))
);
}
@@ -1727,6 +1757,7 @@ class OpenClawShell extends OpenClawLightDomElement {
}
const gatewaySnapshot = context.gateway.snapshot;
const gatewayConnected = gatewaySnapshot.phase === "connected";
const operatorAccess = readGatewayOperatorAccess(gatewaySnapshot);
const outboxScopeHost = this.storedOutboxScopeHost(context);
const outboxStoreRuntime = this.outboxStoreRuntime;
const storedOutboxes = outboxStoreRuntime
@@ -1780,7 +1811,11 @@ class OpenClawShell extends OpenClawLightDomElement {
// Drawer navigation always opens expanded; the desktop collapse preference
// stays persisted for when the viewport returns to the desktop layout.
// The settings sidebar has a fixed width, so the collapse state pauses too.
const navCollapsed = navigationSnapshot.navCollapsed && !navDrawerOpen && !settingsTakeover;
const navCollapsed =
navigationSnapshot.navCollapsed &&
!this.desktopNavigationExpanded &&
!navDrawerOpen &&
!settingsTakeover;
const navigationSurfaceHidden = navigationSurfaceIsHidden({
navCollapsed,
navDrawerOpen,
@@ -1802,6 +1837,77 @@ class OpenClawShell extends OpenClawLightDomElement {
const inlineApproval = isSessionRouteId(activeRoute)
? findInlineApproval(overlaySnapshot.approvalQueue, this.activeSessionKey)
: null;
if (!settingsTakeover) {
Object.assign(this.navigationSidebar, {
basePath: context.basePath,
activeRouteId: activeRoute,
activePluginTabId,
enabledRouteIds: this.enabledRouteIds(),
activeWorkboardBoardId:
workboardBoardIdFromPath(this.routeState.location?.pathname ?? "", context.basePath) ??
"",
sessionKey: this.activeSessionKey,
connected: gatewayConnected,
offline: gatewaySnapshot.offlineStable,
outboxCountForSession,
terminalAvailable,
catalogOpenTarget: normalizeCatalogOpenTarget(uiSettings.catalogOpenTarget),
canPairDevice: gatewayConnected && (operatorAccess.canAdmin || operatorAccess.canPair),
sidebarEntries: navigationSnapshot.sidebarEntries,
workboardBoards: this.sidebarWorkboardSnapshot.boards,
workboardBoardsReady: this.sidebarWorkboardSnapshot.ready,
workboardRenderers: this.sidebarWorkboardRenderers,
sidebarLiveActivity: uiSettings.sidebarLiveActivity !== false,
pinnedAgentIds: navigationSnapshot.pinnedAgentIds,
themeMode: context.theme.mode,
lobsterPetVisits: uiSettings.lobsterPetVisits !== false,
lobsterPetSounds: uiSettings.lobsterPetSounds === true,
gatewayVersion:
context.config.current.serverVersion ?? gatewaySnapshot.hello?.server?.version ?? null,
devGitBranch: context.config.current.devGitBranch,
updateAvailable: navigationSurfaceHidden ? null : overlaySnapshot.updateAvailable,
updateRunning: overlaySnapshot.updateRunning,
onUpdate: () => void context.overlays.runUpdate(),
onOpenApprovals: this.openApprovals,
onRetryConnect: () => context.gateway.connect(),
onOpenNewSession: (agentId: string, target?: NewSessionTarget) =>
this.openNewSession(agentId, target),
draftSessionAgentId: this.draftSessionAgentId(),
onUpdateSidebarEntries: (entries: string[]) =>
context.navigation.update({ sidebarEntries: entries }),
onPairMobile: () => void context.overlays.openDevicePairSetup(),
onNavigate: (routeId: string, options?: ApplicationNavigationOptions) =>
this.navigate(routeId, options),
onPreloadRoute: (routeId: string) =>
isRouteId(routeId) ? context.preload(routeId) : Promise.resolve(),
});
}
const navigationContent = settingsTakeover
? renderSettingsSidebar({
basePath: context.basePath,
activeRouteId: activeRoute,
activeSearch: this.routeState.location?.search ?? "",
activeHash: this.routeState.location?.hash ?? "",
offline: gatewaySnapshot.offlineStable,
queuedOutboxCount: storedOutboxes?.total ?? 0,
lastError: gatewaySnapshot.lastError,
version:
context.config.current.serverVersion ?? gatewaySnapshot.hello?.server?.version ?? "",
updateAvailable: navigationSurfaceHidden ? null : overlaySnapshot.updateAvailable,
updateRunning: overlaySnapshot.updateRunning,
onUpdate: () => void context.overlays.runUpdate(),
searchQuery: this.settingsSearchQuery,
searchBlockMatches: settingsSearchBlocks,
onExit: () => this.exitSettings(),
onRetryConnect: () => context.gateway.connect(),
onNavigate: (routeId, options) => this.navigate(routeId, options),
onPreload: (routeId) => context.preload(routeId),
onSearchQueryChange: (nextQuery) => {
void this.handleSettingsSearchQueryChange(nextQuery);
},
preloadTimers: this.settingsPreloadTimers,
})
: this.navigationSidebar;
// Optional tags stay mounted before definition. Lit replays their properties on upgrade,
// and the upgraded panels catch the first toggle instead of dropping the event.
return html`
@@ -1821,17 +1927,9 @@ class OpenClawShell extends OpenClawLightDomElement {
? "shell--onboarding"
: ""} ${settingsTakeover ? "shell--settings" : ""}"
style=${`--shell-nav-expanded-width: ${navigationSnapshot.navWidth}px`}
@keydown=${this.handleShellKeydown}
@theme-change=${this.handleThemeChange}
>
<a class="shell-skip-link" href="#control-ui-main"> ${t("common.skipToMainContent")} </a>
<button
type="button"
class="shell-nav-backdrop"
aria-label=${t("nav.close")}
?inert=${!mobileNavLayout || !navDrawerOpen}
@click=${() => this.closeNavDrawer({ restoreFocus: true })}
></button>
${isNativeWebChromeHost() && !onboarding
? html`
<openclaw-macos-titlebar-controls
@@ -1902,79 +2000,18 @@ class OpenClawShell extends OpenClawLightDomElement {
`
: nothing}
<div class="shell-nav" ?inert=${navigationSurfaceHidden}>
${settingsTakeover
? renderSettingsSidebar({
basePath: context.basePath,
activeRouteId: activeRoute,
activeSearch: this.routeState.location?.search ?? "",
activeHash: this.routeState.location?.hash ?? "",
offline: gatewaySnapshot.offlineStable,
queuedOutboxCount: storedOutboxes?.total ?? 0,
lastError: gatewaySnapshot.lastError,
version:
context.config.current.serverVersion ??
gatewaySnapshot.hello?.server?.version ??
"",
updateAvailable: navigationSurfaceHidden ? null : overlaySnapshot.updateAvailable,
updateRunning: overlaySnapshot.updateRunning,
onUpdate: () => void context.overlays.runUpdate(),
searchQuery: this.settingsSearchQuery,
searchBlockMatches: settingsSearchBlocks,
onExit: () => this.exitSettings(),
onRetryConnect: () => context.gateway.connect(),
onNavigate: (routeId, options) => this.navigate(routeId, options),
onPreload: (routeId) => context.preload(routeId),
onSearchQueryChange: (nextQuery) => {
void this.handleSettingsSearchQueryChange(nextQuery);
},
preloadTimers: this.settingsPreloadTimers,
})
: html`<openclaw-app-sidebar
.basePath=${context.basePath}
.activeRouteId=${activeRoute}
.activePluginTabId=${activePluginTabId}
.enabledRouteIds=${this.enabledRouteIds()}
.activeWorkboardBoardId=${workboardBoardIdFromPath(
this.routeState.location?.pathname ?? "",
context.basePath,
) ?? ""}
.sessionKey=${this.activeSessionKey}
.connected=${gatewayConnected}
.offline=${gatewaySnapshot.offlineStable}
.outboxCountForSession=${outboxCountForSession}
.terminalAvailable=${terminalAvailable}
.catalogOpenTarget=${normalizeCatalogOpenTarget(uiSettings.catalogOpenTarget)}
.canPairDevice=${gatewayConnected &&
hasOperatorAdminAccess(gatewaySnapshot.hello?.auth ?? null)}
.sidebarEntries=${navigationSnapshot.sidebarEntries}
.workboardBoards=${this.sidebarWorkboardSnapshot.boards}
.workboardBoardsReady=${this.sidebarWorkboardSnapshot.ready}
.workboardRenderers=${this.sidebarWorkboardRenderers}
.sidebarLiveActivity=${uiSettings.sidebarLiveActivity !== false}
.pinnedAgentIds=${navigationSnapshot.pinnedAgentIds}
.themeMode=${context.theme.mode}
.lobsterPetVisits=${uiSettings.lobsterPetVisits !== false}
.lobsterPetSounds=${uiSettings.lobsterPetSounds === true}
.gatewayVersion=${context.config.current.serverVersion ??
gatewaySnapshot.hello?.server?.version ??
null}
.devGitBranch=${context.config.current.devGitBranch}
.updateAvailable=${navigationSurfaceHidden ? null : overlaySnapshot.updateAvailable}
.updateRunning=${overlaySnapshot.updateRunning}
.onUpdate=${() => void context.overlays.runUpdate()}
.onOpenApprovals=${this.openApprovals}
.onRetryConnect=${() => context.gateway.connect()}
.onOpenNewSession=${(agentId: string, target?: NewSessionTarget) =>
this.openNewSession(agentId, target)}
.draftSessionAgentId=${this.draftSessionAgentId()}
.onUpdateSidebarEntries=${(entries: string[]) =>
context.navigation.update({ sidebarEntries: entries })}
.onPairMobile=${() => void context.overlays.openDevicePairSetup()}
.onNavigate=${(routeId: string, options?: ApplicationNavigationOptions) =>
this.navigate(routeId, options)}
.onPreloadRoute=${(routeId: string) =>
isRouteId(routeId) ? context.preload(routeId) : Promise.resolve()}
></openclaw-app-sidebar>`}
${mobileNavLayout
? html`<openclaw-modal-dialog
class="drawer nav-drawer"
.open=${navDrawerOpen}
.label=${t("palette.categories.navigation")}
@modal-cancel=${() => this.closeNavDrawer({ restoreFocus: true })}
>
<div class="shell-nav-modal__content" tabindex="-1" autofocus>
${navigationContent}
</div>
</openclaw-modal-dialog>`
: navigationContent}
</div>
${!navCollapsed && !onboarding && !settingsTakeover
? html`
+203
View File
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
GatewayBrowserClient,
GatewayBrowserClientOptions,
GatewayEventFrame,
GatewayHelloOk,
} from "../api/gateway.ts";
import { createStorageMock } from "../test-helpers/storage.ts";
@@ -27,6 +28,16 @@ const HELLO: GatewayHelloOk = {
auth: { role: "operator", scopes: [] },
};
function createGatewayEvent(event = "chat", payload: unknown = {}, seq = 1): GatewayEventFrame {
return {
type: "event",
event,
payload,
seq,
stateVersion: { presence: seq, health: seq },
};
}
class FakeGatewayClient {
started = 0;
stopped = 0;
@@ -328,6 +339,29 @@ describe("createApplicationGateway connection phase", () => {
expect(gateway.snapshot.phase).toBe("reconnecting");
});
it("discards the gapped frame after recovery synchronously replaces its client", () => {
const { gateway, current } = createStore();
const listener = vi.fn();
gateway.subscribeEvents(listener);
gateway.start();
const stale = current();
stale.opts.onHello?.(HELLO);
// The protocol invokes onGap before onEvent for the same received frame.
stale.opts.onGap?.({ expected: 2, received: 5 });
stale.opts.onEvent?.(createGatewayEvent("stale.gap", { stale: true }, 5));
expect(listener).not.toHaveBeenCalled();
expect(gateway.eventLog).toEqual([]);
const activeEvent = createGatewayEvent("fresh.event", { active: true }, 6);
current().opts.onEvent?.(activeEvent);
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith(activeEvent);
expect(gateway.eventLog).toMatchObject([{ event: "fresh.event", payload: { active: true } }]);
});
it("resets the session lineage on stop so the next start uses the gate again", () => {
const { gateway, current } = createStore();
gateway.start();
@@ -356,6 +390,175 @@ describe("createApplicationGateway connection phase", () => {
expect(gateway.snapshot.phase).toBe("reconnecting");
});
it("fans active events out once without binding subscribers to the transport", () => {
const { gateway, current } = createStore();
const first = vi.fn();
const second = vi.fn();
gateway.subscribeEvents(first);
gateway.start();
const active = current();
const addEventListener = vi.spyOn(active, "addEventListener");
const unsubscribeSecond = gateway.subscribeEvents(second);
const event = createGatewayEvent("chat", { text: "hello" });
active.opts.onEvent?.(event);
expect(first).toHaveBeenCalledExactlyOnceWith(event);
expect(second).toHaveBeenCalledExactlyOnceWith(event);
expect(addEventListener).not.toHaveBeenCalled();
expect(gateway.eventLog).toMatchObject([{ event: "chat", payload: { text: "hello" } }]);
unsubscribeSecond();
const nextEvent = createGatewayEvent("chat", { text: "next" }, 2);
active.opts.onEvent?.(nextEvent);
expect(first).toHaveBeenCalledTimes(2);
expect(second).toHaveBeenCalledOnce();
expect(addEventListener).not.toHaveBeenCalled();
});
it("keeps event subscriptions across reconnects and a stopped gateway", () => {
const { gateway, current } = createStore();
const listener = vi.fn();
gateway.subscribeEvents(listener);
gateway.start();
const first = current();
const firstEvent = createGatewayEvent("chat", { text: "first connection" });
first.opts.onEvent?.(firstEvent);
gateway.connect();
const second = current();
first.opts.onEvent?.(createGatewayEvent("chat", { text: "stale connection" }, 2));
const secondEvent = createGatewayEvent("chat", { text: "second connection" }, 3);
second.opts.onEvent?.(secondEvent);
gateway.stop();
second.opts.onEvent?.(createGatewayEvent("chat", { text: "stopped connection" }, 4));
gateway.start();
const thirdEvent = createGatewayEvent("chat", { text: "restarted connection" }, 5);
current().opts.onEvent?.(thirdEvent);
expect(listener.mock.calls).toEqual([[firstEvent], [secondEvent], [thirdEvent]]);
expect(gateway.eventLog.map((entry) => entry.payload)).toEqual([
{ text: "restarted connection" },
{ text: "second connection" },
{ text: "first connection" },
]);
});
it("snapshots subscribers when an event adds or removes another listener", () => {
const { gateway, current } = createStore();
const second = vi.fn();
const third = vi.fn();
let unsubscribeSecond = () => {};
const first = vi.fn(() => {
unsubscribeSecond();
gateway.subscribeEvents(third);
});
gateway.subscribeEvents(first);
unsubscribeSecond = gateway.subscribeEvents(second);
gateway.start();
const firstEvent = createGatewayEvent("chat", { text: "first" });
current().opts.onEvent?.(firstEvent);
expect(first).toHaveBeenCalledExactlyOnceWith(firstEvent);
expect(second).toHaveBeenCalledExactlyOnceWith(firstEvent);
expect(third).not.toHaveBeenCalled();
const secondEvent = createGatewayEvent("chat", { text: "second" }, 2);
current().opts.onEvent?.(secondEvent);
expect(first).toHaveBeenCalledTimes(2);
expect(second).toHaveBeenCalledOnce();
expect(third).toHaveBeenCalledExactlyOnceWith(secondEvent);
});
it("isolates a failing subscriber from later event subscribers", () => {
const { gateway, current } = createStore();
const failure = new Error("subscriber failed");
const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
const failing = vi.fn(() => {
throw failure;
});
const healthy = vi.fn();
gateway.subscribeEvents(failing);
gateway.subscribeEvents(healthy);
gateway.start();
const event = createGatewayEvent("chat", { text: "still delivered" });
current().opts.onEvent?.(event);
expect(failing).toHaveBeenCalledExactlyOnceWith(event);
expect(healthy).toHaveBeenCalledExactlyOnceWith(event);
expect(reportError).toHaveBeenCalledExactlyOnceWith(
"[gateway] event listener handler error:",
failure,
);
expect(gateway.eventLog).toMatchObject([
{ event: "chat", payload: { text: "still delivered" } },
]);
});
it("delivers active events when an event-log subscriber throws", () => {
const { gateway, current } = createStore();
const failure = new Error("event log subscriber failed");
const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
gateway.subscribeEventLog(() => {
throw failure;
});
const listener = vi.fn();
gateway.subscribeEvents(listener);
gateway.start();
const event = createGatewayEvent("chat", { text: "still delivered" });
current().opts.onEvent?.(event);
expect(listener).toHaveBeenCalledExactlyOnceWith(event);
expect(reportError).toHaveBeenCalledExactlyOnceWith("[gateway] event handler error:", failure);
expect(gateway.eventLog).toMatchObject([
{ event: "chat", payload: { text: "still delivered" } },
]);
});
it("stops delivering a replaced client's event to remaining subscribers", () => {
const { gateway, clients, current } = createStore();
const first = vi.fn(() => gateway.connect());
const second = vi.fn();
gateway.subscribeEvents(first);
gateway.subscribeEvents(second);
gateway.start();
const stale = current();
const event = createGatewayEvent("chat", { text: "replace connection" });
stale.opts.onEvent?.(event);
expect(clients).toHaveLength(2);
expect(first).toHaveBeenCalledExactlyOnceWith(event);
expect(second).not.toHaveBeenCalled();
const activeEvent = createGatewayEvent("chat", { text: "fresh connection" }, 2);
current().opts.onEvent?.(activeEvent);
expect(first).toHaveBeenCalledTimes(2);
expect(second).not.toHaveBeenCalled();
});
it("ignores queued events after the gateway is stopped", () => {
const { gateway, current } = createStore();
const listener = vi.fn();
gateway.subscribeEvents(listener);
gateway.start();
const stale = current();
gateway.stop();
stale.opts.onEvent?.(createGatewayEvent("chat", { text: "stopped" }));
expect(listener).not.toHaveBeenCalled();
expect(gateway.eventLog).toEqual([]);
});
it("ignores presence and event-log callbacks from superseded clients", () => {
const { gateway, current } = createStore();
gateway.start();
+21 -26
View File
@@ -85,20 +85,6 @@ export function createApplicationGateway(
const eventListeners = new Set<GatewayEventListener>();
const eventLogListeners = new Set<(events: readonly EventLogEntry[]) => void>();
let eventLog: EventLogEntry[] = [];
let stopClientEvents: (() => void) | undefined;
const syncClientEvents = (nextClient: GatewayBrowserClient | null) => {
stopClientEvents?.();
stopClientEvents = undefined;
if (!nextClient || eventListeners.size === 0) {
return;
}
const removers = [...eventListeners].map((listener) => nextClient.addEventListener(listener));
stopClientEvents = () => {
for (const remove of removers) {
remove();
}
};
};
const notify = () => {
for (const listener of listeners) {
listener(snapshot);
@@ -295,8 +281,6 @@ export function createApplicationGateway(
);
stopCanvasSurfaceLease();
client?.stop();
stopClientEvents?.();
stopClientEvents = undefined;
const nextClient = createClient({
url: nextConnection.gatewayUrl,
@@ -406,13 +390,31 @@ export function createApplicationGateway(
onEvent: (event) => {
// A replaced socket can still deliver queued events; never let it
// project presence or history into the current gateway connection.
if (client === nextClient) {
if (client !== nextClient) {
return;
}
try {
recordGatewayEvent(event);
} catch (error) {
// Preserve protocol-client isolation: a broken log subscriber must
// not prevent chat, approvals, or the remaining app from updating.
console.error("[gateway] event handler error:", error);
}
// Snapshot listeners so subscriptions changed during delivery affect
// only the next frame, not sibling consumers of the current frame.
for (const listener of Array.from(eventListeners)) {
if (client !== nextClient) {
return;
}
try {
listener(event);
} catch (error) {
console.error("[gateway] event listener handler error:", error);
}
}
},
});
client = nextClient;
syncClientEvents(nextClient);
setSnapshot({
...snapshot,
client: nextClient,
@@ -457,8 +459,6 @@ export function createApplicationGateway(
stopped = true;
clearOfflineIndicatorTimer();
stopCanvasSurfaceLease();
stopClientEvents?.();
stopClientEvents = undefined;
client?.stop();
client = null;
everConnected = false;
@@ -485,12 +485,7 @@ export function createApplicationGateway(
},
subscribeEvents: (listener) => {
eventListeners.add(listener);
syncClientEvents(client);
return () => {
if (eventListeners.delete(listener)) {
syncClientEvents(client);
}
};
return () => eventListeners.delete(listener);
},
updateSelfUser: (patch) => {
if (!snapshot.selfUser) {
+71 -1
View File
@@ -1,6 +1,76 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { hasOperatorApprovalsAccess, hasOperatorPairingAccess } from "./operator-access.ts";
import type { ApplicationGatewaySnapshot } from "./gateway.ts";
import {
hasOperatorApprovalsAccess,
hasOperatorPairingAccess,
readGatewayOperatorAccess,
} from "./operator-access.ts";
describe("readGatewayOperatorAccess", () => {
it.each([
{
name: "an absent snapshot",
snapshot: null,
expected: [true, true, false, true, false],
},
{
name: "an absent hello",
snapshot: { hello: null },
expected: [true, true, false, true, false],
},
{
name: "legacy operator authentication",
snapshot: { hello: { auth: { role: "operator" } } },
expected: [true, true, true, true, true],
},
{
name: "explicitly empty scopes",
snapshot: { hello: { auth: { role: "operator", scopes: [] } } },
expected: [false, false, false, false, false],
},
{
name: "read-only access",
snapshot: { hello: { auth: { role: "operator", scopes: ["operator.read"] } } },
expected: [false, false, false, false, false],
},
{
name: "write access",
snapshot: { hello: { auth: { role: "operator", scopes: ["operator.write"] } } },
expected: [true, false, false, false, false],
},
{
name: "approval access",
snapshot: { hello: { auth: { role: "operator", scopes: ["operator.approvals"] } } },
expected: [false, false, false, true, true],
},
{
name: "pairing access",
snapshot: { hello: { auth: { role: "operator", scopes: ["operator.pairing"] } } },
expected: [false, false, true, false, false],
},
{
name: "administrator access",
snapshot: { hello: { auth: { role: "operator", scopes: ["operator.admin"] } } },
expected: [true, true, true, true, true],
},
{
name: "a foreign role with an operator scope",
snapshot: { hello: { auth: { role: "node", scopes: ["node.read", "operator.admin"] } } },
expected: [false, false, false, false, false],
},
])("projects $name from the current Gateway snapshot", ({ snapshot, expected }) => {
expect(
readGatewayOperatorAccess(snapshot as Pick<ApplicationGatewaySnapshot, "hello"> | null),
).toEqual({
canWrite: expected[0],
canAdmin: expected[1],
canPair: expected[2],
canReviewApprovals: expected[3],
canGrantApprovals: expected[4],
});
});
});
describe("hasOperatorPairingAccess", () => {
it("requires pairing scope while keeping admin and legacy auth compatible", () => {
+24
View File
@@ -1,5 +1,29 @@
// Control UI app-level operator scope checks.
import { roleScopesAllow } from "../../../src/shared/operator-scope-compat.js";
import type { ApplicationGatewaySnapshot } from "./gateway.ts";
type GatewayOperatorAccess = Readonly<{
canWrite: boolean;
canAdmin: boolean;
canPair: boolean;
canReviewApprovals: boolean;
canGrantApprovals: boolean;
}>;
export function readGatewayOperatorAccess(
snapshot: Pick<ApplicationGatewaySnapshot, "hello"> | null | undefined,
): GatewayOperatorAccess {
const auth = snapshot?.hello?.auth ?? null;
return {
canWrite: hasOperatorWriteAccess(auth),
canAdmin: hasOperatorAdminAccess(auth),
canPair: hasOperatorPairingAccess(auth),
// Older Gateways did not advertise auth, but must retain approval review.
canReviewApprovals: !auth || hasOperatorApprovalsAccess(auth),
// Grants require an authenticated approval owner even on legacy snapshots.
canGrantApprovals: hasOperatorApprovalsAccess(auth),
};
}
export function hasOperatorWriteAccess(
auth: { role?: string; scopes?: readonly string[] } | null,
+350
View File
@@ -0,0 +1,350 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient, GatewayEventFrame } from "../api/gateway.ts";
import type { ApplicationGateway, ApplicationGatewaySnapshot } from "./gateway.ts";
import { createApplicationOverlays } from "./overlays.ts";
export type RequestFn = (method: string, params?: unknown) => Promise<unknown>;
const SYSTEM_APPROVAL_TITLE = "OpenClaw change";
const SYSTEM_APPROVAL_COMMAND = "Set gateway.port to 19001";
export function deferred<T = unknown>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}
export function approval(id: string, createdAtMs: number) {
return {
id,
createdAtMs,
expiresAtMs: Date.now() + 60_000,
request: { command: `echo ${id}` },
};
}
export function createGatewayHarness(
initialClient: GatewayBrowserClient | null,
initialConnected = initialClient !== null,
) {
let snapshot: ApplicationGatewaySnapshot = {
assistantAgentId: "main",
client: initialClient,
phase: initialConnected ? "connected" : "stopped",
offlineStable: false,
canvasPluginSurfaceUrl: null,
hello: { auth: { role: "operator" } } as ApplicationGatewaySnapshot["hello"],
lastError: null,
lastErrorCode: null,
sessionKey: "main",
};
const snapshotListeners = new Set<(next: ApplicationGatewaySnapshot) => void>();
const eventListeners = new Set<(event: GatewayEventFrame) => void>();
const connect = vi.fn();
const gateway = {
get snapshot() {
return snapshot;
},
connection: { gatewayUrl: "ws://gateway.test", password: "", token: "", bootstrapToken: "" },
eventLog: [],
connect,
setSessionKey() {},
start() {},
stop() {},
subscribe(listener: (next: ApplicationGatewaySnapshot) => void) {
snapshotListeners.add(listener);
return () => snapshotListeners.delete(listener);
},
subscribeEventLog() {
return () => {};
},
subscribeEvents(listener: (event: GatewayEventFrame) => void) {
eventListeners.add(listener);
return () => eventListeners.delete(listener);
},
} satisfies ApplicationGateway;
return {
emitApproval(id: string, createdAtMs: number) {
const event: GatewayEventFrame = {
event: "exec.approval.requested",
payload: approval(id, createdAtMs),
type: "event",
};
for (const listener of eventListeners) {
listener(event);
}
},
emitDevicePairRequested() {
const event: GatewayEventFrame = {
event: "device.pair.requested",
payload: {},
type: "event",
};
for (const listener of eventListeners) {
listener(event);
}
},
emitSystemApproval(id: string, createdAtMs: number) {
const event: GatewayEventFrame = {
event: "openclaw.approval.requested",
payload: {
id,
createdAtMs,
expiresAtMs: Date.now() + 60_000,
request: {
title: SYSTEM_APPROVAL_TITLE,
description: SYSTEM_APPROVAL_COMMAND,
command: SYSTEM_APPROVAL_COMMAND,
proposalHash: "a".repeat(64),
allowedDecisions: ["allow-once", "deny"],
},
},
type: "event",
};
for (const listener of eventListeners) {
listener(event);
}
},
gateway,
connect,
update(next: Partial<ApplicationGatewaySnapshot>) {
snapshot = { ...snapshot, ...next };
for (const listener of snapshotListeners) {
listener(snapshot);
}
},
};
}
export function client(request: RequestFn): GatewayBrowserClient {
return { request } as unknown as GatewayBrowserClient;
}
export async function flushMicrotasks() {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
export function registerOverlayPairingAccessTests() {
describe("application pairing setup permissions", () => {
it.each([
{
name: "pairing-only to legacy administrator",
previousAuth: { role: "operator", scopes: ["operator.pairing"] },
nextAuth: null,
},
{
name: "legacy administrator to scoped administrator",
previousAuth: null,
nextAuth: { role: "operator", scopes: ["operator.admin"] },
},
])("refreshes an open pairing dialog after $name", async ({ previousAuth, nextAuth }) => {
const stalePending = deferred<{ pending: Array<{ id: string }> }>();
const freshPending = [{ id: "current-first" }, { id: "current-second" }];
let pairingRequests = 0;
const request = vi.fn<RequestFn>((method) => {
if (method !== "device.pair.list") {
return Promise.resolve([]);
}
pairingRequests += 1;
return pairingRequests === 1
? stalePending.promise
: Promise.resolve({ pending: freshPending });
});
const harness = createGatewayHarness(client(request));
harness.update({
hello: previousAuth
? ({ auth: previousAuth } as ApplicationGatewaySnapshot["hello"])
: null,
});
const overlays = createApplicationOverlays(harness.gateway);
try {
await overlays.openDevicePairSetup();
expect(overlays.snapshot.devicePairSetupOpen).toBe(true);
expect(pairingRequests).toBe(1);
harness.update({
hello: nextAuth ? ({ auth: nextAuth } as ApplicationGatewaySnapshot["hello"]) : null,
});
await flushMicrotasks();
expect(overlays.snapshot.devicePairSetupOpen).toBe(true);
expect(pairingRequests).toBe(2);
expect(overlays.snapshot.devicePairPendingCount).toBe(2);
stalePending.resolve({ pending: [{ id: "retired" }] });
await flushMicrotasks();
expect(overlays.snapshot.devicePairPendingCount).toBe(2);
expect(request).not.toHaveBeenCalledWith("device.pair.setupCode", {});
} finally {
stalePending.resolve({ pending: [] });
overlays.dispose();
}
});
it("refreshes legacy admin pairing counts when device-pair events arrive", async () => {
let pending = [{ id: "first-pending" }];
const request = vi.fn<RequestFn>((method) =>
Promise.resolve(method === "device.pair.list" ? { pending } : []),
);
const harness = createGatewayHarness(client(request));
harness.update({ hello: null });
const overlays = createApplicationOverlays(harness.gateway);
await overlays.openDevicePairSetup();
await flushMicrotasks();
expect(overlays.snapshot.devicePairSetupOpen).toBe(true);
expect(overlays.snapshot.devicePairPendingCount).toBe(1);
expect(request).toHaveBeenCalledWith("device.pair.list", {});
pending = [{ id: "first-pending" }, { id: "second-pending" }];
harness.emitDevicePairRequested();
await flushMicrotasks();
expect(overlays.snapshot.devicePairPendingCount).toBe(2);
expect(request.mock.calls.filter(([method]) => method === "device.pair.list")).toHaveLength(
2,
);
harness.update({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
} as ApplicationGatewaySnapshot["hello"],
});
expect(overlays.snapshot.devicePairSetupOpen).toBe(false);
expect(overlays.snapshot.devicePairPendingCount).toBe(0);
harness.emitDevicePairRequested();
await flushMicrotasks();
expect(request.mock.calls.filter(([method]) => method === "device.pair.list")).toHaveLength(
2,
);
expect(request).not.toHaveBeenCalledWith("device.pair.setupCode", {});
overlays.dispose();
});
it("discards an in-flight setup credential after admin access becomes pairing-only", async () => {
const setup = deferred();
const request = vi.fn<RequestFn>((method) => {
if (method === "device.pair.setupCode") {
return setup.promise;
}
if (method === "device.pair.list") {
return Promise.resolve({ pending: [] });
}
return Promise.resolve([]);
});
const harness = createGatewayHarness(client(request));
harness.update({
hello: {
auth: { role: "operator", scopes: ["operator.admin"] },
} as ApplicationGatewaySnapshot["hello"],
});
const overlays = createApplicationOverlays(harness.gateway);
await overlays.openDevicePairSetup();
const mintingSetup = overlays.refreshDevicePairSetup();
expect(request).toHaveBeenCalledWith("device.pair.setupCode", {});
harness.update({
hello: {
auth: { role: "operator", scopes: ["operator.pairing"] },
} as ApplicationGatewaySnapshot["hello"],
});
expect(overlays.snapshot.devicePairSetupOpen).toBe(false);
expect(overlays.snapshot.devicePairSetup).toBeNull();
setup.resolve({
setupCode: "retired-test-setup-code",
gatewayUrl: "ws://gateway.test",
access: "full",
});
await mintingSetup;
expect(overlays.snapshot.devicePairSetupOpen).toBe(false);
expect(overlays.snapshot.devicePairSetup).toBeNull();
expect(overlays.snapshot.devicePairSetupLoading).toBe(false);
expect(
request.mock.calls.filter(([method]) => method === "device.pair.setupCode"),
).toHaveLength(1);
overlays.dispose();
});
it("closes a pairing-only setup when the same client loses pairing authority", async () => {
const request = vi.fn<RequestFn>((method) =>
Promise.resolve(method === "device.pair.list" ? { pending: [{ id: "pending" }] } : []),
);
const harness = createGatewayHarness(client(request));
harness.update({
hello: {
auth: { role: "operator", scopes: ["operator.pairing"] },
} as ApplicationGatewaySnapshot["hello"],
});
const overlays = createApplicationOverlays(harness.gateway);
await overlays.openDevicePairSetup();
await flushMicrotasks();
expect(overlays.snapshot.devicePairSetupOpen).toBe(true);
expect(overlays.snapshot.devicePairPendingCount).toBe(1);
harness.update({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
} as ApplicationGatewaySnapshot["hello"],
});
expect(overlays.snapshot.devicePairSetupOpen).toBe(false);
expect(overlays.snapshot.devicePairSetup).toBeNull();
expect(overlays.snapshot.devicePairPendingCount).toBe(0);
expect(request).not.toHaveBeenCalledWith("device.pair.setupCode", {});
overlays.dispose();
});
it.each([
{ name: "write-only", scopes: ["operator.write"] },
{ name: "approval-only", scopes: ["operator.approvals"] },
{ name: "explicitly ungranted", scopes: [] },
])("does not dispatch pairing or setup requests for a $name operator", async ({ scopes }) => {
const request = vi.fn<RequestFn>(() => Promise.resolve({ pending: [] }));
const harness = createGatewayHarness(client(request));
harness.update({
hello: { auth: { role: "operator", scopes } } as ApplicationGatewaySnapshot["hello"],
});
const overlays = createApplicationOverlays(harness.gateway);
await overlays.openDevicePairSetup();
await overlays.refreshDevicePairSetup();
expect(request).not.toHaveBeenCalledWith("device.pair.list", {});
expect(request).not.toHaveBeenCalledWith("device.pair.setupCode", {});
expect(overlays.snapshot.devicePairSetupOpen).toBe(false);
overlays.dispose();
});
it("allows pairing-only list access without minting an admin setup credential", async () => {
const request = vi.fn<RequestFn>((method) =>
Promise.resolve(method === "device.pair.list" ? { pending: [] } : []),
);
const harness = createGatewayHarness(client(request));
harness.update({
hello: {
auth: { role: "operator", scopes: ["operator.pairing"] },
} as ApplicationGatewaySnapshot["hello"],
});
const overlays = createApplicationOverlays(harness.gateway);
await overlays.openDevicePairSetup();
await overlays.refreshDevicePairSetup();
expect(request).toHaveBeenCalledWith("device.pair.list", {});
expect(request).not.toHaveBeenCalledWith("device.pair.setupCode", {});
overlays.dispose();
});
});
}
+117
View File
@@ -0,0 +1,117 @@
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { createDevicePairSetupState } from "../lib/device-pair-setup.ts";
import { refreshPendingApprovalQueue, type ExecApprovalPromptState } from "./exec-approval.ts";
import type { ApplicationGateway } from "./gateway.ts";
import { readGatewayOperatorAccess } from "./operator-access.ts";
type OverlayOperatorAccess = ReturnType<typeof readGatewayOperatorAccess>;
type DevicePairSetupState = ReturnType<typeof createDevicePairSetupState>;
function canAccessDevicePairing(snapshot: ApplicationGateway["snapshot"]): boolean {
const access = readGatewayOperatorAccess(snapshot);
return access.canAdmin || access.canPair;
}
export function readOverlayOperatorAccessTransition(
previous: OverlayOperatorAccess,
snapshot: ApplicationGateway["snapshot"],
) {
const access = readGatewayOperatorAccess(snapshot);
return {
access,
reviewChanged: previous.canReviewApprovals !== access.canReviewApprovals,
grantChanged: previous.canGrantApprovals !== access.canGrantApprovals,
grantRevoked: previous.canGrantApprovals && !access.canGrantApprovals,
adminRevoked: previous.canAdmin && !access.canAdmin,
pairingChanged: previous.canPair !== access.canPair,
pairingSetupRevoked:
(previous.canAdmin || previous.canPair) && !(access.canAdmin || access.canPair),
};
}
export function createOverlayPairingPendingCount(params: {
gateway: ApplicationGateway;
state: DevicePairSetupState;
isDisposed: () => boolean;
publish: () => void;
}) {
let generation = 0;
return {
invalidate(options: { clear?: boolean } = {}) {
generation += 1;
if (options.clear) {
params.state.pendingCount = 0;
}
},
async refresh() {
const client = params.gateway.snapshot.client;
if (
!client ||
params.gateway.snapshot.phase !== "connected" ||
params.isDisposed() ||
!params.state.devicePairSetupOpen ||
!canAccessDevicePairing(params.gateway.snapshot)
) {
return;
}
const requestGeneration = ++generation;
let result: { pending?: unknown };
try {
result = await client.request<{ pending?: unknown }>("device.pair.list", {});
} catch {
return;
}
if (
params.isDisposed() ||
requestGeneration !== generation ||
params.gateway.snapshot.client !== client ||
params.gateway.snapshot.phase !== "connected" ||
!params.state.devicePairSetupOpen ||
!canAccessDevicePairing(params.gateway.snapshot)
) {
return;
}
params.state.pendingCount = Array.isArray(result.pending) ? result.pending.length : 0;
params.publish();
},
};
}
export function createOverlayApprovalRefresher(params: {
gateway: ApplicationGateway;
state: ExecApprovalPromptState;
getConnectedEpoch: () => number;
getReviewGeneration: () => number;
canReview: () => boolean;
isCurrentClient: (client: GatewayBrowserClient) => boolean;
isDisposed: () => boolean;
publish: () => void;
}) {
return async (
client: GatewayBrowserClient,
epoch = params.getConnectedEpoch(),
reviewGeneration = params.getReviewGeneration(),
) => {
if (
!params.canReview() ||
reviewGeneration !== params.getReviewGeneration() ||
!readGatewayOperatorAccess(params.gateway.snapshot).canReviewApprovals
) {
return;
}
const applied = await refreshPendingApprovalQueue(params.state, {
isCurrentClient: (requestClient) =>
requestClient === client &&
epoch === params.getConnectedEpoch() &&
reviewGeneration === params.getReviewGeneration() &&
params.canReview() &&
readGatewayOperatorAccess(params.gateway.snapshot).canReviewApprovals &&
params.isCurrentClient(client),
});
if (applied && !params.isDisposed()) {
params.publish();
}
};
}
+209 -115
View File
@@ -1,8 +1,16 @@
// @vitest-environment node
// Control UI tests cover application-owned overlay races.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient, GatewayEventFrame } from "../api/gateway.ts";
import type { ApplicationGateway, ApplicationGatewaySnapshot } from "./gateway.ts";
import type { ApplicationGatewaySnapshot } from "./gateway.ts";
import {
approval,
client,
createGatewayHarness,
deferred,
flushMicrotasks,
registerOverlayPairingAccessTests,
type RequestFn,
} from "./overlays-access.test-support.ts";
import { createApplicationOverlays } from "./overlays.ts";
vi.mock("../build-info.ts", () => ({
@@ -16,111 +24,8 @@ vi.mock("../lib/nodes/index.ts", () => ({
peekStoredDeviceIdentityId: peekStoredDeviceIdentityIdMock,
}));
type RequestFn = (method: string, params?: unknown) => Promise<unknown>;
const VERIFICATION_POLL_MS = 250;
function deferred<T = unknown>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}
function approval(id: string, createdAtMs: number) {
return {
id,
createdAtMs,
expiresAtMs: Date.now() + 60_000,
request: { command: `echo ${id}` },
};
}
function createGatewayHarness(
initialClient: GatewayBrowserClient | null,
initialConnected = initialClient !== null,
) {
let snapshot: ApplicationGatewaySnapshot = {
assistantAgentId: "main",
client: initialClient,
phase: initialConnected ? "connected" : "stopped",
offlineStable: false,
canvasPluginSurfaceUrl: null,
hello: null,
lastError: null,
lastErrorCode: null,
sessionKey: "main",
};
const snapshotListeners = new Set<(next: ApplicationGatewaySnapshot) => void>();
const eventListeners = new Set<(event: GatewayEventFrame) => void>();
const connect = vi.fn();
const gateway = {
get snapshot() {
return snapshot;
},
connection: { gatewayUrl: "ws://gateway.test", password: "", token: "", bootstrapToken: "" },
eventLog: [],
connect,
setSessionKey() {},
start() {},
stop() {},
subscribe(listener: (next: ApplicationGatewaySnapshot) => void) {
snapshotListeners.add(listener);
return () => snapshotListeners.delete(listener);
},
subscribeEventLog() {
return () => {};
},
subscribeEvents(listener: (event: GatewayEventFrame) => void) {
eventListeners.add(listener);
return () => eventListeners.delete(listener);
},
} satisfies ApplicationGateway;
return {
emitApproval(id: string, createdAtMs: number) {
const event: GatewayEventFrame = {
event: "exec.approval.requested",
payload: approval(id, createdAtMs),
type: "event",
};
for (const listener of eventListeners) {
listener(event);
}
},
emitSystemApproval(id: string, createdAtMs: number) {
const event: GatewayEventFrame = {
event: "openclaw.approval.requested",
payload: {
id,
createdAtMs,
expiresAtMs: Date.now() + 60_000,
request: {
title: "OpenClaw change",
description: "Set gateway.port to 19001",
command: "Set gateway.port to 19001",
proposalHash: "a".repeat(64),
allowedDecisions: ["allow-once", "deny"],
},
},
type: "event",
};
for (const listener of eventListeners) {
listener(event);
}
},
gateway,
connect,
update(next: Partial<ApplicationGatewaySnapshot>) {
snapshot = { ...snapshot, ...next };
for (const listener of snapshotListeners) {
listener(snapshot);
}
},
};
}
describe("device-auth upgrade migration", () => {
beforeEach(() => {
peekStoredDeviceIdentityIdMock.mockReturnValue("browser-1");
@@ -299,16 +204,6 @@ describe("device-auth upgrade migration", () => {
});
});
function client(request: RequestFn): GatewayBrowserClient {
return { request } as unknown as GatewayBrowserClient;
}
async function flushMicrotasks() {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
describe("Control UI refresh nudge", () => {
it("waits for a reconnect before flagging a version mismatch", () => {
const gatewayClient = client(async () => []);
@@ -363,6 +258,55 @@ describe("Control UI refresh nudge", () => {
});
describe("application approval overlays", () => {
it("keeps no-auth approvals readable without granting resolution authority", async () => {
const request = vi.fn<RequestFn>((method) =>
Promise.resolve(method.endsWith(".list") ? [] : { ok: true }),
);
const harness = createGatewayHarness(client(request));
harness.update({ hello: null });
const overlays = createApplicationOverlays(harness.gateway);
await flushMicrotasks();
harness.emitApproval("approval-review-only", 1_000);
await overlays.decideApproval("allow-once", "approval-review-only");
expect(request).toHaveBeenCalledWith("exec.approval.list", {});
expect(overlays.snapshot.approvalQueue.map((entry) => entry.id)).toEqual([
"approval-review-only",
]);
expect(overlays.snapshot.approvalBusy).toBe(false);
expect(
request.mock.calls.some(
([method]) => method === "exec.approval.resolve" || method === "approval.resolve",
),
).toBe(false);
overlays.dispose();
});
it.each([
{ name: "reviewer", scopes: ["operator.approvals"] },
{ name: "administrator", scopes: ["operator.admin"] },
])("resolves a queued approval with an authenticated $name grant", async ({ scopes }) => {
const request = vi.fn<RequestFn>((method) =>
Promise.resolve(method.endsWith(".list") ? [] : { ok: true }),
);
const harness = createGatewayHarness(client(request));
harness.update({
hello: { auth: { role: "operator", scopes } } as ApplicationGatewaySnapshot["hello"],
});
const overlays = createApplicationOverlays(harness.gateway);
await flushMicrotasks();
harness.emitApproval("approval-authorized", 1_000);
await overlays.decideApproval("allow-once", "approval-authorized");
expect(request).toHaveBeenCalledWith("exec.approval.resolve", {
id: "approval-authorized",
decision: "allow-once",
});
overlays.dispose();
});
it.each([
{ name: "read-only", scopes: ["operator.read"] },
{ name: "write-only", scopes: ["operator.write"] },
@@ -471,6 +415,132 @@ describe("application approval overlays", () => {
overlays.dispose();
});
it("rejects a retained approval action after same-client approval access is revoked", async () => {
const request = vi.fn<RequestFn>((method) =>
Promise.resolve(method.endsWith(".list") ? [] : { ok: true }),
);
const harness = createGatewayHarness(client(request));
harness.update({
hello: {
auth: { role: "operator", scopes: ["operator.approvals"] },
} as ApplicationGatewaySnapshot["hello"],
});
const overlays = createApplicationOverlays(harness.gateway);
await flushMicrotasks();
harness.emitApproval("approval-retired", 1_000);
harness.update({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
} as ApplicationGatewaySnapshot["hello"],
});
await overlays.decideApproval("allow-once", "approval-retired");
expect(overlays.snapshot.approvalQueue).toEqual([]);
expect(request.mock.calls.some(([method]) => method === "exec.approval.resolve")).toBe(false);
overlays.dispose();
});
it("does not let a revoked approval decision release a restored decision", async () => {
const staleResolution = deferred();
const currentResolution = deferred();
let resolutionCount = 0;
const request = vi.fn<RequestFn>((method) => {
if (method.endsWith(".list")) {
return Promise.resolve([]);
}
resolutionCount += 1;
return resolutionCount === 1 ? staleResolution.promise : currentResolution.promise;
});
const harness = createGatewayHarness(client(request));
harness.update({
hello: {
auth: { role: "operator", scopes: ["operator.approvals"] },
} as ApplicationGatewaySnapshot["hello"],
});
const overlays = createApplicationOverlays(harness.gateway);
await flushMicrotasks();
harness.emitApproval("approval-stale", 1_000);
const staleDecision = overlays.decideApproval("allow-once");
harness.update({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
} as ApplicationGatewaySnapshot["hello"],
});
harness.update({
hello: {
auth: { role: "operator", scopes: ["operator.approvals"] },
} as ApplicationGatewaySnapshot["hello"],
});
await flushMicrotasks();
harness.emitApproval("approval-current", 2_000);
const currentDecision = overlays.decideApproval("deny");
staleResolution.resolve({ ok: true });
await staleDecision;
expect(overlays.snapshot.approvalBusy).toBe(true);
expect(overlays.snapshot.approvalQueue.map((entry) => entry.id)).toEqual(["approval-current"]);
currentResolution.resolve({ ok: true });
await currentDecision;
expect(overlays.snapshot.approvalBusy).toBe(false);
expect(overlays.snapshot.approvalQueue).toEqual([]);
overlays.dispose();
});
it("retires a grant-only downgrade without clearing the readable approval queue", async () => {
const staleResolution = deferred();
const currentResolution = deferred();
let resolutionCount = 0;
const request = vi.fn<RequestFn>((method) => {
if (method.endsWith(".list")) {
return Promise.resolve([]);
}
resolutionCount += 1;
return resolutionCount === 1 ? staleResolution.promise : currentResolution.promise;
});
const harness = createGatewayHarness(client(request));
harness.update({
hello: {
auth: { role: "operator", scopes: ["operator.approvals"] },
} as ApplicationGatewaySnapshot["hello"],
});
const overlays = createApplicationOverlays(harness.gateway);
await flushMicrotasks();
harness.emitApproval("approval-stale-grant", 1_000);
const staleDecision = overlays.decideApproval("allow-once", "approval-stale-grant");
harness.update({ hello: null });
expect(overlays.snapshot.approvalBusy).toBe(false);
expect(overlays.snapshot.approvalQueue.map((entry) => entry.id)).toEqual([
"approval-stale-grant",
]);
harness.update({
hello: {
auth: { role: "operator", scopes: ["operator.approvals"] },
} as ApplicationGatewaySnapshot["hello"],
});
harness.emitApproval("approval-current-grant", 2_000);
const currentDecision = overlays.decideApproval("deny", "approval-current-grant");
staleResolution.resolve({ ok: true });
await staleDecision;
expect(overlays.snapshot.approvalBusy).toBe(true);
expect(overlays.snapshot.approvalQueue.map((entry) => entry.id)).toEqual([
"approval-stale-grant",
"approval-current-grant",
]);
currentResolution.resolve({ ok: true });
await currentDecision;
expect(overlays.snapshot.approvalBusy).toBe(false);
expect(overlays.snapshot.approvalQueue.map((entry) => entry.id)).toEqual([
"approval-stale-grant",
]);
overlays.dispose();
});
it("resolves OpenClaw changes through unified human approval", async () => {
const request = vi.fn<RequestFn>(async (method) =>
method.endsWith(".list") ? [] : { ok: true },
@@ -716,7 +786,31 @@ describe("application approval overlays", () => {
});
});
registerOverlayPairingAccessTests();
describe("application update overlays", () => {
it.each([
{ name: "read-only", scopes: ["operator.read"] },
{ name: "write-only", scopes: ["operator.write"] },
{ name: "approval-only", scopes: ["operator.approvals"] },
{ name: "explicitly ungranted", scopes: [] },
])("rejects an update request from a $name operator", async ({ scopes }) => {
const request = vi.fn<RequestFn>(() => Promise.resolve({ ok: true }));
const drainConfigWrites = vi.fn(async () => undefined);
const harness = createGatewayHarness(client(request));
harness.update({
hello: { auth: { role: "operator", scopes } } as ApplicationGatewaySnapshot["hello"],
});
const overlays = createApplicationOverlays(harness.gateway, { drainConfigWrites });
await overlays.runUpdate();
expect(request).not.toHaveBeenCalledWith("update.run", {});
expect(drainConfigWrites).not.toHaveBeenCalled();
expect(overlays.snapshot.updateRunning).toBe(false);
overlays.dispose();
});
it("drains config writes after suspending and before issuing update.run", async () => {
const order: string[] = [];
const request = vi.fn<RequestFn>().mockImplementation(async (method) => {
+106 -86
View File
@@ -26,14 +26,18 @@ import {
isStaleApprovalResolutionError,
parseApprovalRequestedEvent,
parseExecApprovalResolved,
refreshPendingApprovalQueue,
resolveApprovalRequest,
type ExecApprovalDecision,
type ExecApprovalPromptState,
type ExecApprovalRequest,
} from "./exec-approval.ts";
import type { ApplicationGateway } from "./gateway.ts";
import { hasOperatorApprovalsAccess } from "./operator-access.ts";
import { readGatewayOperatorAccess } from "./operator-access.ts";
import {
createOverlayApprovalRefresher,
createOverlayPairingPendingCount,
readOverlayOperatorAccessTransition,
} from "./overlays-access.ts";
import {
isPendingUpdateHandoffSentinel,
readUpdateAvailable,
@@ -87,11 +91,6 @@ function isGatewayEvent(value: unknown): value is GatewayEventFrame {
return Boolean(value && typeof value === "object" && "event" in value);
}
function canReviewGatewayApprovals(snapshot: ApplicationGateway["snapshot"]): boolean {
const auth = snapshot.hello?.auth;
return !auth || hasOperatorApprovalsAccess(auth);
}
type UpdateVerificationWait = {
timer: ReturnType<typeof globalThis.setTimeout>;
resolve: (active: boolean) => void;
@@ -128,17 +127,19 @@ export function createApplicationOverlays(
let activeClient = gateway.snapshot.client;
let connectedSource: NonNullable<typeof activeClient> | null = null; // Retries start a new source epoch.
let connectedEpoch = 0;
let approvalsAccess = canReviewGatewayApprovals(gateway.snapshot);
let operatorAccess = readGatewayOperatorAccess(gateway.snapshot);
let approvalAccessGeneration = 0;
let approvalGrantGeneration = 0;
let pendingUpdateExpectedVersion: string | null = null;
let pendingUpdateHandoff = false;
let updateRunGeneration = 0;
let updateVerificationGeneration = 0;
let updateVerificationWait: UpdateVerificationWait | null = null;
let devicePairPendingCountGeneration = 0;
let approvalDecision: {
client: NonNullable<typeof activeClient>;
epoch: number;
accessGeneration: number;
grantGeneration: number;
id: string;
} | null = null;
const devicePairSetupState = createDevicePairSetupState({
@@ -171,6 +172,12 @@ export function createApplicationOverlays(
}
};
promptState.execApprovalChanged = publish;
const pairingPendingCount = createOverlayPairingPendingCount({
gateway,
state: devicePairSetupState,
isDisposed: () => disposed,
publish,
});
const publishDevicePairSetupOperation = async (operation: Promise<void>) => {
publish();
await operation;
@@ -196,61 +203,16 @@ export function createApplicationOverlays(
},
});
const refreshDevicePairPendingCount = async () => {
const client = gateway.snapshot.client;
if (
!client ||
gateway.snapshot.phase !== "connected" ||
disposed ||
!devicePairSetupState.devicePairSetupOpen
) {
return;
}
const generation = ++devicePairPendingCountGeneration;
let result: { pending?: unknown };
try {
result = await client.request<{ pending?: unknown }>("device.pair.list", {});
} catch {
return;
}
if (
disposed ||
generation !== devicePairPendingCountGeneration ||
gateway.snapshot.client !== client ||
gateway.snapshot.phase !== "connected" ||
!devicePairSetupState.devicePairSetupOpen
) {
return;
}
devicePairSetupState.pendingCount = Array.isArray(result.pending) ? result.pending.length : 0;
publish();
};
const refreshApprovals = async (
client: NonNullable<typeof activeClient>,
epoch = connectedEpoch,
accessGeneration = approvalAccessGeneration,
) => {
if (
!approvalsAccess ||
accessGeneration !== approvalAccessGeneration ||
!canReviewGatewayApprovals(gateway.snapshot)
) {
return;
}
const applied = await refreshPendingApprovalQueue(promptState, {
isCurrentClient: (requestClient) =>
requestClient === client &&
epoch === connectedEpoch &&
accessGeneration === approvalAccessGeneration &&
approvalsAccess &&
canReviewGatewayApprovals(gateway.snapshot) &&
isCurrentClient(client),
});
if (applied && !disposed) {
publish();
}
};
const refreshApprovals = createOverlayApprovalRefresher({
gateway,
state: promptState,
getConnectedEpoch: () => connectedEpoch,
getReviewGeneration: () => approvalAccessGeneration,
canReview: () => operatorAccess.canReviewApprovals,
isCurrentClient,
isDisposed: () => disposed,
publish,
});
const publishUpdateBanner = (updateStatusBanner: ApplicationStatusBanner | null) => {
snapshot = { ...snapshot, updateStatusBanner };
@@ -377,12 +339,35 @@ export function createApplicationOverlays(
const connected = next.phase === "connected";
const nextConnectedSource = connected ? next.client : null;
const connectedSourceChanged = connectedSource !== nextConnectedSource;
const nextApprovalsAccess = canReviewGatewayApprovals(next);
const approvalAccessChanged = approvalsAccess !== nextApprovalsAccess;
approvalsAccess = nextApprovalsAccess;
if (approvalAccessChanged) {
const accessTransition = readOverlayOperatorAccessTransition(operatorAccess, next);
operatorAccess = accessTransition.access;
if (accessTransition.reviewChanged) {
approvalAccessGeneration += 1;
}
if (accessTransition.grantChanged) {
approvalGrantGeneration += 1;
}
if (accessTransition.grantRevoked) {
// Review can remain available without a decision grant. Retire the
// in-flight owner without discarding the still-readable approval queue.
approvalDecision = null;
promptState.execApprovalBusy = false;
}
if (accessTransition.adminRevoked || accessTransition.pairingSetupRevoked) {
// Admin revocation invalidates bearer setup codes; losing both setup
// authorities must also close a pairing-only operator's retained modal.
closeDevicePairSetupState(devicePairSetupState);
pairingPendingCount.invalidate({ clear: true });
if (accessTransition.adminRevoked) {
updateRunGeneration += 1;
snapshot = { ...snapshot, updateRunning: false };
}
}
if (accessTransition.pairingChanged) {
pairingPendingCount.invalidate({
clear: !operatorAccess.canAdmin && !operatorAccess.canPair,
});
}
activeClient = next.client;
connectedSource = nextConnectedSource;
promptState.client = next.client;
@@ -394,12 +379,11 @@ export function createApplicationOverlays(
}
if (previousClient !== next.client || !connected) {
approvalDecision = null;
devicePairPendingCountGeneration += 1;
pairingPendingCount.invalidate({ clear: true });
deviceAuthMigration.reset();
closeDevicePairSetupState(devicePairSetupState);
devicePairSetupState.pendingCount = 0;
}
if (connected && !approvalsAccess) {
if (connected && !operatorAccess.canReviewApprovals) {
approvalDecision = null;
promptState.execApprovalQueue = [];
promptState.execApprovalBusy = false;
@@ -431,14 +415,21 @@ export function createApplicationOverlays(
: snapshot.controlUiRefreshRequired,
};
publish();
if (
accessTransition.pairingChanged &&
devicePairSetupState.devicePairSetupOpen &&
(operatorAccess.canAdmin || operatorAccess.canPair)
) {
void pairingPendingCount.refresh();
}
if (connectedSourceChanged) {
connectedEpoch += 1;
if (approvalsAccess) {
if (operatorAccess.canReviewApprovals) {
void refreshApprovals(next.client, connectedEpoch, approvalAccessGeneration);
}
void deviceAuthMigration.refresh(next.client, connectedEpoch);
void verifyPendingUpdateVersion(next.client, connectedEpoch);
} else if (approvalAccessChanged && approvalsAccess) {
} else if (accessTransition.reviewChanged && operatorAccess.canReviewApprovals) {
void refreshApprovals(next.client, connectedEpoch, approvalAccessGeneration);
}
};
@@ -449,7 +440,7 @@ export function createApplicationOverlays(
return;
}
if (event.event === "device.pair.requested" || event.event === "device.pair.resolved") {
void refreshDevicePairPendingCount();
void pairingPendingCount.refresh();
if (activeClient) {
void deviceAuthMigration.refresh(activeClient, connectedEpoch);
}
@@ -461,7 +452,10 @@ export function createApplicationOverlays(
publish();
return;
}
if (!approvalsAccess || !canReviewGatewayApprovals(gateway.snapshot)) {
if (
!operatorAccess.canReviewApprovals ||
!readGatewayOperatorAccess(gateway.snapshot).canReviewApprovals
) {
return;
}
const requestedApproval = parseApprovalRequestedEvent(event.event, event.payload);
@@ -494,7 +488,13 @@ export function createApplicationOverlays(
},
async runUpdate() {
const client = gateway.snapshot.client;
if (!client || gateway.snapshot.phase !== "connected" || disposed || snapshot.updateRunning) {
if (
!client ||
gateway.snapshot.phase !== "connected" ||
disposed ||
snapshot.updateRunning ||
!readGatewayOperatorAccess(gateway.snapshot).canAdmin
) {
return;
}
const generation = ++updateRunGeneration;
@@ -505,7 +505,11 @@ export function createApplicationOverlays(
// into the runtime-config capability); this barrier drains writes
// already in flight so none can commit or restart mid-install.
await hooks.drainConfigWrites?.();
if (disposed || generation !== updateRunGeneration) {
if (
disposed ||
generation !== updateRunGeneration ||
!readGatewayOperatorAccess(gateway.snapshot).canAdmin
) {
return;
}
const response = await client.request<UpdateRunResponse>("update.run", {});
@@ -587,16 +591,32 @@ export function createApplicationOverlays(
? promptState.execApprovalQueue.find((entry) => entry.id === approvalId)
: promptState.execApprovalQueue[0];
const client = gateway.snapshot.client;
if (!active || !client || promptState.execApprovalBusy || disposed) {
if (
!active ||
!client ||
promptState.execApprovalBusy ||
disposed ||
gateway.snapshot.phase !== "connected" ||
!readGatewayOperatorAccess(gateway.snapshot).canGrantApprovals
) {
return;
}
promptState.execApprovalBusy = true;
promptState.execApprovalErrors.delete(active.id);
const operation = { client, epoch: connectedEpoch, id: active.id };
const operation = {
client,
epoch: connectedEpoch,
accessGeneration: approvalAccessGeneration,
grantGeneration: approvalGrantGeneration,
id: active.id,
};
approvalDecision = operation;
const isCurrentOperation = () =>
approvalDecision === operation &&
operation.epoch === connectedEpoch &&
operation.accessGeneration === approvalAccessGeneration &&
operation.grantGeneration === approvalGrantGeneration &&
readGatewayOperatorAccess(gateway.snapshot).canGrantApprovals &&
isCurrentClient(operation.client);
publish();
try {
@@ -638,31 +658,31 @@ export function createApplicationOverlays(
}
},
async openDevicePairSetup() {
if (disposed) {
const access = readGatewayOperatorAccess(gateway.snapshot);
if (disposed || (!access.canAdmin && !access.canPair)) {
return;
}
devicePairSetupState.pendingCount = 0;
const setupOperation = openDevicePairSetupState(devicePairSetupState);
// Pairing-list latency must not keep a ready setup code behind the loading state.
void refreshDevicePairPendingCount();
void pairingPendingCount.refresh();
await publishDevicePairSetupOperation(setupOperation);
},
async refreshDevicePairSetup() {
if (disposed) {
if (disposed || !readGatewayOperatorAccess(gateway.snapshot).canAdmin) {
return;
}
await publishDevicePairSetupOperation(refreshDevicePairSetupState(devicePairSetupState));
},
async setDevicePairSetupAccess(access) {
if (disposed) {
if (disposed || !readGatewayOperatorAccess(gateway.snapshot).canAdmin) {
return;
}
await publishDevicePairSetupOperation(setPairAccess(devicePairSetupState, access));
},
closeDevicePairSetup() {
devicePairPendingCountGeneration += 1;
pairingPendingCount.invalidate({ clear: true });
closeDevicePairSetupState(devicePairSetupState);
devicePairSetupState.pendingCount = 0;
publish();
},
async secureThisBrowser() {
@@ -674,7 +694,7 @@ export function createApplicationOverlays(
disposed = true;
approvalDecision = null;
updateRunGeneration += 1;
devicePairPendingCountGeneration += 1;
pairingPendingCount.invalidate();
deviceAuthMigration.dispose();
cancelUpdateVerification();
closeDevicePairSetupState(devicePairSetupState);
+1
View File
@@ -10,6 +10,7 @@ import "../test-helpers/app-sidebar-cases/catalog-project-activity.ts";
import "../test-helpers/app-sidebar-cases/catalog-live.ts";
import "../test-helpers/app-sidebar-cases/catalog-live-errors.ts";
import "../test-helpers/app-sidebar-cases/catalog-live-state.ts";
import "../test-helpers/app-sidebar-cases/catalog-ownership.ts";
import "../test-helpers/app-sidebar-cases/catalog-pages.ts";
import "../test-helpers/app-sidebar-cases/child-sessions-cap.ts";
import "../test-helpers/app-sidebar-cases/child-sessions.ts";
+78 -1
View File
@@ -7,7 +7,7 @@ import {
installDialogPolyfill,
nextFrame,
} from "../test-helpers/modal-dialog.ts";
import "./modal-dialog.ts";
import { OpenClawModalDialog } from "./modal-dialog.ts";
let container: HTMLDivElement;
let restoreDialogPolyfill: () => void;
@@ -103,6 +103,17 @@ describe("openclaw-modal-dialog", () => {
expect(dialog.open).toBe(true);
});
it("keeps the navigation drawer sidebar in a full-height, shrinkable flex column", () => {
const styles = OpenClawModalDialog.styles.cssText;
expect(styles).toMatch(
/:host\(\.nav-drawer\)\s+wa-dialog::part\(body\)\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;[^}]*min-height:\s*0;/u,
);
expect(styles).toMatch(
/::slotted\(\.shell-nav-modal__content\)\s*\{[^}]*display:\s*flex;[^}]*flex:\s*1\s+1\s+auto;[^}]*flex-direction:\s*column;[^}]*height:\s*100%;[^}]*min-height:\s*0;/u,
);
});
it("emits modal-cancel on Escape", async () => {
const { modal, dialog } = await renderModal();
const onCancel = vi.fn();
@@ -123,6 +134,21 @@ describe("openclaw-modal-dialog", () => {
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("ignores lifecycle events from tooltips and menus nested in the modal", async () => {
const { modal, dialog } = await renderModal();
const nestedSurface = container.querySelector("#first-action");
const onCancel = vi.fn();
modal.addEventListener("modal-cancel", onCancel);
for (const type of ["wa-hide", "wa-after-hide", "wa-show", "wa-after-show"]) {
nestedSurface?.dispatchEvent(new Event(type, { bubbles: true, composed: true }));
}
expect(onCancel).not.toHaveBeenCalled();
expect(modal.open).toBe(true);
expect(dialog.open).toBe(true);
});
it("restores focus when closed and removed", async () => {
const returnTarget = document.createElement("button");
returnTarget.textContent = "Return";
@@ -138,6 +164,57 @@ describe("openclaw-modal-dialog", () => {
returnTarget.remove();
});
it("restores the explicit owner target after Web Awesome restores its original trigger", async () => {
const originalTrigger = document.createElement("button");
const returnTarget = document.createElement("button");
document.body.append(originalTrigger, returnTarget);
originalTrigger.focus();
const { modal, webAwesomeDialog } = await renderModal();
modal.setReturnFocusTarget(returnTarget);
setTimeout(() => originalTrigger.focus(), 0);
webAwesomeDialog.dispatchEvent(new Event("wa-after-hide"));
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
expect(document.activeElement).toBe(returnTarget);
originalTrigger.remove();
returnTarget.remove();
});
it("suppresses Web Awesome's original-trigger restoration when the owner closes without focus", async () => {
const originalTrigger = document.createElement("button");
document.body.append(originalTrigger);
originalTrigger.focus();
const { modal, webAwesomeDialog } = await renderModal();
modal.setReturnFocusTarget(null);
setTimeout(() => originalTrigger.focus(), 0);
webAwesomeDialog.dispatchEvent(new Event("wa-after-hide"));
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
expect(document.activeElement).not.toBe(originalTrigger);
originalTrigger.remove();
});
it("does not restore the original trigger when a suppressed modal is removed", async () => {
const originalTrigger = document.createElement("button");
document.body.append(originalTrigger);
originalTrigger.focus();
const { modal } = await renderModal();
modal.setReturnFocusTarget(null);
container.querySelector<HTMLElement>("#first-action")?.focus();
render(nothing, container);
await nextFrame();
expect(document.activeElement).not.toBe(originalTrigger);
originalTrigger.remove();
});
it("reopens the same dialog element after reconnect", async () => {
const focus = vi.spyOn(HTMLDialogElement.prototype, "focus");
const { modal, dialog } = await renderModal();
+66 -4
View File
@@ -14,6 +14,7 @@ export class OpenClawModalDialog extends OpenClawLitElement {
@query("wa-dialog") private webAwesomeDialog?: WaDialog;
private returnFocus: HTMLElement | null = null;
private returnFocusOverride: HTMLElement | null | undefined;
private syncGeneration = 0;
private suppressNextCancel = false;
@@ -63,6 +64,30 @@ export class OpenClawModalDialog extends OpenClawLitElement {
border-radius: 0;
}
:host(.nav-drawer) wa-dialog {
--width: min(86vw, 320px);
}
:host(.nav-drawer) wa-dialog::part(dialog) {
max-width: min(86vw, 320px);
margin: 0 auto 0 0;
}
:host(.nav-drawer) wa-dialog::part(body) {
display: flex;
flex-direction: column;
min-height: 0;
}
::slotted(.shell-nav-modal__content) {
display: flex;
flex: 1 1 auto;
flex-direction: column;
height: 100%;
min-height: 0;
min-width: 0;
}
@media (max-width: 640px) {
wa-dialog {
--width: calc(100vw - 24px);
@@ -92,8 +117,10 @@ export class OpenClawModalDialog extends OpenClawLitElement {
if (webAwesomeDialog) {
webAwesomeDialog.open = false;
}
const returnFocus = this.returnFocus;
const returnFocus =
this.returnFocusOverride === undefined ? this.returnFocus : this.returnFocusOverride;
this.returnFocus = null;
this.returnFocusOverride = undefined;
if (returnFocus?.isConnected) {
returnFocus.focus({ preventScroll: true });
}
@@ -169,7 +196,10 @@ export class OpenClawModalDialog extends OpenClawLitElement {
}
}
private handleAfterShow = () => {
private handleAfterShow = (event?: Event) => {
if (event && event.target !== event.currentTarget) {
return;
}
if (!this.isConnected) {
return;
}
@@ -185,17 +215,45 @@ export class OpenClawModalDialog extends OpenClawLitElement {
autofocusTarget?.focus({ preventScroll: true });
};
private handleShow = () => {
private handleShow = (event: Event) => {
if (event.target !== event.currentTarget) {
return;
}
// Web Awesome cannot see autofocus targets through this adapter's slot.
queueMicrotask(() => requestAnimationFrame(() => this.handleAfterShow()));
};
private handleAfterHide = () => {
private handleAfterHide = (event: Event) => {
if (event.target !== event.currentTarget) {
return;
}
const returnFocus = this.returnFocusOverride;
const originalReturnFocus = this.returnFocus;
this.returnFocusOverride = undefined;
this.open = false;
this.returnFocus = null;
if (returnFocus === undefined) {
return;
}
// Web Awesome queues its original-trigger restoration immediately before
// wa-after-hide; apply the owner's restoration or suppression after it.
setTimeout(() => {
if (returnFocus === null) {
if (originalReturnFocus && document.activeElement === originalReturnFocus) {
originalReturnFocus.blur();
}
} else if (returnFocus.isConnected) {
returnFocus.focus({ preventScroll: true });
}
}, 0);
};
private handleHide = (event: Event) => {
// Nested overlay lifecycle events bubble through the slot; only the
// dialog's own hide may dismiss or steal focus from its owner.
if (event.target !== event.currentTarget) {
return;
}
if (this.suppressNextCancel) {
this.suppressNextCancel = false;
return;
@@ -215,6 +273,10 @@ export class OpenClawModalDialog extends OpenClawLitElement {
this.open = true;
}
setReturnFocusTarget(target: HTMLElement | null) {
this.returnFocusOverride = target;
}
hide() {
this.open = false;
}
@@ -20,7 +20,6 @@ import { sessionCatalogHostKey } from "./app-sidebar-session-types.ts";
import type { SidebarSessionStatusFilter } from "./app-sidebar-session-types.ts";
import {
completePanelRefresh,
createPanelRefreshStatus,
failPanelRefresh,
type PanelRefreshStatus,
} from "./panel-refresh-status.ts";
@@ -46,12 +45,13 @@ export interface SessionCatalogDataOwner {
loadingMoreSessionCatalogIds: ReadonlySet<string>;
readonly sessionCatalogLive: SessionCatalogLiveState;
sessionCatalogAgentId: string | null;
sessionCatalogGeneration: number;
readonly sessionScopeGeneration: number;
sessionCatalogRevision: number;
readonly sessionCatalogPageDepths: Map<string, number>;
readonly sessionCatalogRevisions: Map<string, number>;
expandedAgentId(): string;
sessionCatalogGatewayClient(): GatewayBrowserClient | null;
synchronizeSessionScope(): void;
requestSessionDataUpdate(): void;
refreshSessionCatalogs(): Promise<void>;
}
@@ -63,30 +63,7 @@ function visibleSessionCatalogClient(owner: SessionCatalogDataOwner): GatewayBro
return sessionCatalogListClient(owner.context?.gateway.snapshot, owner.sessionDataHostConnected);
}
function synchronizeSessionCatalogAgent(
owner: SessionCatalogDataOwner,
agentId: string | null,
): void {
const nextAgentId = resolveSessionCatalogAgentId(owner, agentId);
if (nextAgentId === owner.sessionCatalogAgentId) {
return;
}
owner.sessionCatalogAgentId = nextAgentId;
owner.sessionCatalogGeneration += 1;
owner.sessionCatalogRevision += 1;
owner.sessionCatalogLive.clear();
owner.sessionCatalogRefreshStatus = createPanelRefreshStatus();
owner.loadingMoreSessionCatalogIds = new Set();
if (owner.sessionCatalogs.some((catalog) => catalog.capabilities.createSession)) {
owner.sessionCatalogs = owner.sessionCatalogs.map((catalog) => {
const { createSession: _createSession, ...capabilities } = catalog.capabilities;
return { ...catalog, capabilities };
});
}
owner.requestSessionDataUpdate();
}
function resolveSessionCatalogAgentId(
export function resolveSessionCatalogAgentId(
owner: SessionCatalogDataOwner,
candidateAgentId: string | null | undefined = owner.expandedAgentId(),
): string | null {
@@ -133,7 +110,7 @@ function requestSessionCatalogRefresh(owner: SessionCatalogDataOwner): void {
owner.isSessionDataHostConnected &&
owner.sessionCatalogAgentId !== null &&
Boolean(sessionCatalogListClient(snapshot, owner.sessionDataHostConnected)),
generation: owner.sessionCatalogGeneration,
generation: owner.sessionScopeGeneration,
refresh: () => void owner.refreshSessionCatalogs(),
});
}
@@ -148,12 +125,12 @@ export function scheduleSessionCatalogRefresh(owner: SessionCatalogDataOwner): v
export function updateSessionCatalogData(owner: SessionCatalogDataOwner, defer = false): void {
if (owner.context) {
synchronizeSessionCatalogAgent(owner, owner.expandedAgentId());
owner.synchronizeSessionScope();
}
if (
!visibleSessionCatalogClient(owner) ||
owner.sessionCatalogLive.timer ||
owner.sessionCatalogLive.requestGeneration === owner.sessionCatalogGeneration
owner.sessionCatalogLive.requestGeneration === owner.sessionScopeGeneration
) {
return;
}
@@ -164,6 +141,15 @@ export function updateSessionCatalogData(owner: SessionCatalogDataOwner, defer =
void owner.refreshSessionCatalogs();
}
export function applySessionCatalogPresence(
owner: SessionCatalogDataOwner,
payload: unknown,
): void {
if (owner.sessionCatalogLive.observePresence(payload)) {
scheduleSessionCatalogRefresh(owner);
}
}
export function applySessionCatalogHostEvent(
owner: SessionCatalogDataOwner,
payload: unknown,
@@ -184,7 +170,7 @@ export function applySessionCatalogHostEvent(
owner.sessionCatalogRevisions.set(update.catalogId, catalogRevision + 1);
if (
update.materialChange &&
owner.sessionCatalogLive.requestGeneration !== owner.sessionCatalogGeneration
owner.sessionCatalogLive.requestGeneration !== owner.sessionScopeGeneration
) {
owner.sessionCatalogLive.schedule(
SESSION_CATALOG_CHANGED_REFRESH_MS,
@@ -197,13 +183,13 @@ export function applySessionCatalogHostEvent(
export async function refreshSessionCatalogs(owner: SessionCatalogDataOwner): Promise<void> {
// Hidden pages resume through the coalesced activation handler. Starting
// here without a timer makes catalog state updates poll at request latency.
const agentId = resolveSessionCatalogAgentId(owner);
synchronizeSessionCatalogAgent(owner, agentId);
owner.synchronizeSessionScope();
const agentId = owner.sessionCatalogAgentId;
const client = visibleSessionCatalogClient(owner);
if (!client || !agentId) {
return;
}
const generation = owner.sessionCatalogGeneration;
const generation = owner.sessionScopeGeneration;
const revision = owner.sessionCatalogRevision;
await refreshSessionCatalogsLive({
live: owner.sessionCatalogLive,
@@ -211,7 +197,7 @@ export async function refreshSessionCatalogs(owner: SessionCatalogDataOwner): Pr
agentId,
generation,
revision,
currentGeneration: () => owner.sessionCatalogGeneration,
currentGeneration: () => owner.sessionScopeGeneration,
currentRevision: () => owner.sessionCatalogRevision,
currentClient: () => owner.sessionCatalogGatewayClient(),
catalogs: () => owner.sessionCatalogs,
@@ -266,7 +252,7 @@ export async function loadMoreSessionCatalog(
) {
return;
}
const generation = owner.sessionCatalogGeneration;
const generation = owner.sessionScopeGeneration;
const revision = owner.sessionCatalogRevisions.get(catalogId) ?? 0;
owner.loadingMoreSessionCatalogIds = new Set([...owner.loadingMoreSessionCatalogIds, catalogId]);
owner.requestSessionDataUpdate();
@@ -309,7 +295,7 @@ export async function loadMoreSessionCatalog(
owner.sessionCatalogRevisions.set(catalogId, revision + 1);
owner.sessionCatalogRevision += 1;
} finally {
if (generation === owner.sessionCatalogGeneration) {
if (generation === owner.sessionScopeGeneration) {
const loading = new Set(owner.loadingMoreSessionCatalogIds);
loading.delete(catalogId);
owner.loadingMoreSessionCatalogIds = loading;
@@ -326,7 +312,7 @@ function isCurrentSessionCatalogRequest(
revision: number,
): boolean {
return (
generation === owner.sessionCatalogGeneration &&
generation === owner.sessionScopeGeneration &&
revision === (owner.sessionCatalogRevisions.get(catalogId) ?? 0) &&
client === owner.sessionCatalogGatewayClient()
);
@@ -9,7 +9,9 @@ import {
type SidebarSessionPaginationOwner = {
readonly context: ApplicationContext<RouteId> | undefined;
readonly sessionScopeGeneration: number;
readonly sessionCreatedOrder: Map<string, number>;
readonly sidebarSessionPaginationState: SidebarSessionPaginationState;
sessionMutationError: string | null;
sessionRowsByAgent: Record<string, SessionsListResult["sessions"]>;
sessionsAgentId: string | null;
@@ -19,25 +21,11 @@ type SidebarSessionPaginationOwner = {
requestSessionDataUpdate(): void;
};
type SidebarSessionPaginationState = {
export type SidebarSessionPaginationState = {
listRequestToken: symbol | null;
pageRequestToken: symbol | null;
};
const sidebarSessionPaginationStates = new WeakMap<
SidebarSessionPaginationOwner,
SidebarSessionPaginationState
>();
function sidebarSessionPaginationState(owner: SidebarSessionPaginationOwner) {
let state = sidebarSessionPaginationStates.get(owner);
if (!state) {
state = { listRequestToken: null, pageRequestToken: null };
sidebarSessionPaginationStates.set(owner, state);
}
return state;
}
function publishSidebarSessionResult(
owner: SidebarSessionPaginationOwner,
agentId: string,
@@ -84,12 +72,6 @@ function appendSidebarSessionResults(
};
}
export function invalidateSidebarSessionPagination(owner: SidebarSessionPaginationOwner): void {
const state = sidebarSessionPaginationState(owner);
state.listRequestToken = null;
state.pageRequestToken = null;
}
export async function refreshSidebarSessions(
owner: SidebarSessionPaginationOwner,
agentId: string,
@@ -99,7 +81,7 @@ export async function refreshSidebarSessions(
if (!context) {
return;
}
const state = sidebarSessionPaginationState(owner);
const state = owner.sidebarSessionPaginationState;
state.pageRequestToken = null;
const archivedFilter = statusFilter();
const options = {
@@ -118,14 +100,22 @@ export async function refreshSidebarSessions(
return;
}
const gateway = context.gateway;
const client = gateway.snapshot.client;
const generation = owner.sessionScopeGeneration;
const token = Symbol(agentId);
state.listRequestToken = token;
owner.sessionsLoading = true;
owner.requestSessionDataUpdate();
const isCurrent = () =>
state.listRequestToken === token &&
owner.sessionScopeGeneration === generation &&
owner.context === context &&
owner.context.sessions === context.sessions &&
owner.context.gateway === gateway &&
gateway.snapshot.phase === "connected" &&
gateway.snapshot.client === client &&
normalizeAgentId(agentId) === normalizeAgentId(owner.expandedAgentId()) &&
archivedFilter === statusFilter();
try {
const result = await context.sessions.list(options);
@@ -138,7 +128,7 @@ export async function refreshSidebarSessions(
owner.requestSessionDataUpdate();
}
} finally {
if (state.listRequestToken === token) {
if (state.listRequestToken === token && owner.sessionScopeGeneration === generation) {
owner.sessionsLoading = false;
owner.requestSessionDataUpdate();
}
@@ -155,7 +145,7 @@ export async function loadMoreSidebarSessions(
// Gateway cursors are optional; accumulated rows provide the same next page.
const offset =
previous?.nextOffset === undefined ? previous?.sessions.length : previous.nextOffset;
const state = sidebarSessionPaginationState(owner);
const state = owner.sidebarSessionPaginationState;
// A pending first-page refresh owns the list; its old offset cannot safely
// start a page that would append to a superseded session snapshot.
if (
@@ -172,15 +162,18 @@ export async function loadMoreSidebarSessions(
const gateway = context.gateway;
const client = gateway.snapshot.client;
const generation = owner.sessionScopeGeneration;
const archivedFilter = statusFilter();
const listRequestToken = state.listRequestToken;
const token = Symbol(agentId);
state.pageRequestToken = token;
const isCurrent = () =>
state.pageRequestToken === token &&
owner.sessionScopeGeneration === generation &&
owner.context === context &&
owner.context.sessions === context.sessions &&
owner.context.gateway === gateway &&
gateway.snapshot.phase === "connected" &&
gateway.snapshot.client === client &&
archivedFilter === statusFilter() &&
normalizeAgentId(agentId) === normalizeAgentId(owner.expandedAgentId()) &&
@@ -219,7 +212,7 @@ export async function loadMoreSidebarSessions(
owner.requestSessionDataUpdate();
}
} finally {
if (state.pageRequestToken === token) {
if (state.pageRequestToken === token && owner.sessionScopeGeneration === generation) {
state.pageRequestToken = null;
if (archivedFilter !== "active") {
owner.sessionsLoading = false;
+80 -10
View File
@@ -34,17 +34,19 @@ import {
import { createPanelRefreshStatus, type PanelRefreshStatus } from "./panel-refresh-status.ts";
import {
applySessionCatalogHostEvent as applySessionCatalogHostEventToData,
applySessionCatalogPresence as applySessionCatalogPresenceToData,
loadMoreSessionCatalog as loadMoreSessionCatalogData,
refreshSessionCatalogs as refreshSessionCatalogData,
resolveSessionCatalogAgentId,
scheduleSessionCatalogRefresh,
type SessionCatalogDataOwner,
type SessionDataControllerHost,
updateSessionCatalogData as updateSessionCatalogDataForHost,
} from "./session-data-controller-catalog.ts";
import {
invalidateSidebarSessionPagination,
loadMoreSidebarSessions as loadMoreSidebarSessionPage,
refreshSidebarSessions as refreshSidebarSessionPage,
type SidebarSessionPaginationState,
} from "./session-data-controller-pagination.ts";
/** Gateway-backed session-list and external-catalog data ownership. */
@@ -72,11 +74,16 @@ export class SessionDataController implements ReactiveController, SessionCatalog
private readonly subscriptions: SubscriptionsController;
readonly sessionCatalogLive = new SessionCatalogLiveState();
readonly sidebarSessionPaginationState: SidebarSessionPaginationState = {
listRequestToken: null,
pageRequestToken: null,
};
sessionScopeGeneration = 0;
sessionCatalogAgentId: string | null = null;
sessionCatalogGeneration = 0;
sessionCatalogRevision = 0;
readonly sessionCatalogPageDepths = new Map<string, number>();
readonly sessionCatalogRevisions = new Map<string, number>();
private sessionScopeAgentId: string | null = null;
private sessionsSource: SessionCapability | null = null;
private childSessionGeneration = 0;
private childSessionCanonicalListRevision: number | null = null;
@@ -147,6 +154,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog
.watch(
() => this.context?.agentSelection,
(agentSelection, notify) => agentSelection.subscribe(notify),
() => this.synchronizeSessionScope(),
)
.watch(
() => this.context?.overlays,
@@ -186,6 +194,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog
}
hostUpdated(): void {
this.synchronizeSessionScope();
this.syncSessionsScrollObserver();
this.updateSessionCatalogData(true);
}
@@ -246,22 +255,79 @@ export class SessionDataController implements ReactiveController, SessionCatalog
}
retireSessionCatalogData(): void {
this.sessionCatalogGeneration += 1;
this.sessionScopeGeneration += 1;
this.sidebarSessionPaginationState.listRequestToken = null;
this.sidebarSessionPaginationState.pageRequestToken = null;
this.sessionsLoading = false;
this.loadingMoreSessionCatalogIds = new Set();
this.sessionCatalogLive.clear();
}
resetSessionCatalogConnection(): void {
this.sessionCatalogGeneration += 1;
this.retireSessionCatalogData();
this.sessionCatalogRevision += 1;
this.sessionCatalogLive.resetConnection();
this.sessionCatalogs = [];
this.sessionCatalogRefreshStatus = createPanelRefreshStatus();
this.loadingMoreSessionCatalogIds = new Set();
this.sessionCatalogPageDepths.clear();
this.sessionCatalogRevisions.clear();
this.notify();
}
synchronizeSessionScope(): void {
const context = this.context;
const nextAgentId = context ? normalizeAgentId(this.host.expandedAgentId()) : null;
// A reconnect cannot revoke ownership until its replacement hello is authoritative.
const nextCatalogAgentId =
resolveSessionCatalogAgentId(this) ??
(context?.gateway.snapshot.phase !== "connected" ? this.sessionCatalogAgentId : null);
if (
nextAgentId === this.sessionScopeAgentId &&
nextCatalogAgentId === this.sessionCatalogAgentId
) {
return;
}
const previousAgentId = this.sessionScopeAgentId;
const previousCatalogAgentId = this.sessionCatalogAgentId;
const agentChanged = previousAgentId !== null && previousAgentId !== nextAgentId;
const catalogAgentChanged =
previousCatalogAgentId !== null && previousCatalogAgentId !== nextCatalogAgentId;
const currentCanonicalAgentId = this.sessionsAgentId;
const ownsCurrentCanonicalList =
this.host.sidebarSessionStatusFilter() === "active" &&
nextAgentId !== null &&
currentCanonicalAgentId !== null &&
normalizeAgentId(currentCanonicalAgentId) === nextAgentId &&
this.sessionsResult === context?.sessions.state.result;
this.sessionScopeAgentId = nextAgentId;
this.sessionCatalogAgentId = nextCatalogAgentId;
this.retireSessionCatalogData();
this.sessionCatalogRevision += 1;
this.sessionCatalogRefreshStatus = createPanelRefreshStatus();
if (agentChanged || catalogAgentChanged) {
// Catalog cursors and rows belong to the selected agent, not just its host.
this.sessionCatalogs = [];
this.sessionCatalogPageDepths.clear();
this.sessionCatalogRevisions.clear();
}
if (agentChanged && !ownsCurrentCanonicalList) {
// A replacement capability may publish its new-agent list before selection synchronizes.
this.clearSessionCache();
}
this.notify();
if (
agentChanged &&
context?.gateway.snapshot.phase === "connected" &&
this.host.sidebarSessionStatusFilter() !== "active"
) {
void this.refreshSidebarSessions();
}
}
updateSessionCatalogData(defer = false): void {
updateSessionCatalogDataForHost(this, defer);
}
@@ -271,9 +337,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog
}
handleSessionCatalogPresence(payload: unknown): void {
if (this.sessionCatalogLive.observePresence(payload)) {
scheduleSessionCatalogRefresh(this);
}
applySessionCatalogPresenceToData(this, payload);
}
private readonly handleCatalogSessionContinued = (
@@ -427,6 +491,8 @@ export class SessionDataController implements ReactiveController, SessionCatalog
const connectedStarted = connected && !this.gatewayConnected;
const sourceOrClientChanged = gateway !== this.gatewaySource || client !== this.gatewayClient;
const connectionChanged = connected !== this.gatewayConnected;
// Presence and auth snapshots must not retire this client's in-flight
// native or catalog pages unless its connection phase actually changes.
if (!sourceOrClientChanged && !connectionChanged) {
return;
}
@@ -443,6 +509,10 @@ export class SessionDataController implements ReactiveController, SessionCatalog
}
this.notify();
if (!sourceOrClientChanged) {
this.retireSessionCatalogData();
if (connected && this.sessionsSource && this.host.sidebarSessionStatusFilter() !== "active") {
void this.refreshSidebarSessions();
}
return;
}
this.clearSessionCache();
@@ -453,7 +523,6 @@ export class SessionDataController implements ReactiveController, SessionCatalog
}
private clearSessionCache(): void {
invalidateSidebarSessionPagination(this);
this.childSessionGeneration += 1;
this.childSessionCanonicalListRevision = null;
this.reconnectListRevision = null;
@@ -617,7 +686,8 @@ export class SessionDataController implements ReactiveController, SessionCatalog
}
resetForStatusFilter(statusFilter: SidebarSessionStatusFilter): void {
invalidateSidebarSessionPagination(this);
this.sidebarSessionPaginationState.listRequestToken = null;
this.sidebarSessionPaginationState.pageRequestToken = null;
this.sessionsLoading = false;
this.visibleSessionLimits.clear();
this.childSessionRowsByParent = {};
@@ -9,7 +9,9 @@ import {
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
type ControlUiMockGatewayScenario,
} from "../test-helpers/control-ui-e2e.ts";
import { chatSessionListResponse } from "./chat-flow.test-support.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
@@ -39,7 +41,12 @@ describeControlUiE2e("Control UI native-nav sidebar toggle E2E", () => {
context = undefined;
});
async function openPage(options: { nativeNav?: boolean; webChrome?: boolean; width?: number }) {
async function openPage(options: {
nativeNav?: boolean;
scenario?: ControlUiMockGatewayScenario;
webChrome?: boolean;
width?: number;
}) {
context = await browser.newContext({
locale: "en-US",
serviceWorkers: "block",
@@ -99,12 +106,15 @@ describeControlUiE2e("Control UI native-nav sidebar toggle E2E", () => {
}
});
}
await installMockGateway(page);
const gateway = await installMockGateway(page, options.scenario);
const response = await page.goto(server.baseUrl);
expect(response?.status()).toBe(200);
// The brand row only becomes visible on desktop widths; drawer widths keep
// the sidebar hidden, so wait for DOM attachment instead of visibility.
await page.locator(".sidebar-brand").waitFor({ state: "attached" });
if (options.scenario) {
await gateway.waitForRequest("sessions.list");
}
return page;
}
@@ -303,24 +313,27 @@ describeControlUiE2e("Control UI native-nav sidebar toggle E2E", () => {
.toBe(true);
});
it("keeps the mobile drawer inert while closed and announces its expanded state", async () => {
const page = await openPage({ nativeNav: false, width: 900 });
it("keeps the mobile drawer modal, keyboard-contained, and focus-restoring", async () => {
const page = await openPage({
nativeNav: false,
scenario: {
methodResponses: { "sessions.list": chatSessionListResponse() },
},
width: 900,
});
const navigation = page.locator(".shell-nav");
const backdrop = page.locator(".shell-nav-backdrop");
const drawer = navigation.locator("openclaw-modal-dialog.nav-drawer");
const dialog = page.getByRole("dialog", { name: "Navigation" });
const trigger = page.locator(".chat-pane__nav-toggle").first();
await expect.poll(() => navigation.getAttribute("inert")).toBe("");
await expect.poll(() => backdrop.getAttribute("inert")).toBe("");
await expect.poll(() => page.locator(".shell-nav-backdrop").count()).toBe(0);
await expect.poll(() => dialog.isVisible()).toBe(false);
await page.locator(".shell-skip-link").focus();
await page.keyboard.press("Tab");
await expect
.poll(() =>
page.evaluate(() => ({
backdrop: document.activeElement?.matches(".shell-nav-backdrop") ?? false,
navigation: document.activeElement?.closest(".shell-nav") !== null,
})),
)
.toEqual({ backdrop: false, navigation: false });
.poll(() => page.evaluate(() => document.activeElement?.closest(".shell-nav") !== null))
.toBe(false);
await expect.poll(() => trigger.getAttribute("aria-expanded")).toBe("false");
await expect.poll(() => trigger.getAttribute("aria-label")).toBe("Expand sidebar");
@@ -331,9 +344,35 @@ describeControlUiE2e("Control UI native-nav sidebar toggle E2E", () => {
.poll(() => page.locator(".shell").getAttribute("class"))
.toContain("shell--nav-drawer-open");
await expect.poll(() => navigation.getAttribute("inert")).toBeNull();
await expect.poll(() => backdrop.getAttribute("inert")).toBeNull();
await expect.poll(() => dialog.isVisible()).toBe(true);
await expect.poll(() => trigger.getAttribute("aria-expanded")).toBe("true");
await expect.poll(() => trigger.getAttribute("aria-label")).toBe("Collapse sidebar");
await expect
.poll(() => navigation.evaluate((element) => element.contains(document.activeElement)))
.toBe(true);
for (const key of ["Tab", "Tab", "Shift+Tab", "Shift+Tab"] as const) {
await page.keyboard.press(key);
await expect
.poll(() => navigation.evaluate((element) => element.contains(document.activeElement)))
.toBe(true);
}
expect(
await page.locator("#control-ui-main").evaluate((element) => {
element.focus();
return element === document.activeElement;
}),
).toBe(false);
const row = navigation.locator(".sidebar-recent-session").first();
await row.hover();
await row.getByRole("button", { name: "Open thread menu" }).click();
const sessionMenu = page.getByRole("menu", { name: /Actions for/ });
await expect.poll(() => sessionMenu.isVisible()).toBe(true);
await page.keyboard.press("Escape");
await expect.poll(() => sessionMenu.count()).toBe(0);
await expect.poll(() => dialog.isVisible()).toBe(true);
await page.keyboard.press("Escape");
await expect
@@ -345,9 +384,17 @@ describeControlUiE2e("Control UI native-nav sidebar toggle E2E", () => {
.poll(() => trigger.evaluate((element) => element === document.activeElement))
.toBe(true);
await trigger.click();
await expect.poll(() => dialog.isVisible()).toBe(true);
await page.mouse.click(899, 450);
await expect.poll(() => dialog.isVisible()).toBe(false);
await expect
.poll(() => trigger.evaluate((element) => element === document.activeElement))
.toBe(true);
await page.setViewportSize({ width: 1280, height: 900 });
await expect.poll(() => navigation.getAttribute("inert")).toBeNull();
await expect.poll(() => backdrop.getAttribute("inert")).toBe("");
await expect.poll(() => drawer.count()).toBe(0);
});
it("keeps the sidebar rail beside a half-width native link browser", async () => {
@@ -217,6 +217,27 @@ suite.define(() => {
.toBe(true);
await expectHiddenShortcutsInert(beforeNarrowTransition);
await drawerToggle.click();
await expect.poll(() => shell.getAttribute("class")).toContain("shell--nav-drawer-open");
await expect
.poll(() => shellNav.evaluate((element) => element.getBoundingClientRect().left))
.toBe(0);
// Leaving an open drawer must close its fixed menu and clear the drawer
// before the same sidebar moves back into the desktop navigation slot.
await openSessionMenu();
const beforeWideTransition = await hiddenActionCounts();
await page.setViewportSize({ height: 900, width: 1280 });
await expect.poll(() => shell.getAttribute("class")).not.toContain("shell--mobile-nav");
await expect.poll(() => shell.getAttribute("class")).not.toContain("shell--nav-drawer-open");
await expect.poll(() => sidebar.isVisible()).toBe(true);
await expect.poll(() => sessionMenu.count()).toBe(0);
await expectHiddenShortcutsInert(beforeWideTransition);
// Returning to drawer layout must not resurrect the prior open drawer.
await page.setViewportSize({ height: 900, width: 900 });
await expectDrawerClosed();
await expect.poll(() => sessionMenu.count()).toBe(0);
await drawerToggle.click();
await expect.poll(() => shell.getAttribute("class")).toContain("shell--nav-drawer-open");
await expect
+10 -18
View File
@@ -32,6 +32,7 @@ export class GatewayBoardProvider implements BoardProvider {
private readonly snapshotSignal: ValueSignal<BoardSnapshot>;
private readonly eventStream = new EventStream<BoardCommandEvent>();
private client: BoardGatewayClient;
private readonly retiredClients = new WeakSet<BoardGatewayClient>();
private clientGeneration = 0;
private unsubscribe: (() => void) | undefined;
private refreshLoop: Promise<void> | undefined;
@@ -48,10 +49,10 @@ export class GatewayBoardProvider implements BoardProvider {
readonly sessionKey: string,
client: BoardGatewayClient,
connected = true,
public canPinWidgets = true,
public canPinMcpApps = false,
public canMutate = true,
public canGrant = true,
public readonly canPinWidgets = true,
public readonly canPinMcpApps = false,
public readonly canMutate = true,
public readonly canGrant = true,
) {
this.snapshotSignal = new ValueSignal(emptyBoardSnapshot(sessionKey));
this.snapshot$ = this.snapshotSignal;
@@ -64,29 +65,20 @@ export class GatewayBoardProvider implements BoardProvider {
}
}
attachClient(
client: BoardGatewayClient,
connected = true,
canPinWidgets = true,
canPinMcpApps = false,
canMutate = true,
canGrant = true,
): void {
if (this.disposed) {
attachClient(client: BoardGatewayClient, connected = true): void {
if (this.disposed || (client !== this.client && this.retiredClients.has(client))) {
return;
}
const connectionActivated = connected && !this.connected;
this.connected = connected;
this.canPinWidgets = canPinWidgets;
this.canPinMcpApps = canPinMcpApps;
this.canMutate = canMutate;
this.canGrant = canGrant;
if (client === this.client) {
if (connectionActivated) {
void this.activate();
}
return;
}
// Gateway clients never become current again after a replacement; stale leases must not roll back the shared transport.
this.retiredClients.add(this.client);
this.unsubscribe?.();
this.client = client;
this.clientGeneration += 1;
@@ -245,7 +237,7 @@ export class GatewayBoardProvider implements BoardProvider {
private subscribe(client: BoardGatewayClient): void {
this.unsubscribe = client.addEventListener((event) => {
if (this.disposed) {
if (this.disposed || client !== this.client) {
return;
}
if (event.event === "board.changed") {
@@ -0,0 +1,237 @@
import { expect, it, vi } from "vitest";
import type { GatewayEventFrame } from "../../api/gateway.ts";
import { t } from "../../i18n/index.ts";
import { acquireBoardProviderForSession, mcpAppWidgetNameForViewId } from "./provider.ts";
export function registerBoardProviderLeaseCases(disableMockBoard: () => void): void {
it("keeps retired gateway clients from rolling back shared board leases", async () => {
disableMockBoard();
const sessionKey = "agent:main:monotonic-board-lease";
const capabilities = {
canPinWidgets: true,
canPinMcpApps: true,
canMutate: true,
canGrant: true,
};
const createClient = (revision: number) => {
const snapshot = { sessionKey, revision, tabs: [], widgets: [] };
const requests = vi.fn(async () => snapshot);
const listeners = new Set<(event: GatewayEventFrame) => void>();
const addEventListener = vi.fn((listener: (event: GatewayEventFrame) => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
});
return {
snapshot,
requests,
listeners,
addEventListener,
request: requests as never,
};
};
const previous = createClient(1);
const current = createClient(2);
const future = createClient(3);
const writer = acquireBoardProviderForSession(sessionKey, previous);
const stale = acquireBoardProviderForSession(sessionKey, previous);
try {
await vi.waitFor(() => expect(writer.provider.snapshot$.value).toEqual(previous.snapshot));
const retiredListener = [...previous.listeners][0];
expect(retiredListener).toBeDefined();
writer.update(current, true, capabilities);
await vi.waitFor(() => expect(writer.provider.snapshot$.value).toEqual(current.snapshot));
expect(stale.provider.snapshot$.value).toEqual(current.snapshot);
expect(previous.listeners.size).toBe(0);
expect(current.listeners.size).toBe(1);
stale.update(previous, false, capabilities);
expect(writer.provider.snapshot$.value).toEqual(current.snapshot);
expect(stale.provider.snapshot$.value).toEqual(current.snapshot);
expect(previous.addEventListener).toHaveBeenCalledOnce();
expect(previous.requests).toHaveBeenCalledOnce();
expect(current.listeners.size).toBe(1);
retiredListener?.({
type: "event",
event: "board.changed",
payload: { sessionKey, revision: 4 },
});
await Promise.resolve();
expect(current.requests).toHaveBeenCalledOnce();
await expect(writer.provider.applyOps([])).resolves.toBeUndefined();
expect(current.requests).toHaveBeenCalledWith("board.update", { sessionKey, ops: [] });
expect(previous.requests).toHaveBeenCalledOnce();
writer.update(current, false, capabilities);
writer.update(current, true, capabilities);
await vi.waitFor(() => expect(current.requests).toHaveBeenCalledTimes(3));
expect(writer.provider.snapshot$.value).toEqual(current.snapshot);
stale.update(future, true, capabilities);
await vi.waitFor(() => expect(writer.provider.snapshot$.value).toEqual(future.snapshot));
expect(stale.provider.snapshot$.value).toEqual(future.snapshot);
expect(current.listeners.size).toBe(0);
expect(future.listeners.size).toBe(1);
await expect(writer.provider.applyOps([])).resolves.toBeUndefined();
expect(future.requests).toHaveBeenCalledWith("board.update", { sessionKey, ops: [] });
expect(previous.requests).toHaveBeenCalledOnce();
} finally {
writer.release();
stale.release();
}
});
it.each(["chat-first", "dashboard-first"] as const)(
"isolates concurrent board lease capabilities in %s order",
async (order) => {
disableMockBoard();
const sessionKey = `agent:main:lease-capabilities-${order}`;
const snapshot = { sessionKey, revision: 1, tabs: [], widgets: [] };
const removeListener = vi.fn();
const client = {
request: vi.fn(async () => snapshot) as never,
addEventListener: vi.fn(() => removeListener),
};
const acquireChat = () =>
acquireBoardProviderForSession(sessionKey, client, true, true, true, true, true);
const acquireDashboard = () =>
acquireBoardProviderForSession(sessionKey, client, true, false, false, false, false);
const first = order === "chat-first" ? acquireChat() : acquireDashboard();
const second = order === "chat-first" ? acquireDashboard() : acquireChat();
const chat = order === "chat-first" ? first : second;
const dashboard = order === "chat-first" ? second : first;
try {
await vi.waitFor(() => expect(chat.provider.snapshot$.value).toEqual(snapshot));
expect(chat.provider).not.toBe(dashboard.provider);
expect(chat.provider.snapshot$).toBe(dashboard.provider.snapshot$);
expect(chat.provider.events).toBe(dashboard.provider.events);
expect(chat.provider).toMatchObject({
canPinWidgets: true,
canPinMcpApps: true,
canMutate: true,
canGrant: true,
});
expect(dashboard.provider).toMatchObject({
canPinWidgets: false,
canPinMcpApps: false,
canMutate: false,
canGrant: false,
});
expect(client.request).toHaveBeenCalledOnce();
expect(client.addEventListener).toHaveBeenCalledOnce();
await expect(chat.provider.applyOps([])).resolves.toBeUndefined();
await expect(chat.provider.pinWidget({ docId: "cv-allowed" })).resolves.toBeUndefined();
await expect(chat.provider.pinMcpApp({ viewId: "app-allowed" })).resolves.toBeUndefined();
await expect(dashboard.provider.pinWidget({ docId: "cv-denied" })).rejects.toThrow();
await expect(dashboard.provider.pinMcpApp({ viewId: "app-denied" })).rejects.toThrow();
expect(client.request).toHaveBeenCalledTimes(4);
expect(client.request).toHaveBeenCalledWith("board.update", { sessionKey, ops: [] });
expect(client.request).toHaveBeenCalledWith("board.widget.put", {
sessionKey,
name: "canvas-cv-allowed",
content: { kind: "canvas-doc", docId: "cv-allowed" },
});
expect(client.request).toHaveBeenCalledWith("board.widget.put", {
sessionKey,
name: mcpAppWidgetNameForViewId("app-allowed"),
content: { kind: "mcp-app", viewId: "app-allowed" },
});
first.release();
first.release();
expect(removeListener).not.toHaveBeenCalled();
expect(second.provider.snapshot$.value).toEqual(snapshot);
await expect(first.provider.applyOps([])).rejects.toThrow();
expect(client.request).toHaveBeenCalledTimes(4);
second.release();
expect(removeListener).toHaveBeenCalledOnce();
} finally {
first.release();
second.release();
}
},
);
it("enforces write and approval scopes separately for concurrent board leases", async () => {
disableMockBoard();
const sessionKey = "agent:main:independent-board-scopes";
const snapshot = {
sessionKey,
revision: 1,
tabs: [
{
tabId: "main",
title: t("chat.board.defaultTab"),
position: 0,
chatDock: "right" as const,
},
],
widgets: [
{
name: "pending-widget",
tabId: "main",
contentKind: "html" as const,
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "pending" as const,
revision: 1,
},
],
};
const client = {
request: vi.fn(async () => snapshot) as never,
addEventListener: vi.fn(() => () => {}),
};
const writer = acquireBoardProviderForSession(
sessionKey,
client,
true,
true,
true,
true,
false,
);
const approver = acquireBoardProviderForSession(
sessionKey,
client,
true,
false,
false,
false,
true,
);
try {
await vi.waitFor(() => expect(writer.provider.snapshot$.value).toEqual(snapshot));
await expect(writer.provider.applyOps([])).resolves.toBeUndefined();
await expect(writer.provider.grant("pending-widget", "granted")).rejects.toThrow();
await expect(approver.provider.applyOps([])).rejects.toThrow();
await expect(approver.provider.pinWidget({ docId: "cv-restricted" })).rejects.toThrow();
await expect(approver.provider.pinMcpApp({ viewId: "app-restricted" })).rejects.toThrow();
await expect(approver.provider.grant("pending-widget", "granted")).resolves.toBeUndefined();
expect(client.request).toHaveBeenCalledTimes(3);
expect(client.request).toHaveBeenCalledWith("board.update", { sessionKey, ops: [] });
expect(client.request).toHaveBeenCalledWith("board.widget.grant", {
sessionKey,
name: "pending-widget",
decision: "granted",
revision: 1,
});
expect(client.addEventListener).toHaveBeenCalledOnce();
} finally {
writer.release();
approver.release();
}
});
}
+248 -5
View File
@@ -1,11 +1,13 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerBoardProviderLeaseCases } from "./provider.lease-cases.test-support.ts";
import {
acquireBoardProviderForSession,
boardExists,
boardProviderForSession,
canvasWidgetNameForDocument,
GatewayBoardProvider,
hasLoadedBoardSnapshot,
mcpAppWidgetNameForViewId,
recordSessionBoardAvailability,
sessionHasBoard,
@@ -76,7 +78,7 @@ describe("board providers", () => {
expect(boardExists(provider.snapshot$.value)).toBe(false);
});
it("updates pin capability independently from board availability", () => {
it("keeps the cached gateway transport stable across consumer capability profiles", () => {
mockLocation.search = "";
const client = {
request: vi.fn(),
@@ -102,10 +104,249 @@ describe("board providers", () => {
false,
),
).toBe(provider);
expect(provider.canPinWidgets).toBe(true);
expect(provider.canPinWidgets).toBe(false);
expect(provider.canPinMcpApps).toBe(false);
boardProviderForSession("agent:main:pin-capability", client as never, true, false, true, true);
expect(provider.canPinMcpApps).toBe(true);
expect(
boardProviderForSession(
"agent:main:pin-capability",
client as never,
true,
false,
true,
true,
),
).toBe(provider);
expect(provider.canPinMcpApps).toBe(false);
});
registerBoardProviderLeaseCases(() => {
mockLocation.search = "";
});
it("updates only the capabilities of the owning gateway board lease", async () => {
mockLocation.search = "";
const sessionKey = "agent:main:lease-capability-update";
const snapshot = { sessionKey, revision: 1, tabs: [], widgets: [] };
const client = {
request: vi.fn(async () => snapshot) as never,
addEventListener: vi.fn(() => () => {}),
};
const writable = acquireBoardProviderForSession(
sessionKey,
client,
true,
true,
true,
true,
false,
);
const approver = acquireBoardProviderForSession(
sessionKey,
client,
true,
false,
false,
false,
true,
);
try {
await vi.waitFor(() => expect(writable.provider.snapshot$.value).toEqual(snapshot));
writable.update(client, true, {
canPinWidgets: false,
canPinMcpApps: false,
canMutate: false,
canGrant: false,
});
expect(writable.provider).toMatchObject({
canPinWidgets: false,
canPinMcpApps: false,
canMutate: false,
canGrant: false,
});
expect(approver.provider).toMatchObject({
canPinWidgets: false,
canPinMcpApps: false,
canMutate: false,
canGrant: true,
});
expect(client.request).toHaveBeenCalledOnce();
expect(client.addEventListener).toHaveBeenCalledOnce();
writable.update(client, true, {
canPinWidgets: true,
canPinMcpApps: true,
canMutate: true,
canGrant: false,
});
expect(writable.provider.canMutate).toBe(true);
expect(writable.provider.canPinWidgets).toBe(true);
expect(writable.provider.canPinMcpApps).toBe(true);
expect(writable.provider.canGrant).toBe(false);
expect(approver.provider.canGrant).toBe(true);
expect(approver.provider.canMutate).toBe(false);
expect(client.request).toHaveBeenCalledOnce();
} finally {
writable.release();
approver.release();
}
});
it("dispatches newly authorized board actions after upgrading a read-only lease", async () => {
mockLocation.search = "";
const sessionKey = "agent:main:lease-scope-upgrade";
const snapshot = {
sessionKey,
revision: 1,
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
widgets: [
{
name: "pending-widget",
tabId: "main",
contentKind: "html" as const,
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "pending" as const,
revision: 1,
},
],
};
const client = {
request: vi.fn(async () => snapshot) as never,
addEventListener: vi.fn(() => () => {}),
};
const lease = acquireBoardProviderForSession(
sessionKey,
client,
true,
false,
false,
false,
false,
);
try {
await vi.waitFor(() => expect(lease.provider.snapshot$.value).toEqual(snapshot));
await expect(lease.provider.applyOps([])).rejects.toThrow();
await expect(lease.provider.pinWidget({ docId: "cv-upgraded" })).rejects.toThrow();
await expect(lease.provider.pinMcpApp({ viewId: "app-upgraded" })).rejects.toThrow();
await expect(lease.provider.grant("pending-widget", "granted")).rejects.toThrow();
expect(client.request).toHaveBeenCalledOnce();
lease.update(client, true, {
canPinWidgets: true,
canPinMcpApps: true,
canMutate: true,
canGrant: true,
});
await expect(lease.provider.applyOps([])).resolves.toBeUndefined();
await expect(lease.provider.pinWidget({ docId: "cv-upgraded" })).resolves.toBeUndefined();
await expect(lease.provider.pinMcpApp({ viewId: "app-upgraded" })).resolves.toBeUndefined();
await expect(lease.provider.grant("pending-widget", "granted")).resolves.toBeUndefined();
expect(client.request).toHaveBeenCalledTimes(5);
expect(client.request).toHaveBeenCalledWith("board.update", { sessionKey, ops: [] });
expect(client.request).toHaveBeenCalledWith("board.widget.put", {
sessionKey,
name: "canvas-cv-upgraded",
content: { kind: "canvas-doc", docId: "cv-upgraded" },
});
expect(client.request).toHaveBeenCalledWith("board.widget.put", {
sessionKey,
name: mcpAppWidgetNameForViewId("app-upgraded"),
content: { kind: "mcp-app", viewId: "app-upgraded" },
});
expect(client.request).toHaveBeenCalledWith("board.widget.grant", {
sessionKey,
name: "pending-widget",
decision: "granted",
revision: 1,
});
expect(client.addEventListener).toHaveBeenCalledOnce();
lease.update(client, true, {
canPinWidgets: false,
canPinMcpApps: false,
canMutate: false,
canGrant: false,
});
await expect(lease.provider.applyOps([])).rejects.toThrow();
await expect(lease.provider.pinWidget({ docId: "cv-upgraded" })).rejects.toThrow();
await expect(lease.provider.pinMcpApp({ viewId: "app-upgraded" })).rejects.toThrow();
await expect(lease.provider.grant("pending-widget", "granted")).rejects.toThrow();
expect(client.request).toHaveBeenCalledTimes(5);
} finally {
lease.release();
}
});
it("reconnects concurrent board leases through the same cached gateway transport", async () => {
mockLocation.search = "";
const sessionKey = "agent:main:shared-lease-reconnect";
const previousSnapshot = { sessionKey, revision: 1, tabs: [], widgets: [] };
const nextSnapshot = { ...previousSnapshot, revision: 2 };
const removePreviousListener = vi.fn();
const removeNextListener = vi.fn();
const previousClient = {
request: vi.fn(async () => previousSnapshot) as never,
addEventListener: vi.fn(() => removePreviousListener),
};
const nextClient = {
request: vi.fn(async () => nextSnapshot) as never,
addEventListener: vi.fn(() => removeNextListener),
};
const writer = acquireBoardProviderForSession(
sessionKey,
previousClient,
true,
true,
true,
true,
false,
);
const approver = acquireBoardProviderForSession(
sessionKey,
previousClient,
true,
false,
false,
false,
true,
);
const cached = boardProviderForSession(sessionKey);
try {
await vi.waitFor(() => expect(writer.provider.snapshot$.value).toEqual(previousSnapshot));
writer.update(nextClient, true, {
canPinWidgets: true,
canPinMcpApps: true,
canMutate: true,
canGrant: false,
});
await vi.waitFor(() => expect(writer.provider.snapshot$.value).toEqual(nextSnapshot));
expect(approver.provider.snapshot$.value).toEqual(nextSnapshot);
expect(boardProviderForSession(sessionKey)).toBe(cached);
expect(removePreviousListener).toHaveBeenCalledOnce();
expect(nextClient.addEventListener).toHaveBeenCalledOnce();
expect(nextClient.request).toHaveBeenCalledOnce();
expect(approver.provider.canGrant).toBe(true);
expect(approver.provider.canMutate).toBe(false);
writer.release();
expect(removeNextListener).not.toHaveBeenCalled();
approver.release();
expect(removeNextListener).toHaveBeenCalledOnce();
} finally {
writer.release();
approver.release();
}
});
it("disposes a released gateway provider and creates a fresh provider on reacquire", async () => {
@@ -173,12 +414,14 @@ describe("board providers", () => {
});
try {
expect(lease.provider).toBeInstanceOf(GatewayBoardProvider);
expect(boardProviderForSession(sessionKey)).toBeInstanceOf(GatewayBoardProvider);
expect(hasLoadedBoardSnapshot(lease.provider)).toBe(false);
expect(sessionHasBoard(sessionKey)).toBe(true);
resolveSnapshot?.(emptySnapshot);
await vi.waitFor(() => expect(lease.provider.snapshot$.value).toEqual(emptySnapshot));
expect(hasLoadedBoardSnapshot(lease.provider)).toBe(true);
expect(sessionHasBoard(sessionKey)).toBe(false);
} finally {
resolveSnapshot?.(emptySnapshot);
+129 -11
View File
@@ -258,6 +258,105 @@ class MockBoardProvider implements BoardProvider {
}
}
type BoardProviderCapabilities = Pick<
BoardProvider,
"canPinWidgets" | "canPinMcpApps" | "canMutate" | "canGrant"
>;
// Snapshots and gateway subscriptions are session-owned, but authority belongs
// to each live consumer; sharing it would let another dashboard widen an action.
class ScopedGatewayBoardProvider implements BoardProvider {
readonly snapshot$: BoardSnapshotSignal<BoardSnapshot>;
readonly events: BoardEventStream<BoardCommandEvent>;
private active = true;
constructor(
private readonly transport: GatewayBoardProvider,
private capabilities: BoardProviderCapabilities,
) {
this.snapshot$ = transport.snapshot$;
this.events = transport.events;
}
get sessionKey(): string {
return this.transport.sessionKey;
}
get canPinWidgets(): boolean {
return this.active && this.capabilities.canPinWidgets;
}
get canPinMcpApps(): boolean {
return this.active && this.capabilities.canPinMcpApps;
}
get canMutate(): boolean {
return this.active && this.capabilities.canMutate;
}
get canGrant(): boolean {
return this.active && this.capabilities.canGrant;
}
get hasLoadedSnapshot(): boolean {
return this.transport.hasLoadedSnapshot;
}
updateCapabilities(capabilities: BoardProviderCapabilities): void {
if (this.active) {
this.capabilities = capabilities;
}
}
deactivate(): void {
this.active = false;
}
async applyOps(ops: BoardOp[]): Promise<void> {
if (!this.canMutate) {
throw new Error("Session dashboard mutation unavailable");
}
await this.transport.applyOps(ops);
}
async grant(name: string, decision: "granted" | "rejected"): Promise<void> {
if (!this.canGrant) {
throw new Error("Session dashboard approval unavailable");
}
await this.transport.grant(name, decision);
}
async pinWidget(input: BoardPinWidgetInput): Promise<void> {
if (!this.canMutate || !this.canPinWidgets) {
throw new Error("Session dashboard widget pinning unavailable");
}
await this.transport.pinWidget(input);
}
async pinMcpApp(input: BoardPinMcpAppInput): Promise<void> {
if (!this.canMutate || !this.canPinMcpApps) {
throw new Error("Session dashboard MCP App pinning unavailable");
}
await this.transport.pinMcpApp(input);
}
widgetFrameUrl(name: string, revision: number): string {
return this.transport.widgetFrameUrl(name, revision);
}
refreshWidgetFrame(name: string): Promise<void> {
return this.transport.refreshWidgetFrame(name);
}
widgetAppView(name: string, revision: number): Promise<BoardWidgetAppViewState> {
return this.transport.widgetAppView(name, revision);
}
refreshWidgetAppView(name: string, revision: number): Promise<BoardWidgetAppViewState> {
return this.transport.refreshWidgetAppView(name, revision);
}
}
const nullProviders = new Map<string, NullProvider>();
const mockProviders = new Map<string, MockBoardProvider>();
const gatewayProviders = new Map<string, { provider: GatewayBoardProvider; consumers: number }>();
@@ -332,14 +431,7 @@ export function boardProviderForSession(
entry = { provider, consumers: 0 };
gatewayProviders.set(key, entry);
} else {
entry.provider.attachClient(
client,
connected,
canPinWidgets,
canPinMcpApps,
canMutate,
canGrant,
);
entry.provider.attachClient(client, connected);
}
return entry.provider;
}
@@ -357,6 +449,11 @@ export function boardProviderForSession(
export type BoardProviderLease = {
provider: BoardProvider;
update: (
client: BoardGatewayClient,
connected: boolean,
capabilities: BoardProviderCapabilities,
) => void;
release: () => void;
};
@@ -382,19 +479,33 @@ export function acquireBoardProviderForSession(
);
const entry = gatewayProviders.get(key);
if (!entry || entry.provider !== provider) {
return { provider, release: () => undefined };
return { provider, update: () => undefined, release: () => undefined };
}
const scopedProvider = new ScopedGatewayBoardProvider(entry.provider, {
canPinWidgets,
canPinMcpApps,
canMutate,
canGrant,
});
entry.consumers += 1;
let released = false;
return {
provider,
provider: scopedProvider,
update: (nextClient, nextConnected, capabilities) => {
if (released || gatewayProviders.get(key)?.provider !== entry.provider) {
return;
}
scopedProvider.updateCapabilities(capabilities);
entry.provider.attachClient(nextClient, nextConnected);
},
release: () => {
if (released) {
return;
}
released = true;
scopedProvider.deactivate();
const current = gatewayProviders.get(key);
if (!current || current.provider !== provider) {
if (!current || current.provider !== entry.provider) {
return;
}
current.consumers -= 1;
@@ -410,6 +521,13 @@ export function acquireBoardProviderForSession(
};
}
export function hasLoadedBoardSnapshot(provider: BoardProvider): boolean {
if (provider instanceof GatewayBoardProvider || provider instanceof ScopedGatewayBoardProvider) {
return provider.hasLoadedSnapshot;
}
return true;
}
export function recordSessionBoardAvailability(sessionKey: string, available: boolean): boolean {
const key = boardProviderCacheKey(sessionKey);
const previous = boardAvailability.get(key);
+228 -3
View File
@@ -71,13 +71,19 @@ function expiredApproval(): ExpiredApprovalSnapshot {
} as ExpiredApprovalSnapshot;
}
function createGateway(client: GatewayBrowserClient, connected = true) {
function createGateway(
client: GatewayBrowserClient,
connected = true,
hello: ApplicationGatewaySnapshot["hello"] = {
auth: { role: "operator" },
} as ApplicationGatewaySnapshot["hello"],
) {
let snapshot: ApplicationGatewaySnapshot = {
client,
phase: connected ? "connected" : "stopped",
offlineStable: false,
canvasPluginSurfaceUrl: null,
hello: null,
hello,
assistantAgentId: "main",
sessionKey: "main",
lastError: null,
@@ -107,10 +113,11 @@ function createGateway(client: GatewayBrowserClient, connected = true) {
function createPage(params: {
client: GatewayBrowserClient;
connected?: boolean;
hello?: ApplicationGatewaySnapshot["hello"];
id?: string;
withBootFallback?: boolean;
}) {
const source = createGateway(params.client, params.connected);
const source = createGateway(params.client, params.connected, params.hello);
const page = document.createElement(APPROVAL_PAGE_ELEMENT_NAME) as TestApprovalPage;
const provider = createApplicationContextProvider({
basePath: "",
@@ -149,6 +156,224 @@ afterEach(async () => {
});
describe("ApprovalPage", () => {
it("keeps a no-auth approval readable without enabling its decisions", async () => {
const request = vi.fn(
async (_method: string) => ({ approval: pendingApproval() }) satisfies ApprovalGetResult,
);
const { page } = createPage({
client: { request } as unknown as GatewayBrowserClient,
hello: null,
});
await settle(page);
const decision = page.querySelector('[data-decision="allow-once"]') as HTMLButtonElement;
expect(request).toHaveBeenCalledWith("approval.get", { id: "exec:approval-1" });
expect(page.querySelector(".approval-page__preview")?.textContent).toBe("printf safe");
expect(decision.disabled).toBe(true);
decision.click();
await settle(page);
expect(request).toHaveBeenCalledOnce();
expect(request.mock.calls.some(([method]) => method === "approval.resolve")).toBe(false);
});
it.each([
{ name: "read-only", scopes: ["operator.read"] },
{ name: "write-only", scopes: ["operator.write"] },
{ name: "explicitly ungranted", scopes: [] },
])("does not request or disclose a durable approval to a $name operator", async ({ scopes }) => {
const request = vi.fn(async () => ({ approval: pendingApproval() }));
const { page } = createPage({
client: { request } as unknown as GatewayBrowserClient,
hello: {
auth: { role: "operator", scopes },
} as ApplicationGatewaySnapshot["hello"],
});
await settle(page);
expect(request).not.toHaveBeenCalled();
expect(page.querySelector(".approval-page")?.getAttribute("data-state")).toBe("missing-scope");
expect(page.querySelector('[role="alert"]')?.textContent).toContain("operator.approvals");
expect(page.querySelector(".approval-page__preview")).toBeNull();
expect(page.querySelectorAll("[data-decision]")).toHaveLength(0);
});
it.each([
{ name: "reviewer", auth: { role: "operator", scopes: ["operator.approvals"] } },
{ name: "administrator", auth: { role: "operator", scopes: ["operator.admin"] } },
{ name: "legacy authenticated operator", auth: { role: "operator" } },
])("loads a durable approval for a $name", async ({ auth }) => {
const request = vi.fn(async () => ({ approval: pendingApproval() }));
const { page } = createPage({
client: { request } as unknown as GatewayBrowserClient,
hello: { auth } as ApplicationGatewaySnapshot["hello"],
});
await settle(page);
expect(request).toHaveBeenCalledOnce();
expect(request).toHaveBeenCalledWith("approval.get", { id: "exec:approval-1" });
expect(page.querySelector('[data-decision="allow-once"]')).not.toBeNull();
});
it.each([
{ name: "reviewer", scopes: ["operator.approvals"] },
{ name: "administrator", scopes: ["operator.admin"] },
])("allows an authenticated $name to resolve a durable approval", async ({ scopes }) => {
const request = vi.fn(
async (method: string): Promise<unknown> =>
method === "approval.get"
? ({ approval: pendingApproval() } satisfies ApprovalGetResult)
: ({ applied: true, approval: allowedApproval() } satisfies ApprovalResolveResult),
);
const { page } = createPage({
client: { request } as unknown as GatewayBrowserClient,
hello: { auth: { role: "operator", scopes } } as ApplicationGatewaySnapshot["hello"],
});
await settle(page);
(page.querySelector('[data-decision="allow-once"]') as HTMLButtonElement).click();
await settle(page);
expect(request).toHaveBeenCalledWith("approval.resolve", {
id: "exec:approval-1",
kind: "exec",
decision: "allow-once",
});
expect(page.querySelector("h1")?.textContent).toBe("Approved here");
});
it("rejects an in-flight resolution when only the approval grant is revoked", async () => {
let resolveDecision!: (value: ApprovalResolveResult) => void;
const staleDecision = new Promise<ApprovalResolveResult>((resolve) => {
resolveDecision = resolve;
});
const request = vi.fn(
(method: string): Promise<unknown> =>
method === "approval.get"
? Promise.resolve({ approval: pendingApproval() } satisfies ApprovalGetResult)
: staleDecision,
);
const { page, source } = createPage({
client: { request } as unknown as GatewayBrowserClient,
hello: {
auth: { role: "operator", scopes: ["operator.approvals"] },
} as ApplicationGatewaySnapshot["hello"],
});
await settle(page);
(page.querySelector('[data-decision="allow-once"]') as HTMLButtonElement).click();
await page.updateComplete;
source.update({ hello: null });
await settle(page);
expect(page.querySelector(".approval-page__preview")?.textContent).toBe("printf safe");
expect((page.querySelector('[data-decision="allow-once"]') as HTMLButtonElement).disabled).toBe(
true,
);
resolveDecision({ applied: true, approval: allowedApproval() });
await settle(page);
expect(page.querySelector(".approval-page__preview")?.textContent).toBe("printf safe");
expect(page.querySelector("h1")?.textContent).not.toBe("Approved here");
expect(request.mock.calls.filter(([method]) => method === "approval.resolve")).toHaveLength(1);
});
it("redacts a pending approval and rejects a retained action after a scope downgrade", async () => {
const request = vi.fn(
async (_method: string) => ({ approval: pendingApproval() }) satisfies ApprovalGetResult,
);
const { page, source } = createPage({
client: { request } as unknown as GatewayBrowserClient,
hello: {
auth: { role: "operator", scopes: ["operator.approvals"] },
} as ApplicationGatewaySnapshot["hello"],
});
await settle(page);
const staleDecision = page.querySelector('[data-decision="allow-once"]') as HTMLButtonElement;
source.update({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
} as ApplicationGatewaySnapshot["hello"],
});
staleDecision.click();
await settle(page);
expect(request).toHaveBeenCalledOnce();
expect(page.querySelector(".approval-page")?.getAttribute("data-state")).toBe("missing-scope");
expect(page.querySelector(".approval-page__preview")).toBeNull();
expect(page.querySelectorAll("[data-decision]")).toHaveLength(0);
expect(request.mock.calls.some(([method]) => method === "approval.resolve")).toBe(false);
});
it("redacts approval details when one snapshot disconnects and revokes access", async () => {
const request = vi.fn(async () => ({ approval: pendingApproval() }));
const { page, source } = createPage({
client: { request } as unknown as GatewayBrowserClient,
hello: {
auth: { role: "operator", scopes: ["operator.approvals"] },
} as ApplicationGatewaySnapshot["hello"],
});
await settle(page);
expect(page.querySelector(".approval-page__preview")?.textContent).toBe("printf safe");
source.update({
phase: "stopped",
client: null,
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
} as ApplicationGatewaySnapshot["hello"],
});
await settle(page);
expect(page.querySelector(".approval-page__preview")).toBeNull();
expect(page.querySelectorAll("[data-decision]")).toHaveLength(0);
expect(page.textContent).not.toContain("printf safe");
expect(page.querySelector(".approval-page")?.getAttribute("data-state")).toBe(
"connection-error",
);
expect(request).toHaveBeenCalledOnce();
});
it("rejects a pre-revocation lookup when approval access is restored", async () => {
let resolveStale!: (value: ApprovalGetResult) => void;
const staleLookup = new Promise<ApprovalGetResult>((resolve) => {
resolveStale = resolve;
});
const request = vi
.fn()
.mockReturnValueOnce(staleLookup)
.mockResolvedValueOnce({ approval: allowedApproval() } satisfies ApprovalGetResult);
const { page, source } = createPage({
client: { request } as unknown as GatewayBrowserClient,
hello: {
auth: { role: "operator", scopes: ["operator.approvals"] },
} as ApplicationGatewaySnapshot["hello"],
});
await settle(page);
source.update({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
} as ApplicationGatewaySnapshot["hello"],
});
source.update({
hello: {
auth: { role: "operator", scopes: ["operator.approvals"] },
} as ApplicationGatewaySnapshot["hello"],
});
await settle(page);
resolveStale({ approval: pendingApproval() });
await settle(page);
expect(request).toHaveBeenCalledTimes(2);
expect(page.querySelector("h1")?.textContent).toBe("Approved");
expect(page.querySelectorAll("[data-decision]")).toHaveLength(0);
});
it("replaces the host boot fallback instead of duplicating the page", async () => {
const request = vi.fn(async () => ({ approval: pendingApproval() }));
const { page } = createPage({
+98 -35
View File
@@ -19,11 +19,13 @@ import {
type ApplicationContext,
type ApplicationGatewaySnapshot,
} from "../../app/context.ts";
import { readGatewayOperatorAccess } from "../../app/operator-access.ts";
import { controlUiPublicAssetPath } from "../../app/public-assets.ts";
import { i18n, t } from "../../i18n/index.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
const APPROVAL_POLL_INTERVAL_MS = 2_000;
const APPROVAL_MIN_POLL_DELAY_MS = 250;
const APPROVAL_REQUIRED_SCOPE = "operator.approvals";
type ApprovalRequestError = "connection" | "unavailable" | null;
type ResolutionOrigin = "here" | "elsewhere" | "observed";
@@ -182,6 +184,8 @@ export class ApprovalPage extends OpenClawLightDomElement {
@state() private approval: ApprovalSnapshot | null = null;
@state() private connected = false;
@state() private approvalsAccess = true;
@state() private approvalGrantAccess = false;
@state() private loading = true;
@state() private resolving = false;
@state() private resolvingDecision: ApprovalDecision | null = null;
@@ -249,7 +253,7 @@ export class ApprovalPage extends OpenClawLightDomElement {
this.resolvingDecision = null;
this.requestError = this.approvalId ? null : "unavailable";
this.resolutionOrigin = "observed";
if (this.approvalId && this.connected && this.client) {
if (this.approvalId && this.connected && this.client && this.hasApprovalAccess) {
void this.loadApproval();
}
}
@@ -258,14 +262,25 @@ export class ApprovalPage extends OpenClawLightDomElement {
const clientChanged = snapshot.client !== this.client;
const connectionChanged = (snapshot.phase === "connected") !== this.connected;
const becameConnected = snapshot.phase === "connected" && !this.connected;
const access = readGatewayOperatorAccess(snapshot);
const nextApprovalsAccess = access.canReviewApprovals;
const approvalAccessChanged = nextApprovalsAccess !== this.approvalsAccess;
const approvalGrantAccessChanged = access.canGrantApprovals !== this.approvalGrantAccess;
this.client = snapshot.client;
this.connected = snapshot.phase === "connected";
if (clientChanged || connectionChanged) {
this.approvalsAccess = nextApprovalsAccess;
this.approvalGrantAccess = access.canGrantApprovals;
if (clientChanged || connectionChanged || approvalAccessChanged || approvalGrantAccessChanged) {
this.invalidateOperations();
this.clearPollTimer();
this.resolving = false;
this.resolvingDecision = null;
}
if (!this.approvalsAccess) {
// A revoke can arrive in the same snapshot as disconnect; redact before
// the connection branch can preserve the previously visible command.
this.approval = null;
}
if (snapshot.phase !== "connected" || !snapshot.client) {
if (this.approvalId) {
this.loading = false;
@@ -274,12 +289,18 @@ export class ApprovalPage extends OpenClawLightDomElement {
}
return;
}
if (!this.approvalsAccess) {
this.approval = null;
this.loading = false;
this.requestError = null;
return;
}
if (!this.approvalId) {
this.loading = false;
this.requestError = "unavailable";
return;
}
if (clientChanged || becameConnected || !this.approval) {
if (clientChanged || becameConnected || approvalAccessChanged || !this.approval) {
void this.loadApproval();
return;
}
@@ -297,6 +318,7 @@ export class ApprovalPage extends OpenClawLightDomElement {
}): boolean {
return (
this.hasGatewayConnection &&
this.hasApprovalAccess &&
this.client === params.client &&
this.approvalId === params.id &&
this.operationGeneration === params.generation
@@ -307,10 +329,24 @@ export class ApprovalPage extends OpenClawLightDomElement {
return this.connected && Boolean(this.client);
}
private get hasApprovalAccess(): boolean {
return (
this.approvalsAccess &&
readGatewayOperatorAccess(this.context.gateway.snapshot).canReviewApprovals
);
}
private get hasApprovalGrantAccess(): boolean {
return (
this.approvalGrantAccess &&
readGatewayOperatorAccess(this.context.gateway.snapshot).canGrantApprovals
);
}
private async loadApproval(options: { background?: boolean } = {}) {
const client = this.client;
const id = this.approvalId;
if (!client || !this.connected || !id) {
if (!client || !this.connected || !id || !this.hasApprovalAccess) {
return;
}
const generation = ++this.operationGeneration;
@@ -366,6 +402,7 @@ export class ApprovalPage extends OpenClawLightDomElement {
if (
!client ||
!this.connected ||
!this.hasApprovalGrantAccess ||
!id ||
approval?.status !== "pending" ||
!Array.prototype.includes.call(approval.presentation.allowedDecisions, decision) ||
@@ -375,6 +412,8 @@ export class ApprovalPage extends OpenClawLightDomElement {
}
const kind = approval.presentation.kind;
const generation = ++this.operationGeneration;
const isCurrentDecision = () =>
this.isCurrentOperation({ client, generation, id }) && this.hasApprovalGrantAccess;
let shouldFocusTerminal = false;
let shouldRecoverCanonicalState = false;
this.clearPollTimer();
@@ -387,7 +426,7 @@ export class ApprovalPage extends OpenClawLightDomElement {
kind,
decision,
});
if (!this.isCurrentOperation({ client, generation, id })) {
if (!isCurrentDecision()) {
return;
}
if (
@@ -406,22 +445,22 @@ export class ApprovalPage extends OpenClawLightDomElement {
shouldFocusTerminal = true;
}
} catch (error) {
if (!this.isCurrentOperation({ client, generation, id })) {
if (!isCurrentDecision()) {
return;
}
this.requestError = isUnavailableApprovalError(error) ? "unavailable" : "connection";
} finally {
if (this.isCurrentOperation({ client, generation, id })) {
if (isCurrentDecision()) {
this.resolving = false;
this.resolvingDecision = null;
this.schedulePoll();
}
}
if (shouldRecoverCanonicalState && this.isCurrentOperation({ client, generation, id })) {
if (shouldRecoverCanonicalState && isCurrentDecision()) {
await this.loadApproval({ background: true });
return;
}
if (shouldFocusTerminal && this.isCurrentOperation({ client, generation, id })) {
if (shouldFocusTerminal && isCurrentDecision()) {
await this.focusTerminalState();
}
}
@@ -450,6 +489,7 @@ export class ApprovalPage extends OpenClawLightDomElement {
const approval = this.approval;
if (
!this.hasGatewayConnection ||
!this.hasApprovalAccess ||
this.resolving ||
this.requestError === "unavailable" ||
approval?.status !== "pending" ||
@@ -473,7 +513,12 @@ export class ApprovalPage extends OpenClawLightDomElement {
this.clearPollTimer();
return;
}
if (this.approval?.status === "pending" && this.hasGatewayConnection && !this.resolving) {
if (
this.approval?.status === "pending" &&
this.hasGatewayConnection &&
this.hasApprovalAccess &&
!this.resolving
) {
void this.loadApproval({ background: true });
}
};
@@ -514,6 +559,16 @@ export class ApprovalPage extends OpenClawLightDomElement {
`;
}
private renderMissingScope() {
return html`
<div class="approval-page__state approval-page__state--unavailable" role="alert">
<div class="approval-page__state-mark" aria-hidden="true">!</div>
<h1 id="approval-page-title">${t("common.disabled")}</h1>
<p><code>${APPROVAL_REQUIRED_SCOPE}</code></p>
</div>
`;
}
private renderConnectionState() {
return html`
<div class="approval-page__state approval-page__state--connection" role="alert">
@@ -523,7 +578,7 @@ export class ApprovalPage extends OpenClawLightDomElement {
<button
type="button"
class="btn"
?disabled=${!this.hasGatewayConnection || this.loading}
?disabled=${!this.hasGatewayConnection || !this.hasApprovalAccess || this.loading}
@click=${() => void this.loadApproval()}
>
${t("approvalPage.retry")}
@@ -542,7 +597,7 @@ export class ApprovalPage extends OpenClawLightDomElement {
<button
type="button"
class="btn btn--sm"
?disabled=${!this.hasGatewayConnection || this.loading}
?disabled=${!this.hasGatewayConnection || !this.hasApprovalAccess || this.loading}
@click=${() => void this.loadApproval()}
>
${t("approvalPage.retry")}
@@ -599,6 +654,7 @@ export class ApprovalPage extends OpenClawLightDomElement {
data-decision=${decision}
?disabled=${this.resolving ||
!this.hasGatewayConnection ||
!this.hasApprovalGrantAccess ||
this.requestError !== null}
@click=${() => void this.resolveApproval(decision)}
>
@@ -619,13 +675,16 @@ export class ApprovalPage extends OpenClawLightDomElement {
}
override render() {
const missingScope = this.connected && !this.approvalsAccess;
const unavailable = this.requestError === "unavailable";
const disconnected = this.requestError === "connection" && !this.approval;
const documentState = unavailable
? "unavailable"
: disconnected
? "connection-error"
: (this.approval?.status ?? "loading");
const documentState = missingScope
? "missing-scope"
: unavailable
? "unavailable"
: disconnected
? "connection-error"
: (this.approval?.status ?? "loading");
return html`
<main class="approval-page" data-state=${documentState}>
<div class="approval-page__backdrop" aria-hidden="true"></div>
@@ -636,13 +695,15 @@ export class ApprovalPage extends OpenClawLightDomElement {
>
${this.renderHeader()}
<div class="approval-page__content">
${this.loading && !this.approval
? this.renderLoading()
: disconnected
? this.renderConnectionState()
: unavailable || !this.approval
? this.renderUnavailable()
: this.renderApproval(this.approval)}
${missingScope
? this.renderMissingScope()
: this.loading && !this.approval
? this.renderLoading()
: disconnected
? this.renderConnectionState()
: unavailable || !this.approval
? this.renderUnavailable()
: this.renderApproval(this.approval)}
</div>
</section>
<a class="approval-page__back-link" href=${`${this.context.basePath}/chat`}>
@@ -654,17 +715,19 @@ export class ApprovalPage extends OpenClawLightDomElement {
private updateDocumentTitle() {
const pageTitle =
this.requestError === "unavailable"
? t("approvalPage.unavailableTitle")
: this.requestError === "connection" && !this.approval
? t("approvalPage.connectionErrorTitle")
: this.approval
? this.approval.status === "pending"
? this.approval.presentation.kind === "plugin"
? this.approval.presentation.title
: t("approvalPage.execTitle")
: terminalTitle(this.approval, this.resolutionOrigin)
: t("approvalPage.loadingTitle");
this.connected && !this.approvalsAccess
? t("common.disabled")
: this.requestError === "unavailable"
? t("approvalPage.unavailableTitle")
: this.requestError === "connection" && !this.approval
? t("approvalPage.connectionErrorTitle")
: this.approval
? this.approval.status === "pending"
? this.approval.presentation.kind === "plugin"
? this.approval.presentation.title
: t("approvalPage.execTitle")
: terminalTitle(this.approval, this.resolutionOrigin)
: t("approvalPage.loadingTitle");
const title = `${pageTitle}${t("approvalPage.brandName")}`;
document.title = title;
this.activeDocumentTitle = title;
+4 -3
View File
@@ -18,7 +18,7 @@ import {
type ApplicationContext,
type ApplicationGatewaySnapshot,
} from "../../app/context.ts";
import { hasOperatorApprovalsAccess } from "../../app/operator-access.ts";
import { readGatewayOperatorAccess } from "../../app/operator-access.ts";
import { renderSettingsPage } from "../../components/settings-ui.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { i18n, t } from "../../i18n/index.ts";
@@ -167,8 +167,7 @@ class ApprovalsPage extends OpenClawLightDomElement {
private applyGatewaySnapshot(snapshot: ApplicationGatewaySnapshot) {
const clientChanged = snapshot.client !== this.client;
const connectionChanged = (snapshot.phase === "connected") !== this.connected;
const auth = snapshot.hello?.auth;
const nextApprovalsAccess = !auth || hasOperatorApprovalsAccess(auth);
const nextApprovalsAccess = readGatewayOperatorAccess(snapshot).canReviewApprovals;
const approvalAccessChanged = nextApprovalsAccess !== this.approvalsAccess;
this.connected = snapshot.phase === "connected";
this.approvalsAccess = nextApprovalsAccess;
@@ -208,6 +207,7 @@ class ApprovalsPage extends OpenClawLightDomElement {
!gateway ||
!this.connected ||
!this.approvalsAccess ||
!readGatewayOperatorAccess(gateway.snapshot).canReviewApprovals ||
this.loading ||
this.loadingMore
) {
@@ -231,6 +231,7 @@ class ApprovalsPage extends OpenClawLightDomElement {
this.gatewaySource === gateway &&
this.context.gateway === gateway &&
gateway.snapshot.phase === "connected" &&
readGatewayOperatorAccess(gateway.snapshot).canReviewApprovals &&
this.client === client &&
this.requestGeneration === generation;
try {
+97
View File
@@ -5,6 +5,7 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ApplicationContext } from "../../app/context.ts";
import { loadSettings, patchSettings } from "../../app/settings.ts";
import {
acquireBoardProviderForSession,
boardProviderForSession,
type BoardCommandEvent,
type BoardProvider,
@@ -569,6 +570,102 @@ describe("chat pane board shell", () => {
}
});
it("updates chat authorization without changing another consumer of the same board", async () => {
window.history.replaceState({}, "", "/");
const pane = createTestPane();
const sessionKey = "agent:main:chat-lease-scope-change";
const snapshot = { sessionKey, revision: 1, tabs: [], widgets: [] };
const removeListener = vi.fn();
const request = vi.fn(async () => snapshot);
const addEventListener = vi.fn(() => removeListener);
const client = {
request,
addEventListener,
} as unknown as GatewayBrowserClient;
const features = {
methods: ["board.get", "board.widget.appView", "board.widget.put"],
capabilities: ["board-widget-put-canvas-doc"],
};
pane.state.sessionKey = sessionKey;
pane.state.client = client;
Reflect.set(pane, "boardProviderLifecycleConnected", true);
pane.context = {
...pane.context,
gateway: {
snapshot: {
client,
phase: "connected",
hello: {
auth: { role: "operator", scopes: ["operator.read", "operator.write"] },
features,
},
},
},
} as unknown as ApplicationContext;
const chat = pane.resolveBoardProvider();
const approvals = acquireBoardProviderForSession(
sessionKey,
client,
true,
false,
false,
false,
true,
);
try {
await vi.waitFor(() => expect(chat.snapshot$.value).toEqual(snapshot));
expect(chat).toMatchObject({
canPinWidgets: true,
canPinMcpApps: true,
canMutate: true,
canGrant: false,
});
expect(approvals.provider).toMatchObject({
canPinWidgets: false,
canPinMcpApps: false,
canMutate: false,
canGrant: true,
});
pane.context = {
...pane.context,
gateway: {
snapshot: {
client,
phase: "connected",
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features,
},
},
},
} as unknown as ApplicationContext;
expect(pane.resolveBoardProvider()).toBe(chat);
expect(chat).toMatchObject({
canPinWidgets: false,
canPinMcpApps: false,
canMutate: false,
canGrant: false,
});
expect(approvals.provider.canGrant).toBe(true);
expect(approvals.provider.canMutate).toBe(false);
expect(request).toHaveBeenCalledOnce();
expect(addEventListener).toHaveBeenCalledOnce();
approvals.release();
expect(removeListener).not.toHaveBeenCalled();
const release = Reflect.get(pane, "releaseBoardProviderLease") as () => void;
release.call(pane);
expect(removeListener).toHaveBeenCalledOnce();
} finally {
approvals.release();
const release = Reflect.get(pane, "releaseBoardProviderLease") as () => void;
release.call(pane);
}
});
it.each([
{
profile: "read-only",
+2 -6
View File
@@ -189,16 +189,12 @@ export abstract class ChatPaneBoard extends ChatPaneHistory {
sessionKey: key,
};
} else {
boardProviderForSession(
key,
client,
true,
gateway.phase === "connected",
this.boardProviderLease.update(client, gateway.phase === "connected", {
canPinWidgets,
canPinMcpApps,
canMutate,
canGrant,
);
});
}
return this.boardProviderLease.provider;
}
+10
View File
@@ -33,6 +33,8 @@ import {
type ApplicationGatewaySnapshot,
} from "./chat-pane-deps.ts";
import { ChatPaneLifecycle } from "./chat-pane-lifecycle.ts";
import { resolveAssistantAttachmentAuthToken } from "./chat-pane-state.ts";
import { releaseChatMediaResourceSubscriber } from "./components/chat-message-media.ts";
export abstract class ChatPaneContext extends ChatPaneLifecycle {
protected applySessionsState(stateValue: ApplicationContext["sessions"]["state"]) {
@@ -137,6 +139,9 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle {
) {
return;
}
if (rootsChanged) {
releaseChatMediaResourceSubscriber(state.requestUpdate);
}
state.localMediaPreviewRoots = config.localMediaPreviewRoots;
state.embedSandboxMode = config.embedSandboxMode;
state.allowExternalEmbedUrls = config.allowExternalEmbedUrls;
@@ -148,6 +153,7 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle {
if (!state) {
return;
}
const previousMediaAuthToken = resolveAssistantAttachmentAuthToken(state);
const wasConnected = state.connected;
const previousSidebarSessionKey = canonicalUiSessionKeyForPersistence(state, state.sessionKey);
const sourceChanged =
@@ -160,6 +166,7 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle {
this.presencePayload = presence ? { presence } : undefined;
}
if (sourceChanged) {
releaseChatMediaResourceSubscriber(state.requestUpdate);
// A reconnect can retain the browser client. Keep async ownership tied
// to the logical connection, not only the transport object identity.
this.connectionGeneration += 1;
@@ -193,6 +200,9 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle {
state.connected = snapshot.phase === "connected";
state.connectionEpoch = this.connectionGeneration;
state.hello = snapshot.hello;
if (!sourceChanged && previousMediaAuthToken !== resolveAssistantAttachmentAuthToken(state)) {
releaseChatMediaResourceSubscriber(state.requestUpdate);
}
state.canvasPluginSurfaceUrl = snapshot.canvasPluginSurfaceUrl;
const sidebarSessionKey = canonicalUiSessionKeyForPersistence(state, state.sessionKey);
const sidebarKeyChanged = sidebarSessionKey !== previousSidebarSessionKey;
@@ -5,6 +5,7 @@ import type { ChatPageHost } from "./chat-state-host.ts";
import { invalidateImageLightbox } from "./chat-state-page.ts";
import { cancelChatStreamRenderFrame } from "./chat-state-render.ts";
import { ChatAttachmentReadLifecycle } from "./components/chat-attachments.ts";
import { releaseChatMediaResourceSubscriber } from "./components/chat-message-media.ts";
import { clearSessionWorkspaceTimers } from "./components/chat-session-workspace.ts";
import {
ChatComposerPersistence,
@@ -74,6 +75,7 @@ export class ChatStateController<TState extends ChatPageHost> implements Reactiv
attach(state: TState) {
if (this.stateValue && this.stateValue !== state) {
releaseChatMediaResourceSubscriber(this.stateValue.requestUpdate);
this.attachmentReads.abortReads();
this.composerPersistence.stop();
cancelChatStreamRenderFrame(this.stateValue);
@@ -319,6 +321,7 @@ export class ChatStateController<TState extends ChatPageHost> implements Reactiv
adoptComposerRoute() {
// File reads belong to their original session; abort before a late load can
// attach its payload to the pane's newly adopted route.
releaseChatMediaResourceSubscriber(this.stateValue?.requestUpdate);
this.attachmentReads.abortReads();
this.composerPersistence.adoptCurrentRoute();
}
@@ -357,6 +360,7 @@ export class ChatStateController<TState extends ChatPageHost> implements Reactiv
}
const state = this.stateValue;
if (state) {
releaseChatMediaResourceSubscriber(state.requestUpdate);
cancelChatStreamRenderFrame(state);
cancelChatScroll(state);
invalidateImageLightbox(state);
@@ -8,15 +8,20 @@ import {
isLocalAssistantAttachmentSource,
isLocalAttachmentPreviewAllowed,
} from "./chat-message-local-media.ts";
import type { AttachmentItem } from "./chat-message-media.ts";
import {
isChatMediaResourceCurrent,
notifyChatMediaResourceSubscribers,
observeChatMediaResource,
scheduleChatMediaResourceRefresh,
type AttachmentItem,
type ChatMediaResource,
} from "./chat-message-media.ts";
type AssistantAttachmentAvailability =
| { status: "checking" }
| { status: "available"; mediaTicket?: string; mediaTicketExpiresAt?: number }
| { status: "unavailable"; reason: string; checkedAt: number; retryAttempted?: true };
const assistantAttachmentAvailabilityCache = new Map<string, AssistantAttachmentAvailability>();
const assistantAttachmentRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
const ASSISTANT_ATTACHMENT_UNAVAILABLE_RETRY_MS = 5_000;
const ASSISTANT_ATTACHMENT_METADATA_FETCH_TIMEOUT_MS = 30_000;
const ASSISTANT_ATTACHMENT_MEDIA_TICKET_REFRESH_SKEW_MS = 30_000;
@@ -44,19 +49,15 @@ function bumpAssistantAttachmentAvailabilityRenderVersion() {
}
function setAssistantAttachmentAvailability(
cacheKey: string,
resource: ChatMediaResource<AssistantAttachmentAvailability>,
availability: AssistantAttachmentAvailability,
onRequestUpdate?: () => void,
) {
assistantAttachmentAvailabilityCache.set(cacheKey, availability);
bumpAssistantAttachmentAvailabilityRenderVersion();
scheduleAssistantAttachmentRefresh(cacheKey, availability, onRequestUpdate);
}
function deleteAssistantAttachmentAvailability(cacheKey: string) {
if (assistantAttachmentAvailabilityCache.delete(cacheKey)) {
bumpAssistantAttachmentAvailabilityRenderVersion();
if (!isChatMediaResourceCurrent(resource)) {
return;
}
resource.value = availability;
bumpAssistantAttachmentAvailabilityRenderVersion();
scheduleAssistantAttachmentRefresh(resource, availability);
}
function buildAssistantAttachmentMetaUrl(source: string, basePath?: string): string {
@@ -64,23 +65,10 @@ function buildAssistantAttachmentMetaUrl(source: string, basePath?: string): str
return `${attachmentUrl}${attachmentUrl.includes("?") ? "&" : "?"}meta=1`;
}
function clearAssistantAttachmentRefreshTimer(cacheKey: string) {
const timer = assistantAttachmentRefreshTimers.get(cacheKey);
if (timer) {
clearTimeout(timer);
assistantAttachmentRefreshTimers.delete(cacheKey);
}
}
function scheduleAssistantAttachmentRefresh(
cacheKey: string,
resource: ChatMediaResource<AssistantAttachmentAvailability>,
availability: AssistantAttachmentAvailability,
onRequestUpdate: (() => void) | undefined,
) {
clearAssistantAttachmentRefreshTimer(cacheKey);
if (!onRequestUpdate) {
return;
}
const refreshAt =
availability.status === "unavailable" && !availability.retryAttempted
? availability.checkedAt + ASSISTANT_ATTACHMENT_UNAVAILABLE_RETRY_MS
@@ -89,23 +77,18 @@ function scheduleAssistantAttachmentRefresh(
availability.mediaTicketExpiresAt
? availability.mediaTicketExpiresAt - ASSISTANT_ATTACHMENT_MEDIA_TICKET_REFRESH_SKEW_MS
: undefined;
if (refreshAt === undefined) {
return;
}
const refreshInMs = Math.max(0, refreshAt - Date.now());
const timer = setTimeout(() => {
assistantAttachmentRefreshTimers.delete(cacheKey);
if (assistantAttachmentAvailabilityCache.get(cacheKey) !== availability) {
scheduleChatMediaResourceRefresh(resource, refreshAt, () => {
if (resource.value !== availability) {
return;
}
// Keep the failed generation until its retry can inherit the one-attempt
// budget; ticket refreshes must still invalidate their current generation.
if (availability.status !== "unavailable") {
deleteAssistantAttachmentAvailability(cacheKey);
resource.value = undefined;
bumpAssistantAttachmentAvailabilityRenderVersion();
}
onRequestUpdate();
}, refreshInMs);
assistantAttachmentRefreshTimers.set(cacheKey, timer);
notifyChatMediaResourceSubscribers(resource);
});
}
export function resolveAssistantAttachmentAvailability(
@@ -123,8 +106,13 @@ export function resolveAssistantAttachmentAvailability(
}
const normalizedAuthToken = authToken?.trim() ?? "";
const cacheKey = `${basePath ?? ""}::${normalizedAuthToken}::${source}`;
const cached = assistantAttachmentAvailabilityCache.get(cacheKey);
let retryAttempted = false;
const resource = observeChatMediaResource<AssistantAttachmentAvailability>(
"assistant-attachment",
cacheKey,
onRequestUpdate,
source,
);
const cached = resource.value;
if (cached) {
const now = Date.now();
if (
@@ -132,28 +120,30 @@ export function resolveAssistantAttachmentAvailability(
!cached.retryAttempted &&
now - cached.checkedAt >= ASSISTANT_ATTACHMENT_UNAVAILABLE_RETRY_MS
) {
retryAttempted = true;
deleteAssistantAttachmentAvailability(cacheKey);
resource.retryAttempted = true;
resource.value = undefined;
bumpAssistantAttachmentAvailabilityRenderVersion();
} else if (
cached.status === "available" &&
cached.mediaTicket &&
(!cached.mediaTicketExpiresAt ||
cached.mediaTicketExpiresAt - now <= ASSISTANT_ATTACHMENT_MEDIA_TICKET_REFRESH_SKEW_MS)
) {
deleteAssistantAttachmentAvailability(cacheKey);
resource.value = undefined;
bumpAssistantAttachmentAvailabilityRenderVersion();
} else {
scheduleAssistantAttachmentRefresh(cacheKey, cached, onRequestUpdate);
scheduleAssistantAttachmentRefresh(resource, cached);
return cached;
}
}
clearAssistantAttachmentRefreshTimer(cacheKey);
setAssistantAttachmentAvailability(cacheKey, { status: "checking" });
setAssistantAttachmentAvailability(resource, { status: "checking" });
if (typeof fetch === "function") {
const headers = new Headers({ Accept: "application/json" });
if (normalizedAuthToken) {
headers.set("Authorization", `Bearer ${normalizedAuthToken}`);
}
const controller = new AbortController();
resource.abortController = controller;
const timeout = setTimeout(
() =>
controller.abort(
@@ -161,7 +151,7 @@ export function resolveAssistantAttachmentAvailability(
),
ASSISTANT_ATTACHMENT_METADATA_FETCH_TIMEOUT_MS,
);
void fetch(buildAssistantAttachmentMetaUrl(source, basePath), {
const pending = fetch(buildAssistantAttachmentMetaUrl(source, basePath), {
method: "GET",
headers,
credentials: "same-origin",
@@ -178,40 +168,47 @@ export function resolveAssistantAttachmentAvailability(
const mediaTicket = payload.mediaTicket?.trim();
const mediaTicketExpiresAt = Date.parse(payload.mediaTicketExpiresAt ?? "");
if (mediaTicket && !Number.isFinite(mediaTicketExpiresAt)) {
setAssistantAttachmentAvailability(
cacheKey,
createUnavailableAssistantAttachment("Attachment unavailable", retryAttempted),
onRequestUpdate,
const unavailable = createUnavailableAssistantAttachment(
"Attachment unavailable",
resource.retryAttempted,
);
return;
setAssistantAttachmentAvailability(resource, unavailable);
return unavailable;
}
const availability: AssistantAttachmentAvailability = {
status: "available",
...(mediaTicket ? { mediaTicket, mediaTicketExpiresAt } : {}),
};
setAssistantAttachmentAvailability(cacheKey, availability, onRequestUpdate);
} else {
setAssistantAttachmentAvailability(
cacheKey,
createUnavailableAssistantAttachment(
payload?.reason?.trim() || "Attachment unavailable",
retryAttempted,
),
onRequestUpdate,
);
resource.retryAttempted = false;
setAssistantAttachmentAvailability(resource, availability);
return availability;
}
const unavailable = createUnavailableAssistantAttachment(
payload?.reason?.trim() || "Attachment unavailable",
resource.retryAttempted,
);
setAssistantAttachmentAvailability(resource, unavailable);
return unavailable;
})
.catch(() => {
setAssistantAttachmentAvailability(
cacheKey,
createUnavailableAssistantAttachment("Attachment unavailable", retryAttempted),
onRequestUpdate,
const unavailable = createUnavailableAssistantAttachment(
"Attachment unavailable",
resource.retryAttempted,
);
setAssistantAttachmentAvailability(resource, unavailable);
return unavailable;
})
.finally(() => {
clearTimeout(timeout);
onRequestUpdate?.();
if (resource.abortController === controller) {
resource.abortController = undefined;
}
if (resource.pending === pending) {
resource.pending = undefined;
}
notifyChatMediaResourceSubscribers(resource);
});
resource.pending = pending;
}
return { status: "checking" };
}
@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
import { html, noChange, nothing, type TemplateResult } from "lit";
import { AsyncDirective, directive } from "lit/async-directive.js";
import { until } from "lit/directives/until.js";
import { t } from "../../../i18n/index.ts";
import {
@@ -15,17 +16,93 @@ import {
} from "./chat-message-local-media.ts";
import {
cacheManagedImageBlobUrl,
cacheManagedImageBlobUrlMiss,
hasRecentManagedImageBlobUrlMiss,
isChatMediaResourceCurrent,
notifyChatMediaResourceSubscribers,
observeChatMediaResource,
observeChatMediaResourceSubscriber,
readManagedImageBlobUrl,
releaseChatMediaResourceSubscriber,
retainManagedImageBlobUrl,
scheduleChatMediaResourceRefresh,
trimManagedImageMissResources,
type ChatMediaResource,
type ImageBlock,
type ImageRenderOptions,
type RenderableImageBlock,
} from "./chat-message-media.ts";
const MANAGED_OUTGOING_IMAGE_FETCH_TIMEOUT_MS = 30_000;
const managedImageBlobUrlCache = new Map<string, Promise<string | null>>();
const MANAGED_OUTGOING_IMAGE_RETRY_MS = 5_000;
class ManagedImageResourceDirective extends AsyncDirective {
private cacheKey: string | undefined;
private image: RenderableImageBlock | undefined;
private options: ImageRenderOptions | undefined;
private renderImageElement:
| ((image: RenderableImageBlock, previewUrl: string) => TemplateResult)
| undefined;
private onRequestUpdate: (() => void) | undefined;
private readonly requestUpdate = () => this.onRequestUpdate?.();
override render(
image: RenderableImageBlock,
options: ImageRenderOptions | undefined,
renderImageElement: (image: RenderableImageBlock, previewUrl: string) => TemplateResult,
) {
this.image = image;
this.options = options;
this.renderImageElement = renderImageElement;
if (!this.isConnected) {
releaseChatMediaResourceSubscriber(this.requestUpdate);
this.cacheKey = undefined;
this.onRequestUpdate = options?.onRequestUpdate;
return noChange;
}
const cacheKey = resolveManagedOutgoingImageBlobUrlCacheKey(
image.displayUrl,
options,
image.artifactId,
);
if (
(this.cacheKey !== undefined && this.cacheKey !== cacheKey) ||
this.onRequestUpdate !== options?.onRequestUpdate
) {
releaseChatMediaResourceSubscriber(this.requestUpdate);
}
this.cacheKey = cacheKey;
this.onRequestUpdate = options?.onRequestUpdate;
// A transcript shares one pane callback across many guarded rows. Lit owns
// each image part, so only disconnecting that part may release its resource.
if (this.onRequestUpdate) {
observeChatMediaResourceSubscriber(this.onRequestUpdate, this.requestUpdate);
}
const subscriptionOptions = this.onRequestUpdate
? { ...options, onRequestUpdate: this.requestUpdate }
: options;
const preview = resolveManagedOutgoingImageBlobUrl(
image.displayUrl,
subscriptionOptions,
image.artifactId,
).then((previewUrl) => (previewUrl ? renderImageElement(image, previewUrl) : nothing));
return until(preview, nothing);
}
protected override disconnected() {
releaseChatMediaResourceSubscriber(this.requestUpdate);
}
protected override reconnected() {
if (this.image && this.renderImageElement) {
// Guarded transcript rows can skip their next pane render. Reinstall the
// image promise and its subscriber directly when Lit reconnects its part.
this.setValue(this.render(this.image, this.options, this.renderImageElement));
}
}
}
const renderManagedImageResource = directive(ManagedImageResourceDirective);
export function resolveRenderableMessageImages(
images: ImageBlock[],
@@ -134,15 +211,7 @@ export function renderMessageImages(images: RenderableImageBlock[], opts?: Image
if (!isManagedOutgoingImageSource(img.displayUrl)) {
return renderImageElement(img, img.displayUrl);
}
const preview = resolveManagedOutgoingImageBlobUrl(img.displayUrl, opts, img.artifactId).then(
(previewUrl) => {
if (!previewUrl) {
return nothing;
}
return renderImageElement(img, previewUrl);
},
);
return until(preview, nothing);
return renderManagedImageResource(img, opts, renderImageElement);
};
return html` <div class="chat-message-images">${images.map((img) => renderImage(img))}</div> `;
@@ -201,16 +270,34 @@ async function resolveManagedOutgoingImageBlobUrl(
): Promise<string | null> {
const authToken = opts?.authToken?.trim() ?? "";
const cacheKey = resolveManagedOutgoingImageBlobUrlCacheKey(source, opts, artifactId);
const resource = observeChatMediaResource<string | null>(
"managed-image",
cacheKey,
opts?.onRequestUpdate,
`${source}::${artifactId?.trim() ?? ""}`,
);
const cached = readManagedImageBlobUrl(cacheKey);
if (cached) {
resource.value = cached;
resource.retryAttempted = false;
resource.unavailableAt = undefined;
return cached;
}
if (hasRecentManagedImageBlobUrlMiss(cacheKey)) {
return null;
if (resource.value === null) {
if (
resource.retryAttempted ||
resource.unavailableAt === undefined ||
Date.now() - resource.unavailableAt < MANAGED_OUTGOING_IMAGE_RETRY_MS
) {
return null;
}
resource.retryAttempted = true;
resource.value = undefined;
}
let pending = managedImageBlobUrlCache.get(cacheKey);
if (!pending) {
pending = (async () => {
if (!resource.pending) {
const controller = new AbortController();
resource.abortController = controller;
const pending = (async () => {
const requesterSessionKey = resolveManagedOutgoingImageRequesterSessionKey(source);
const artifactDownload =
requesterSessionKey && artifactId && opts?.resolveArtifactDownload
@@ -218,6 +305,9 @@ async function resolveManagedOutgoingImageBlobUrl(
.resolveArtifactDownload({ sessionKey: requesterSessionKey, artifactId })
.catch(() => null)
: null;
if (!isChatMediaResourceCurrent(resource)) {
return null;
}
const requestUrl = artifactDownload?.url ?? source;
const headers = new Headers({ Accept: "image/*" });
if (!artifactDownload && authToken) {
@@ -226,7 +316,6 @@ async function resolveManagedOutgoingImageBlobUrl(
if (!artifactDownload && requesterSessionKey) {
headers.set("x-openclaw-requester-session-key", requesterSessionKey);
}
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort(
new DOMException("managed outgoing image fetch timed out", "TimeoutError"),
@@ -242,29 +331,60 @@ async function resolveManagedOutgoingImageBlobUrl(
signal: controller.signal,
});
if (!res.ok) {
cacheManagedImageBlobUrlMiss(cacheKey);
return null;
return markManagedOutgoingImageUnavailable(resource);
}
const blob = await res.blob();
if (!blob.type.startsWith("image/")) {
cacheManagedImageBlobUrlMiss(cacheKey);
return markManagedOutgoingImageUnavailable(resource);
}
if (!isChatMediaResourceCurrent(resource)) {
return null;
}
const blobUrl = URL.createObjectURL(blob);
cacheManagedImageBlobUrl(cacheKey, blobUrl);
resource.value = blobUrl;
resource.retryAttempted = false;
resource.unavailableAt = undefined;
return blobUrl;
} catch {
// The render path treats a missing preview as `nothing`; never reject
// its `until` promise for an optional image fetch or body failure.
cacheManagedImageBlobUrlMiss(cacheKey);
return null;
return markManagedOutgoingImageUnavailable(resource);
} finally {
clearTimeout(timeout);
}
})().finally(() => {
managedImageBlobUrlCache.delete(cacheKey);
if (resource.abortController === controller) {
resource.abortController = undefined;
}
if (resource.pending === pending) {
resource.pending = undefined;
}
trimManagedImageMissResources();
notifyChatMediaResourceSubscribers(resource);
});
managedImageBlobUrlCache.set(cacheKey, pending);
resource.pending = pending;
}
return pending;
return resource.pending;
}
function markManagedOutgoingImageUnavailable(resource: ChatMediaResource<string | null>): null {
if (!isChatMediaResourceCurrent(resource)) {
return null;
}
resource.value = null;
resource.unavailableAt = Date.now();
if (!resource.retryAttempted) {
scheduleChatMediaResourceRefresh(resource, Date.now() + MANAGED_OUTGOING_IMAGE_RETRY_MS, () => {
if (resource.value !== null) {
return;
}
// A missing preview gets one lifecycle-owned retry, never a polling loop.
resource.retryAttempted = true;
resource.value = undefined;
resource.unavailableAt = undefined;
notifyChatMediaResourceSubscribers(resource);
});
}
return null;
}
@@ -0,0 +1,645 @@
/* @vitest-environment jsdom */
import { html, render } from "lit";
import { guard } from "lit/directives/guard.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveAssistantAttachmentAvailability } from "./chat-message-attachments.ts";
import { renderMessageImages } from "./chat-message-images.ts";
import {
isChatMediaResourceCurrent,
observeChatMediaResource,
readManagedImageBlobUrl,
releaseChatMediaResourceSubscriber,
type ImageRenderOptions,
type RenderableImageBlock,
} from "./chat-message-media.ts";
const subscribers = new Set<() => void>();
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-28T00:00:00.000Z"));
});
afterEach(() => {
for (const subscriber of subscribers) {
releaseChatMediaResourceSubscriber(subscriber);
}
subscribers.clear();
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function managedImageSource(): string {
return `/api/chat/media/outgoing/agent%3Amain%3Amain/${crypto.randomUUID()}/full`;
}
function installManagedImageUrls(): string {
const NativeUrl = URL;
const blobUrl = `blob:managed-image-${crypto.randomUUID()}`;
vi.stubGlobal(
"URL",
class extends NativeUrl {
static override createObjectURL = vi.fn(() => blobUrl);
static override revokeObjectURL = vi.fn();
},
);
return blobUrl;
}
function imageResponse() {
return {
ok: true,
blob: async () => new Blob(["png"], { type: "image/png" }),
};
}
function renderManagedImage(
container: HTMLElement,
source: string,
options: ImageRenderOptions = {},
artifactId?: string,
) {
const image: RenderableImageBlock = {
url: source,
displayUrl: source,
alt: "Managed image",
...(artifactId ? { artifactId } : {}),
};
render(renderMessageImages([image], options), container);
}
function observeSubscriber(subscriber: () => void): () => void {
subscribers.add(subscriber);
return subscriber;
}
describe("chat media resource lifecycle", () => {
it("wakes a managed image after one transient failure without an external render", async () => {
const source = managedImageSource();
const blobUrl = installManagedImageUrls();
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: false })
.mockResolvedValueOnce(imageResponse());
vi.stubGlobal("fetch", fetchMock);
const container = document.createElement("div");
const rerender = observeSubscriber(() =>
renderManagedImage(container, source, { onRequestUpdate: rerender }),
);
rerender();
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(container.querySelector(".chat-message-image")).toBeNull();
await vi.advanceTimersByTimeAsync(5_000);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(
container.querySelector<HTMLImageElement>(".chat-message-image")?.getAttribute("src"),
).toBe(blobUrl);
});
it("stops after one automatic retry for a permanently unavailable managed image", async () => {
const source = managedImageSource();
const fetchMock = vi.fn(async () => ({ ok: false }));
vi.stubGlobal("fetch", fetchMock);
const container = document.createElement("div");
const rerender = observeSubscriber(() =>
renderManagedImage(container, source, { onRequestUpdate: rerender }),
);
rerender();
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(5_000);
await vi.advanceTimersByTimeAsync(20_000);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(vi.getTimerCount()).toBe(0);
expect(container.querySelector(".chat-message-image")).toBeNull();
});
it("preserves the bounded retry window when an image has no pane subscriber", async () => {
const source = managedImageSource();
const blobUrl = installManagedImageUrls();
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: false })
.mockResolvedValueOnce(imageResponse());
vi.stubGlobal("fetch", fetchMock);
const container = document.createElement("div");
renderManagedImage(container, source);
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(5_000);
expect(fetchMock).toHaveBeenCalledTimes(1);
renderManagedImage(container, source);
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(
container.querySelector<HTMLImageElement>(".chat-message-image")?.getAttribute("src"),
).toBe(blobUrl);
});
it("releases replaced managed images while keeping one pane callback stable", async () => {
const NativeUrl = URL;
let blobIndex = 0;
const revokeObjectURL = vi.fn();
vi.stubGlobal(
"URL",
class extends NativeUrl {
static override createObjectURL = vi.fn(() => `blob:replaced-managed-image-${blobIndex++}`);
static override revokeObjectURL = revokeObjectURL;
},
);
const fetchMock = vi.fn(async () => imageResponse());
vi.stubGlobal("fetch", fetchMock);
const container = document.createElement("div");
const firstSource = managedImageSource();
const sources = [firstSource, ...Array.from({ length: 64 }, () => managedImageSource())];
let currentSource = firstSource;
const rerender = observeSubscriber(() =>
renderManagedImage(container, currentSource, { onRequestUpdate: rerender }),
);
const resources = [];
for (const source of sources) {
currentSource = source;
rerender();
resources.push(observeChatMediaResource<string | null>("managed-image", `${source}::::`));
await vi.advanceTimersByTimeAsync(0);
}
expect(fetchMock).toHaveBeenCalledTimes(65);
expect(resources.filter((resource) => isChatMediaResourceCurrent(resource))).toHaveLength(1);
expect(resources.at(-1)).toSatisfy(isChatMediaResourceCurrent);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:replaced-managed-image-0");
});
it("keeps simultaneous managed images until their individual render parts disappear", async () => {
installManagedImageUrls();
const fetchMock = vi.fn(async () => imageResponse());
vi.stubGlobal("fetch", fetchMock);
const container = document.createElement("div");
const firstSource = managedImageSource();
const secondSource = managedImageSource();
let sources = [firstSource, secondSource];
const rerender = observeSubscriber(() => {
const images = sources.map((source) => ({
url: source,
displayUrl: source,
alt: "Managed image",
}));
render(renderMessageImages(images, { onRequestUpdate: rerender }), container);
});
rerender();
const firstResource = observeChatMediaResource<string | null>(
"managed-image",
`${firstSource}::::`,
);
const secondResource = observeChatMediaResource<string | null>(
"managed-image",
`${secondSource}::::`,
);
await vi.advanceTimersByTimeAsync(0);
expect(isChatMediaResourceCurrent(firstResource)).toBe(true);
expect(isChatMediaResourceCurrent(secondResource)).toBe(true);
expect(container.querySelectorAll(".chat-message-image")).toHaveLength(2);
sources = [firstSource];
rerender();
await vi.advanceTimersByTimeAsync(0);
expect(isChatMediaResourceCurrent(firstResource)).toBe(true);
expect(isChatMediaResourceCurrent(secondResource)).toBe(false);
expect(container.querySelectorAll(".chat-message-image")).toHaveLength(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("resubscribes a guarded managed image when its Lit root reconnects", async () => {
const blobUrl = installManagedImageUrls();
const fetchMock = vi.fn(async () => imageResponse());
vi.stubGlobal("fetch", fetchMock);
const container = document.createElement("div");
const source = managedImageSource();
const image = { url: source, displayUrl: source, alt: "Managed image" };
const renderImageRow = vi.fn(() => renderMessageImages([image], { onRequestUpdate: rerender }));
let root!: ReturnType<typeof render>;
const rerender = observeSubscriber(() => {
root = render(html`${guard([source], renderImageRow)}`, container);
});
rerender();
await vi.advanceTimersByTimeAsync(0);
const originalResource = observeChatMediaResource<string | null>(
"managed-image",
`${source}::::`,
);
expect(originalResource.subscribers.size).toBe(1);
root.setConnected(false);
expect(isChatMediaResourceCurrent(originalResource)).toBe(false);
root.setConnected(true);
await vi.advanceTimersByTimeAsync(0);
const reconnectedResource = observeChatMediaResource<string | null>(
"managed-image",
`${source}::::`,
);
expect(reconnectedResource.subscribers.size).toBe(1);
expect(renderImageRow).toHaveBeenCalledTimes(1);
expect(container.querySelector<HTMLImageElement>(".chat-message-image")?.src).toBe(blobUrl);
});
it("does not subscribe or fetch a managed image while its Lit root is disconnected", async () => {
installManagedImageUrls();
const fetchMock = vi.fn(async () => imageResponse());
vi.stubGlobal("fetch", fetchMock);
const container = document.createElement("div");
let source = managedImageSource();
let root!: ReturnType<typeof render>;
const rerender = observeSubscriber(() => {
const image = { url: source, displayUrl: source, alt: "Managed image" };
root = render(
html`${guard([source], () => renderMessageImages([image], { onRequestUpdate: rerender }))}`,
container,
);
});
rerender();
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(1);
root.setConnected(false);
source = managedImageSource();
rerender();
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(1);
root.setConnected(true);
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(container.querySelector(".chat-message-image")).not.toBeNull();
});
it("evicts settled subscriber-free resources with their managed image blobs", async () => {
const NativeUrl = URL;
let blobIndex = 0;
const revokeObjectURL = vi.fn();
vi.stubGlobal(
"URL",
class extends NativeUrl {
static override createObjectURL = vi.fn(() => `blob:bounded-managed-image-${blobIndex++}`);
static override revokeObjectURL = revokeObjectURL;
},
);
const fetchMock = vi.fn(async () => imageResponse());
vi.stubGlobal("fetch", fetchMock);
const sources = Array.from({ length: 65 }, () => managedImageSource());
const resources = sources.map((source) => {
renderManagedImage(document.createElement("div"), source);
return observeChatMediaResource<string | null>("managed-image", `${source}::::`);
});
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(65);
expect(resources.filter((resource) => isChatMediaResourceCurrent(resource))).toHaveLength(64);
const oldestResource = resources[0];
const oldestSource = sources[0];
const latestSource = sources[64];
if (!oldestResource || !oldestSource || !latestSource) {
throw new Error("expected the oldest and newest managed images");
}
expect(isChatMediaResourceCurrent(oldestResource)).toBe(false);
expect(readManagedImageBlobUrl(`${oldestSource}::::`)).toBeUndefined();
expect(readManagedImageBlobUrl(`${latestSource}::::`)).toBe("blob:bounded-managed-image-64");
expect(revokeObjectURL).toHaveBeenCalledWith("blob:bounded-managed-image-0");
renderManagedImage(document.createElement("div"), latestSource);
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(65);
renderManagedImage(document.createElement("div"), oldestSource);
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(66);
expect(readManagedImageBlobUrl(`${oldestSource}::::`)).toBe("blob:bounded-managed-image-65");
});
it("shares a managed image retry and wakes both subscribed split panes", async () => {
const source = managedImageSource();
const blobUrl = installManagedImageUrls();
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: false })
.mockResolvedValueOnce(imageResponse());
vi.stubGlobal("fetch", fetchMock);
const first = document.createElement("div");
const second = document.createElement("div");
const rerenderFirst = observeSubscriber(() =>
renderManagedImage(first, source, { onRequestUpdate: rerenderFirst }),
);
const rerenderSecond = observeSubscriber(() =>
renderManagedImage(second, source, { onRequestUpdate: rerenderSecond }),
);
rerenderFirst();
rerenderSecond();
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(5_000);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(first.querySelector<HTMLImageElement>(".chat-message-image")?.getAttribute("src")).toBe(
blobUrl,
);
expect(second.querySelector<HTMLImageElement>(".chat-message-image")?.getAttribute("src")).toBe(
blobUrl,
);
});
it("shares assistant attachment completion and ticket refresh across split panes", async () => {
const source = `/tmp/openclaw/${crypto.randomUUID()}.png`;
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
available: true,
mediaTicket: "ticket-before-refresh",
mediaTicketExpiresAt: new Date(Date.now() + 31_000).toISOString(),
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
available: true,
mediaTicket: "ticket-after-refresh",
mediaTicketExpiresAt: new Date(Date.now() + 90_000).toISOString(),
}),
});
vi.stubGlobal("fetch", fetchMock);
let firstTicket: string | undefined;
let secondTicket: string | undefined;
const rerenderFirst = observeSubscriber(() => {
const availability = resolveAssistantAttachmentAvailability(
source,
["/tmp/openclaw"],
"/openclaw",
"split-pane-token",
rerenderFirst,
);
firstTicket = availability.status === "available" ? availability.mediaTicket : undefined;
});
const rerenderSecond = observeSubscriber(() => {
const availability = resolveAssistantAttachmentAvailability(
source,
["/tmp/openclaw"],
"/openclaw",
"split-pane-token",
rerenderSecond,
);
secondTicket = availability.status === "available" ? availability.mediaTicket : undefined;
});
rerenderFirst();
rerenderSecond();
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(firstTicket).toBe("ticket-before-refresh");
expect(secondTicket).toBe("ticket-before-refresh");
await vi.advanceTimersByTimeAsync(1_000);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(firstTicket).toBe("ticket-after-refresh");
expect(secondTicket).toBe("ticket-after-refresh");
});
it("shares the one bounded assistant attachment retry across split panes", async () => {
const source = `/tmp/openclaw/${crypto.randomUUID()}.png`;
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ available: false }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
available: true,
mediaTicket: "ticket-after-retry",
mediaTicketExpiresAt: new Date(Date.now() + 90_000).toISOString(),
}),
});
vi.stubGlobal("fetch", fetchMock);
let firstTicket: string | undefined;
let secondTicket: string | undefined;
const rerenderFirst = observeSubscriber(() => {
const availability = resolveAssistantAttachmentAvailability(
source,
["/tmp/openclaw"],
"/openclaw",
"split-pane-token",
rerenderFirst,
);
firstTicket = availability.status === "available" ? availability.mediaTicket : undefined;
});
const rerenderSecond = observeSubscriber(() => {
const availability = resolveAssistantAttachmentAvailability(
source,
["/tmp/openclaw"],
"/openclaw",
"split-pane-token",
rerenderSecond,
);
secondTicket = availability.status === "available" ? availability.mediaTicket : undefined;
});
rerenderFirst();
rerenderSecond();
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(5_000);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(firstTicket).toBe("ticket-after-retry");
expect(secondTicket).toBe("ticket-after-retry");
});
it("aborts pending media and clears its retry when the last pane disconnects", async () => {
const source = managedImageSource();
let requestSignal: AbortSignal | undefined;
const fetchMock = vi.fn(
(_source: string, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
requestSignal = init?.signal ?? undefined;
requestSignal?.addEventListener(
"abort",
() => reject(new DOMException("pane disconnected", "AbortError")),
{ once: true },
);
}),
);
vi.stubGlobal("fetch", fetchMock);
const container = document.createElement("div");
const rerender = observeSubscriber(() =>
renderManagedImage(container, source, { onRequestUpdate: rerender }),
);
rerender();
await vi.advanceTimersByTimeAsync(0);
releaseChatMediaResourceSubscriber(rerender);
await vi.advanceTimersByTimeAsync(0);
expect(requestSignal?.aborted).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});
it("keeps shared image work alive until the last split pane disconnects", async () => {
const source = managedImageSource();
let requestSignal: AbortSignal | undefined;
const fetchMock = vi.fn(
(_source: string, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
requestSignal = init?.signal ?? undefined;
requestSignal?.addEventListener(
"abort",
() => reject(new DOMException("last pane disconnected", "AbortError")),
{ once: true },
);
}),
);
vi.stubGlobal("fetch", fetchMock);
const first = document.createElement("div");
const second = document.createElement("div");
const rerenderFirst = observeSubscriber(() =>
renderManagedImage(first, source, { onRequestUpdate: rerenderFirst }),
);
const rerenderSecond = observeSubscriber(() =>
renderManagedImage(second, source, { onRequestUpdate: rerenderSecond }),
);
rerenderFirst();
rerenderSecond();
await vi.advanceTimersByTimeAsync(0);
releaseChatMediaResourceSubscriber(rerenderFirst);
expect(requestSignal?.aborted).toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(1);
releaseChatMediaResourceSubscriber(rerenderSecond);
await vi.advanceTimersByTimeAsync(0);
expect(requestSignal?.aborted).toBe(true);
expect(vi.getTimerCount()).toBe(0);
});
it("replaces an old auth scope without accepting its late image", async () => {
const source = managedImageSource();
const blobUrl = installManagedImageUrls();
let previousSignal: AbortSignal | undefined;
const fetchMock = vi.fn((_source: string, init?: RequestInit) => {
if (new Headers(init?.headers).get("Authorization") === "Bearer old-token") {
return new Promise<Response>((_resolve, reject) => {
previousSignal = init?.signal ?? undefined;
previousSignal?.addEventListener(
"abort",
() => reject(new DOMException("auth changed", "AbortError")),
{ once: true },
);
});
}
return Promise.resolve(imageResponse());
});
vi.stubGlobal("fetch", fetchMock);
const container = document.createElement("div");
let authToken = "old-token";
const rerender = observeSubscriber(() =>
renderManagedImage(container, source, { authToken, onRequestUpdate: rerender }),
);
rerender();
await vi.advanceTimersByTimeAsync(0);
authToken = "new-token";
rerender();
await vi.advanceTimersByTimeAsync(0);
expect(previousSignal?.aborted).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(
container.querySelector<HTMLImageElement>(".chat-message-image")?.getAttribute("src"),
).toBe(blobUrl);
});
it("retries signed artifact tickets without exposing gateway or requester credentials", async () => {
const source = managedImageSource();
const artifactId = `artifact-${crypto.randomUUID()}`;
const ticketedUrl = `${source}?mediaTicket=signed`;
const blobUrl = installManagedImageUrls();
const resolveArtifactDownload = vi.fn(async () => ({ url: ticketedUrl }));
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: false })
.mockResolvedValueOnce(imageResponse());
vi.stubGlobal("fetch", fetchMock);
const container = document.createElement("div");
const rerender = observeSubscriber(() =>
renderManagedImage(
container,
source,
{
authToken: "must-never-be-forwarded",
onRequestUpdate: rerender,
resolveArtifactDownload,
},
artifactId,
),
);
rerender();
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(5_000);
expect(resolveArtifactDownload).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
for (const [requestUrl, init] of fetchMock.mock.calls as Array<[string, RequestInit]>) {
expect(requestUrl).toBe(ticketedUrl);
const headers = new Headers(init.headers);
expect(headers.get("Authorization")).toBeNull();
expect(headers.get("x-openclaw-requester-session-key")).toBeNull();
}
expect(
container.querySelector<HTMLImageElement>(".chat-message-image")?.getAttribute("src"),
).toBe(blobUrl);
});
});
@@ -45,11 +45,204 @@ export type RenderableImageBlock = ImageBlock & {
export type AttachmentItem = Extract<MessageContentItem, { type: "attachment" }>;
type ChatMediaResourceKind = "assistant-attachment" | "managed-image";
export type ChatMediaResource<Value> = {
kind: ChatMediaResourceKind;
cacheKey: string;
value: Value | undefined;
pending: Promise<Value | null> | undefined;
subscribers: Set<() => void>;
retryAttempted: boolean;
unavailableAt: number | undefined;
abortController: AbortController | undefined;
refresh: { at: number; timer: ReturnType<typeof setTimeout> } | undefined;
};
const chatMediaResources = new Map<string, ChatMediaResource<unknown>>();
const chatMediaSubscriberResources = new Map<() => void, Map<string, ChatMediaResource<unknown>>>();
const chatMediaSubscriberChildren = new Map<() => void, Set<() => void>>();
const chatMediaSubscriberOwners = new Map<() => void, () => void>();
const managedImageBlobUrlResolvedCache = new Map<string, string>();
const managedImageBlobUrlMissCache = new Map<string, number>();
const managedImageBlobUrlRetainCounts = new Map<string, number>();
const MANAGED_IMAGE_BLOB_URL_CACHE_MAX_ENTRIES = 64;
const MANAGED_IMAGE_BLOB_URL_MISS_RETRY_MS = 5_000;
function chatMediaResourceKey(kind: ChatMediaResourceKind, cacheKey: string): string {
return `${kind}\0${cacheKey}`;
}
function detachChatMediaResourceSubscriber(
resource: ChatMediaResource<unknown>,
subscriber: () => void,
) {
resource.subscribers.delete(subscriber);
if (resource.subscribers.size > 0) {
return;
}
if (resource.refresh) {
clearTimeout(resource.refresh.timer);
resource.refresh = undefined;
}
const resourceKey = chatMediaResourceKey(resource.kind, resource.cacheKey);
if (chatMediaResources.get(resourceKey) === resource) {
chatMediaResources.delete(resourceKey);
}
resource.abortController?.abort();
resource.abortController = undefined;
}
export function observeChatMediaResource<Value>(
kind: ChatMediaResourceKind,
cacheKey: string,
subscriber?: () => void,
subscriberScope = cacheKey,
): ChatMediaResource<Value> {
const resourceKey = chatMediaResourceKey(kind, cacheKey);
let resource = chatMediaResources.get(resourceKey) as ChatMediaResource<Value> | undefined;
if (!resource) {
resource = {
kind,
cacheKey,
value: undefined,
pending: undefined,
subscribers: new Set(),
retryAttempted: false,
unavailableAt: undefined,
abortController: undefined,
refresh: undefined,
};
chatMediaResources.set(resourceKey, resource as ChatMediaResource<unknown>);
}
if (subscriber) {
let subscriptions = chatMediaSubscriberResources.get(subscriber);
if (!subscriptions) {
subscriptions = new Map();
chatMediaSubscriberResources.set(subscriber, subscriptions);
}
const subscriptionKey = chatMediaResourceKey(kind, subscriberScope);
const previous = subscriptions.get(subscriptionKey);
if (previous && previous !== resource) {
detachChatMediaResourceSubscriber(previous, subscriber);
}
subscriptions.set(subscriptionKey, resource as ChatMediaResource<unknown>);
resource.subscribers.add(subscriber);
}
return resource;
}
export function isChatMediaResourceCurrent<Value>(resource: ChatMediaResource<Value>): boolean {
return (
chatMediaResources.get(chatMediaResourceKey(resource.kind, resource.cacheKey)) === resource
);
}
export function notifyChatMediaResourceSubscribers<Value>(resource: ChatMediaResource<Value>) {
if (!isChatMediaResourceCurrent(resource)) {
return;
}
// A pane can change its subscription while another pane is being notified.
// Snapshot the current generation so a replacement never receives stale work.
for (const subscriber of Array.from(resource.subscribers)) {
if (resource.subscribers.has(subscriber)) {
subscriber();
}
}
}
export function scheduleChatMediaResourceRefresh<Value>(
resource: ChatMediaResource<Value>,
refreshAt: number | undefined,
onRefresh: () => void,
) {
if (resource.refresh?.at === refreshAt) {
return;
}
if (resource.refresh) {
clearTimeout(resource.refresh.timer);
resource.refresh = undefined;
}
if (refreshAt === undefined || resource.subscribers.size === 0) {
return;
}
const refresh = {
at: refreshAt,
timer: setTimeout(
() => {
if (!isChatMediaResourceCurrent(resource) || resource.refresh !== refresh) {
return;
}
resource.refresh = undefined;
onRefresh();
},
Math.max(0, refreshAt - Date.now()),
),
};
resource.refresh = refresh;
}
export function observeChatMediaResourceSubscriber(owner: () => void, subscriber: () => void) {
const previousOwner = chatMediaSubscriberOwners.get(subscriber);
if (previousOwner === owner) {
return;
}
if (previousOwner) {
const previousChildren = chatMediaSubscriberChildren.get(previousOwner);
previousChildren?.delete(subscriber);
if (previousChildren?.size === 0) {
chatMediaSubscriberChildren.delete(previousOwner);
}
}
let children = chatMediaSubscriberChildren.get(owner);
if (!children) {
children = new Set();
chatMediaSubscriberChildren.set(owner, children);
}
children.add(subscriber);
chatMediaSubscriberOwners.set(subscriber, owner);
}
export function releaseChatMediaResourceSubscriber(subscriber: (() => void) | undefined) {
if (!subscriber) {
return;
}
const children = chatMediaSubscriberChildren.get(subscriber);
if (children) {
chatMediaSubscriberChildren.delete(subscriber);
for (const child of children) {
releaseChatMediaResourceSubscriber(child);
}
}
const owner = chatMediaSubscriberOwners.get(subscriber);
if (owner) {
chatMediaSubscriberOwners.delete(subscriber);
const ownerChildren = chatMediaSubscriberChildren.get(owner);
ownerChildren?.delete(subscriber);
if (ownerChildren?.size === 0) {
chatMediaSubscriberChildren.delete(owner);
}
}
const subscriptions = chatMediaSubscriberResources.get(subscriber);
if (!subscriptions) {
return;
}
chatMediaSubscriberResources.delete(subscriber);
for (const resource of new Set(subscriptions.values())) {
detachChatMediaResourceSubscriber(resource, subscriber);
}
}
export function trimManagedImageMissResources() {
const misses = [...chatMediaResources.entries()].filter(
([, resource]) =>
resource.kind === "managed-image" &&
resource.value === null &&
resource.subscribers.size === 0 &&
!resource.pending,
);
for (const [resourceKey] of misses.slice(0, -MANAGED_IMAGE_BLOB_URL_CACHE_MAX_ENTRIES)) {
chatMediaResources.delete(resourceKey);
}
}
export function readManagedImageBlobUrl(cacheKey: string): string | undefined {
const cached = managedImageBlobUrlResolvedCache.get(cacheKey);
@@ -72,6 +265,13 @@ function trimManagedImageBlobUrlCache() {
const evicted = managedImageBlobUrlResolvedCache.get(evictable);
managedImageBlobUrlResolvedCache.delete(evictable);
if (evicted) {
const resourceKey = chatMediaResourceKey("managed-image", evictable);
const resource = chatMediaResources.get(resourceKey);
// Subscriber-free successful resources share their blob's LRU lifetime.
// The promise finalizer may still be queued, but a matching value is settled.
if (resource?.value === evicted && resource.subscribers.size === 0) {
chatMediaResources.delete(resourceKey);
}
URL.revokeObjectURL(evicted);
}
}
@@ -105,7 +305,6 @@ export function cacheManagedImageBlobUrl(cacheKey: string, blobUrl: string) {
const previous = managedImageBlobUrlResolvedCache.get(cacheKey);
managedImageBlobUrlResolvedCache.delete(cacheKey);
managedImageBlobUrlResolvedCache.set(cacheKey, blobUrl);
managedImageBlobUrlMissCache.delete(cacheKey);
if (previous && previous !== blobUrl) {
URL.revokeObjectURL(previous);
}
@@ -115,32 +314,6 @@ export function cacheManagedImageBlobUrl(cacheKey: string, blobUrl: string) {
trimManagedImageBlobUrlCache();
}
export function hasRecentManagedImageBlobUrlMiss(cacheKey: string): boolean {
const missAt = managedImageBlobUrlMissCache.get(cacheKey);
if (missAt === undefined) {
return false;
}
if (Date.now() - missAt >= MANAGED_IMAGE_BLOB_URL_MISS_RETRY_MS) {
managedImageBlobUrlMissCache.delete(cacheKey);
return false;
}
managedImageBlobUrlMissCache.delete(cacheKey);
managedImageBlobUrlMissCache.set(cacheKey, missAt);
return true;
}
export function cacheManagedImageBlobUrlMiss(cacheKey: string) {
managedImageBlobUrlMissCache.delete(cacheKey);
managedImageBlobUrlMissCache.set(cacheKey, Date.now());
while (managedImageBlobUrlMissCache.size > MANAGED_IMAGE_BLOB_URL_CACHE_MAX_ENTRIES) {
const oldest = managedImageBlobUrlMissCache.keys().next();
if (oldest.done) {
break;
}
managedImageBlobUrlMissCache.delete(oldest.value);
}
}
function appendImageBlock(images: ImageBlock[], block: ImageBlock) {
if (!images.some((entry) => entry.url === block.url && entry.alt === block.alt)) {
images.push(block);
@@ -8,9 +8,16 @@
import { render } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BoardProvider } from "../../../lib/board/provider.ts";
import { resolveAssistantAttachmentAuthToken } from "../chat-pane-state.ts";
import { createTestChatPane } from "../chat-pane.test-support.ts";
import * as chatThreadBuild from "../chat-thread-build.ts";
import { buildCachedChatItems, resetChatThreadState } from "../chat-thread.ts";
import { createTestTranscript } from "../chat-view.test-helpers.ts";
import {
isChatMediaResourceCurrent,
observeChatMediaResource,
releaseChatMediaResourceSubscriber,
} from "./chat-message-media.ts";
import {
renderChatThread,
resetChatThreadPresentationState,
@@ -321,6 +328,229 @@ describe("chat transcript row measurement", () => {
expect(observedElements.size).toBe(0);
});
it("rebinds guarded transcript images when the gateway rotates its auth token", async () => {
const NativeUrl = URL;
const blobUrl = `blob:transcript-media-${crypto.randomUUID()}`;
vi.stubGlobal(
"URL",
class extends NativeUrl {
static override createObjectURL = vi.fn(() => blobUrl);
static override revokeObjectURL = vi.fn();
},
);
let previousSignal: AbortSignal | undefined;
const fetchMock = vi.fn((_source: string, init?: RequestInit) => {
if (fetchMock.mock.calls.length === 1) {
return new Promise<Response>((_resolve, reject) => {
previousSignal = init?.signal ?? undefined;
previousSignal?.addEventListener(
"abort",
() => reject(new DOMException("media scope changed", "AbortError")),
{ once: true },
);
});
}
return Promise.resolve({
ok: true,
blob: async () => new Blob(["png"], { type: "image/png" }),
} as Response);
});
vi.stubGlobal("fetch", fetchMock);
const source = `/api/chat/media/outgoing/agent%3Amain%3Amain/${crypto.randomUUID()}/full`;
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
const client = {
request: vi.fn(async () => null),
} as unknown as Parameters<typeof createTestChatPane>[0]["client"];
const sessions = {} as Parameters<typeof createTestChatPane>[0]["sessions"];
const { pane, state } = createTestChatPane({ client, sessions });
state.hello = {
auth: { deviceToken: "old-token" },
} as typeof state.hello;
const messages = [
{
role: "assistant",
content: [{ type: "image", url: source }],
timestamp: 1_000,
},
];
const renderPane = () => {
render(
renderChatThread(
{
...threadProps("pane-gateway-media-auth", state.sessionKey, messages),
assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state),
onRequestUpdate: renderPane,
},
transcript,
),
container,
);
transcript.hostUpdated();
};
state.requestUpdate = renderPane;
renderPane();
transcript.hostConnected();
transcript.hostUpdated();
await flushDeferredRowPrune();
const previousResource = observeChatMediaResource<string | null>(
"managed-image",
`${source}::old-token::`,
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(previousResource.subscribers.size).toBe(1);
pane.applyGatewaySnapshot({
...pane.context.gateway.snapshot,
client,
phase: "connected",
hello: {
...pane.context.gateway.snapshot.hello,
auth: { deviceToken: "next-token" },
} as typeof pane.context.gateway.snapshot.hello,
});
expect(previousSignal?.aborted).toBe(true);
expect(isChatMediaResourceCurrent(previousResource)).toBe(false);
await flushDeferredRowPrune();
const nextResource = observeChatMediaResource<string | null>(
"managed-image",
`${source}::next-token::`,
);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get("Authorization")).toBe(
"Bearer next-token",
);
expect(isChatMediaResourceCurrent(nextResource)).toBe(true);
expect(nextResource.subscribers.size).toBe(1);
expect(container.querySelector<HTMLImageElement>(".chat-message-image")?.src).toBe(blobUrl);
releaseChatMediaResourceSubscriber(renderPane);
transcript.hostDisconnected();
});
it("reconciles guarded local attachments when pane preview roots change", async () => {
let previousSignal: AbortSignal | undefined;
const fetchMock = vi.fn((_source: string, init?: RequestInit) => {
if (fetchMock.mock.calls.length === 1) {
return new Promise<Response>((_resolve, reject) => {
previousSignal = init?.signal ?? undefined;
previousSignal?.addEventListener(
"abort",
() => reject(new DOMException("preview roots changed", "AbortError")),
{ once: true },
);
});
}
return Promise.resolve({
ok: true,
json: async () => ({
available: true,
mediaTicket: "root-restored-ticket",
mediaTicketExpiresAt: new Date(Date.now() + 90_000).toISOString(),
}),
} as Response);
});
vi.stubGlobal("fetch", fetchMock);
const client = {
request: vi.fn(async () => null),
} as unknown as Parameters<typeof createTestChatPane>[0]["client"];
const sessions = {} as Parameters<typeof createTestChatPane>[0]["sessions"];
const { pane, state } = createTestChatPane({ client, sessions });
const configPane = pane as typeof pane & {
applyApplicationConfig: (config: typeof pane.context.config.current) => void;
};
state.hello = {
auth: { deviceToken: "old-token" },
} as typeof state.hello;
state.localMediaPreviewRoots = ["/tmp/openclaw"];
state.embedSandboxMode = "scripts";
state.allowExternalEmbedUrls = false;
const source = `/tmp/openclaw/${crypto.randomUUID()}.pdf`;
const messages = [
{
role: "assistant",
content: `Local document\nMEDIA:${source}`,
timestamp: 1_000,
},
];
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
const renderPane = () => {
render(
renderChatThread(
{
...threadProps("pane-local-media-roots", state.sessionKey, messages),
assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state),
localMediaPreviewRoots: state.localMediaPreviewRoots,
onRequestUpdate: renderPane,
},
transcript,
),
container,
);
transcript.hostUpdated();
};
state.requestUpdate = renderPane;
renderPane();
transcript.hostConnected();
transcript.hostUpdated();
await flushDeferredRowPrune();
const previousResource = observeChatMediaResource(
"assistant-attachment",
`::old-token::${source}`,
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(previousResource.subscribers.size).toBe(1);
const config = {
...pane.context.config.current,
localMediaPreviewRoots: ["/tmp/elsewhere"],
embedSandboxMode: "scripts" as const,
allowExternalEmbedUrls: false,
};
configPane.applyApplicationConfig(config);
await flushDeferredRowPrune();
expect(previousSignal?.aborted).toBe(true);
expect(isChatMediaResourceCurrent(previousResource)).toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(
container.querySelector(".chat-assistant-attachment-card__reason")?.textContent,
).toContain("Outside allowed folders");
configPane.applyApplicationConfig({
...config,
localMediaPreviewRoots: ["/tmp/openclaw"],
});
await flushDeferredRowPrune();
const restoredResource = observeChatMediaResource(
"assistant-attachment",
`::old-token::${source}`,
);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get("Authorization")).toBe(
"Bearer old-token",
);
expect(isChatMediaResourceCurrent(restoredResource)).toBe(true);
expect(restoredResource.subscribers.size).toBe(1);
expect(
container.querySelector(".chat-assistant-attachment-card__link")?.getAttribute("href"),
).toContain("mediaTicket=root-restored-ticket");
releaseChatMediaResourceSubscriber(renderPane);
transcript.hostDisconnected();
});
it("updates MCP App pinning when the same provider's capability changes", async () => {
const provider = {
sessionKey: "agent:main:main",
+3 -2
View File
@@ -58,6 +58,7 @@ import {
buildSessionObserverTogglePatch,
buildSessionObserverUtilityModelPatch,
} from "./session-observer-settings.ts";
import { SETTINGS_SEARCH_TARGETS } from "./settings-targets.ts";
import {
createConfigViewState,
renderConfig,
@@ -89,8 +90,8 @@ type ConfigPageSetting =
// settings-search links predating the move must land on the new home.
const MOVED_TARGET_ROUTES: Record<string, { routeId: RouteId; hash: string }> = {
"config:settings-general-model": {
routeId: "model-providers",
hash: "#settings-model-behavior",
routeId: SETTINGS_SEARCH_TARGETS.modelBehavior.routeId,
hash: SETTINGS_SEARCH_TARGETS.modelBehavior.hash,
},
};
+23 -32
View File
@@ -11,52 +11,28 @@ export type ConfigPageId =
| "ai-agents"
| "advanced";
export const COMMUNICATION_SECTION_KEYS = ["messages", "talk", "tts"] as const;
const COMMUNICATION_SECTION_KEYS = ["messages", "talk", "tts"] as const;
export const APPEARANCE_SECTION_KEYS = ["__appearance__", "ui", "wizard"] as const;
const APPEARANCE_SECTION_KEYS = ["__appearance__", "ui", "wizard"] as const;
const NOTIFICATION_SECTION_KEYS = ["__notifications__"] as const;
// Curated Privacy & Security home: the schema-backed security/approvals policy
// sections render here, below the curated status rows (security.ts).
export const SECURITY_SECTION_KEYS = ["security", "approvals"] as const;
const SECURITY_SECTION_KEYS = ["security", "approvals"] as const;
export const AUTOMATION_SECTION_KEYS = [
"commands",
"hooks",
"bindings",
"cron",
"plugins",
] as const;
const AUTOMATION_SECTION_KEYS = ["commands", "hooks", "bindings", "cron", "plugins"] as const;
export const INFRASTRUCTURE_SECTION_KEYS = [
"gateway",
"browser",
"nodeHost",
"discovery",
"acp",
] as const;
const INFRASTRUCTURE_SECTION_KEYS = ["gateway", "browser", "nodeHost", "discovery", "acp"] as const;
export const MCP_SECTION_KEYS = ["mcp"] as const;
const MCP_SECTION_KEYS = ["mcp"] as const;
// Curated Memory home: engine/backend/add-on rows plus the Dreaming tab render
// above the memory schema section (memory.ts). Memory left AI & Agents because
// the engine choice and dreaming's global cron are not agent defaults.
export const MEMORY_SECTION_KEYS = ["memory"] as const;
const MEMORY_SECTION_KEYS = ["memory"] as const;
export const AI_AGENTS_SECTION_KEYS = ["agents", "skills", "tools", "session"] as const;
export const SCOPED_CONFIG_SECTION_KEYS = new Set<string>([
...COMMUNICATION_SECTION_KEYS,
...APPEARANCE_SECTION_KEYS,
...NOTIFICATION_SECTION_KEYS,
...SECURITY_SECTION_KEYS,
...AUTOMATION_SECTION_KEYS,
...INFRASTRUCTURE_SECTION_KEYS,
...MCP_SECTION_KEYS,
...MEMORY_SECTION_KEYS,
...AI_AGENTS_SECTION_KEYS,
]);
const AI_AGENTS_SECTION_KEYS = ["agents", "skills", "tools", "session"] as const;
// "config" (the curated General hub) and "advanced" render without an include
// list: General shows no schema sections at all, Advanced shows every section
@@ -75,6 +51,21 @@ const CONFIG_SECTION_KEYS_BY_PAGE = {
advanced: undefined,
} as const satisfies Record<ConfigPageId, readonly string[] | undefined>;
// Search and page rendering must agree on section ownership, or a result can
// open a page whose editor rejects the section it promised to reveal.
const CONFIG_PAGE_BY_SECTION = new Map<string, ConfigPageId>(
Object.entries(CONFIG_SECTION_KEYS_BY_PAGE).flatMap(([pageId, sectionKeys]) =>
(sectionKeys ?? []).map((sectionKey) => [sectionKey, pageId as ConfigPageId] as const),
),
);
export const SCOPED_CONFIG_SECTION_KEYS = new Set(CONFIG_PAGE_BY_SECTION.keys());
export function configSectionKeysForPage(pageId: ConfigPageId): readonly string[] | undefined {
return CONFIG_SECTION_KEYS_BY_PAGE[pageId];
}
export function configPageForSection(sectionKey: string): ConfigPageId {
// Sections without a curated home render on the Advanced page.
return CONFIG_PAGE_BY_SECTION.get(sectionKey) ?? "advanced";
}
@@ -394,6 +394,24 @@ describe("findSettingsSearchBlocks", () => {
]);
});
it("finds archived workspace threads using translated filter text", async () => {
await i18n.setLocale("es");
const matches = findSettingsSearchBlocks({
query: "archivadas",
schema: null,
value: null,
uiHints: {},
});
expect(matches).toEqual([
expect.objectContaining({
routeId: "sessions",
hash: "",
}),
]);
});
it("does not create block results for an empty query", () => {
expect(
findSettingsSearchBlocks({
+12 -289
View File
@@ -1,6 +1,5 @@
import type { ConfigUiHints } from "../../api/types.ts";
import { settingsSearchTextMatches, type SettingsSearchBlock } from "../../app-navigation.ts";
import type { RouteId } from "../../app-route-paths.ts";
import { SECTION_META } from "../../components/config-form.meta.ts";
import {
matchesConfigSectionSearch,
@@ -9,277 +8,30 @@ import {
import { schemaType, type JsonSchema } from "../../components/config-form.shared.ts";
import { splitConfigSchemaByTier } from "../../components/config-form.tiers.ts";
import { t } from "../../i18n/index.ts";
import {
AI_AGENTS_SECTION_KEYS,
APPEARANCE_SECTION_KEYS,
AUTOMATION_SECTION_KEYS,
COMMUNICATION_SECTION_KEYS,
INFRASTRUCTURE_SECTION_KEYS,
MCP_SECTION_KEYS,
MEMORY_SECTION_KEYS,
SECURITY_SECTION_KEYS,
} from "./config-sections.ts";
import { configPageForSection } from "./config-sections.ts";
import {
memoryVisibleSchemaKeys,
resolveMemoryBackend,
MEMORY_BACKEND_ANCHOR_ID,
MEMORY_CURATED_SCHEMA_KEYS,
} from "./memory-schema.ts";
import {
APPEARANCE_SETTINGS_TARGET_IDS,
COMMUNICATION_SETTINGS_TARGET_IDS,
CONNECTION_SETTINGS_TARGET_IDS,
MODEL_SETTINGS_TARGET_IDS,
PROFILE_SETTINGS_TARGET_IDS,
} from "./settings-targets.ts";
type StaticSettingsBlockDescriptor = Omit<SettingsSearchBlock, "label"> & {
labelKey: string;
searchKeys: readonly string[];
aliases?: string;
};
import { SETTINGS_SEARCH_TARGETS, type SettingsSearchTarget } from "./settings-targets.ts";
type StaticSettingsBlock = SettingsSearchBlock & {
searchText: string;
};
const GENERAL_SETTINGS_BLOCKS = {
channels: {
routeId: "channels",
labelKey: "quickSettings.channels.title",
hash: "",
searchKeys: ["quickSettings.channels.connect"],
aliases: "telegram discord slack whatsapp signal imessage",
},
security: {
routeId: "security",
labelKey: "quickSettings.security.title",
hash: "",
searchKeys: [
"quickSettings.security.gatewayAuth",
"quickSettings.security.execPolicy",
"quickSettings.security.deviceAuth",
"quickSettings.security.browserEnabled",
"quickSettings.security.toolProfile",
],
},
system: {
routeId: "connection",
labelKey: "quickSettings.system.gatewayHost",
hash: `#${CONNECTION_SETTINGS_TARGET_IDS.host}`,
searchKeys: [
"quickSettings.system.cpu",
"quickSettings.system.memory",
"quickSettings.system.disk",
"quickSettings.system.loadAverage",
"quickSettings.system.runtime",
],
aliases: "system uptime node address pid",
},
personal: {
routeId: "profile",
labelKey: "profilePage.identity.title",
hash: `#${PROFILE_SETTINGS_TARGET_IDS.identity}`,
searchKeys: [
"profilePage.identity.description",
"profilePage.identity.avatar",
"profilePage.identity.chooseAvatar",
"profilePage.identity.displayName",
"profilePage.identity.linkedEmails",
],
aliases: "profile avatar image email",
},
} as const satisfies Record<string, StaticSettingsBlockDescriptor>;
const STATIC_SETTINGS_BLOCKS: readonly SettingsSearchTarget[] =
Object.values(SETTINGS_SEARCH_TARGETS);
const MODEL_SETTINGS_BLOCKS = {
behavior: {
routeId: "model-providers",
labelKey: "quickSettings.model.title",
hash: `#${MODEL_SETTINGS_TARGET_IDS.behavior}`,
searchKeys: [
"quickSettings.model.model",
"quickSettings.model.thinking",
"quickSettings.model.fastMode",
"quickSettings.model.thinkingLevels.off",
"quickSettings.model.thinkingLevels.low",
"quickSettings.model.thinkingLevels.medium",
"quickSettings.model.thinkingLevels.high",
"quickSettings.model.fastModes.auto",
"quickSettings.model.fastModes.fast",
"quickSettings.model.fastModes.standard",
],
},
} as const satisfies Record<string, StaticSettingsBlockDescriptor>;
const APPEARANCE_SETTINGS_BLOCKS = {
theme: {
routeId: "appearance",
labelKey: "configView.appearance.theme",
search: "?section=__appearance__",
hash: `#${APPEARANCE_SETTINGS_TARGET_IDS.theme}`,
searchKeys: [
"configView.appearance.chooseTheme",
"configView.appearance.importedTheme",
"configView.appearance.import",
"configView.appearance.importFromTweakcn",
"configView.appearance.browseTweakcn",
],
aliases: "tweakcn light dark system",
},
textSize: {
routeId: "appearance",
labelKey: "configView.appearance.textSize",
search: "?section=__appearance__",
hash: `#${APPEARANCE_SETTINGS_TARGET_IDS.textSize}`,
searchKeys: [
"configView.textSizes.small",
"configView.textSizes.default",
"configView.textSizes.large",
"configView.textSizes.xl",
"configView.textSizes.xxl",
],
aliases: "scale",
},
sidebar: {
routeId: "appearance",
labelKey: "configView.sidebarPrefs.title",
search: "?section=__appearance__",
hash: `#${APPEARANCE_SETTINGS_TARGET_IDS.sidebar}`,
searchKeys: [
"configView.sidebarPrefs.hint",
"configView.sidebarPrefs.liveActivity",
"configView.sidebarPrefs.liveActivityHint",
"configView.sessionObserver.title",
"configView.sessionObserver.hint",
"configView.sessionObserver.toggle",
"configView.sessionObserver.toggleHint",
"configView.sessionObserver.resolvedModel",
"configView.sessionObserver.modelPicker",
"configView.sessionObserver.modelPickerHint",
],
},
chat: {
routeId: "appearance",
labelKey: "configView.chatPrefs.title",
search: "?section=__appearance__",
hash: `#${APPEARANCE_SETTINGS_TARGET_IDS.chat}`,
searchKeys: [
"configView.chatPrefs.messageWidth",
"configView.chatPrefs.messageWidthHint",
"chat.sendShortcut",
"chat.sendShortcutEnter",
"chat.sendShortcutModifierEnter",
"chat.followUpMode",
"chat.followUpModeSteer",
"chat.followUpModeQueue",
"chat.followUpModeServer",
"chat.followUpModeLoading",
"chat.followUpModeUsingServer",
"chat.followUpModeOverriding",
"chat.followUpModeReset",
"chat.catalogOpenTarget",
"chat.catalogOpenTargetViewer",
"chat.catalogOpenTargetTerminal",
"chat.composer.cameraInput",
"chat.composer.systemDefaultCamera",
"chat.composer.microphoneInput",
"chat.composer.systemDefaultMicrophone",
"chat.composer.holdToRecordSetting",
"chat.composer.holdToRecordSettingDescription",
],
aliases:
"keyboard enter follow-up followup steer queue microphone voice audio input codex claude terminal viewer camera dictation dictate width",
},
connection: {
routeId: "appearance",
labelKey: "configView.connection.title",
search: "?section=__appearance__",
hash: `#${APPEARANCE_SETTINGS_TARGET_IDS.connection}`,
searchKeys: [
"configView.connection.gateway",
"configView.connection.status",
"configView.connection.assistant",
],
aliases: "version",
},
} as const satisfies Record<string, StaticSettingsBlockDescriptor>;
const COMMUNICATION_SETTINGS_BLOCKS = {
notifications: {
routeId: "notifications",
labelKey: "configView.notifications.title",
hash: `#${COMMUNICATION_SETTINGS_TARGET_IDS.notifications}`,
searchKeys: [
"configView.notifications.hint",
"configView.notifications.browserSupport",
"configView.notifications.permission",
"configView.notifications.status",
"configView.notifications.subscribed",
"configView.notifications.notSubscribed",
"configView.notifications.enable",
"configView.notifications.nativeTitle",
"configView.notifications.nativeHint",
"configView.notifications.openSystemSettings",
],
aliases: "vapid gateway",
},
} as const satisfies Record<string, StaticSettingsBlockDescriptor>;
// Workspace pages without a schema-backed config section only surface in
// search through these static entries.
const WORKSPACE_SETTINGS_BLOCKS = {
usage: {
routeId: "usage",
labelKey: "profilePage.usageStatistics",
hash: "",
searchKeys: [
"profilePage.usageStatisticsDescription",
"usage.heatmap.title",
"usage.heatmap.subtitle",
"usage.overview.title",
],
aliases: "stats statistics analytics tokens costs activity streaks",
},
sessions: {
routeId: "sessions",
labelKey: "sessionsView.title",
hash: "",
searchKeys: ["sessionsView.subtitle", "sessionsView.archivedOnly"],
aliases: "history archive overrides",
},
worktrees: {
routeId: "worktrees",
labelKey: "worktrees.title",
hash: "",
searchKeys: ["worktrees.subtitle"],
aliases: "git checkout branch cleanup",
},
} as const satisfies Record<string, StaticSettingsBlockDescriptor>;
const STATIC_SETTINGS_BLOCKS: readonly StaticSettingsBlockDescriptor[] = [
...Object.values(GENERAL_SETTINGS_BLOCKS),
...Object.values(MODEL_SETTINGS_BLOCKS),
...Object.values(APPEARANCE_SETTINGS_BLOCKS),
...Object.values(COMMUNICATION_SETTINGS_BLOCKS),
...Object.values(WORKSPACE_SETTINGS_BLOCKS),
];
const COMMUNICATION_SECTIONS = new Set<string>(COMMUNICATION_SECTION_KEYS);
const APPEARANCE_SECTIONS = new Set<string>(APPEARANCE_SECTION_KEYS);
const SECURITY_SECTIONS = new Set<string>(SECURITY_SECTION_KEYS);
const AUTOMATION_SECTIONS = new Set<string>(AUTOMATION_SECTION_KEYS);
const MCP_SECTIONS = new Set<string>(MCP_SECTION_KEYS);
const MEMORY_SECTIONS = new Set<string>(MEMORY_SECTION_KEYS);
const INFRASTRUCTURE_SECTIONS = new Set<string>(INFRASTRUCTURE_SECTION_KEYS);
const AI_AGENTS_SECTIONS = new Set<string>(AI_AGENTS_SECTION_KEYS);
function resolveStaticSettingsBlock(block: StaticSettingsBlockDescriptor): StaticSettingsBlock {
const { labelKey, searchKeys, aliases, ...destination } = block;
const label = t(labelKey);
function resolveStaticSettingsBlock(block: SettingsSearchTarget): StaticSettingsBlock {
const label = t(block.labelKey);
return {
...destination,
routeId: block.routeId,
...(block.search === undefined ? {} : { search: block.search }),
hash: block.hash,
label,
searchText: [label, ...searchKeys.map((key) => t(key)), aliases ?? ""].join(" "),
searchText: [label, ...block.searchKeys.map((key) => t(key)), block.aliases ?? ""].join(" "),
};
}
@@ -349,35 +101,6 @@ function memoryDestination(params: {
return { search: "&tab=settings", hash: params.editorHash };
}
function routeForConfigSection(key: string): RouteId {
if (MCP_SECTIONS.has(key)) {
return "mcp";
}
if (MEMORY_SECTIONS.has(key)) {
return "memory";
}
if (COMMUNICATION_SECTIONS.has(key)) {
return "communications";
}
if (APPEARANCE_SECTIONS.has(key)) {
return "appearance";
}
if (SECURITY_SECTIONS.has(key)) {
return "security";
}
if (AUTOMATION_SECTIONS.has(key)) {
return "automation";
}
if (INFRASTRUCTURE_SECTIONS.has(key)) {
return "infrastructure";
}
if (AI_AGENTS_SECTIONS.has(key)) {
return "ai-agents";
}
// Sections without a curated home render on the Advanced page.
return "advanced";
}
export function findSettingsSearchBlocks(params: {
query: string;
schema: unknown;
@@ -392,7 +115,7 @@ export function findSettingsSearchBlocks(params: {
const matches: SettingsSearchBlock[] =
criteria.tags.length === 0 && criteria.text
? STATIC_SETTINGS_BLOCKS.filter(
(block) => params.identityAvailable || block !== GENERAL_SETTINGS_BLOCKS.personal,
(block) => params.identityAvailable || !block.requiresIdentity,
)
.map(resolveStaticSettingsBlock)
.filter((block) => settingsSearchTextMatches(block.searchText, criteria.text))
@@ -406,7 +129,7 @@ export function findSettingsSearchBlocks(params: {
}
const value = params.value ?? {};
for (const [key, rawSectionSchema] of Object.entries(schema.properties)) {
const routeId = routeForConfigSection(key);
const routeId = configPageForSection(key);
const sectionSchema =
routeId === "memory" ? visibleMemorySchema(rawSectionSchema, value) : rawSectionSchema;
const meta = SECTION_META[key];
@@ -0,0 +1,141 @@
// @vitest-environment node
import { afterEach, describe, expect, it } from "vitest";
import { pathForRoute } from "../../app-route-paths.ts";
import { i18n, t } from "../../i18n/index.ts";
import {
configPageForSection,
configSectionKeysForPage,
SCOPED_CONFIG_SECTION_KEYS,
type ConfigPageId,
} from "./config-sections.ts";
import { SETTINGS_SEARCH_TARGETS, type SettingsSearchTarget } from "./settings-targets.ts";
afterEach(async () => {
await i18n.setLocale("en");
});
describe("settings search target manifest", () => {
const targets: readonly SettingsSearchTarget[] = Object.values(SETTINGS_SEARCH_TARGETS);
it("preserves the ordered, canonical paths, sections, and bookmark hashes", () => {
expect(
Object.entries(SETTINGS_SEARCH_TARGETS).map(([name, target]) => [
name,
pathForRoute(target.routeId),
"search" in target ? target.search : "",
target.hash,
]),
).toEqual([
["channels", "/settings/channels", "", ""],
["security", "/settings/security", "", ""],
["system", "/settings/connection", "", "#settings-connection-host"],
["personal", "/settings/profile", "", "#settings-profile-identity"],
["modelBehavior", "/settings/model-providers", "", "#settings-model-behavior"],
[
"appearanceTheme",
"/settings/appearance",
"?section=__appearance__",
"#settings-appearance-theme",
],
[
"appearanceTextSize",
"/settings/appearance",
"?section=__appearance__",
"#settings-appearance-text-size",
],
[
"appearanceSidebar",
"/settings/appearance",
"?section=__appearance__",
"#settings-appearance-sidebar",
],
[
"appearanceChat",
"/settings/appearance",
"?section=__appearance__",
"#settings-appearance-chat",
],
[
"appearanceConnection",
"/settings/appearance",
"?section=__appearance__",
"#settings-appearance-connection",
],
["notifications", "/settings/notifications", "", "#settings-communications-notifications"],
["usage", "/usage", "", ""],
["sessions", "/sessions", "", ""],
["worktrees", "/worktrees", "", ""],
]);
});
it("assigns every destination exactly once", () => {
const destinations = targets.map(
(target) => `${target.routeId}\u0000${target.search ?? ""}\u0000${target.hash}`,
);
expect(new Set(destinations).size).toBe(destinations.length);
});
it("indexes only translation keys present in the English source catalog", () => {
for (const target of targets) {
for (const key of [target.labelKey, ...target.searchKeys]) {
expect(t(key), `Missing settings search translation: ${key}`).not.toBe(key);
}
}
});
it("keeps translations in metadata until the active locale resolves them", async () => {
const modelLabel = t(SETTINGS_SEARCH_TARGETS.modelBehavior.labelKey);
await i18n.setLocale("es");
expect(t(SETTINGS_SEARCH_TARGETS.modelBehavior.labelKey)).not.toBe(modelLabel);
expect(SETTINGS_SEARCH_TARGETS.modelBehavior.labelKey).toBe("quickSettings.model.title");
});
it("marks only the identity-dependent target unavailable before connection", () => {
expect(targets.filter((target) => target.requiresIdentity)).toEqual([
SETTINGS_SEARCH_TARGETS.personal,
]);
});
});
describe("settings config section ownership", () => {
const pages: ReadonlyArray<readonly [ConfigPageId, readonly string[]]> = [
["communications", ["messages", "talk", "tts"]],
["appearance", ["__appearance__", "ui", "wizard"]],
["notifications", ["__notifications__"]],
["security", ["security", "approvals"]],
["automation", ["commands", "hooks", "bindings", "cron", "plugins"]],
["mcp", ["mcp"]],
["memory", ["memory"]],
["infrastructure", ["gateway", "browser", "nodeHost", "discovery", "acp"]],
["ai-agents", ["agents", "skills", "tools", "session"]],
];
it.each(pages)("routes every %s section back to its rendering page", (pageId, sections) => {
expect(configSectionKeysForPage(pageId)).toEqual(sections);
for (const section of sections) {
expect(configPageForSection(section)).toBe(pageId);
}
});
it("assigns each curated section to exactly one page", () => {
const sections = pages.flatMap(([, pageSections]) => pageSections);
expect(new Set(sections).size).toBe(sections.length);
expect([...SCOPED_CONFIG_SECTION_KEYS].toSorted()).toEqual(sections.toSorted());
});
it("keeps uncurated sections on Advanced", () => {
expect(configPageForSection("secrets")).toBe("advanced");
expect(configPageForSection("broadcast")).toBe("advanced");
expect(configPageForSection("models")).toBe("advanced");
});
it("keeps General and Advanced free of curated include lists", () => {
expect(configSectionKeysForPage("config")).toBeUndefined();
expect(configSectionKeysForPage("advanced")).toBeUndefined();
});
});
+219
View File
@@ -1,3 +1,5 @@
import type { RouteId } from "../../app-route-paths.ts";
export const MODEL_SETTINGS_TARGET_IDS = {
behavior: "settings-model-behavior",
} as const;
@@ -23,3 +25,220 @@ export const COMMUNICATION_SETTINGS_TARGET_IDS = {
export const PROFILE_SETTINGS_TARGET_IDS = {
identity: "settings-profile-identity",
} as const;
export type SettingsSearchTarget = {
readonly routeId: RouteId;
readonly labelKey: string;
readonly hash: string;
readonly searchKeys: readonly string[];
readonly search?: string;
readonly aliases?: string;
readonly requiresIdentity?: true;
};
// Keep destinations and translation keys together without importing page
// renderers: settings search runs before the destination page is loaded.
export const SETTINGS_SEARCH_TARGETS = {
channels: {
routeId: "channels",
labelKey: "quickSettings.channels.title",
hash: "",
searchKeys: ["quickSettings.channels.connect"],
aliases: "telegram discord slack whatsapp signal imessage",
},
security: {
routeId: "security",
labelKey: "quickSettings.security.title",
hash: "",
searchKeys: [
"quickSettings.security.gatewayAuth",
"quickSettings.security.execPolicy",
"quickSettings.security.deviceAuth",
"quickSettings.security.browserEnabled",
"quickSettings.security.toolProfile",
],
},
system: {
routeId: "connection",
labelKey: "quickSettings.system.gatewayHost",
hash: `#${CONNECTION_SETTINGS_TARGET_IDS.host}`,
searchKeys: [
"quickSettings.system.cpu",
"quickSettings.system.memory",
"quickSettings.system.disk",
"quickSettings.system.loadAverage",
"quickSettings.system.runtime",
],
aliases: "system uptime node address pid",
},
personal: {
routeId: "profile",
labelKey: "profilePage.identity.title",
hash: `#${PROFILE_SETTINGS_TARGET_IDS.identity}`,
searchKeys: [
"profilePage.identity.description",
"profilePage.identity.avatar",
"profilePage.identity.chooseAvatar",
"profilePage.identity.displayName",
"profilePage.identity.linkedEmails",
],
aliases: "profile avatar image email",
requiresIdentity: true,
},
modelBehavior: {
routeId: "model-providers",
labelKey: "quickSettings.model.title",
hash: `#${MODEL_SETTINGS_TARGET_IDS.behavior}`,
searchKeys: [
"quickSettings.model.model",
"quickSettings.model.thinking",
"quickSettings.model.fastMode",
"quickSettings.model.thinkingLevels.off",
"quickSettings.model.thinkingLevels.low",
"quickSettings.model.thinkingLevels.medium",
"quickSettings.model.thinkingLevels.high",
"quickSettings.model.fastModes.auto",
"quickSettings.model.fastModes.fast",
"quickSettings.model.fastModes.standard",
],
},
appearanceTheme: {
routeId: "appearance",
labelKey: "configView.appearance.theme",
search: "?section=__appearance__",
hash: `#${APPEARANCE_SETTINGS_TARGET_IDS.theme}`,
searchKeys: [
"configView.appearance.chooseTheme",
"configView.appearance.importedTheme",
"configView.appearance.import",
"configView.appearance.importFromTweakcn",
"configView.appearance.browseTweakcn",
],
aliases: "tweakcn light dark system",
},
appearanceTextSize: {
routeId: "appearance",
labelKey: "configView.appearance.textSize",
search: "?section=__appearance__",
hash: `#${APPEARANCE_SETTINGS_TARGET_IDS.textSize}`,
searchKeys: [
"configView.textSizes.small",
"configView.textSizes.default",
"configView.textSizes.large",
"configView.textSizes.xl",
"configView.textSizes.xxl",
],
aliases: "scale",
},
appearanceSidebar: {
routeId: "appearance",
labelKey: "configView.sidebarPrefs.title",
search: "?section=__appearance__",
hash: `#${APPEARANCE_SETTINGS_TARGET_IDS.sidebar}`,
searchKeys: [
"configView.sidebarPrefs.hint",
"configView.sidebarPrefs.liveActivity",
"configView.sidebarPrefs.liveActivityHint",
"configView.sessionObserver.title",
"configView.sessionObserver.hint",
"configView.sessionObserver.toggle",
"configView.sessionObserver.toggleHint",
"configView.sessionObserver.resolvedModel",
"configView.sessionObserver.modelPicker",
"configView.sessionObserver.modelPickerHint",
],
},
appearanceChat: {
routeId: "appearance",
labelKey: "configView.chatPrefs.title",
search: "?section=__appearance__",
hash: `#${APPEARANCE_SETTINGS_TARGET_IDS.chat}`,
searchKeys: [
"configView.chatPrefs.messageWidth",
"configView.chatPrefs.messageWidthHint",
"chat.sendShortcut",
"chat.sendShortcutEnter",
"chat.sendShortcutModifierEnter",
"chat.followUpMode",
"chat.followUpModeSteer",
"chat.followUpModeQueue",
"chat.followUpModeServer",
"chat.followUpModeLoading",
"chat.followUpModeUsingServer",
"chat.followUpModeOverriding",
"chat.followUpModeReset",
"chat.catalogOpenTarget",
"chat.catalogOpenTargetViewer",
"chat.catalogOpenTargetTerminal",
"chat.composer.cameraInput",
"chat.composer.systemDefaultCamera",
"chat.composer.microphoneInput",
"chat.composer.systemDefaultMicrophone",
"chat.composer.holdToRecordSetting",
"chat.composer.holdToRecordSettingDescription",
],
aliases:
"keyboard enter follow-up followup steer queue microphone voice audio input codex claude terminal viewer camera dictation dictate width",
},
appearanceConnection: {
routeId: "appearance",
labelKey: "configView.connection.title",
search: "?section=__appearance__",
hash: `#${APPEARANCE_SETTINGS_TARGET_IDS.connection}`,
searchKeys: [
"configView.connection.gateway",
"configView.connection.status",
"configView.connection.assistant",
],
aliases: "version",
},
notifications: {
routeId: "notifications",
labelKey: "configView.notifications.title",
hash: `#${COMMUNICATION_SETTINGS_TARGET_IDS.notifications}`,
searchKeys: [
"configView.notifications.hint",
"configView.notifications.browserSupport",
"configView.notifications.permission",
"configView.notifications.status",
"configView.notifications.subscribed",
"configView.notifications.notSubscribed",
"configView.notifications.enable",
"configView.notifications.nativeTitle",
"configView.notifications.nativeHint",
"configView.notifications.openSystemSettings",
],
aliases: "vapid gateway",
},
// Workspace pages without config schemas need explicit search destinations.
usage: {
routeId: "usage",
labelKey: "profilePage.usageStatistics",
hash: "",
searchKeys: [
"profilePage.usageStatisticsDescription",
"usage.heatmap.title",
"usage.heatmap.subtitle",
"usage.overview.title",
],
aliases: "stats statistics analytics tokens costs activity streaks",
},
sessions: {
routeId: "sessions",
labelKey: "sessionsView.title",
hash: "",
searchKeys: [
"sessionsView.subtitle",
"sessionsView.archived",
"sessionsView.archivedOnlyTooltip",
],
aliases: "history archive overrides",
},
worktrees: {
routeId: "worktrees",
labelKey: "worktrees.title",
hash: "",
searchKeys: ["worktrees.subtitle"],
aliases: "git checkout branch cleanup",
},
} as const satisfies Record<string, SettingsSearchTarget>;
+2 -2
View File
@@ -4,7 +4,7 @@ import { state } from "lit/decorators.js";
import type { AgentsListResult, CronJob } from "../../api/types.ts";
import { titleForRoute } from "../../app-navigation.ts";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
import { hasOperatorAdminAccess } from "../../app/operator-access.ts";
import { readGatewayOperatorAccess } from "../../app/operator-access.ts";
import { renderAgentScopeControl } from "../../components/agent-scope-control.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import {
@@ -59,7 +59,7 @@ class CronPage extends OpenClawLightDomElement {
private modelSuggestionsState: CronState | null = null;
private gatewaySource?: ApplicationContext["gateway"];
private get canManageCron(): boolean {
return hasOperatorAdminAccess(this.context.gateway.snapshot.hello?.auth ?? null);
return readGatewayOperatorAccess(this.context.gateway.snapshot).canAdmin;
}
private readonly subscriptions = new SubscriptionsController(this)
@@ -2,6 +2,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import {
acquireBoardProviderForSession,
type BoardProvider,
type BoardProviderLease,
} from "../../lib/board/provider.ts";
import "./workboard-card-dashboard.ts";
type DashboardElement = HTMLElementTagNameMap["openclaw-workboard-card-dashboard"] & {
@@ -36,11 +41,14 @@ function createClient(
async function mountDashboard(
sessionKey: string,
client: GatewayBrowserClient,
capabilities: { canMutate?: boolean; canGrant?: boolean } = {},
): Promise<DashboardElement> {
const element = document.createElement("openclaw-workboard-card-dashboard");
element.sessionKey = sessionKey;
element.client = client;
element.connected = true;
element.canMutate = capabilities.canMutate ?? false;
element.canGrant = capabilities.canGrant ?? false;
document.body.append(element);
mounted.push(element);
// The provider is acquired in updated(), after the first DOM render.
@@ -57,6 +65,171 @@ afterEach(() => {
});
describe("Workboard card dashboard", () => {
it("waits for the initial gateway snapshot before choosing its expanded state", async () => {
const sessionKey = "agent:main:workboard-delayed-snapshot";
const snapshot = {
sessionKey,
revision: 1,
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
widgets: [
{
name: "status",
tabId: "main",
title: "Status",
contentKind: "html" as const,
sizeW: 12,
sizeH: 2,
position: 0,
grantState: "none" as const,
revision: 1,
},
],
};
let resolveSnapshot: ((value: typeof snapshot) => void) | undefined;
const request = vi.fn(
() =>
new Promise<typeof snapshot>((resolve) => {
resolveSnapshot = resolve;
}),
);
const client = {
request,
addEventListener: vi.fn(() => () => {}),
} as unknown as GatewayBrowserClient;
const element = await mountDashboard(sessionKey, client);
await vi.waitFor(() => expect(request).toHaveBeenCalledOnce());
expect(
element.querySelector(".workboard-card-dashboard__toggle")?.getAttribute("aria-expanded"),
).toBe("false");
expect(element.querySelector(".workboard-card-dashboard__collapsed-empty")).toBeNull();
resolveSnapshot?.(snapshot);
await vi.waitFor(() =>
expect(
element.querySelector(".workboard-card-dashboard__toggle")?.getAttribute("aria-expanded"),
).toBe("true"),
);
expect(element.querySelector("openclaw-board-view")).not.toBeNull();
});
it("updates mounted dashboard controls immediately when gateway permissions change", async () => {
const { client, request } = createClient([
{
name: "pending-status",
tabId: "main",
title: "Pending status",
contentKind: "html",
sizeW: 12,
sizeH: 2,
position: 0,
grantState: "pending",
revision: 1,
},
]);
const element = await mountDashboard("agent:main:workboard-live-scopes", client, {
canMutate: true,
canGrant: true,
});
await vi.waitFor(() => expect(element.querySelector("openclaw-board-view")).not.toBeNull());
const board = element.querySelector("openclaw-board-view")!;
await board.updateComplete;
await vi.waitFor(() =>
expect(board.querySelector('[data-test-id="board-grant-allow"]')).not.toBeNull(),
);
const allow = board.querySelector<HTMLButtonElement>('[data-test-id="board-grant-allow"]')!;
expect(board.canMutate).toBe(true);
expect(board.canGrant).toBe(true);
expect(allow.disabled).toBe(false);
element.canMutate = false;
element.canGrant = false;
await element.updateComplete;
await board.updateComplete;
await board.querySelector("openclaw-board-widget-cell")?.updateComplete;
expect(board.canMutate).toBe(false);
expect(board.canGrant).toBe(false);
expect(allow.disabled).toBe(true);
element.canMutate = true;
element.canGrant = true;
await element.updateComplete;
await board.updateComplete;
await board.querySelector("openclaw-board-widget-cell")?.updateComplete;
expect(board.canMutate).toBe(true);
expect(board.canGrant).toBe(true);
expect(allow.disabled).toBe(false);
expect(request).toHaveBeenCalledOnce();
});
it.each(["chat-first", "dashboard-first"] as const)(
"shares gateway state without leaking dashboard capabilities in %s order",
async (order) => {
const sessionKey = `agent:main:workboard-shared-${order}`;
const { client, request, removeListener } = createClient();
let chat: BoardProviderLease | undefined;
let dashboard: DashboardElement | undefined;
try {
if (order === "chat-first") {
chat = acquireBoardProviderForSession(sessionKey, client, true, true, true, true, true);
dashboard = await mountDashboard(sessionKey, client, {
canMutate: true,
canGrant: false,
});
} else {
dashboard = await mountDashboard(sessionKey, client, {
canMutate: true,
canGrant: false,
});
chat = acquireBoardProviderForSession(sessionKey, client, true, true, true, true, true);
}
await vi.waitFor(() => expect(request).toHaveBeenCalledOnce());
const dashboardProvider = Reflect.get(dashboard, "provider") as BoardProvider;
expect(chat.provider).not.toBe(dashboardProvider);
expect(chat.provider.snapshot$).toBe(dashboardProvider.snapshot$);
expect(chat.provider).toMatchObject({
canPinWidgets: true,
canPinMcpApps: true,
canMutate: true,
canGrant: true,
});
expect(dashboardProvider).toMatchObject({
canPinWidgets: false,
canPinMcpApps: false,
canMutate: true,
canGrant: false,
});
dashboard.canMutate = false;
await dashboard.updateComplete;
expect(Reflect.get(dashboard, "provider")).toBe(dashboardProvider);
expect(dashboardProvider.canMutate).toBe(false);
expect(chat.provider.canMutate).toBe(true);
expect(chat.provider.canPinWidgets).toBe(true);
expect(chat.provider.canPinMcpApps).toBe(true);
expect(chat.provider.canGrant).toBe(true);
expect(request).toHaveBeenCalledOnce();
dashboard.remove();
expect(removeListener).not.toHaveBeenCalled();
chat.release();
expect(removeListener).toHaveBeenCalledOnce();
} finally {
dashboard?.remove();
chat?.release();
}
},
);
it("expands a non-empty live dashboard by default", async () => {
const { client, request } = createClient([
{
@@ -8,8 +8,7 @@ import {
acquireBoardProviderForSession,
boardExists,
boardProviderCacheKey,
boardProviderForSession,
GatewayBoardProvider,
hasLoadedBoardSnapshot,
type BoardProvider,
type BoardProviderLease,
type BoardViewCallbacks,
@@ -59,16 +58,12 @@ class WorkboardCardDashboard extends OpenClawLightDomElement {
}
const key = boardProviderCacheKey(sessionKey);
if (this.lease?.client === client && this.lease.sessionKey === key) {
boardProviderForSession(
key,
client,
true,
this.connected,
false,
false,
this.canMutate,
this.canGrant,
);
this.lease.update(client, this.connected, {
canPinWidgets: false,
canPinMcpApps: false,
canMutate: this.canMutate,
canGrant: this.canGrant,
});
return;
}
@@ -108,8 +103,7 @@ class WorkboardCardDashboard extends OpenClawLightDomElement {
if (!snapshot.tabs.some((tab) => tab.tabId === this.activeTabId)) {
this.activeTabId = firstTabId;
}
const loaded = !(provider instanceof GatewayBoardProvider) || provider.hasLoadedSnapshot;
if (!this.expansionInitialized && loaded) {
if (!this.expansionInitialized && hasLoadedBoardSnapshot(provider)) {
this.expansionInitialized = true;
this.expanded = boardExists(snapshot);
}
@@ -161,8 +155,8 @@ class WorkboardCardDashboard extends OpenClawLightDomElement {
provider.widgetFrameUrl(name, revision)}
.callbacks=${callbacks}
.sessions=${[]}
.canMutate=${provider.canMutate}
.canGrant=${provider.canGrant}
.canMutate=${this.canMutate}
.canGrant=${this.canGrant}
.ticketRefreshEnabled=${this.expanded}
></openclaw-board-view>
`
@@ -119,6 +119,47 @@ describe("WorkboardPage lifecycle", () => {
});
});
it("ignores snapshot and invalidation callbacks retained by a retired Gateway", async () => {
const firstWorkboard = createWorkboardCapability();
const secondWorkboard = createWorkboardCapability();
const firstContext = contextWithWorkboard(firstWorkboard);
const secondContext = contextWithWorkboard(secondWorkboard);
let retiredSnapshot: Parameters<typeof firstContext.gateway.subscribe>[0] | undefined;
let retiredEvent: Parameters<typeof firstContext.gateway.subscribeEvents>[0] | undefined;
firstContext.gateway.subscribe = (listener) => {
retiredSnapshot = listener;
return () => undefined;
};
firstContext.gateway.subscribeEvents = (listener) => {
retiredEvent = listener;
return () => undefined;
};
firstContext.gateway.snapshot.phase = "connected";
firstContext.gateway.snapshot.client = { request: vi.fn() } as never;
secondContext.gateway.snapshot.phase = "connected";
secondContext.gateway.snapshot.client = { request: vi.fn() } as never;
const page = document.createElement("openclaw-workboard-page") as WorkboardPageTestElement;
page.context = firstContext;
document.body.append(page);
await page.updateComplete;
page.context = secondContext;
(page as unknown as { requestUpdate: () => void }).requestUpdate();
await page.updateComplete;
vi.clearAllMocks();
retiredSnapshot?.({ ...firstContext.gateway.snapshot, phase: "stopped", client: null });
retiredEvent?.({
type: "event",
event: "plugin.workboard.changed",
payload: { epoch: "retired", revision: 1 },
});
expect(stopLiveRefresh).not.toHaveBeenCalledWith(secondWorkboard);
expect(stopLifecycleRefresh).not.toHaveBeenCalledWith(secondWorkboard);
expect(handleChanged).not.toHaveBeenCalled();
});
it("forces one canonical reload when the live client is newly installed", async () => {
const workboard = createWorkboardCapability();
const context = contextWithWorkboard(workboard);
+12 -11
View File
@@ -4,11 +4,7 @@ import { property } from "lit/decorators.js";
import { titleForRoute } from "../../app-navigation.ts";
import { pathForRoute, pathForWorkboardBoard } from "../../app-route-paths.ts";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
import {
hasOperatorAdminAccess,
hasOperatorApprovalsAccess,
hasOperatorWriteAccess,
} from "../../app/operator-access.ts";
import { readGatewayOperatorAccess } from "../../app/operator-access.ts";
import { renderAgentScopeControl } from "../../components/agent-scope-control.ts";
import { renderWorkboardBoardGlyph } from "../../components/workboard-board-glyph.ts";
import { isWorkboardEnabledInConfigSnapshot } from "../../lib/plugin-activation.ts";
@@ -92,6 +88,9 @@ class WorkboardPage extends OpenClawLightDomElement {
() => this.context?.gateway,
(gateway) => {
const handleSnapshot = (snapshot: ApplicationContext["gateway"]["snapshot"]) => {
if (this.context?.gateway !== gateway) {
return;
}
if (snapshot.phase === "connected" && snapshot.client) {
this.ensureInitialData();
} else if (this.context?.workboard) {
@@ -112,6 +111,7 @@ class WorkboardPage extends OpenClawLightDomElement {
const workboard = this.context?.workboard;
if (
workboard &&
this.context?.gateway === gateway &&
gateway.snapshot.phase === "connected" &&
event.event === WORKBOARD_CHANGED_EVENT
) {
@@ -188,6 +188,7 @@ class WorkboardPage extends OpenClawLightDomElement {
return;
}
const state = context.workboard.state;
const access = readGatewayOperatorAccess(gateway);
const requiresCanonicalReload = configureWorkboardLiveRefresh({
host: context.workboard,
client: gateway.client,
@@ -198,14 +199,14 @@ class WorkboardPage extends OpenClawLightDomElement {
client: gateway.client,
requestUpdate: this.requestPageUpdate,
force: requiresCanonicalReload,
refreshDiagnostics: hasOperatorWriteAccess(gateway.hello?.auth ?? null),
refreshDiagnostics: access.canWrite,
});
if (!state.dispatching) {
void syncWorkboardLifecycle({
host: context.workboard,
client: gateway.client,
sessions: context.sessions.state.result?.sessions ?? [],
canWrite: hasOperatorWriteAccess(gateway.hello?.auth ?? null),
canWrite: access.canWrite,
requestUpdate: this.requestPageUpdate,
});
}
@@ -353,7 +354,7 @@ class WorkboardPage extends OpenClawLightDomElement {
}
const gateway = context.gateway.snapshot;
const config = context.runtimeConfig.state;
const auth = gateway.hello?.auth ?? null;
const access = readGatewayOperatorAccess(gateway);
const pluginEnabled = this.pluginEnabled();
const selectedBoard = this.selectedBoard();
return html`
@@ -382,9 +383,9 @@ class WorkboardPage extends OpenClawLightDomElement {
host: context.workboard,
client: gateway.client,
connected: gateway.phase === "connected",
canWrite: hasOperatorWriteAccess(auth),
canGrant: hasOperatorApprovalsAccess(auth),
canModelOverride: hasOperatorAdminAccess(auth),
canWrite: access.canWrite,
canGrant: access.canGrantApprovals,
canModelOverride: access.canAdmin,
pluginEnabled,
pluginEnablementError:
!config.configSnapshot && !config.configLoading ? config.lastError : null,
-4
View File
@@ -788,10 +788,6 @@ html.openclaw-native-web-chrome .shell-chrome-controls {
transition: width var(--shell-focus-duration) var(--shell-focus-ease);
}
.shell-nav-backdrop {
display: none;
}
/* 1px overlay on the nav border edge; the divider paints its own centered
line, so the host needs no background here. */
.sidebar-resizer {
-17
View File
@@ -171,23 +171,6 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-
pointer-events: auto;
}
.shell--mobile-nav .shell-nav-backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 65;
border: 0;
background: color-mix(in srgb, black 52%, transparent);
opacity: 0;
pointer-events: none;
transition: opacity var(--shell-focus-duration) var(--shell-focus-ease);
}
.shell--mobile-nav.shell--nav-drawer-open .shell-nav-backdrop {
opacity: 1;
pointer-events: auto;
}
/* Inside the drawer there is no Escape key to hint at. */
.shell--mobile-nav .settings-sidebar__esc {
display: none;
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ApplicationGatewaySnapshot } from "../../app/context.ts";
import { catalogPage, createGatewayHarness, createSessions, mountSidebar } from "../app-sidebar.ts";
import "../../components/app-sidebar.ts";
describe("AppSidebar session catalog ownership", () => {
it("retires catalog rows and creation after reconnect loses its catalog owner", async () => {
vi.useFakeTimers();
let provider: HTMLElement | undefined;
try {
const firstPage = catalogPage([{ threadId: "thread-1", name: "Retired session" }], "page-2");
const catalog = firstPage.catalogs[0];
if (!catalog) {
throw new Error("expected a session catalog");
}
catalog.capabilities.createSession = { model: "anthropic/claude-opus-4-8" };
const expandedPage = catalogPage([{ threadId: "thread-2", name: "Retired page" }]);
const expandedCatalog = expandedPage.catalogs[0];
if (!expandedCatalog) {
throw new Error("expected an expanded session catalog");
}
expandedCatalog.capabilities.createSession = catalog.capabilities.createSession;
const request = vi.fn().mockResolvedValueOnce(firstPage).mockResolvedValueOnce(expandedPage);
const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient);
const catalogHello = {
type: "hello-ok",
protocol: 1,
auth: { role: "operator", scopes: ["operator.admin"] },
features: { methods: ["sessions.catalog.list"] },
} satisfies NonNullable<ApplicationGatewaySnapshot["hello"]>;
gateway.publish({ hello: catalogHello });
const mounted = await mountSidebar(
gateway.gateway,
createSessions("main", ["agent:main:main"]),
);
const { sidebar } = mounted;
provider = mounted.provider;
sidebar.connected = true;
await sidebar.updateComplete;
await vi.advanceTimersByTimeAsync(0);
await sidebar.sessionData.loadMoreSessionCatalog("codex");
await sidebar.updateComplete;
expect(sidebar.textContent).toContain("Retired session");
expect(sidebar.textContent).toContain("Retired page");
expect(sidebar.querySelector(".sidebar-session-catalog-new")).not.toBeNull();
expect(sidebar.sessionData.sessionCatalogPageDepths.size).toBe(1);
expect(sidebar.sessionData.sessionCatalogRevisions.size).toBe(1);
gateway.publish({ phase: "reconnecting", hello: null });
await sidebar.updateComplete;
expect(sidebar.textContent).toContain("Retired page");
expect(sidebar.sessionData.sessionCatalogPageDepths.size).toBe(1);
gateway.publish({
phase: "connected",
assistantAgentId: null,
hello: { ...catalogHello, features: { ...catalogHello.features, methods: [] } },
});
await sidebar.updateComplete;
await vi.advanceTimersByTimeAsync(0);
await sidebar.updateComplete;
expect(sidebar.sessionData.sessionCatalogAgentId).toBeNull();
expect(sidebar.sessionData.sessionCatalogs).toEqual([]);
expect(sidebar.sessionData.sessionCatalogPageDepths.size).toBe(0);
expect(sidebar.sessionData.sessionCatalogRevisions.size).toBe(0);
expect(sidebar.textContent).not.toContain("Retired session");
expect(sidebar.textContent).not.toContain("Retired page");
expect(sidebar.querySelector(".sidebar-session-catalog-new")).toBeNull();
expect(request).toHaveBeenCalledTimes(2);
} finally {
provider?.remove();
vi.useRealTimers();
}
});
});
@@ -12,10 +12,218 @@ import {
createSessions,
deferred,
mountSidebar,
TWO_AGENTS,
} from "../app-sidebar.ts";
import "../../components/app-sidebar.ts";
describe("AppSidebar session catalog pagination", () => {
it("keeps an in-flight catalog refresh across a stable same-client Gateway notification", async () => {
vi.useFakeTimers();
let provider: HTMLElement | undefined;
try {
// Shared UI workers retain real custom elements despite later module mocks.
await vi.importActual("../../components/sidebar-attention.ts");
const pendingPage = deferred<SessionsCatalogListResult>();
const request = vi.fn().mockReturnValue(pendingPage.promise);
const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient);
expect(gateway.gateway.connection).toEqual({
gatewayUrl: "ws://gateway.test",
token: "",
bootstrapToken: "",
password: "",
});
gateway.publish({
hello: {
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const mounted = await mountSidebar(
gateway.gateway,
createSessions("main", ["agent:main:main"]),
);
const { sidebar } = mounted;
provider = mounted.provider;
sidebar.connected = true;
await sidebar.updateComplete;
await vi.advanceTimersByTimeAsync(0);
expect(request).toHaveBeenCalledTimes(1);
const generation = sidebar.sessionData.sessionScopeGeneration;
gateway.publish({ offlineStable: true });
await sidebar.updateComplete;
await vi.advanceTimersByTimeAsync(0);
expect(sidebar.sessionData.sessionScopeGeneration).toBe(generation);
expect(request).toHaveBeenCalledTimes(1);
pendingPage.resolve(catalogPage([{ threadId: "thread-1", name: "Current session" }]));
await vi.advanceTimersByTimeAsync(0);
await sidebar.updateComplete;
expect(sidebar.textContent).toContain("Current session");
expect(request).toHaveBeenCalledTimes(1);
} finally {
provider?.remove();
vi.useRealTimers();
}
});
it("releases a pending catalog page across a same-client Gateway reconnect", async () => {
vi.useFakeTimers();
let provider: HTMLElement | undefined;
try {
const pendingStalePage = deferred<SessionsCatalogListResult>();
const pendingFreshPage = deferred<SessionsCatalogListResult>();
const firstPage = catalogPage([{ threadId: "thread-1", name: "Newest" }], "page-2");
const expandedPage = catalogPage([{ threadId: "thread-2", name: "Expanded" }], "page-3");
let requestedThirdPages = 0;
const request = vi.fn((_method: string, params: { cursors?: Record<string, string> }) => {
const cursor = params.cursors?.["gateway:local"];
if (cursor === "page-2") {
return Promise.resolve(expandedPage);
}
if (cursor === "page-3") {
requestedThirdPages += 1;
return requestedThirdPages === 1 ? pendingStalePage.promise : pendingFreshPage.promise;
}
return Promise.resolve(firstPage);
});
const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient);
const hello = {
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"];
gateway.publish({ hello });
const mounted = await mountSidebar(
gateway.gateway,
createSessions("main", ["agent:main:main"]),
);
const { sidebar } = mounted;
provider = mounted.provider;
sidebar.connected = true;
await sidebar.updateComplete;
await vi.advanceTimersByTimeAsync(0);
await sidebar.sessionData.loadMoreSessionCatalog("codex");
await sidebar.updateComplete;
expect(sidebar.textContent).toContain("Expanded");
expect(sidebar.sessionData.sessionCatalogPageDepths.size).toBe(1);
const staleLoad = sidebar.sessionData.loadMoreSessionCatalog("codex");
await sidebar.updateComplete;
expect(sidebar.sessionData.loadingMoreSessionCatalogIds.has("codex")).toBe(true);
gateway.publish({ phase: "reconnecting", hello: null });
sidebar.sessionData.hostUpdate();
expect(sidebar.sessionData.loadingMoreSessionCatalogIds.has("codex")).toBe(false);
await sidebar.updateComplete;
expect(sidebar.sessionData.sessionCatalogPageDepths.size).toBe(1);
gateway.publish({ phase: "connected", hello });
await sidebar.updateComplete;
await vi.advanceTimersByTimeAsync(0);
await sidebar.updateComplete;
expect(sidebar.textContent).toContain("Expanded");
const loadMore = sidebar.querySelector<HTMLButtonElement>(
'[data-session-catalog-load-more="codex"]',
);
expect(loadMore?.disabled).toBe(false);
loadMore?.click();
await sidebar.updateComplete;
expect(sidebar.sessionData.loadingMoreSessionCatalogIds.has("codex")).toBe(true);
pendingStalePage.resolve(catalogPage([{ threadId: "stale", name: "Stale page" }]));
await staleLoad;
await sidebar.updateComplete;
expect(sidebar.sessionData.loadingMoreSessionCatalogIds.has("codex")).toBe(true);
expect(sidebar.textContent).not.toContain("Stale page");
pendingFreshPage.resolve(catalogPage([{ threadId: "thread-3", name: "Fresh page" }]));
await vi.advanceTimersByTimeAsync(0);
await sidebar.updateComplete;
expect(sidebar.sessionData.loadingMoreSessionCatalogIds.has("codex")).toBe(false);
expect(sidebar.textContent).toContain("Fresh page");
} finally {
provider?.remove();
vi.useRealTimers();
}
});
it("retires visible catalog rows and expanded cursors when the agent changes", async () => {
vi.useFakeTimers();
try {
const pendingResearch = deferred<SessionsCatalogListResult>();
const mainFirstPage = catalogPage(
[{ threadId: "main-newest", name: "Main newest" }],
"main-page-2",
);
const mainSecondPage = catalogPage([{ threadId: "main-older", name: "Main older" }]);
const researchFirstPage = catalogPage(
[{ threadId: "research-newest", name: "Research newest" }],
"research-page-2",
);
const request = vi
.fn()
.mockResolvedValueOnce(mainFirstPage)
.mockResolvedValueOnce(mainSecondPage)
.mockReturnValueOnce(pendingResearch.promise)
.mockResolvedValue(catalogPage([{ threadId: "research-older", name: "Research older" }]));
const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient);
gateway.publish({
hello: {
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar, context } = await mountSidebar(
gateway.gateway,
createSessions("main", ["agent:main:main"]),
"panel",
TWO_AGENTS,
);
sidebar.connected = true;
await sidebar.updateComplete;
await vi.advanceTimersByTimeAsync(0);
await sidebar.updateComplete;
sidebar.querySelector<HTMLButtonElement>('[data-session-catalog-load-more="codex"]')?.click();
await vi.advanceTimersByTimeAsync(0);
await sidebar.updateComplete;
expect(sidebar.textContent).toContain("Main newest");
expect(sidebar.textContent).toContain("Main older");
expect(sidebar.sessionData.sessionCatalogPageDepths.size).toBe(1);
context.agentSelection.state.selectedId = "research";
context.agentSelection.state.scopeId = "research";
sidebar.requestUpdate();
await sidebar.updateComplete;
await vi.advanceTimersByTimeAsync(50);
await sidebar.updateComplete;
expect(sidebar.textContent).not.toContain("Main newest");
expect(sidebar.textContent).not.toContain("Main older");
expect(sidebar.sessionData.sessionCatalogPageDepths.size).toBe(0);
pendingResearch.resolve(researchFirstPage);
await vi.advanceTimersByTimeAsync(0);
await sidebar.updateComplete;
expect(sidebar.textContent).toContain("Research newest");
expect(sidebar.textContent).not.toContain("Research older");
expect(request).not.toHaveBeenCalledWith(
"sessions.catalog.list",
expect.objectContaining({
agentId: "research",
catalogId: "codex",
cursors: { "gateway:local": "research-page-2" },
}),
);
} finally {
vi.useRealTimers();
}
});
it.each(["catalog", "host"] as const)(
"preserves the current page while exposing a structured %s load-more error",
async (errorOwner) => {
@@ -2,15 +2,135 @@ import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import {
createGateway,
createGatewayHarness,
createSessionsHarness,
createSessionState,
deferred,
mountSidebar,
TWO_AGENTS,
} from "../app-sidebar.ts";
import { waitForFast } from "../wait-for.ts";
import "../../components/app-sidebar.ts";
describe("AppSidebar gateway session pagination", () => {
it.each(["archived", "all"] as const)(
"keeps a pending %s first page across a stable same-client Gateway notification",
async (statusFilter) => {
const harness = createSessionsHarness("main", ["agent:main:canonical-active"]);
const result = createSessionState("main", ["agent:main:current-archive"]).result;
if (!result) {
throw new Error("expected a scoped session result");
}
const pendingPage = deferred<typeof result>();
harness.list.mockImplementation(async () => await pendingPage.promise);
const gateway = createGatewayHarness({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway.gateway, harness.sessions);
(sidebar as unknown as { sessionsStatusFilter: "archived" | "all" }).sessionsStatusFilter =
statusFilter;
sidebar.sessionData.resetForStatusFilter(statusFilter);
const pendingRefresh = sidebar.sessionData.refreshSidebarSessions("main");
const generation = sidebar.sessionData.sessionScopeGeneration;
gateway.publish({ offlineStable: true });
await sidebar.updateComplete;
expect(sidebar.sessionData.sessionScopeGeneration).toBe(generation);
expect(harness.list).toHaveBeenCalledTimes(1);
pendingPage.resolve(result);
await pendingRefresh;
await sidebar.updateComplete;
expect(sidebar.sessionData.sessionsAgentId).toBe("main");
expect(sidebar.sessionData.sessionsResult?.sessions.map((row) => row.key)).toEqual([
"agent:main:current-archive",
]);
},
);
it.each(["archived", "all"] as const)(
"retires a pending %s first page when the selected agent changes",
async (statusFilter) => {
const harness = createSessionsHarness("main", ["agent:main:canonical-active"]);
const mainResult = createSessionState("main", ["agent:main:stale-archive"]).result;
const researchResult = createSessionState("research", ["agent:research:current"]).result;
if (!mainResult || !researchResult) {
throw new Error("expected scoped session results");
}
const pendingMain = deferred<typeof mainResult>();
harness.list.mockImplementation(async (options) =>
options?.agentId === "research" ? researchResult : await pendingMain.promise,
);
const { sidebar, context } = await mountSidebar(
createGateway({} as GatewayBrowserClient),
harness.sessions,
"panel",
TWO_AGENTS,
);
(sidebar as unknown as { sessionsStatusFilter: "archived" | "all" }).sessionsStatusFilter =
statusFilter;
sidebar.sessionData.resetForStatusFilter(statusFilter);
const staleRefresh = sidebar.sessionData.refreshSidebarSessions("main");
context.agentSelection.state.selectedId = "research";
context.agentSelection.state.scopeId = "research";
sidebar.requestUpdate();
await sidebar.updateComplete;
expect(harness.list).toHaveBeenCalledWith(
expect.objectContaining({ agentId: "research", archivedFilter: statusFilter }),
);
pendingMain.resolve(mainResult);
await staleRefresh;
await sidebar.updateComplete;
expect(sidebar.sessionData.sessionsAgentId).toBe("research");
expect(sidebar.sessionData.sessionsResult?.sessions.map((row) => row.key)).toEqual([
"agent:research:current",
]);
},
);
it.each(["archived", "all"] as const)(
"retires a pending %s first page across a same-client reconnect",
async (statusFilter) => {
const harness = createSessionsHarness("main", ["agent:main:canonical-active"]);
const staleResult = createSessionState("main", ["agent:main:before-reconnect"]).result;
const freshResult = createSessionState("main", ["agent:main:after-reconnect"]).result;
if (!staleResult || !freshResult) {
throw new Error("expected reconnect session results");
}
const pendingPage = deferred<typeof staleResult>();
harness.list
.mockImplementationOnce(async () => await pendingPage.promise)
.mockResolvedValue(freshResult);
const gateway = createGatewayHarness({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway.gateway, harness.sessions);
(sidebar as unknown as { sessionsStatusFilter: "archived" | "all" }).sessionsStatusFilter =
statusFilter;
sidebar.sessionData.resetForStatusFilter(statusFilter);
const staleRefresh = sidebar.sessionData.refreshSidebarSessions("main");
gateway.publish({ phase: "reconnecting" });
await sidebar.updateComplete;
gateway.publish({ phase: "connected" });
await sidebar.updateComplete;
expect(harness.list).toHaveBeenCalledTimes(2);
pendingPage.resolve(staleResult);
await staleRefresh;
await sidebar.updateComplete;
expect(sidebar.sessionData.sessionsAgentId).toBe("main");
expect(sidebar.sessionData.sessionsResult?.sessions.map((row) => row.key)).toEqual([
"agent:main:after-reconnect",
]);
},
);
it.each(["archived", "all"] as const)(
"does not append a stale %s page during a full session refresh",
async (statusFilter) => {
+22 -1
View File
@@ -79,6 +79,27 @@ export type TestSessionMenu = HTMLElement & {
};
export function createGatewayHarness(client: GatewayBrowserClient) {
const originalRequest =
typeof client.request === "function"
? (client.request.bind(client) as GatewayBrowserClient["request"])
: undefined;
// Custom-element registrations survive non-isolated test files, so real
// attention health requests must not consume sidebar feature response mocks.
client.request = <T = unknown>(
...args: Parameters<GatewayBrowserClient["request"]>
): Promise<T> => {
const [method] = args;
if (method === "cron.list") {
return Promise.resolve({ jobs: [], total: 0 } as T);
}
if (method === "models.authStatus") {
return Promise.resolve({ ts: 0, providers: [] } as T);
}
if (!originalRequest) {
return Promise.reject(new Error(`Unexpected sidebar gateway request: ${method}`));
}
return originalRequest<T>(...args);
};
let snapshot: ApplicationGatewaySnapshot = {
client,
phase: "connected",
@@ -462,8 +483,8 @@ export function setupSidebarTest() {
});
afterEach(() => {
vi.useRealTimers();
document.body.replaceChildren();
vi.useRealTimers();
if (originalLocalStorage) {
Object.defineProperty(globalThis, "localStorage", originalLocalStorage);
} else {
+1 -2
View File
@@ -1,8 +1,7 @@
import type WaDialog from "@awesome.me/webawesome/dist/components/dialog/dialog.js";
// Control UI test helper supports modal dialog setup.
import { expect } from "vitest";
type OpenClawModalDialog = HTMLElement & { updateComplete: Promise<boolean> };
import type { OpenClawModalDialog } from "../components/modal-dialog.ts";
type DialogMethodName = "showModal" | "close";
type DialogDescriptorSnapshot = Record<DialogMethodName, PropertyDescriptor | undefined>;