fix(ui): hide the terminal dock while Settings is open (#114737)

* fix(ui): hide docked terminal and browser panels during settings takeover

* test(ui): align browser panel casts with core test typecheck

* test(ui): split dock suppression coverage
This commit is contained in:
Peter Steinberger
2026-07-27 17:08:52 -04:00
committed by GitHub
parent c0c55a82fa
commit 1c0ccdddcd
9 changed files with 463 additions and 68 deletions
@@ -0,0 +1,115 @@
/* @vitest-environment jsdom */
import { render as renderLit, type TemplateResult } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { RouteId } from "../app-routes.ts";
import { createStorageMock } from "../test-helpers/storage.ts";
import { resetAppHostTestGlobals } from "./app-host.test-support.ts";
import "./app-host.ts";
import type { ApplicationRuntime } from "./bootstrap.ts";
import type { ApplicationContext } from "./context.ts";
type ShellRenderState = {
runtime: ApplicationRuntime;
routeState: { routeId: RouteId };
render: () => TemplateResult;
};
afterEach(() => {
resetAppHostTestGlobals();
});
describe("OpenClaw shell dock suppression", () => {
it("suppresses docked panels only while a settings route owns the viewport", () => {
vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal(
"matchMedia",
vi.fn(() => ({ matches: false })),
);
const client = {} as GatewayBrowserClient;
const context = {
basePath: "",
gateway: {
snapshot: {
phase: "connected",
client,
sessionKey: "agent:main:main",
assistantAgentId: "main",
hello: {
auth: { role: "operator", scopes: ["operator.admin"] },
features: { methods: ["terminal.open", "browser.request"] },
},
lastError: null,
offlineStable: false,
selfUser: null,
},
connection: { gatewayUrl: "ws://gateway.test", token: "", password: "" },
connect: vi.fn(),
},
agents: { state: { agentsList: null } },
agentSelection: { state: { selectedId: "main" } },
config: {
current: { terminalEnabled: true, serverVersion: null, devGitBranch: null },
},
runtimeConfig: {
state: { configSchema: null, configForm: null, configSnapshot: null, configUiHints: null },
},
sessions: { state: { result: null } },
navigation: {
snapshot: {
navCollapsed: false,
navWidth: 280,
sidebarEntries: [],
pinnedAgentIds: [],
},
update: vi.fn(),
},
overlays: {
snapshot: {
updateAvailable: null,
updateRunning: false,
updateStatusBanner: null,
controlUiRefreshRequired: false,
approvalQueue: [],
approvalBusy: false,
approvalErrors: new Map(),
approvalNowMs: 0,
devicePairSetupOpen: false,
devicePairSetupLoading: false,
devicePairSetupError: null,
devicePairSetup: null,
devicePairSetupAccess: "full",
devicePairPendingCount: 0,
deviceAuthMigration: { error: null },
},
runUpdate: vi.fn(),
},
theme: { mode: "dark" },
preload: vi.fn(),
} as unknown as ApplicationContext;
const shell = document.createElement("openclaw-app-shell") as unknown as ShellRenderState;
shell.runtime = { context, router: {} } as unknown as ApplicationRuntime;
const container = document.createElement("div");
shell.routeState = { routeId: "config" };
renderLit(shell.render(), container);
expect(
(
container.querySelector("openclaw-terminal-panel") as HTMLElement & {
suppressed: boolean;
}
).suppressed,
).toBe(true);
shell.routeState = { routeId: "chat" };
renderLit(shell.render(), container);
expect(
(
container.querySelector("openclaw-terminal-panel") as HTMLElement & {
suppressed: boolean;
}
).suppressed,
).toBe(false);
});
});
+2
View File
@@ -2015,11 +2015,13 @@ class OpenClawShell extends OpenClawLightDomElement {
<openclaw-terminal-panel
.client=${gatewayConnected ? gatewaySnapshot.client : null}
.available=${terminalAvailable}
.suppressed=${settingsTakeover}
.themeMode=${resolveTerminalThemeMode()}
></openclaw-terminal-panel>
<openclaw-browser-panel
.client=${gatewayConnected ? gatewaySnapshot.client : null}
.available=${browserPanelAvailable}
.suppressed=${settingsTakeover}
.basePath=${context.basePath}
.authToken=${resolveControlUiAuthToken({
hello: gatewaySnapshot.hello,
@@ -1,5 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { createStorageMock } from "../../test-helpers/storage.ts";
import { waitForFast } from "../../test-helpers/wait-for.ts";
import "./browser-panel.ts";
import { normalizeBrowserUrlDraft } from "./browser-url.ts";
@@ -59,6 +61,94 @@ describe("normalizeBrowserUrlDraft", () => {
expect(panel.browserPanelIsOpen()).toBe(true);
});
it("suppresses an open dock without overwriting its persisted preference", async () => {
localStorage.setItem(
"openclaw.browser.panel.v1",
JSON.stringify({ open: true, dock: "right", height: 420, width: 560 }),
);
const panel = document.createElement("openclaw-browser-panel") as unknown as HTMLElement & {
available: boolean;
suppressed: boolean;
renderRoot: ShadowRoot;
updateComplete: Promise<unknown>;
};
panel.available = true;
document.body.append(panel);
await panel.updateComplete;
expect(panel.renderRoot.querySelector(".bp")).not.toBeNull();
panel.suppressed = true;
await waitForFast(() => expect(panel.renderRoot.querySelector(".bp")).toBeNull());
expect(document.documentElement.style.getPropertyValue("--oc-browser-reserve-right")).toBe(
"0px",
);
expect(JSON.parse(localStorage.getItem("openclaw.browser.panel.v1") ?? "{}")).toMatchObject({
open: true,
});
panel.suppressed = false;
await waitForFast(() => expect(panel.renderRoot.querySelector(".bp")).not.toBeNull());
expect(panel.renderRoot.querySelector(".bp")).not.toBeNull();
});
it("waits for availability before restoring after suppression", async () => {
localStorage.setItem(
"openclaw.browser.panel.v1",
JSON.stringify({ open: true, dock: "right", height: 420, width: 560 }),
);
const panel = document.createElement("openclaw-browser-panel") as unknown as HTMLElement & {
available: boolean;
suppressed: boolean;
browserPanelIsOpen(): boolean;
updateComplete: Promise<unknown>;
};
panel.suppressed = true;
document.body.append(panel);
await panel.updateComplete;
panel.suppressed = false;
await panel.updateComplete;
expect(panel.browserPanelIsOpen()).toBe(false);
panel.available = true;
await waitForFast(() => expect(panel.browserPanelIsOpen()).toBe(true));
});
it("mounts closed inside a takeover instead of refreshing a hidden dock", async () => {
localStorage.setItem(
"openclaw.browser.panel.v1",
JSON.stringify({ open: true, dock: "right", height: 420, width: 560 }),
);
const requests: string[] = [];
const client = {
request: async <T>(method: string) => {
requests.push(method);
return {} as T;
},
} as GatewayBrowserClient;
const panel = document.createElement("openclaw-browser-panel") as unknown as HTMLElement & {
client: GatewayBrowserClient | null;
available: boolean;
suppressed: boolean;
browserPanelIsOpen(): boolean;
updateComplete: Promise<unknown>;
};
panel.client = client;
panel.available = true;
panel.suppressed = true;
document.body.append(panel);
await panel.updateComplete;
await Promise.resolve();
expect(panel.browserPanelIsOpen()).toBe(false);
expect(requests).toEqual([]);
panel.suppressed = false;
await waitForFast(() => expect(panel.browserPanelIsOpen()).toBe(true));
});
it("keeps an already closed panel closed for an explicit close request", () => {
const panel = document.createElement("openclaw-browser-panel") as unknown as HTMLElement & {
available: boolean;
@@ -42,6 +42,8 @@ class OpenClawBrowserPanel extends OpenClawLitElement implements BrowserPanelCon
@property({ attribute: false }) client: GatewayBrowserClient | null = null;
/** Whether the connected gateway advertises browser.request to this operator. */
@property({ type: Boolean }) available = false;
/** Full-page route takeovers (settings) own the viewport; the dock hides while one renders. */
@property({ type: Boolean }) suppressed = false;
/** Control UI base path, used for the authenticated media fetch. */
@property({ attribute: false }) basePath = "";
/** Bearer credential for the assistant-media screenshot fetch. */
@@ -60,6 +62,9 @@ class OpenClawBrowserPanel extends OpenClawLitElement implements BrowserPanelCon
override connectedCallback(): void {
super.connectedCallback();
window.addEventListener(BROWSER_PANEL_TOGGLE_EVENT, this.onToggleRequest);
// A settings takeover can already own the viewport when the panel mounts.
// Suppress before the restored open state refreshes a dock nobody can see.
this.dockLayout.setSuppressed(this.suppressed);
if (this.dockLayout.open) {
void this.browserPanelController.refreshAll();
}
@@ -71,6 +76,9 @@ class OpenClawBrowserPanel extends OpenClawLitElement implements BrowserPanelCon
}
override updated(changed: Map<string, unknown>): void {
if (changed.has("suppressed") && this.dockLayout.setSuppressed(this.suppressed)) {
void this.browserPanelController.refreshAll();
}
this.browserPanelController.synchronizeHostProperties(changed);
if (changed.has("client") || changed.has("available")) {
if (!this.available && this.dockLayout.open) {
+30 -1
View File
@@ -24,6 +24,7 @@ export class DockLayoutController<TDock extends DockPanelSide> implements Reacti
height: number;
width: number;
private suppressed = false;
private resizeCleanup: (() => void) | null = null;
private readonly onViewportResize = () => {
const height = Math.min(this.height, this.options.layout.maxHeight());
@@ -80,8 +81,36 @@ export class DockLayoutController<TDock extends DockPanelSide> implements Reacti
this.setOpen(false, false);
}
/**
* Full-page route takeovers (settings) own the viewport, so docks hide while
* one renders. Hiding never persists — the user's open preference must survive
* the visit — and suppression also blocks `restoreOpenState()` so a reconnect
* mid-takeover cannot pop the panel back over settings. Returns true when the
* caller must resume its surface after the takeover ends.
*
* Only automatic restores are blocked. An explicit open (Ctrl+`, toolbar,
* `ui.command`) still wins and shows the dock over the takeover: swallowing a
* requested terminal would be a worse papercut than the one this fixes.
*/
setSuppressed(suppressed: boolean): boolean {
if (this.suppressed === suppressed) {
return false;
}
this.suppressed = suppressed;
if (suppressed) {
this.hideWithoutPersisting();
return false;
}
return this.restoreOpenState();
}
restoreOpenState(): boolean {
if (this.open || (!this.isFullscreen() && !this.options.layout.load().open)) {
if (
this.suppressed ||
!this.options.isAvailable() ||
this.open ||
(!this.isFullscreen() && !this.options.layout.load().open)
) {
return false;
}
this.open = true;
@@ -0,0 +1,127 @@
/* @vitest-environment jsdom */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "../../i18n/index.ts";
import { createStorageMock } from "../../test-helpers/storage.ts";
import { waitForFast } from "../../test-helpers/wait-for.ts";
import type { TerminalGatewayClient } from "./terminal-connection.ts";
import {
createTerminalController,
defineTestTerminalPanelElement,
terminalOpenResult,
type CreateGhosttyTerminalMock,
} from "./terminal-panel.test-support.ts";
import { OpenClawTerminalPanel } from "./terminal-panel.ts";
const createGhosttyTerminalMock: CreateGhosttyTerminalMock = vi.fn();
const TERMINAL_PANEL_ELEMENT_NAME = defineTestTerminalPanelElement(createGhosttyTerminalMock);
describe("OpenClawTerminalPanel dock suppression", () => {
beforeEach(async () => {
vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal("sessionStorage", createStorageMock());
await i18n.setLocale("en");
});
afterEach(async () => {
document.body.replaceChildren();
localStorage.clear();
sessionStorage.clear();
createGhosttyTerminalMock.mockReset();
vi.unstubAllGlobals();
await i18n.setLocale("en");
});
it("suppresses an open dock without overwriting its persisted preference", async () => {
localStorage.setItem(
"openclaw.terminal.panel.v1",
JSON.stringify({ open: true, dock: "bottom", height: 320, width: 520 }),
);
const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel;
panel.available = true;
document.body.append(panel);
await panel.updateComplete;
expect(panel.renderRoot.querySelector(".tp")).not.toBeNull();
panel.suppressed = true;
await waitForFast(() => expect(panel.renderRoot.querySelector(".tp")).toBeNull());
expect(document.documentElement.style.getPropertyValue("--oc-terminal-reserve-bottom")).toBe(
"0px",
);
expect(JSON.parse(localStorage.getItem("openclaw.terminal.panel.v1") ?? "{}")).toMatchObject({
open: true,
});
panel.suppressed = false;
await waitForFast(() => expect(panel.renderRoot.querySelector(".tp")).not.toBeNull());
expect(panel.renderRoot.querySelector(".tp")).not.toBeNull();
});
it("defers availability restore until suppression ends", async () => {
localStorage.setItem(
"openclaw.terminal.panel.v1",
JSON.stringify({ open: true, dock: "bottom", height: 320, width: 520 }),
);
createGhosttyTerminalMock.mockResolvedValue(createTerminalController());
const requests: string[] = [];
const client: TerminalGatewayClient = {
forceReconnect: () => {},
request: async <T>(method: string) => {
requests.push(method);
return (method === "terminal.open" ? terminalOpenResult("session-1") : {}) as T;
},
addEventListener: () => () => {},
};
const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel;
panel.client = client;
panel.suppressed = true;
document.body.append(panel);
await panel.updateComplete;
panel.available = true;
await panel.updateComplete;
await Promise.resolve();
expect(panel.terminalPanelOpen).toBe(false);
expect(requests).not.toContain("terminal.open");
panel.suppressed = false;
await panel.updateComplete;
await waitForFast(() => expect(requests).toContain("terminal.open"));
expect(panel.terminalPanelOpen).toBe(true);
});
it("mounts closed inside a takeover instead of booting a hidden session", async () => {
localStorage.setItem(
"openclaw.terminal.panel.v1",
JSON.stringify({ open: true, dock: "bottom", height: 320, width: 520 }),
);
createGhosttyTerminalMock.mockResolvedValue(createTerminalController());
const requests: string[] = [];
const client: TerminalGatewayClient = {
forceReconnect: () => {},
request: async <T>(method: string) => {
requests.push(method);
return (method === "terminal.open" ? terminalOpenResult("session-1") : {}) as T;
},
addEventListener: () => () => {},
};
const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel;
panel.client = client;
panel.available = true;
panel.suppressed = true;
document.body.append(panel);
await panel.updateComplete;
await Promise.resolve();
expect(panel.terminalPanelOpen).toBe(false);
expect(requests).not.toContain("terminal.open");
panel.suppressed = false;
await panel.updateComplete;
await waitForFast(() => expect(requests).toContain("terminal.open"));
});
});
@@ -0,0 +1,70 @@
import { vi, type Mock } from "vitest";
import { OpenClawTerminalPanel } from "./terminal-panel.ts";
export type CreateOptions = {
parent: HTMLElement;
terminalOptions?: {
fontFamily?: string;
theme?: { background?: string; foreground?: string };
};
onData?: (bytes: Uint8Array) => void;
onResize?: (size: { columns: number; rows: number }) => void;
};
export type CreateGhosttyTerminalMock = Mock<
(options: CreateOptions) => Promise<ReturnType<typeof createTerminalController>>
>;
export function createTerminalController(dispose: () => void = vi.fn()) {
const wasmTerm = {};
const renderer = {
setTheme: vi.fn(),
render: vi.fn(),
};
return {
readOnly: false,
terminal: {
cols: 100,
rows: 30,
viewportY: 0,
wasmTerm,
renderer,
write: vi.fn(),
focus: vi.fn(),
reset: vi.fn(),
paste: vi.fn(),
},
write: vi.fn(),
fit: vi.fn(),
resize: vi.fn(),
setReadOnly: vi.fn(),
attach: vi.fn(),
dispose,
};
}
export function terminalOpenResult(sessionId: string) {
return {
sessionId,
agentId: "ops",
shell: "/bin/zsh",
cwd: "/work/ops",
confined: false,
};
}
export function defineTestTerminalPanelElement(
createGhosttyTerminalMock: CreateGhosttyTerminalMock,
tagName = `test-openclaw-terminal-panel-${crypto.randomUUID()}`,
): string {
type TerminalFactory = typeof import("./terminal-runtime.ts").createIsolatedGhosttyTerminal;
// The full non-isolated UI suite can import the production panel before this
// test. Override its factory instead of relying on a module mock import order.
class TestTerminalPanel extends OpenClawTerminalPanel {
override createTerminalController = createGhosttyTerminalMock as unknown as TerminalFactory;
}
customElements.define(tagName, TestTerminalPanel);
return tagName;
}
@@ -1,66 +1,21 @@
/* @vitest-environment jsdom */
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "../../i18n/index.ts";
import { createStorageMock } from "../../test-helpers/storage.ts";
import { waitForFast } from "../../test-helpers/wait-for.ts";
import type { TerminalGatewayClient } from "./terminal-connection.ts";
type CreateOptions = {
parent: HTMLElement;
terminalOptions?: {
fontFamily?: string;
theme?: { background?: string; foreground?: string };
};
onData?: (bytes: Uint8Array) => void;
onResize?: (size: { columns: number; rows: number }) => void;
};
type CreateGhosttyTerminalMock = Mock<
(options: CreateOptions) => Promise<ReturnType<typeof createTerminalController>>
>;
type TerminalFactory = typeof import("./terminal-runtime.ts").createIsolatedGhosttyTerminal;
import {
createTerminalController,
defineTestTerminalPanelElement,
terminalOpenResult,
type CreateGhosttyTerminalMock,
type CreateOptions,
} from "./terminal-panel.test-support.ts";
import { OpenClawTerminalPanel } from "./terminal-panel.ts";
const createGhosttyTerminalMock: CreateGhosttyTerminalMock = vi.fn();
function createTerminalController(dispose: () => void = vi.fn()) {
const wasmTerm = {};
const renderer = {
setTheme: vi.fn(),
render: vi.fn(),
};
return {
readOnly: false,
terminal: {
cols: 100,
rows: 30,
viewportY: 0,
wasmTerm,
renderer,
write: vi.fn(),
focus: vi.fn(),
reset: vi.fn(),
paste: vi.fn(),
},
write: vi.fn(),
fit: vi.fn(),
resize: vi.fn(),
setReadOnly: vi.fn(),
attach: vi.fn(),
dispose,
};
}
function terminalOpenResult(sessionId: string) {
return {
sessionId,
agentId: "ops",
shell: "/bin/zsh",
cwd: "/work/ops",
confined: false,
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
@@ -71,17 +26,7 @@ function deferred<T>() {
return { promise, resolve, reject };
}
import { OpenClawTerminalPanel } from "./terminal-panel.ts";
const TERMINAL_PANEL_ELEMENT_NAME = `test-openclaw-terminal-panel-${crypto.randomUUID()}`;
// The full non-isolated UI suite can import the production panel before this
// test. Override its factory instead of relying on a module mock import order.
class TestTerminalPanel extends OpenClawTerminalPanel {
override createTerminalController = createGhosttyTerminalMock as unknown as TerminalFactory;
}
customElements.define(TERMINAL_PANEL_ELEMENT_NAME, TestTerminalPanel);
const TERMINAL_PANEL_ELEMENT_NAME = defineTestTerminalPanelElement(createGhosttyTerminalMock);
async function startPanelWithPendingOpen() {
let createOptions: CreateOptions | undefined;
@@ -136,8 +81,7 @@ describe("OpenClawTerminalPanel", () => {
element.available = true;
document.body.append(element);
class LazyUpgradeTerminalPanel extends TestTerminalPanel {}
customElements.define(tagName, LazyUpgradeTerminalPanel);
defineTestTerminalPanelElement(createGhosttyTerminalMock, tagName);
const panel = element as unknown as OpenClawTerminalPanel;
await panel.updateComplete;
await waitForFast(() => expect(panel.terminalPanelOpen).toBe(true));
@@ -58,6 +58,8 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
@property({ attribute: false }) agentId: string | null = null;
/** Whether the connected gateway advertises the terminal surface. */
@property({ type: Boolean }) available = false;
/** Full-page route takeovers (settings) own the viewport; the dock hides while one renders. */
@property({ type: Boolean }) suppressed = false;
/** Active Control UI color mode, mirrored into the terminal theme. */
@property({ attribute: false }) themeMode: "dark" | "light" = "dark";
/**
@@ -104,6 +106,9 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
override connectedCallback(): void {
super.connectedCallback();
this.terminalSessions.connectHost();
// A settings takeover can already own the viewport when the panel mounts.
// Suppress before the restored open state boots a session nobody can see.
this.dockLayout.setSuppressed(this.suppressed);
if (!this.fullscreen) {
window.addEventListener("keydown", this.onGlobalKeyDown);
window.addEventListener(TERMINAL_PANEL_TOGGLE_EVENT, this.onToggleRequest);
@@ -121,6 +126,11 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
}
override updated(changed: Map<string, unknown>): void {
if (changed.has("suppressed") && this.dockLayout.setSuppressed(this.suppressed)) {
// Restoring after a takeover: a reconnect during settings disposed the tabs
// without restoring them, so re-run the normal open path.
void this.terminalSessions.restoreSessions();
}
if (changed.has("client") || changed.has("available")) {
this.terminalSessions.scheduleLifecycleSync();
}