mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
committed by
GitHub
parent
9e5e6f961e
commit
d239a59d82
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,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>;
|
||||
|
||||
Reference in New Issue
Block a user