@@ -34,13 +34,15 @@ import { createAppShell } from "./shell/index.js";
3434import type { AppShell } from "./shell/internals.js" ;
3535import {
3636 acceptOverlaySelection ,
37+ closeInsetOverlay ,
3738 openListOverlay ,
3839} from "./shell/overlay-host.js" ;
3940import { moveOverlaySelection } from "./shell/overlay-list.js" ;
4041import { streamRowCount } from "./shell/transcript.js" ;
4142import { wireGates } from "./gate-wire.js" ;
4243import type { PermissionGateEvent } from "./gate-events.js" ;
4344import { createGateRequestApproval } from "./request-approval.js" ;
45+ import { openAddProviderOverlay , openModelPickerOverlay } from "./overlays.js" ;
4446
4547const shellCall = ( command : string ) : ToolCall => ( {
4648 id : "c" ,
@@ -475,3 +477,148 @@ describe("CL-8792 elapsed: pending tool row freezes while a gate is outstanding
475477 ) ;
476478 } ) ;
477479} ) ;
480+
481+ describe ( "CL-8792 overlay host: suspend preserves the surface instead of dismissing it" , ( ) => {
482+ test ( "suspend does not fire onCancel or onDispose" , async ( ) => {
483+ await withWiredWorld ( async ( { shell, emitter } ) => {
484+ const events : string [ ] = [ ] ;
485+ openListOverlay ( shell , {
486+ kind : "help" ,
487+ title : "Slash" ,
488+ items : [ "Help" ] ,
489+ onCancel : ( ) => events . push ( "cancel" ) ,
490+ onDispose : ( ) => events . push ( "dispose" ) ,
491+ } ) ;
492+ expect ( shell . overlayKind ) . toBe ( "help" ) ;
493+
494+ let resolved : unknown ;
495+ emitter . emit ( "permission.gate" , {
496+ id : "req-suspend-hooks" ,
497+ request : destructiveRequest ( "rm -rf /tmp/cl8792-suspend-hooks" ) ,
498+ resolve : ( outcome : unknown ) => {
499+ resolved = outcome ;
500+ } ,
501+ } ) ;
502+ expect ( shell . overlayKind ) . toBe ( "permissions" ) ;
503+ expect ( events ) . toEqual ( [ ] ) ;
504+
505+ acceptChoice ( shell , 1 ) ;
506+ expect ( resolved ) . toEqual ( { allow : true } ) ;
507+ expect ( shell . overlayKind ) . toBe ( "help" ) ;
508+ expect ( events ) . toEqual ( [ ] ) ;
509+
510+ closeInsetOverlay ( shell ) ;
511+ expect ( events ) . toEqual ( [ "dispose" , "cancel" ] ) ;
512+ } ) ;
513+ } ) ;
514+
515+ test ( "restored SelectRenderable is live and parented in overlayView.body" , async ( ) => {
516+ await withWiredWorld ( async ( { shell, emitter } ) => {
517+ openSlash ( shell ) ;
518+ const list = defined ( shell . overlayList , "slash list" ) ;
519+ expect ( list . select . isDestroyed ) . toBe ( false ) ;
520+ expect ( list . select . parent ) . toBe ( shell . overlayView . body ) ;
521+
522+ let resolved : unknown ;
523+ emitter . emit ( "permission.gate" , {
524+ id : "req-restore-list" ,
525+ request : destructiveRequest ( "rm -rf /tmp/cl8792-restore-list" ) ,
526+ resolve : ( outcome : unknown ) => {
527+ resolved = outcome ;
528+ } ,
529+ } ) ;
530+ expect ( shell . overlayKind ) . toBe ( "permissions" ) ;
531+
532+ acceptChoice ( shell , 1 ) ;
533+ expect ( resolved ) . toEqual ( { allow : true } ) ;
534+ expect ( shell . overlayKind ) . toBe ( "help" ) ;
535+ expect ( shell . overlayList ) . toBe ( list ) ;
536+ expect ( list . select . isDestroyed ) . toBe ( false ) ;
537+ expect ( list . select . parent ) . toBe ( shell . overlayView . body ) ;
538+ expect ( shell . overlayView . body . getChildren ( ) ) . toContain ( list . select ) ;
539+ } ) ;
540+ } ) ;
541+
542+ test . each ( [
543+ {
544+ kind : "model_picker" as const ,
545+ open : ( shell : AppShell ) =>
546+ openModelPickerOverlay ( shell , { items : [ "grok-3" ] } ) ,
547+ } ,
548+ {
549+ kind : "add_provider" as const ,
550+ open : ( shell : AppShell ) =>
551+ openAddProviderOverlay ( shell , {
552+ items : [ "custom" ] ,
553+ itemIds : [ "custom" ] ,
554+ } ) ,
555+ } ,
556+ ] ) (
557+ "$kind yields to a newly raised gate and returns after settle" ,
558+ async ( { kind, open } ) => {
559+ await withWiredWorld ( async ( { shell, emitter } ) => {
560+ open ( shell ) ;
561+ expect ( shell . overlayKind ) . toBe ( kind ) ;
562+
563+ let resolved : unknown ;
564+ emitter . emit ( "permission.gate" , {
565+ id : `req-yield-${ kind } ` ,
566+ request : destructiveRequest ( `rm -rf /tmp/cl8792-yield-${ kind } ` ) ,
567+ resolve : ( outcome : unknown ) => {
568+ resolved = outcome ;
569+ } ,
570+ } ) ;
571+ expect ( shell . overlayKind ) . toBe ( "permissions" ) ;
572+
573+ acceptChoice ( shell , 1 ) ;
574+ expect ( resolved ) . toEqual ( { allow : true } ) ;
575+ expect ( shell . overlayKind ) . toBe ( kind ) ;
576+ } ) ;
577+ } ,
578+ ) ;
579+
580+ test ( "MCP onCancel during suspend does not steal the host from a queued gate while a deferred slash occupies idle" , async ( ) => {
581+ await withWiredWorld ( async ( { shell, emitter } ) => {
582+ let cancelOpens = 0 ;
583+ openListOverlay ( shell , {
584+ kind : "mcp" ,
585+ title : `remove stolen` ,
586+ items : [ "Remove stolen" , "Cancel" ] ,
587+ onCancel : ( ) => {
588+ cancelOpens += 1 ;
589+ openListOverlay ( shell , {
590+ kind : "mcp" ,
591+ title : "mcp" ,
592+ items : [ "stolen-server" ] ,
593+ } ) ;
594+ } ,
595+ } ) ;
596+ expect ( shell . overlayKind ) . toBe ( "mcp" ) ;
597+
598+ openSlash ( shell ) ;
599+ expect ( shell . overlayKind ) . toBe ( "mcp" ) ;
600+
601+ let resolved : unknown ;
602+ emitter . emit ( "permission.gate" , {
603+ id : "req-mcp-cancel-steal" ,
604+ request : destructiveRequest ( "rm -rf /tmp/cl8792-mcp-steal" ) ,
605+ resolve : ( outcome : unknown ) => {
606+ resolved = outcome ;
607+ } ,
608+ } ) ;
609+ expect ( cancelOpens ) . toBe ( 0 ) ;
610+ expect ( shell . overlayKind ) . toBe ( "permissions" ) ;
611+ expect ( shell . overlayItems ) . toContain ( "Accept once" ) ;
612+
613+ acceptChoice ( shell , 1 ) ;
614+ expect ( resolved ) . toEqual ( { allow : true } ) ;
615+ expect ( cancelOpens ) . toBe ( 0 ) ;
616+ expect ( shell . overlayKind ) . toBe ( "mcp" ) ;
617+ expect ( shell . overlayItems ) . toEqual ( [ "Remove stolen" , "Cancel" ] ) ;
618+
619+ await Promise . resolve ( ) ;
620+ expect ( shell . overlayKind ) . toBe ( "mcp" ) ;
621+ expect ( shell . overlayItems ) . not . toEqual ( [ "Help" ] ) ;
622+ } ) ;
623+ } ) ;
624+ } ) ;
0 commit comments