@@ -19,7 +19,9 @@ import {
1919 closeInsetOverlay ,
2020 isOverlayHostIdle ,
2121 onOverlayClosed ,
22+ resumeSuspendedCommandSurface ,
2223 setOverlayBody ,
24+ suspendReplaceableOverlay ,
2325} from "./shell/overlay-host.js" ;
2426import { EXPAND_KEY } from "./stream.js" ;
2527import {
@@ -258,6 +260,7 @@ export function wireGates(
258260 // nothing on screen to answer — so a gate that arrives while another overlay
259261 // is up waits here and opens as soon as the host frees up.
260262 const pending : ( ( ) => void ) [ ] = [ ] ;
263+ let disposed = false ;
261264 // Owns queued-approval reconciliation (see src/permission/queue.ts): this
262265 // host only enqueues requests and renders whatever settle calls the queue
263266 // hands back — it never decides which grant covers which request.
@@ -295,11 +298,45 @@ export function wireGates(
295298 }
296299
297300 function openOrQueue ( open : ( ) => void ) : void {
298- if ( ! isOverlayHostIdle ( shell ) ) {
301+ if ( isOverlayHostIdle ( shell ) ) {
302+ openHost ( open ) ;
303+ return ;
304+ }
305+ if ( shell . overlayList !== null ) {
306+ // A replaceable command surface yields to the decision gate and is
307+ // restored after the gate settles. The suspend is a no-op for live
308+ // gates and non-surface popups (palette, mentions, pickers — they keep
309+ // their stacking contracts), so those arrivals simply stay queued.
299310 pending . push ( open ) ;
311+ suspendReplaceableOverlay ( shell ) ;
312+ // The suspend-close's idle-notify may already have opened an older
313+ // queued gate (FIFO): drain here only if the host is still free, so a
314+ // close-notify drain is never doubled.
315+ if ( shell . overlayList === null ) {
316+ const next = pending . shift ( ) ;
317+ if ( next !== undefined ) openHost ( next ) ;
318+ }
319+ return ;
320+ }
321+ pending . push ( open ) ;
322+ }
323+
324+ /**
325+ * Open the next queued gate, else return a suspended command surface to
326+ * the host. Every gate settle path runs this after resolving. Skipped past
327+ * teardown so a late settle cannot paint onto a dead shell.
328+ */
329+ function drainPendingOrResume ( ) : void {
330+ if ( disposed || shell . disposed ) return ;
331+ // A close-notify drain may already have taken the host (Esc / timeout
332+ // while displayed): never double-open, and never tear down a live gate.
333+ if ( shell . overlayList !== null ) return ;
334+ const next = pending . shift ( ) ;
335+ if ( next !== undefined ) {
336+ openHost ( next ) ;
300337 return ;
301338 }
302- openHost ( open ) ;
339+ resumeSuspendedCommandSurface ( shell ) ;
303340 }
304341
305342 function unqueue ( open : ( ) => void ) : void {
@@ -351,6 +388,9 @@ export function wireGates(
351388 closeInsetOverlay ( shell ) ;
352389 }
353390 resolve ( outcome ) ;
391+ // The next queued gate takes the host before any deferred surface;
392+ // a suspended command surface returns only when no gate is waiting.
393+ drainPendingOrResume ( ) ;
354394 } ) ;
355395
356396 const onToggleExpand = ( ) : void => {
@@ -496,11 +536,7 @@ export function wireGates(
496536 // ask — or the overlay's generic accept echo — into the transcript.
497537 echoChoice : false ,
498538 onAccept : ( sel : OverlaySelection ) => {
499- if ( settled ) return ;
500- settled = true ;
501- clearTimers ( ) ;
502- operatorTeardowns . delete ( teardown ) ;
503- resolve (
539+ settleOnce (
504540 operatorResultFromSelection ( choices , {
505541 index : sel . index ,
506542 ...( sel . id !== undefined ? { id : sel . id } : { } ) ,
@@ -510,11 +546,7 @@ export function wireGates(
510546 // The ask_operator contract offers a free-form answer, so the overlay
511547 // must be able to send one back rather than only an option index.
512548 onTextAnswer : ( text : string ) => {
513- if ( settled ) return ;
514- settled = true ;
515- clearTimers ( ) ;
516- operatorTeardowns . delete ( teardown ) ;
517- resolve ( operatorCustomResult ( text ) ) ;
549+ settleOnce ( operatorCustomResult ( text ) ) ;
518550 } ,
519551 // Esc must settle the awaited promise (as a cancel), not abandon it —
520552 // an unresolved gate hangs the run until the process is killed.
@@ -523,11 +555,7 @@ export function wireGates(
523555 // closeInsetOverlay itself; doing so would reenter this same
524556 // onCancel (see the permission gate's identical note on `settle`).
525557 onCancel : ( ) => {
526- if ( settled ) return ;
527- settled = true ;
528- clearTimers ( ) ;
529- operatorTeardowns . delete ( teardown ) ;
530- resolve ( operatorCancelResult ( ) ) ;
558+ settleOnce ( operatorCancelResult ( ) ) ;
531559 } ,
532560 isGate : true ,
533561 } ) ;
@@ -544,6 +572,9 @@ export function wireGates(
544572 closeInsetOverlay ( shell ) ;
545573 }
546574 resolve ( result ) ;
575+ // The next queued gate takes the host before any deferred surface;
576+ // a suspended command surface returns only when no gate is waiting.
577+ drainPendingOrResume ( ) ;
547578 } ;
548579 const autoCancel = ( ) : void => {
549580 settleOnce ( operatorCancelResult ( ) ) ;
@@ -568,6 +599,7 @@ export function wireGates(
568599 emitter . on ( "operator.gate" , onOperator ) ;
569600
570601 return ( ) => {
602+ disposed = true ;
571603 emitter . off ( "permission.gate" , onPermission ) ;
572604 emitter . off ( "operator.gate" , onOperator ) ;
573605 disposeReconciliation ( ) ;
0 commit comments