@@ -1964,7 +1964,7 @@ interface ShellInternals {
19641964 overlayRawBodyText : string ;
19651965 /** Snapshot when palette stacks over another primary overlay. */
19661966 priorOverlay : PriorOverlaySnapshot | null ;
1967- /** Advances whenever a new overlay takes ownership of the shared host. */
1967+ /** Advances on a new overlay taking the host, and when the host empties . */
19681968 overlayGeneration : number ;
19691969 /** Optional stable ids aligned with overlayItems for the open primary. */
19701970 overlayItemIds : readonly string [ ] ;
@@ -2013,13 +2013,13 @@ interface ShellInternals {
20132013 overlayOnDispose : ( ( ) => void ) | null ;
20142014 /** True while the open primary is a decision gate that must not be replaced. */
20152015 overlayIsGate : boolean ;
2016- /** Fired once the shell has no overlay open , so queued gates can re-open. */
2016+ /** Fired once the overlay host is idle , so queued gates can re-open. */
20172017 overlayClosedListeners : Set < ( ) => void > ;
20182018 /**
20192019 * Command-surface open while a live overlay still holds the host. One slot;
20202020 * a newer command replaces an older one. Flushed only after that overlay
2021- * has actually closed and the host is idle — never from notifyOverlayClosed,
2022- * which would let wireGates drain a queued gate onto the same host.
2021+ * has actually closed and the host is idle — never from idle-notify, which
2022+ * would let wireGates drain a queued gate onto the same host.
20232023 */
20242024 deferredCommandOverlay : OpenListOverlayOpts | null ;
20252025 /** True while a microtask to flush deferredCommandOverlay is queued. */
@@ -4313,11 +4313,6 @@ function notifyOverlayClosed(shell: AppShell): void {
43134313 for ( const listener of [ ...bag . overlayClosedListeners ] ) listener ( ) ;
43144314}
43154315
4316- function notifyIfHostIdle ( shell : AppShell ) : void {
4317- if ( ! isOverlayHostIdle ( shell ) ) return ;
4318- notifyOverlayClosed ( shell ) ;
4319- }
4320-
43214316/**
43224317 * Hold the overlay host idle-notify while an async command surface is still
43234318 * claiming it (permissions.list() before settings/permissions paint). Release
@@ -4337,7 +4332,7 @@ export function reserveOverlayHost(shell: AppShell): () => void {
43374332 if ( ! current || current . overlayReservationEpoch !== epoch ) return ;
43384333 if ( current . overlayHostReservations > 0 ) current . overlayHostReservations -= 1 ;
43394334 scheduleDeferredCommandFlush ( shell ) ;
4340- notifyIfHostIdle ( shell ) ;
4335+ notifyOverlayClosed ( shell ) ;
43414336 } ;
43424337}
43434338
@@ -4349,8 +4344,6 @@ function abortOverlayHostReservations(shell: AppShell): void {
43494344 bag . overlayHostReservations = 0 ;
43504345 bag . overlayGeneration += 1 ;
43514346 scheduleDeferredCommandFlush ( shell ) ;
4352- // Next tick so the same Esc cannot also dismiss a gate that this abort drains.
4353- queueMicrotask ( ( ) => notifyIfHostIdle ( shell ) ) ;
43544347}
43554348
43564349/** One deferred command-surface slot while the host is busy. */
@@ -4387,7 +4380,7 @@ function flushDeferredCommandOverlay(shell: AppShell): void {
43874380 if ( shell . overlayList !== null ) return ;
43884381 const opts = bag . deferredCommandOverlay ;
43894382 if ( opts === null ) {
4390- notifyIfHostIdle ( shell ) ;
4383+ notifyOverlayClosed ( shell ) ;
43914384 return ;
43924385 }
43934386 bag . deferredCommandOverlay = null ;
@@ -4482,7 +4475,7 @@ export interface OverlayContinuationToken {
44824475 readonly generation : number ;
44834476}
44844477
4485- /** Capture overlay generation for an async continuation. Stale after a newer open or Esc during a reserved in-flight open . */
4478+ /** Capture overlay generation for an async continuation. Stale after a newer open, a full close, or Esc abort . */
44864479export function captureOverlayContinuation ( shell : AppShell ) : OverlayContinuationToken {
44874480 return { generation : internals . get ( shell ) ?. overlayGeneration ?? - 1 } ;
44884481}
@@ -4495,7 +4488,7 @@ export function isOverlayContinuationCurrent(
44954488 return isOverlayGenerationCurrent ( shell , token ) && shell . overlayList === null ;
44964489}
44974490
4498- /** True while the shell is live and no newer overlay has advanced the generation . */
4491+ /** True while the shell is live and generation has not advanced . */
44994492export function isOverlayGenerationCurrent (
45004493 shell : AppShell ,
45014494 token : OverlayContinuationToken ,
@@ -5453,19 +5446,19 @@ export function openSlashCommands(shell: AppShell): boolean {
54535446
54545447 // Every keystroke lands here while the popup is already open. Closing and
54555448 // reopening released the overlay host between the two calls (closeSlashPopup
5456- // routes through closeInsetOverlay, which fires notifyOverlayClosed ) — long
5457- // enough for a queued permission/operator gate to drain onto it. Refreshing
5458- // the open palette in place never releases the host, so a queued gate has
5459- // nothing to drain into. priorOverlay stacking is untouched here (it is only
5460- // ever written by openListOverlay's stack-on-open path), so a palette
5461- // stacked over a prior overlay keeps that snapshot across the refresh.
5449+ // routes through closeInsetOverlay, which idle-notifies ) — long enough for a
5450+ // queued permission/operator gate to drain onto it. Refreshing the open
5451+ // palette in place never releases the host, so a queued gate has nothing to
5452+ // drain into. priorOverlay stacking is untouched here (it is only ever
5453+ // written by openListOverlay's stack-on-open path), so a palette stacked
5454+ // over a prior overlay keeps that snapshot across the refresh.
54625455 //
54635456 // A typo that zeroes the matches must not fall through to closeSlashPopup
5464- // while the popup is already open — that closes through the same
5465- // notifyOverlayClosed path and drains a queued gate mid-filter. Instead
5466- // this refreshes in place to a "(no matches)" row, same as the general
5467- // palette does, and holds the host until a real dismiss (deleting the `/`,
5468- // Esc, accept) or a backspace that restores matches.
5457+ // while the popup is already open — that closes through the same idle-notify
5458+ // path and drains a queued gate mid-filter. Instead this refreshes in place
5459+ // to a "(no matches)" row, same as the general palette does, and holds the
5460+ // host until a real dismiss (deleting the `/`, Esc, accept) or a backspace
5461+ // that restores matches.
54695462 if ( isSlashPopupOpen ( shell ) && shell . overlayKind === "palette" ) {
54705463 refreshSlashPopupInPlace ( shell , matches ) ;
54715464 return true ;
@@ -5930,6 +5923,8 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption
59305923 if ( internals . get ( shell ) ?. overlayHostReservations ) {
59315924 abortOverlayHostReservations ( shell ) ;
59325925 key . preventDefault ( ) ;
5926+ // Next tick so the same Esc cannot also dismiss a gate this abort drains.
5927+ queueMicrotask ( ( ) => notifyOverlayClosed ( shell ) ) ;
59335928 return ;
59345929 }
59355930 if ( shell . observe ) {
@@ -6480,11 +6475,7 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption
64806475 // release subscriptions or settle awaited cancellation exactly once.
64816476 let overlayGuard = 4 ;
64826477 while ( shell . overlayList !== null && overlayGuard -- > 0 ) closeInsetOverlay ( shell ) ;
6483- const bag = internals . get ( shell ) ;
6484- if ( bag ) {
6485- bag . overlayReservationEpoch += 1 ;
6486- bag . overlayHostReservations = 0 ;
6487- }
6478+ abortOverlayHostReservations ( shell ) ;
64886479 disposed = true ;
64896480 shell . disposed = true ;
64906481 if ( wireKeys ) {
0 commit comments