Skip to content

Commit 2399ce7

Browse files
committed
fix(control-ui): harden terminal panel lifecycle (pending-open cancel, live theme, viewport clamp)
1 parent e275cd6 commit 2399ce7

1 file changed

Lines changed: 77 additions & 9 deletions

File tree

ui/src/ui/terminal/terminal-panel.ts

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ type TerminalTabState = {
3939
host: HTMLDivElement;
4040
status: "live" | "exited";
4141
statusLabel?: string;
42+
/**
43+
* Set when the tab is closed while its terminal.open RPC is still in flight
44+
* (gatewaySessionId is empty in that window, so closeTab cannot close the
45+
* server session). The open continuation checks this and closes the freshly
46+
* created session instead of wiring it to the disposed terminal.
47+
*/
48+
cancelled?: boolean;
4249
};
4350

4451
/** Reduces a shell path to a tab label, e.g. "/bin/zsh" -> "zsh". */
@@ -63,16 +70,29 @@ function loadLayout(): PanelLayout {
6370
return {
6471
open: Boolean(parsed.open),
6572
dock: parsed.dock === "right" ? "right" : "bottom",
66-
height: clampSize(parsed.height, MIN_HEIGHT, DEFAULT_LAYOUT.height),
67-
width: clampSize(parsed.width, MIN_WIDTH, DEFAULT_LAYOUT.width),
73+
height: clampSize(parsed.height, MIN_HEIGHT, maxPanelHeight(), DEFAULT_LAYOUT.height),
74+
width: clampSize(parsed.width, MIN_WIDTH, maxPanelWidth(), DEFAULT_LAYOUT.width),
6875
};
6976
} catch {
7077
return { ...DEFAULT_LAYOUT };
7178
}
7279
}
7380

74-
function clampSize(value: unknown, min: number, fallback: number): number {
75-
return typeof value === "number" && Number.isFinite(value) && value >= min ? value : fallback;
81+
// A size persisted on a large desktop must not swallow a smaller window: cap
82+
// the dock at 80% of the viewport so the header/resizer stay reachable and the
83+
// shell content keeps a usable slice.
84+
function maxPanelHeight(): number {
85+
return Math.max(MIN_HEIGHT, Math.floor((globalThis.innerHeight || 800) * 0.8));
86+
}
87+
88+
function maxPanelWidth(): number {
89+
return Math.max(MIN_WIDTH, Math.floor((globalThis.innerWidth || 1280) * 0.8));
90+
}
91+
92+
function clampSize(value: unknown, min: number, max: number, fallback: number): number {
93+
const size =
94+
typeof value === "number" && Number.isFinite(value) && value >= min ? value : fallback;
95+
return Math.min(size, max);
7696
}
7797

7898
/** `<openclaw-terminal-panel>` — the dockable Control UI shell surface. */
@@ -98,6 +118,19 @@ export class OpenClawTerminalPanel extends LitElement {
98118
private tabSeq = 0;
99119
private readonly onGlobalKeyDown = (event: KeyboardEvent) => this.handleGlobalKey(event);
100120
private readonly onToggleRequest = () => this.toggle();
121+
// Re-clamp a dock sized on a larger window so the header/resizer never end
122+
// up off-screen after the viewport shrinks (e.g. rotate, window resize).
123+
private readonly onViewportResize = () => {
124+
const height = Math.min(this.height, maxPanelHeight());
125+
const width = Math.min(this.width, maxPanelWidth());
126+
if (height === this.height && width === this.width) {
127+
return;
128+
}
129+
this.height = height;
130+
this.width = width;
131+
this.syncLayoutReservation();
132+
this.tabs.find((tab) => tab.id === this.activeId)?.fit.fit();
133+
};
101134

102135
override connectedCallback(): void {
103136
super.connectedCallback();
@@ -109,6 +142,7 @@ export class OpenClawTerminalPanel extends LitElement {
109142
this.open = layout.open && this.available;
110143
window.addEventListener("keydown", this.onGlobalKeyDown);
111144
window.addEventListener(TOGGLE_EVENT, this.onToggleRequest);
145+
window.addEventListener("resize", this.onViewportResize);
112146
if (this.open) {
113147
void this.ensureInitialSession();
114148
}
@@ -118,6 +152,7 @@ export class OpenClawTerminalPanel extends LitElement {
118152
super.disconnectedCallback();
119153
window.removeEventListener("keydown", this.onGlobalKeyDown);
120154
window.removeEventListener(TOGGLE_EVENT, this.onToggleRequest);
155+
window.removeEventListener("resize", this.onViewportResize);
121156
// Release the content-area reservation so the shell reflows to full size.
122157
document.documentElement.style.setProperty("--oc-terminal-reserve-bottom", "0px");
123158
document.documentElement.style.setProperty("--oc-terminal-reserve-right", "0px");
@@ -145,7 +180,15 @@ export class OpenClawTerminalPanel extends LitElement {
145180
if (changed.has("themeMode")) {
146181
const theme = terminalTheme(this.themeMode);
147182
for (const tab of this.tabs) {
148-
tab.term.options.theme = theme;
183+
// ghostty-web 0.4.0 ignores options.theme after open() (its option
184+
// handler only warns), so update the renderer directly and force one
185+
// full render — the frame loop repaints only dirty rows, which would
186+
// leave a static screen on the old palette.
187+
const { term } = tab;
188+
if (term.renderer && term.wasmTerm) {
189+
term.renderer.setTheme(theme);
190+
term.renderer.render(term.wasmTerm, true, term.viewportY, term);
191+
}
149192
}
150193
}
151194
// Hiding the panel returns `nothing`, which detaches each session's ghostty
@@ -227,6 +270,9 @@ export class OpenClawTerminalPanel extends LitElement {
227270
if (!this.connection) {
228271
this.connection = new TerminalConnection(this.client);
229272
}
273+
// Captured so the cancelled-open cleanup below can close the session even
274+
// if a teardown swaps this.connection while the open RPC is in flight.
275+
const connection = this.connection;
230276
const host = document.createElement("div");
231277
host.className = "tp-host";
232278
const term = new Terminal({
@@ -269,13 +315,26 @@ export class OpenClawTerminalPanel extends LitElement {
269315
const cols = term.cols || 80;
270316
const rows = term.rows || 24;
271317

272-
const result = await this.connection.open(
318+
const result = await connection.open(
273319
{ cols, rows },
274320
{
275-
onData: (data) => term.write(data),
321+
// The cancelled guard also protects the buffered-event replay inside
322+
// connection.open from writing to an already-disposed terminal.
323+
onData: (data) => {
324+
if (!tab.cancelled) {
325+
term.write(data);
326+
}
327+
},
276328
onExit: (info) => this.handleExit(id, info),
277329
},
278330
);
331+
if (tab.cancelled) {
332+
// The tab's close button was clicked while the open RPC was in flight.
333+
// The server session is live and its sink registered; close it now or
334+
// it survives invisibly (eating the session cap) until disconnect.
335+
void connection.close(result.sessionId);
336+
return;
337+
}
279338
tab.gatewaySessionId = result.sessionId;
280339
tab.shellName = shellBasename(result.shell);
281340
tab.hint = t("terminal.tabHint", { agent: result.agentId, cwd: result.cwd });
@@ -327,6 +386,10 @@ export class OpenClawTerminalPanel extends LitElement {
327386
}
328387
if (tab.gatewaySessionId && tab.status === "live") {
329388
void this.connection?.close(tab.gatewaySessionId);
389+
} else if (!tab.gatewaySessionId && tab.status === "live") {
390+
// Open still in flight: no session id to close yet. Flag it so the open
391+
// continuation closes the server session as soon as the RPC resolves.
392+
tab.cancelled = true;
330393
}
331394
this.disposeTab(tab);
332395
this.tabs = this.tabs.filter((entry) => entry.id !== tabId);
@@ -363,6 +426,9 @@ export class OpenClawTerminalPanel extends LitElement {
363426
if (tab.gatewaySessionId && tab.status === "live") {
364427
void this.connection?.close(tab.gatewaySessionId);
365428
}
429+
// Covers a tab whose open RPC is still in flight; its continuation
430+
// closes the fresh session instead of adopting the disposed terminal.
431+
tab.cancelled = true;
366432
this.disposeTab(tab);
367433
}
368434
this.tabs = [];
@@ -405,9 +471,11 @@ export class OpenClawTerminalPanel extends LitElement {
405471
const startWidth = this.width;
406472
const onMove = (move: PointerEvent) => {
407473
if (this.dock === "bottom") {
408-
this.height = Math.max(MIN_HEIGHT, startHeight + (startY - move.clientY));
474+
const next = Math.max(MIN_HEIGHT, startHeight + (startY - move.clientY));
475+
this.height = Math.min(next, maxPanelHeight());
409476
} else {
410-
this.width = Math.max(MIN_WIDTH, startWidth + (startX - move.clientX));
477+
const next = Math.max(MIN_WIDTH, startWidth + (startX - move.clientX));
478+
this.width = Math.min(next, maxPanelWidth());
411479
}
412480
// Reflow the content reservation live so the shell tracks the drag.
413481
this.syncLayoutReservation();

0 commit comments

Comments
 (0)