mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
committed by
GitHub
parent
c0c55a82fa
commit
1c0ccdddcd
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user