mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(ui): reopen web terminals without stale content (#100665)
This commit is contained in:
@@ -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.
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user