experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(izui): prevent ESC from closing content windows in active sessions

The escPolicy function unconditionally closed child-content windows on ESC
regardless of session state. This caused root-level content windows (web
pages opened from workspace views like groups) to close when the user
pressed ESC while actively working in the app.

Fix: remove the separate child-content case from escPolicy so it follows
the same logic as content and workspace roles — only close in transient
sessions, never in active sessions. This matches the IZUI spec where
ACTIVE state means "Internal navigation only, never close."

Updated both backend/electron/windows.ts and app/lib/izui-state.js.
Added 15 new unit tests for escPolicy covering all role/state combinations
plus regression tests. Added Playwright integration test for child-content
ESC behavior in active sessions.

+204 -8
+4 -2
app/lib/izui-state.js
··· 378 378 // These roles always close regardless of session state 379 379 if (['quick-view', 'palette', 'utility'].includes(role)) return 'close'; 380 380 if (role === 'overlay') return 'close-and-restore'; 381 - if (role === 'child-content') return 'close'; 382 - // workspace and content: only close in transient sessions 381 + // child-content, workspace, and content: only close in transient sessions. 382 + // In active sessions, ESC navigates internally (renderer handles it) but 383 + // never closes the window — even child-content windows are user-opened 384 + // content that should persist. 383 385 if (sessionState === 'transient') return 'close'; 384 386 return 'nothing'; 385 387 }
+10 -3
backend/electron/ipc.ts
··· 2266 2266 2267 2267 // Determine opener window and whether it's a real content parent 2268 2268 // Background/infrastructure windows (background.html, extension-host.html) are 2269 - // not real parents — they're just the IPC sender. Only content windows count 2270 - // as parents for IZUI child-window semantics (ESC closes child, focuses parent). 2269 + // not real parents — they're just the IPC sender. Transient UI windows (palette, 2270 + // quick-view, overlay) are also not real parents — they're ephemeral launchers. 2271 + // Only content/workspace windows count as parents for IZUI child-window semantics 2272 + // (ESC closes child, focuses parent). 2271 2273 const openerWindow = BrowserWindow.fromWebContents(ev.sender); 2272 2274 const INTERNAL_URLS = ['peek://app/background.html', 'peek://app/extension-host.html']; 2273 2275 const openerUrl = openerWindow && !openerWindow.isDestroyed() ? openerWindow.webContents?.getURL() ?? '' : ''; 2274 - const isRealParent = openerWindow && !openerWindow.isDestroyed() && !INTERNAL_URLS.some(u => openerUrl === u); 2276 + const TRANSIENT_ROLES = ['palette', 'quick-view', 'overlay', 'utility']; 2277 + const openerInfo = openerWindow && !openerWindow.isDestroyed() ? getWindowInfo(openerWindow.id) : null; 2278 + const openerRole = (openerInfo?.params?.role as string) || ''; 2279 + const isRealParent = openerWindow && !openerWindow.isDestroyed() 2280 + && !INTERNAL_URLS.some(u => openerUrl === u) 2281 + && !TRANSIENT_ROLES.includes(openerRole); 2275 2282 2276 2283 // Center window on parent if opener exists and no explicit position 2277 2284 // Priority: explicit x/y > center on parent > center on screen
+121
backend/electron/izui-state.test.ts
··· 55 55 // Import will be done after build 56 56 let izuiState: typeof import('./izui-state.js'); 57 57 58 + // Import escPolicy from the shared module (pure function, no Electron deps) 59 + // At runtime (from dist/backend/electron/) the path is ../../../app/lib/izui-state.js 60 + let escPolicy: (sessionState: string, role: string) => 'close' | 'close-and-restore' | 'nothing'; 61 + 58 62 describe('IZUI State Coordinator Tests', () => { 59 63 let mockWindows: MockWindow[]; 60 64 61 65 before(async () => { 62 66 // Dynamic import of the compiled module 63 67 izuiState = await import('./izui-state.js'); 68 + // Import escPolicy from shared module (pure function, no Electron deps) 69 + // @ts-expect-error — shared JS module in app/lib, no .d.ts in tsconfig rootDir 70 + const shared = await import('../../../app/lib/izui-state.js'); 71 + escPolicy = shared.escPolicy; 64 72 }); 65 73 66 74 beforeEach(() => { ··· 669 677 const coordinator1 = izuiState.getIzuiCoordinator(); 670 678 const coordinator2 = izuiState.getIzuiCoordinator(); 671 679 assert.strictEqual(coordinator1, coordinator2); 680 + }); 681 + }); 682 + 683 + describe('escPolicy', () => { 684 + // Transient UI roles always close regardless of session state 685 + it('should close quick-view in any session state', () => { 686 + assert.strictEqual(escPolicy('active', 'quick-view'), 'close'); 687 + assert.strictEqual(escPolicy('transient', 'quick-view'), 'close'); 688 + assert.strictEqual(escPolicy('idle', 'quick-view'), 'close'); 689 + }); 690 + 691 + it('should close palette in any session state', () => { 692 + assert.strictEqual(escPolicy('active', 'palette'), 'close'); 693 + assert.strictEqual(escPolicy('transient', 'palette'), 'close'); 694 + assert.strictEqual(escPolicy('idle', 'palette'), 'close'); 695 + }); 696 + 697 + it('should close utility in any session state', () => { 698 + assert.strictEqual(escPolicy('active', 'utility'), 'close'); 699 + assert.strictEqual(escPolicy('transient', 'utility'), 'close'); 700 + assert.strictEqual(escPolicy('idle', 'utility'), 'close'); 701 + }); 702 + 703 + it('should close-and-restore overlay in any session state', () => { 704 + assert.strictEqual(escPolicy('active', 'overlay'), 'close-and-restore'); 705 + assert.strictEqual(escPolicy('transient', 'overlay'), 'close-and-restore'); 706 + assert.strictEqual(escPolicy('idle', 'overlay'), 'close-and-restore'); 707 + }); 708 + 709 + // Content roles: close in transient, nothing in active 710 + it('should close content in transient session', () => { 711 + assert.strictEqual(escPolicy('transient', 'content'), 'close'); 712 + }); 713 + 714 + it('should NOT close content in active session', () => { 715 + assert.strictEqual(escPolicy('active', 'content'), 'nothing'); 716 + }); 717 + 718 + it('should close workspace in transient session', () => { 719 + assert.strictEqual(escPolicy('transient', 'workspace'), 'close'); 720 + }); 721 + 722 + it('should NOT close workspace in active session', () => { 723 + assert.strictEqual(escPolicy('active', 'workspace'), 'nothing'); 724 + }); 725 + 726 + // REGRESSION TEST: child-content must NOT close in active sessions 727 + // This was a bug where child-content always returned 'close' regardless 728 + // of session state, causing root-level content windows to close on ESC 729 + // when the user was actively working in the app. 730 + it('should close child-content in transient session', () => { 731 + assert.strictEqual(escPolicy('transient', 'child-content'), 'close'); 732 + }); 733 + 734 + it('should NOT close child-content in active session (regression test)', () => { 735 + assert.strictEqual(escPolicy('active', 'child-content'), 'nothing', 736 + 'child-content windows must not close on ESC in active sessions — ' + 737 + 'they are user-opened content, not transient UI'); 738 + }); 739 + 740 + it('should NOT close child-content in idle session', () => { 741 + assert.strictEqual(escPolicy('idle', 'child-content'), 'nothing'); 742 + }); 743 + 744 + // Idle state: content roles should not close 745 + it('should NOT close content in idle state', () => { 746 + assert.strictEqual(escPolicy('idle', 'content'), 'nothing'); 747 + }); 748 + 749 + it('should NOT close workspace in idle state', () => { 750 + assert.strictEqual(escPolicy('idle', 'workspace'), 'nothing'); 751 + }); 752 + }); 753 + 754 + describe('ESC on root content window in active session (regression)', () => { 755 + it('should not close a content window when session is active', () => { 756 + // Simulate: user is working in Peek (active session), has a content window open 757 + const coordinator = izuiState.getIzuiCoordinator(); 758 + // appFocused defaults to true → active session 759 + coordinator.evaluateOnShow(); 760 + coordinator.pushWindow(1); // workspace window 761 + coordinator.pushWindow(2); // content window opened from workspace 762 + 763 + // Verify session is active 764 + assert.strictEqual(coordinator.getState(), 'active'); 765 + assert.strictEqual(coordinator.isTransient(), false); 766 + 767 + // escPolicy for child-content in active session should be 'nothing' 768 + const action = escPolicy(coordinator.getState(), 'child-content'); 769 + assert.strictEqual(action, 'nothing', 770 + 'ESC on child-content in active session must return nothing (not close)'); 771 + 772 + // Same for plain content role 773 + const contentAction = escPolicy(coordinator.getState(), 'content'); 774 + assert.strictEqual(contentAction, 'nothing'); 775 + }); 776 + 777 + it('should close a content window when session is transient', () => { 778 + // Simulate: user invoked from background (transient session) 779 + const coordinator = izuiState.getIzuiCoordinator(); 780 + coordinator.setAppFocused(false); 781 + coordinator.evaluateOnShow(); 782 + coordinator.pushWindow(1); 783 + coordinator.pushWindow(2); 784 + 785 + // Verify session is transient 786 + assert.strictEqual(coordinator.getState(), 'transient'); 787 + assert.strictEqual(coordinator.isTransient(), true); 788 + 789 + // In transient mode, ESC should close all content roles 790 + assert.strictEqual(escPolicy(coordinator.getState(), 'child-content'), 'close'); 791 + assert.strictEqual(escPolicy(coordinator.getState(), 'content'), 'close'); 792 + assert.strictEqual(escPolicy(coordinator.getState(), 'workspace'), 'close'); 672 793 }); 673 794 }); 674 795 });
+6 -3
backend/electron/windows.ts
··· 103 103 * ESC policy: given session state and window role, determine what to do. 104 104 * Pure function — no side effects. 105 105 */ 106 - function escPolicy(sessionState: string, role: string): 'close' | 'close-and-restore' | 'nothing' { 106 + export function escPolicy(sessionState: string, role: string): 'close' | 'close-and-restore' | 'nothing' { 107 107 // These roles always close regardless of session state 108 108 if (['quick-view', 'palette', 'utility'].includes(role)) return 'close'; 109 109 if (role === 'overlay') return 'close-and-restore'; 110 - if (role === 'child-content') return 'close'; 111 - // workspace and content: only close in transient sessions 110 + // child-content, workspace, and content: only close in transient sessions. 111 + // In active sessions, ESC navigates internally (renderer handles it) but 112 + // never closes the window — even child-content windows are user-opened 113 + // content that should persist. Root-level content windows must not close 114 + // on ESC in an active session. 112 115 if (sessionState === 'transient') return 'close'; 113 116 return 'nothing'; 114 117 }
+2
docs/izui.md
··· 183 183 - Window exclusion in focus checks 184 184 - Overlay mode and window restoration 185 185 - Complex multi-window workflows 186 + - ESC policy for all role/session-state combinations 187 + - Regression: child-content windows not closing in active sessions 186 188 187 189 ## Files 188 190
+61
tests/desktop/smoke.spec.ts
··· 686 686 } 687 687 }); 688 688 689 + test('active mode: ESC on child-content window does NOT close it (regression)', async () => { 690 + const bgWindow = sharedBgWindow; 691 + 692 + // First open a workspace window (like groups) to establish an active session 693 + const workspaceResult = await bgWindow.evaluate(async () => { 694 + return await (window as any).app.window.open('peek://ext/groups/home.html', { 695 + role: 'workspace', 696 + width: 400, 697 + height: 300, 698 + }); 699 + }); 700 + expect(workspaceResult.success).toBe(true); 701 + const workspaceWindow = await sharedApp.getWindow('groups/home.html', 5000); 702 + expect(workspaceWindow).toBeTruthy(); 703 + await workspaceWindow.waitForLoadState('domcontentloaded'); 704 + 705 + // Verify session is active 706 + const izuiState = await bgWindow.evaluate(async () => { 707 + return await (window as any).app.izui.getState(); 708 + }); 709 + expect(izuiState).toBe('active'); 710 + 711 + // Now open a child-content window (simulates opening a web page from groups) 712 + // Using the workspace window as the opener gives it child-content role 713 + const contentResult = await workspaceWindow.evaluate(async () => { 714 + return await (window as any).app.window.open('peek://ext/search/home.html', { 715 + role: 'child-content', 716 + width: 400, 717 + height: 300, 718 + }); 719 + }); 720 + expect(contentResult.success).toBe(true); 721 + const contentWindow = await sharedApp.getWindow('search/home.html', 5000); 722 + expect(contentWindow).toBeTruthy(); 723 + await contentWindow.waitForLoadState('domcontentloaded'); 724 + 725 + // Press ESC on the child-content window 726 + await contentWindow.keyboard.press('Escape'); 727 + 728 + // Wait for async ESC handling 729 + await new Promise(resolve => setTimeout(resolve, 600)); 730 + 731 + // Verify child-content window is still alive — this is the regression test. 732 + // Before the fix, child-content always closed on ESC regardless of session state. 733 + const stillAlive = await contentWindow.evaluate(() => true).catch(() => false); 734 + expect(stillAlive).toBe(true); 735 + 736 + // Clean up 737 + for (const id of [contentResult.id, workspaceResult.id]) { 738 + if (id) { 739 + try { 740 + await bgWindow.evaluate(async (wid: number) => { 741 + return await (window as any).app.window.close(wid); 742 + }, id); 743 + } catch { 744 + // Window may already be closed 745 + } 746 + } 747 + } 748 + }); 749 + 689 750 test('navigate mode: timeout does not close window', async () => { 690 751 const bgWindow = sharedBgWindow; 691 752