Files
openclaw/ui/src/components/command-palette.test.ts
Peter Steinberger aeee426180 feat(control-ui): persistent Ask OpenClaw companion with global toggle (#125107)
* feat(control-ui): persistent Ask OpenClaw companion with global toggle

The custodian surface now behaves like the persistent machine-wide agent it
already is on the Gateway: the session id persists in localStorage so a
reopened surface rebinds to the live engine (wizard and approval state
survive close/reopen), the durable transcript is refetched when a surface
opens or the gateway reconnects (idle-gated so active question/wizard cards
are never clobbered), and the panel toggles from anywhere via the shared
panel-toggle contract, a command-palette action, and an admin-gated lobster
chrome button.

One server-side line: the openclaw.chat owner-mismatch rejection now carries
the existing structured session-invalidated details so persisted clients
re-mint their id from a closed code instead of matching error prose.

No gateway events, no protocol schema changes, no polling. Splits
(session-identity/variant modules, session-lifecycle and panel-toggle test
files) keep the touched files under the max-lines ratchet.

* fix(control-ui): coerce custodian toggle detail without a type assertion

The assertion-safety ratchet holds custodian-panel.ts at zero uncommented
assertions; parse the toggle CustomEvent detail through the canonical
record-coerce guard and literal narrowing instead of casting.

* fix(control-ui): delete unused CustodianPanelToggleDetail export

The record-coerce toggle parsing left the exported type without a
production consumer; the deadcode gate rightly flags it. The palette test
keeps a local shape.

* test(control-ui): select the palette custodian item via keyboard

Async session-search results can reflow the palette list mid-click on slow
CI runners, silently dropping the positional click; keyboard selection of
the asserted-active item is atomic against reflow. Also stage the reopen
wait (panel section, then text) for sharper failure localization.

* fix(control-ui): project live wizard state on rejoin and scope-gate the toggles

Address both ClawSweeper P1 findings. The welcome-only rejoin of an
existing session now routes through engine.decorateRejoinReply (the
existing ChatWizardHost projection), so a reconnecting client re-renders
the live wizard/question controls the session still awaits; the stale
welcome question only fills in when no interaction is live. The chrome
button, palette action, and deferred panel loading now use the
scope-aware canCallGatewayMethod gate (operator.admin) that the session
store already used, so advertised-but-read-scoped clients see nothing.

* test(control-ui): fix the cloud-workers e2e flake at both roots

The mocked config.get stayed frozen at the empty initial config while
patch responses advanced, so a config-store reconciliation refetch could
flap the snapshot to empty and saveProfile silently dropped the next
save; the mock now stays consistent before each patch resolution. Also
give waitForRequest an opt-in after-cursor: it is satisfied by any prior
same-method request and returns the latest match, so a second wait could
assert against the stale earlier request; the cloud-workers waits pin it
(15x green locally, previously failing 1-in-3).

* fix(ci): cover rejoin projection in sibling engine mocks; bump startup baseline

The greeting-welcome and session-ownership suites' engine mocks now
export decorateRejoinReply like the handler requires. The Control UI
startup-JS baseline moves 337511 -> 338920 B via the documented update
command: the shell chrome toggle, palette action, and scope-aware gating
are genuine startup surface (~1.4 KiB gzip, within the committed
ceiling).

* fix(control-ui): settle interrupted structured replies and racing turns on rejoin

Address both ClawSweeper reconnect P1s. A submitted question/wizard reply
with an unknown outcome now triggers a full session rejoin on reconnect
instead of being blocked by its own uncertainty flag: the Gateway projects
whether the answer was consumed and which control is live. A restored
persisted id also arms a one-shot rejoin barrier: the welcome-only request
queues behind any in-flight turn on the Gateway's per-session queue, so a
post-response history refresh deterministically surfaces rows a racing
turn persisted after the initial fetch. The open-agent handoff moved to
custodian-navigation (its owner) to keep the store under the size cap.
Live-Gateway proof (isolated state dir, real gpt-5.6-luna turns): video
and screenshots on the PR.

* test(control-ui): reopen via the chrome toggle in the custodian e2e

The palette click-through composition proved timing-flaky on loaded CI
runners in three different ways while adding no coverage: the palette
action's dispatch is pinned by the palette unit test and the event-opens-
panel path by the chrome-toggle step. Keep the gated palette entry
assertion + screenshot; reopen through the chrome path.

* fix(control-ui): keep the agent-handoff path helper module-local

The store now routes through performCustodianAgentHandoff, leaving the
path builder without external callers; the deadcode gate rightly flags
the export.

* fix(control-ui): run the rejoin barrier even when a live control projects

The racing-history refresh happens before the reply/control message is
appended, so skipping it for projected wizard/question rejoins had no
purpose and lost rows a turn persisted while the page was closed mid-
wizard. Regression covers the live-step rejoin reconciling racing rows.
2026-08-17 16:28:43 -07:00

344 lines
12 KiB
TypeScript

/* @vitest-environment jsdom */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../../test/helpers/promise.js";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { SessionsListResult } from "../api/types.ts";
import type { RouteId } from "../app-route-paths.ts";
import type {
ApplicationContext,
ApplicationGateway,
ApplicationGatewaySnapshot,
} from "../app/context.ts";
import { createApplicationContextProvider } from "../test-helpers/application-context.ts";
import { installDialogPolyfill } from "../test-helpers/modal-dialog.ts";
import { CommandPalette } from "./command-palette.ts";
import {
CUSTODIAN_PANEL_TOGGLE_EVENT,
DESKTOP_PANEL_TOGGLE_EVENT,
type DesktopPanelToggleDetail,
} from "./panel-toggle-contract.ts";
type CustodianPanelToggleDetail = { open?: boolean };
type GatewayHarness = {
gateway: ApplicationGateway;
setConnected: (connected: boolean) => void;
};
function createGateway(connected: boolean): GatewayHarness {
const client = {} as GatewayBrowserClient;
let snapshot: ApplicationGatewaySnapshot = {
client,
phase: connected ? "connected" : "reconnecting",
offlineStable: false,
canvasPluginSurfaceUrl: null,
hello: null,
assistantAgentId: "main",
sessionKey: "main",
lastError: null,
lastErrorCode: null,
};
const listeners = new Set<(next: ApplicationGatewaySnapshot) => void>();
const gateway = {
get snapshot() {
return snapshot;
},
connection: { gatewayUrl: "ws://localhost", token: "", bootstrapToken: "", password: "" },
eventLog: [],
connect: () => undefined,
setSessionKey: () => undefined,
start: () => undefined,
stop: () => undefined,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
subscribeEventLog: () => () => undefined,
subscribeEvents: () => () => undefined,
} satisfies ApplicationGateway;
return {
gateway,
setConnected(nextConnected) {
snapshot = {
...snapshot,
phase: nextConnected ? "connected" : "reconnecting",
};
for (const listener of listeners) {
listener(snapshot);
}
},
};
}
function createContext(
gateway: ApplicationGateway,
list: ApplicationContext<RouteId>["sessions"]["list"],
): ApplicationContext<RouteId> {
return {
gateway,
sessions: {
list,
},
} as unknown as ApplicationContext<RouteId>;
}
function createSessionResult(key: string, displayName: string): SessionsListResult {
return {
ts: 1,
path: "",
count: 1,
defaults: {},
sessions: [{ key, kind: "direct", displayName, updatedAt: 1 }],
} as SessionsListResult;
}
async function mountPalette(context: ApplicationContext<RouteId>) {
const provider = createApplicationContextProvider(context);
const palette = document.createElement("openclaw-command-palette") as CommandPalette;
palette.onNavigate = vi.fn();
palette.onSelectSession = vi.fn();
provider.append(palette);
document.body.append(provider);
await palette.updateComplete;
return { palette, provider };
}
async function enterQuery(palette: CommandPalette, query: string) {
palette.openPalette();
await palette.updateComplete;
const input = palette.querySelector<HTMLInputElement>(".cmd-palette__input");
if (!input) {
throw new Error("Expected command palette input");
}
input.value = query;
input.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
await palette.updateComplete;
}
describe("CommandPalette lifecycle", () => {
let restoreDialogPolyfill: () => void;
beforeEach(() => {
vi.useFakeTimers();
restoreDialogPolyfill = installDialogPolyfill();
});
afterEach(() => {
document.body.replaceChildren();
restoreDialogPolyfill();
vi.useRealTimers();
vi.restoreAllMocks();
});
it("closes and clears its query before a retained element reconnects", async () => {
const { gateway } = createGateway(true);
const list = vi.fn(async () => createSessionResult("agent:main:old", "Old chat"));
const { palette, provider } = await mountPalette(createContext(gateway, list));
await enterQuery(palette, "old");
await vi.advanceTimersByTimeAsync(250);
await palette.updateComplete;
expect(palette.textContent).toContain("Old chat");
palette.remove();
provider.append(palette);
const modal = palette.querySelector("openclaw-modal-dialog");
const dialog = modal?.shadowRoot
?.querySelector("wa-dialog")
?.shadowRoot?.querySelector("dialog");
expect(dialog?.open).toBe(false);
await palette.updateComplete;
expect(palette.querySelector("dialog")).toBeNull();
palette.openPalette();
await palette.updateComplete;
expect(palette.querySelector<HTMLInputElement>(".cmd-palette__input")?.value).toBe("");
expect(palette.textContent).not.toContain("Old chat");
});
it("retries the pending query after the gateway reconnects", async () => {
const harness = createGateway(true);
const stale = createDeferred<SessionsListResult | null>();
const list = vi
.fn<ApplicationContext<RouteId>["sessions"]["list"]>()
.mockImplementationOnce(() => stale.promise)
.mockResolvedValueOnce(createSessionResult("agent:main:retry", "Retry chat"));
const { palette } = await mountPalette(createContext(harness.gateway, list));
await enterQuery(palette, "retry");
await vi.advanceTimersByTimeAsync(250);
expect(list).toHaveBeenCalledOnce();
harness.setConnected(false);
stale.resolve(createSessionResult("agent:main:stale", "Stale chat"));
await Promise.resolve();
expect(palette.textContent).not.toContain("Stale chat");
harness.setConnected(true);
await palette.updateComplete;
await vi.advanceTimersByTimeAsync(250);
await palette.updateComplete;
expect(list).toHaveBeenCalledTimes(2);
expect(list).toHaveBeenLastCalledWith(expect.objectContaining({ search: "retry" }));
expect(palette.textContent).toContain("Retry chat");
});
it("drops an old provider response and searches the replacement context", async () => {
const initial = createGateway(true);
const replacement = createGateway(true);
const stale = createDeferred<SessionsListResult | null>();
const initialList = vi.fn(() => stale.promise);
const replacementList = vi.fn(async () =>
createSessionResult("agent:main:fresh", "Fresh chat"),
);
const { palette, provider } = await mountPalette(createContext(initial.gateway, initialList));
await enterQuery(palette, "chat");
await vi.advanceTimersByTimeAsync(250);
expect(initialList).toHaveBeenCalledOnce();
stale.resolve(createSessionResult("agent:main:stale", "Stale chat"));
provider.setContext(createContext(replacement.gateway, replacementList));
await palette.updateComplete;
await vi.advanceTimersByTimeAsync(250);
await palette.updateComplete;
expect(replacementList).toHaveBeenCalledOnce();
expect(palette.textContent).toContain("Fresh chat");
expect(palette.textContent).not.toContain("Stale chat");
});
it("shows a search failure instead of a false empty result", async () => {
const { gateway } = createGateway(true);
const list = vi
.fn<ApplicationContext<RouteId>["sessions"]["list"]>()
.mockRejectedValueOnce(new Error("store needs doctor migration"))
.mockResolvedValueOnce(createSessionResult("agent:main:zz", "Recovered chat"));
const { palette } = await mountPalette(createContext(gateway, list));
// The query matches no navigation item, so a swallowed search failure
// would render the plain "No results" empty state.
await enterQuery(palette, "zzz-unmatched");
await vi.advanceTimersByTimeAsync(250);
await palette.updateComplete;
expect(list).toHaveBeenCalledOnce();
expect(palette.textContent).toContain("Chat search failed");
expect(palette.textContent).not.toContain("No results");
// A new keystroke clears the failure state and retries cleanly.
await enterQuery(palette, "zz");
await palette.updateComplete;
expect(palette.textContent).not.toContain("Chat search failed");
await vi.advanceTimersByTimeAsync(250);
await palette.updateComplete;
expect(palette.textContent).toContain("Recovered chat");
});
it("navigates to the plugin manager from search", async () => {
const { gateway } = createGateway(true);
const { palette } = await mountPalette(
createContext(
gateway,
vi.fn(async () => createSessionResult("agent:main:test", "Test")),
),
);
await enterQuery(palette, "plugins");
const item = palette.querySelector<HTMLButtonElement>("#cmd-palette-option-nav-plugins");
expect(item?.textContent).toContain("Plugins");
item?.click();
expect(palette.onNavigate).toHaveBeenCalledWith("plugins");
});
it.each([
{ available: true, expectedCount: 1 },
{ available: false, expectedCount: 0 },
])(
"shows the desktop action only when availability is $available",
async ({ available, expectedCount }) => {
const { gateway } = createGateway(true);
const { palette } = await mountPalette(
createContext(
gateway,
vi.fn(async () => createSessionResult("agent:main:test", "Test")),
),
);
palette.desktopAvailable = available;
await enterQuery(palette, "desktop");
expect(palette.querySelectorAll("#cmd-palette-option-panel-desktop")).toHaveLength(
expectedCount,
);
},
);
it("opens the desktop panel from its palette action", async () => {
const { gateway } = createGateway(true);
const { palette } = await mountPalette(
createContext(
gateway,
vi.fn(async () => createSessionResult("agent:main:test", "Test")),
),
);
palette.desktopAvailable = true;
await enterQuery(palette, "desktop");
const events: CustomEvent<DesktopPanelToggleDetail>[] = [];
const listener = (event: Event) => events.push(event as CustomEvent<DesktopPanelToggleDetail>);
window.addEventListener(DESKTOP_PANEL_TOGGLE_EVENT, listener);
try {
palette.querySelector<HTMLElement>("#cmd-palette-option-panel-desktop")?.click();
} finally {
window.removeEventListener(DESKTOP_PANEL_TOGGLE_EVENT, listener);
}
expect(events).toHaveLength(1);
expect(events[0]?.detail).toEqual({ open: true });
});
it.each([
{ available: true, expectedCount: 1 },
{ available: false, expectedCount: 0 },
])(
"shows Ask OpenClaw only when availability is $available",
async ({ available, expectedCount }) => {
const { gateway } = createGateway(true);
const { palette } = await mountPalette(
createContext(
gateway,
vi.fn(async () => createSessionResult("agent:main:test", "Test")),
),
);
palette.custodianAvailable = available;
await enterQuery(palette, "openclaw");
expect(palette.querySelectorAll("#cmd-palette-option-panel-custodian")).toHaveLength(
expectedCount,
);
},
);
it("opens Ask OpenClaw from its palette action", async () => {
const { gateway } = createGateway(true);
const { palette } = await mountPalette(
createContext(
gateway,
vi.fn(async () => createSessionResult("agent:main:test", "Test")),
),
);
palette.custodianAvailable = true;
await enterQuery(palette, "openclaw");
const events: CustomEvent<CustodianPanelToggleDetail>[] = [];
const listener = (event: Event) =>
events.push(event as CustomEvent<CustodianPanelToggleDetail>);
window.addEventListener(CUSTODIAN_PANEL_TOGGLE_EVENT, listener);
try {
palette.querySelector<HTMLElement>("#cmd-palette-option-panel-custodian")?.click();
} finally {
window.removeEventListener(CUSTODIAN_PANEL_TOGGLE_EVENT, listener);
}
expect(events).toHaveLength(1);
expect(events[0]?.detail).toEqual({ open: true });
});
});