fix(ui): reopen web terminals without stale content (#100665)

This commit is contained in:
Peter Steinberger
2026-07-06 07:42:38 +01:00
committed by GitHub
parent 2830b3ef38
commit 6e71b0bf30
7 changed files with 229 additions and 4 deletions
+1
View File
@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- **Small-context compaction:** cap the effective reserve against the known model context window so small local models do not enter compaction from the first token. (#100621) Thanks @vincentkoc.
- **Control UI terminal reset:** isolate each web terminal's Ghostty WASM runtime so closing a tab and reopening starts with an empty screen instead of reusing freed terminal cells.
- **Plugin install diagnostics:** suppress the misleading hook-pack fallback after plugin install failures only when the hook manifest is absent, while preserving actionable malformed hook-pack errors. (#100554) Thanks @vincentkoc.
- **Config validation diagnostics:** emit each unchanged sanitized validation-warning payload once per config path, reset deduplication after a clean validation, and preserve the warning fingerprint across transient invalid reads and failed refreshes. (#100569, #25574) Thanks @vincentkoc.
- **Config size-drop guard:** compare writes against canonical bytes for parseable object configs instead of raw BOM and indentation overhead, while preserving raw audit telemetry and the conservative malformed-input fallback. (#100591, #71865) Thanks @vincentkoc.
+3
View File
@@ -1984,6 +1984,9 @@ importers:
dompurify:
specifier: 3.4.11
version: 3.4.11
ghostty-web:
specifier: 0.4.0
version: 0.4.0
highlight.js:
specifier: 11.11.1
version: 11.11.1
+1
View File
@@ -16,6 +16,7 @@
"@openclaw/normalization-core": "workspace:*",
"@openclaw/uirouter": "0.1.0",
"dompurify": "3.4.11",
"ghostty-web": "0.4.0",
"highlight.js": "11.11.1",
"json5": "2.2.3",
"lit": "3.3.3",
@@ -12,8 +12,8 @@ type CreateOptions = {
const createGhosttyTerminalMock = vi.hoisted(() => vi.fn());
vi.mock("@openclaw/libterminal/browser", () => {
return { createGhosttyTerminal: createGhosttyTerminalMock };
vi.mock("./terminal-runtime.ts", () => {
return { createIsolatedGhosttyTerminal: createGhosttyTerminalMock };
});
import { OpenClawTerminalPanel } from "./terminal-panel.ts";
@@ -21,6 +21,8 @@ import { OpenClawTerminalPanel } from "./terminal-panel.ts";
describe("OpenClawTerminalPanel", () => {
afterEach(() => {
document.body.replaceChildren();
localStorage.clear();
sessionStorage.clear();
createGhosttyTerminalMock.mockReset();
});
@@ -147,4 +149,85 @@ describe("OpenClawTerminalPanel", () => {
expect(panel.renderRoot.querySelector(".tp")).not.toBeNull();
expect(panel.renderRoot.querySelector(".tp-new")).not.toBeNull();
});
it("opens a fresh terminal after the last tab is closed", async () => {
const controllers = Array.from({ length: 2 }, () => ({
terminal: {
cols: 100,
rows: 30,
viewportY: 0,
write: vi.fn(),
focus: vi.fn(),
},
write: vi.fn(),
fit: vi.fn(),
dispose: vi.fn(),
}));
createGhosttyTerminalMock
.mockResolvedValueOnce(controllers[0])
.mockResolvedValueOnce(controllers[1]);
const requests: Array<{ method: string; params: unknown }> = [];
let listener: ((event: { event: string; payload: unknown }) => void) | undefined;
let openCount = 0;
const client: TerminalGatewayClient = {
request: async <T>(method: string, params?: unknown) => {
requests.push({ method, params });
if (method === "terminal.open") {
openCount += 1;
return {
sessionId: `session-${openCount}`,
agentId: "main",
shell: "/bin/bash",
cwd: "/work",
confined: false,
} as T;
}
return {} as T;
},
addEventListener: (nextListener) => {
listener = nextListener;
return () => {
if (listener === nextListener) {
listener = undefined;
}
};
},
};
const panel = document.createElement("openclaw-terminal-panel") as OpenClawTerminalPanel;
panel.client = client;
panel.available = true;
document.body.append(panel);
panel.toggle();
await vi.waitFor(() => {
expect(requests.filter((entry) => entry.method === "terminal.open")).toHaveLength(1);
});
const staleOutput = "CLOSE_RESET_SENTINEL";
listener?.({
event: "terminal.data",
payload: { sessionId: "session-1", seq: 0, data: staleOutput },
});
expect(new TextDecoder().decode(controllers[0].write.mock.calls[0]?.[0])).toBe(staleOutput);
await panel.updateComplete;
(panel.renderRoot.querySelector(".tp-tab__close") as HTMLElement).click();
await vi.waitFor(() => {
expect(requests).toContainEqual({
method: "terminal.close",
params: { sessionId: "session-1" },
});
});
expect(controllers[0].dispose).toHaveBeenCalledOnce();
expect(sessionStorage.getItem("openclaw.terminal.sessions.v1")).toBe("[]");
panel.toggle();
await vi.waitFor(() => {
expect(requests.filter((entry) => entry.method === "terminal.open")).toHaveLength(2);
});
expect(requests.some((entry) => entry.method === "terminal.attach")).toBe(false);
expect(createGhosttyTerminalMock).toHaveBeenCalledTimes(2);
expect(controllers[1].write).not.toHaveBeenCalled();
});
});
+2 -2
View File
@@ -9,6 +9,7 @@ import { LitElement, css, html, nothing, svg } from "lit";
import { property, state } from "lit/decorators.js";
import { t } from "../../i18n/index.ts";
import { TerminalConnection, type TerminalGatewayClient } from "./terminal-connection.ts";
import { createIsolatedGhosttyTerminal } from "./terminal-runtime.ts";
import { terminalTheme } from "./terminal-theme.ts";
// Inline icon set (self-contained; the Control UI blocks external asset loads).
@@ -341,7 +342,6 @@ export class OpenClawTerminalPanel extends LitElement {
if (!this.client) {
throw new Error("terminal client unavailable");
}
const { createGhosttyTerminal } = await import("@openclaw/libterminal/browser");
if (!this.connection) {
this.connection = new TerminalConnection(this.client);
}
@@ -362,7 +362,7 @@ export class OpenClawTerminalPanel extends LitElement {
const tabRef = { current: undefined as TerminalTabState | undefined };
let controller: GhosttyTerminalController;
try {
controller = await createGhosttyTerminal({
controller = await createIsolatedGhosttyTerminal({
parent: host,
readOnly: false,
terminalOptions: {
@@ -0,0 +1,18 @@
import type {
CreateGhosttyTerminalOptions,
GhosttyTerminalController,
} from "@openclaw/libterminal/browser";
/** Creates a terminal whose WASM memory is never reused by another tab. */
export async function createIsolatedGhosttyTerminal(
options: CreateGhosttyTerminalOptions,
): Promise<GhosttyTerminalController> {
const [{ createGhosttyTerminal, loadGhosttyRuntime }, ghosttyModule] = await Promise.all([
import("@openclaw/libterminal/browser"),
import("ghostty-web"),
]);
// ghostty-web 0.4.0 reuses freed WASM pages, exposing stale cells and corrupting
// later terminals (coder/ghostty-web#142). Per-tab runtimes confine disposal.
const runtime = await loadGhosttyRuntime({ module: ghosttyModule });
return createGhosttyTerminal({ ...options, runtime });
}
+119
View File
@@ -0,0 +1,119 @@
import { chromium, type Browser } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
canRunPlaywrightChromium,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
} from "../test-helpers/control-ui-e2e.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
type BrowserTerminalController = {
terminal: {
wasmTerm?: {
getLine: (row: number) => Array<{ codepoint: number }> | null;
};
};
dispose: () => void;
write: (bytes: Uint8Array) => void;
};
type BrowserTerminalFactory = (options: {
autoFit: boolean;
parent: HTMLElement;
readOnly: boolean;
size: { columns: number; rows: number };
}) => Promise<BrowserTerminalController>;
let browser: Browser;
let server: ControlUiE2eServer;
describeControlUiE2e("Control UI terminal runtime isolation", () => {
beforeAll(async () => {
if (!chromiumAvailable) {
throw new Error(
`Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`,
);
}
server = await startControlUiE2eServer();
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
});
afterAll(async () => {
await browser?.close();
await server?.close();
});
it("does not reuse freed terminal cells in the next tab", async () => {
const context = await browser.newContext({ serviceWorkers: "block" });
const page = await context.newPage();
const moduleUrl = new URL("src/components/terminal/terminal-runtime.ts", server.baseUrl).href;
try {
await page.goto(server.baseUrl);
await page.addScriptTag({
content: `globalThis.openclawTerminalRuntimeModule = import(${JSON.stringify(moduleUrl)});`,
type: "module",
});
const sentinel = "CLOSE_RESET_SENTINEL";
const result = await page.evaluate(
async ({ staleText }) => {
const runtimeModule = await (
window as unknown as Window & {
openclawTerminalRuntimeModule: Promise<{
createIsolatedGhosttyTerminal: BrowserTerminalFactory;
}>;
}
).openclawTerminalRuntimeModule;
const createTerminal = async () => {
const host = document.createElement("div");
host.style.height = "400px";
host.style.width = "800px";
document.body.append(host);
const controller = await runtimeModule.createIsolatedGhosttyTerminal({
autoFit: false,
parent: host,
readOnly: true,
size: { columns: 80, rows: 24 },
});
return { controller, host };
};
const lineText = (controller: BrowserTerminalController) =>
(controller.terminal.wasmTerm?.getLine(0) ?? [])
.map((cell) =>
cell.codepoint > 0 && cell.codepoint <= 0x10ffff
? String.fromCodePoint(cell.codepoint)
: " ",
)
.join("");
const first = await createTerminal();
first.controller.write(new TextEncoder().encode(`${staleText} 👋🏽`));
const firstLine = lineText(first.controller);
first.controller.dispose();
first.host.remove();
const second = await createTerminal();
const initialSecondLine = lineText(second.controller);
second.controller.write(new TextEncoder().encode("FRESH"));
const finalSecondLine = lineText(second.controller);
second.controller.dispose();
second.host.remove();
return { finalSecondLine, firstLine, initialSecondLine };
},
{ staleText: sentinel },
);
expect(result.firstLine).toContain(sentinel);
expect(result.initialSecondLine).not.toContain(sentinel);
expect(result.initialSecondLine.trim()).toBe("");
expect(result.finalSecondLine).toContain("FRESH");
} finally {
await context.close();
}
});
});