@@ -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