experiments in a post-browser web
10
fork

Configure Feed

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

feat(izui): implement IZUI state machine for transient vs active window behavior

Centralized state management for IZUI (Invocable Zoom User Interface):
- IzuiStateCoordinator manages session-based transient/active tracking
- Fixes cmd bar stuck on "group" mode when invoked transiently
- Fixes ESC from overlay showing groups instead of returning to previous app
- Transient state propagates to child windows (cmd → overlay)
- Uses isFocused() for accurate system focus detection
- KeepLive windows re-evaluate transient state on each show
- Added api.izui.* renderer API for querying state
- 54 unit tests with dependency injection for testability

Files:
- backend/electron/izui-state.ts: State coordinator singleton
- backend/electron/izui-state.test.ts: Comprehensive unit tests
- backend/electron/ipc.ts: IZUI IPC handlers, transient propagation
- backend/electron/main.ts: Window lifecycle hooks
- backend/electron/windows.ts: ESC handler integration
- preload.js: api.izui.* namespace
- extensions/cmd/panel.js: Mode display based on transient state
- docs/izui.md: Full documentation

+1494 -13
+10
CHANGELOG.md
··· 29 29 - Respects system theme (dark/light) 30 30 - Only applies when page doesn't set its own background 31 31 - [x] fix(build): exclude tmp/ agent workspaces from electron-builder 32 + - [x] feat(izui): implement IZUI state machine for transient vs active window behavior 33 + - Centralized `IzuiStateCoordinator` manages session-based state tracking 34 + - Fixes cmd bar stuck on "group" mode when invoked transiently 35 + - Fixes ESC from overlay showing groups instead of returning to previous app 36 + - Transient state propagates to child windows (cmd → overlay) 37 + - Uses `isFocused()` for accurate system focus detection 38 + - KeepLive windows re-evaluate transient state on each show 39 + - Added `api.izui.*` renderer API for querying state 40 + - 54 unit tests with dependency injection for testability 41 + - See `docs/izui.md` for full documentation 32 42 33 43 Testing 34 44 - [x] feat(tests): add Playwright browser extension e2e tests
+5 -1
DEVELOPMENT.md
··· 199 199 - Windows identified by keys for lifecycle management (e.g., `peek:${address}`) 200 200 - Modal windows use `type: 'panel'` to return focus to previous app on close 201 201 - Parameters: `modal`, `keepLive`, `persistState`, `transparent`, `height`, `width`, `key` 202 - - "Escape IZUI" design - ESC key always returns to previous context 202 + - **IZUI State Machine** - manages transient vs active window behavior. See `docs/izui.md` 203 + - **Transient**: Invoked from another app (global hotkey) - ESC closes immediately 204 + - **Active**: Invoked within Peek - ESC navigates internally, never closes 205 + - Transient state propagates to child windows (e.g., cmd → overlay) 206 + - Use `api.izui.isTransient()` to query current session state 203 207 204 208 ### Data Storage 205 209
+67 -5
backend/electron/ipc.ts
··· 171 171 getProfileSession, 172 172 } from './session-partition.js'; 173 173 174 + import { 175 + getIzuiCoordinator, 176 + } from './izui-state.js'; 177 + 174 178 // ============================================================================ 175 179 // Window Focus Tracking for Window-Targeted Commands 176 180 // ============================================================================ ··· 1549 1553 const { url, options } = msg; 1550 1554 1551 1555 // IMPORTANT: Determine transient state BEFORE creating the window 1552 - // A window is transient if opened when no Peek window was focused (e.g., via global hotkey) 1553 - // This must be checked before window creation because the new window will become focused 1554 - const focusedWindowBeforeCreate = BrowserWindow.getFocusedWindow(); 1555 - const isTransient = !focusedWindowBeforeCreate || focusedWindowBeforeCreate.isDestroyed(); 1556 - DEBUG && console.log('Transient detection:', { isTransient, focusedWindowId: focusedWindowBeforeCreate?.id }); 1556 + // A window is transient if: 1557 + // 1. The current IZUI session is transient (user invoked from background), OR 1558 + // 2. No Peek window was focused when evaluated 1559 + // Check session state FIRST before evaluateOnShow() might change it 1560 + const coordinator = getIzuiCoordinator(); 1561 + const sessionWasTransient = coordinator.isTransient(); 1562 + 1563 + // Now evaluate (this might update session state based on current focus) 1564 + const entryMode = coordinator.evaluateOnShow(); 1565 + 1566 + // Use session state if available, otherwise use evaluated state 1567 + const isTransient = sessionWasTransient || entryMode === 'transient'; 1568 + DEBUG && console.log('[izui] Transient detection:', { sessionWasTransient, entryMode, isTransient }); 1557 1569 1558 1570 // Check if window with this key already exists 1559 1571 if (options.key) { 1560 1572 const existingWindow = findWindowByKey(msg.source, options.key); 1561 1573 if (existingWindow) { 1562 1574 DEBUG && console.log('Reusing existing window with key:', options.key); 1575 + 1576 + // RE-EVALUATE transient state on show, not just creation 1577 + // This fixes keepLive windows (like cmd bar) being stuck with stale transient state 1578 + const coordinator = getIzuiCoordinator(); 1579 + const newTransientState = coordinator.evaluateOnShow(); 1580 + const existingData = existingWindow.data as { params: Record<string, unknown> }; 1581 + existingData.params.transient = (newTransientState === 'transient'); 1582 + DEBUG && console.log('Reused window transient re-evaluated:', newTransientState); 1583 + 1563 1584 if (!isHeadless()) { 1564 1585 existingWindow.window.show(); 1565 1586 } ··· 2389 2410 ipcMain.handle('get-window-id', (ev) => { 2390 2411 const win = BrowserWindow.fromWebContents(ev.sender); 2391 2412 return win ? win.id : null; 2413 + }); 2414 + 2415 + // Check if current window is transient (opened when app wasn't focused) 2416 + // Used for IZUI policy: transient windows have different escape/mode behavior 2417 + ipcMain.handle('window-is-transient', (ev) => { 2418 + const win = BrowserWindow.fromWebContents(ev.sender); 2419 + if (!win) return false; 2420 + const windowData = getWindowInfo(win.id); 2421 + return windowData?.params?.transient === true; 2422 + }); 2423 + 2424 + // ============================================================================ 2425 + // IZUI State Coordinator IPC Handlers 2426 + // ============================================================================ 2427 + // Centralized IZUI state management for consistent transient detection, 2428 + // mode display, and ESC behavior across all windows. 2429 + 2430 + // Check if IZUI session is in transient mode (app wasn't focused when invoked) 2431 + // Re-evaluates at query time, excluding the requesting window from focus check 2432 + // (since the cmd window itself has focus by the time it asks this question) 2433 + ipcMain.handle('izui-is-transient', (ev) => { 2434 + const coordinator = getIzuiCoordinator(); 2435 + const requestingWindow = BrowserWindow.fromWebContents(ev.sender); 2436 + // Re-evaluate excluding the requesting window 2437 + const entryMode = coordinator.evaluateOnShow(requestingWindow?.id); 2438 + const result = entryMode === 'transient'; 2439 + DEBUG && console.log('[izui] izui-is-transient:', result, 'windowId:', requestingWindow?.id); 2440 + return result; 2441 + }); 2442 + 2443 + // Get the effective mode for display 2444 + // Returns 'default' for transient sessions, 'active' otherwise (caller resolves actual mode) 2445 + ipcMain.handle('izui-get-effective-mode', () => { 2446 + const coordinator = getIzuiCoordinator(); 2447 + return coordinator.getEffectiveMode(); 2448 + }); 2449 + 2450 + // Get the current IZUI state (idle, transient, active, overlay) 2451 + ipcMain.handle('izui-get-state', () => { 2452 + const coordinator = getIzuiCoordinator(); 2453 + return coordinator.getState(); 2392 2454 }); 2393 2455 2394 2456 // Get window position
+705
backend/electron/izui-state.test.ts
··· 1 + /** 2 + * Unit tests for IZUI State Coordinator 3 + * Tests the state machine managing transient vs active state for windows 4 + * 5 + * Key behaviors tested: 6 + * - Transient detection based on window focus 7 + * - Session lifecycle management 8 + * - Window stack management 9 + * - Overlay mode 10 + * - Focus tracking 11 + */ 12 + 13 + import { describe, it, before, beforeEach } from 'node:test'; 14 + import * as assert from 'node:assert'; 15 + import type { WindowProvider } from './izui-state.js'; 16 + 17 + // Mock window interface 18 + interface MockWindow { 19 + id: number; 20 + _destroyed: boolean; 21 + _focused: boolean; 22 + _visible: boolean; 23 + isDestroyed: () => boolean; 24 + isFocused: () => boolean; 25 + show: () => void; 26 + } 27 + 28 + function createMockWindow(id: number, options: { focused?: boolean; destroyed?: boolean } = {}): MockWindow { 29 + const win: MockWindow = { 30 + id, 31 + _destroyed: options.destroyed ?? false, 32 + _focused: options.focused ?? false, 33 + _visible: true, 34 + isDestroyed: function() { return this._destroyed; }, 35 + isFocused: function() { return this._focused; }, 36 + show: function() { this._visible = true; }, 37 + }; 38 + return win; 39 + } 40 + 41 + // Create a mock window provider for testing 42 + function createMockWindowProvider(windows: MockWindow[]): WindowProvider { 43 + return { 44 + getAllWindows: () => windows, 45 + fromId: (id: number) => windows.find(w => w.id === id) || null, 46 + }; 47 + } 48 + 49 + // Import will be done after build 50 + let izuiState: typeof import('./izui-state.js'); 51 + 52 + describe('IZUI State Coordinator Tests', () => { 53 + let mockWindows: MockWindow[]; 54 + 55 + before(async () => { 56 + // Dynamic import of the compiled module 57 + izuiState = await import('./izui-state.js'); 58 + }); 59 + 60 + beforeEach(() => { 61 + // Clear mock windows before each test 62 + mockWindows = []; 63 + // End any existing session and reset window provider 64 + const coordinator = izuiState.getIzuiCoordinator(); 65 + coordinator.endSession(); 66 + coordinator._setWindowProvider(createMockWindowProvider(mockWindows)); 67 + }); 68 + 69 + describe('evaluateOnShow', () => { 70 + it('should return transient when no window has system focus', () => { 71 + // Add unfocused windows 72 + mockWindows.push(createMockWindow(1, { focused: false })); 73 + mockWindows.push(createMockWindow(2, { focused: false })); 74 + 75 + const coordinator = izuiState.getIzuiCoordinator(); 76 + const result = coordinator.evaluateOnShow(); 77 + 78 + assert.strictEqual(result, 'transient'); 79 + }); 80 + 81 + it('should return active when a window has system focus', () => { 82 + // Add one focused window 83 + mockWindows.push(createMockWindow(1, { focused: true })); 84 + mockWindows.push(createMockWindow(2, { focused: false })); 85 + 86 + const coordinator = izuiState.getIzuiCoordinator(); 87 + const result = coordinator.evaluateOnShow(); 88 + 89 + assert.strictEqual(result, 'active'); 90 + }); 91 + 92 + it('should correctly exclude the specified windowId from focus check', () => { 93 + // The querying window (cmd panel) is focused, but should be excluded 94 + const cmdPanelId = 100; 95 + mockWindows.push(createMockWindow(cmdPanelId, { focused: true })); 96 + mockWindows.push(createMockWindow(2, { focused: false })); 97 + 98 + const coordinator = izuiState.getIzuiCoordinator(); 99 + const result = coordinator.evaluateOnShow(cmdPanelId); 100 + 101 + // Should be transient because the only focused window is excluded 102 + assert.strictEqual(result, 'transient'); 103 + }); 104 + 105 + it('should return active when another window has focus besides excluded one', () => { 106 + const cmdPanelId = 100; 107 + mockWindows.push(createMockWindow(cmdPanelId, { focused: true })); 108 + mockWindows.push(createMockWindow(2, { focused: true })); // Another focused window 109 + 110 + const coordinator = izuiState.getIzuiCoordinator(); 111 + const result = coordinator.evaluateOnShow(cmdPanelId); 112 + 113 + // Should be active because window 2 has focus 114 + assert.strictEqual(result, 'active'); 115 + }); 116 + 117 + it('should ignore destroyed windows in focus check', () => { 118 + // Only window is focused but destroyed 119 + mockWindows.push(createMockWindow(1, { focused: true, destroyed: true })); 120 + mockWindows.push(createMockWindow(2, { focused: false })); 121 + 122 + const coordinator = izuiState.getIzuiCoordinator(); 123 + const result = coordinator.evaluateOnShow(); 124 + 125 + // Destroyed focused window should be ignored, result is transient 126 + assert.strictEqual(result, 'transient'); 127 + }); 128 + 129 + it('should update session.entryMode when called', () => { 130 + mockWindows.push(createMockWindow(1, { focused: false })); 131 + 132 + const coordinator = izuiState.getIzuiCoordinator(); 133 + coordinator.evaluateOnShow(); 134 + 135 + const session = coordinator.getSession(); 136 + assert.ok(session, 'Session should exist'); 137 + assert.strictEqual(session!.entryMode, 'transient'); 138 + }); 139 + 140 + it('should start a session if none exists', () => { 141 + mockWindows.push(createMockWindow(1, { focused: true })); 142 + 143 + const coordinator = izuiState.getIzuiCoordinator(); 144 + assert.strictEqual(coordinator.getSession(), null, 'No session should exist initially'); 145 + 146 + coordinator.evaluateOnShow(); 147 + 148 + assert.ok(coordinator.getSession(), 'Session should be created'); 149 + }); 150 + 151 + it('should update existing session entry mode without creating new session', () => { 152 + mockWindows.push(createMockWindow(1, { focused: true })); 153 + 154 + const coordinator = izuiState.getIzuiCoordinator(); 155 + coordinator.startSession('active'); 156 + const originalSessionId = coordinator.getSession()!.id; 157 + 158 + // Now simulate no focus 159 + mockWindows[0]._focused = false; 160 + coordinator.evaluateOnShow(); 161 + 162 + const session = coordinator.getSession(); 163 + assert.strictEqual(session!.id, originalSessionId, 'Should be same session'); 164 + assert.strictEqual(session!.entryMode, 'transient', 'Entry mode should be updated'); 165 + }); 166 + }); 167 + 168 + describe('isTransient', () => { 169 + it('should return false when no session exists', () => { 170 + const coordinator = izuiState.getIzuiCoordinator(); 171 + assert.strictEqual(coordinator.isTransient(), false); 172 + }); 173 + 174 + it('should return true when session.entryMode is transient', () => { 175 + const coordinator = izuiState.getIzuiCoordinator(); 176 + coordinator.startSession('transient'); 177 + assert.strictEqual(coordinator.isTransient(), true); 178 + }); 179 + 180 + it('should return false when session.entryMode is active', () => { 181 + const coordinator = izuiState.getIzuiCoordinator(); 182 + coordinator.startSession('active'); 183 + assert.strictEqual(coordinator.isTransient(), false); 184 + }); 185 + 186 + it('should persist transient state even after windows open', () => { 187 + mockWindows.push(createMockWindow(1, { focused: false })); 188 + 189 + const coordinator = izuiState.getIzuiCoordinator(); 190 + coordinator.evaluateOnShow(); // Starts as transient 191 + coordinator.pushWindow(1); 192 + 193 + // Add another window 194 + mockWindows.push(createMockWindow(2, { focused: false })); 195 + coordinator.pushWindow(2); 196 + 197 + // Session should still be transient 198 + assert.strictEqual(coordinator.isTransient(), true); 199 + }); 200 + }); 201 + 202 + describe('startSession and endSession', () => { 203 + it('should create a new session with startSession', () => { 204 + const coordinator = izuiState.getIzuiCoordinator(); 205 + coordinator.startSession('active'); 206 + 207 + const session = coordinator.getSession(); 208 + assert.ok(session, 'Session should exist'); 209 + assert.ok(session!.id.startsWith('izui-'), 'Session ID should have correct prefix'); 210 + assert.strictEqual(session!.entryMode, 'active'); 211 + assert.deepStrictEqual(session!.windowStack, []); 212 + assert.strictEqual(session!.overlayWindowId, null); 213 + assert.deepStrictEqual(session!.hiddenWindowIds, []); 214 + assert.strictEqual(session!.focusedWindowId, null); 215 + }); 216 + 217 + it('should not create a new session if one already exists', () => { 218 + const coordinator = izuiState.getIzuiCoordinator(); 219 + coordinator.startSession('active'); 220 + const firstSessionId = coordinator.getSession()!.id; 221 + 222 + coordinator.startSession('transient'); // Should be ignored 223 + 224 + assert.strictEqual(coordinator.getSession()!.id, firstSessionId); 225 + assert.strictEqual(coordinator.getSession()!.entryMode, 'active'); // Not changed to transient 226 + }); 227 + 228 + it('should clear session with endSession', () => { 229 + const coordinator = izuiState.getIzuiCoordinator(); 230 + coordinator.startSession('active'); 231 + assert.ok(coordinator.getSession()); 232 + 233 + coordinator.endSession(); 234 + 235 + assert.strictEqual(coordinator.getSession(), null); 236 + assert.strictEqual(coordinator.getState(), 'idle'); 237 + }); 238 + 239 + it('should handle endSession when no session exists', () => { 240 + const coordinator = izuiState.getIzuiCoordinator(); 241 + // Should not throw 242 + coordinator.endSession(); 243 + assert.strictEqual(coordinator.getSession(), null); 244 + }); 245 + 246 + it('should set state to entryMode when starting session', () => { 247 + const coordinator = izuiState.getIzuiCoordinator(); 248 + 249 + coordinator.startSession('transient'); 250 + assert.strictEqual(coordinator.getState(), 'transient'); 251 + 252 + coordinator.endSession(); 253 + 254 + coordinator.startSession('active'); 255 + assert.strictEqual(coordinator.getState(), 'active'); 256 + }); 257 + }); 258 + 259 + describe('pushWindow and popWindow', () => { 260 + it('should add window to stack with pushWindow', () => { 261 + const coordinator = izuiState.getIzuiCoordinator(); 262 + coordinator.startSession('active'); 263 + 264 + coordinator.pushWindow(1); 265 + coordinator.pushWindow(2); 266 + 267 + const session = coordinator.getSession()!; 268 + assert.deepStrictEqual(session.windowStack, [1, 2]); 269 + }); 270 + 271 + it('should not add duplicate windows to stack', () => { 272 + const coordinator = izuiState.getIzuiCoordinator(); 273 + coordinator.startSession('active'); 274 + 275 + coordinator.pushWindow(1); 276 + coordinator.pushWindow(1); // Duplicate 277 + 278 + const session = coordinator.getSession()!; 279 + assert.deepStrictEqual(session.windowStack, [1]); 280 + }); 281 + 282 + it('should not add window without active session', () => { 283 + const coordinator = izuiState.getIzuiCoordinator(); 284 + // No session started 285 + coordinator.pushWindow(1); 286 + 287 + // Should not throw, but also no session to verify 288 + assert.strictEqual(coordinator.getSession(), null); 289 + }); 290 + 291 + it('should remove window from stack with popWindow', () => { 292 + const coordinator = izuiState.getIzuiCoordinator(); 293 + coordinator.startSession('active'); 294 + coordinator.pushWindow(1); 295 + coordinator.pushWindow(2); 296 + coordinator.pushWindow(3); 297 + 298 + coordinator.popWindow(2); 299 + 300 + const session = coordinator.getSession()!; 301 + assert.deepStrictEqual(session.windowStack, [1, 3]); 302 + }); 303 + 304 + it('should auto-end session when stack becomes empty', () => { 305 + const coordinator = izuiState.getIzuiCoordinator(); 306 + coordinator.startSession('active'); 307 + coordinator.pushWindow(1); 308 + coordinator.pushWindow(2); 309 + 310 + coordinator.popWindow(1); 311 + assert.ok(coordinator.getSession(), 'Session should still exist'); 312 + 313 + coordinator.popWindow(2); 314 + assert.strictEqual(coordinator.getSession(), null, 'Session should end when stack is empty'); 315 + assert.strictEqual(coordinator.getState(), 'idle'); 316 + }); 317 + 318 + it('should handle popping non-existent window', () => { 319 + const coordinator = izuiState.getIzuiCoordinator(); 320 + coordinator.startSession('active'); 321 + coordinator.pushWindow(1); 322 + 323 + // Pop window that's not in stack - should not throw 324 + coordinator.popWindow(99); 325 + 326 + const session = coordinator.getSession()!; 327 + assert.deepStrictEqual(session.windowStack, [1]); 328 + }); 329 + 330 + it('should handle popWindow without session', () => { 331 + const coordinator = izuiState.getIzuiCoordinator(); 332 + // No session - should not throw 333 + coordinator.popWindow(1); 334 + assert.strictEqual(coordinator.getSession(), null); 335 + }); 336 + }); 337 + 338 + describe('enterOverlay and exitOverlay', () => { 339 + it('should enter overlay mode', () => { 340 + const coordinator = izuiState.getIzuiCoordinator(); 341 + coordinator.startSession('active'); 342 + 343 + coordinator.enterOverlay(100, [1, 2, 3]); 344 + 345 + assert.strictEqual(coordinator.getState(), 'overlay'); 346 + assert.strictEqual(coordinator.isOverlay(), true); 347 + 348 + const session = coordinator.getSession()!; 349 + assert.strictEqual(session.overlayWindowId, 100); 350 + assert.deepStrictEqual(session.hiddenWindowIds, [1, 2, 3]); 351 + }); 352 + 353 + it('should not enter overlay without active session', () => { 354 + const coordinator = izuiState.getIzuiCoordinator(); 355 + // No session 356 + 357 + coordinator.enterOverlay(100, [1, 2, 3]); 358 + 359 + assert.strictEqual(coordinator.isOverlay(), false); 360 + assert.strictEqual(coordinator.getState(), 'idle'); 361 + }); 362 + 363 + it('should exit overlay mode and restore hidden windows', () => { 364 + // Set up mock windows that will be "hidden" 365 + const hiddenWindow1 = createMockWindow(1); 366 + const hiddenWindow2 = createMockWindow(2); 367 + hiddenWindow1._visible = false; 368 + hiddenWindow2._visible = false; 369 + mockWindows.push(hiddenWindow1, hiddenWindow2); 370 + 371 + const coordinator = izuiState.getIzuiCoordinator(); 372 + coordinator.startSession('active'); 373 + coordinator.enterOverlay(100, [1, 2]); 374 + 375 + coordinator.exitOverlay(); 376 + 377 + assert.strictEqual(coordinator.isOverlay(), false); 378 + assert.strictEqual(coordinator.getState(), 'active'); 379 + assert.ok(hiddenWindow1._visible, 'Window 1 should be shown'); 380 + assert.ok(hiddenWindow2._visible, 'Window 2 should be shown'); 381 + 382 + const session = coordinator.getSession()!; 383 + assert.strictEqual(session.overlayWindowId, null); 384 + assert.deepStrictEqual(session.hiddenWindowIds, []); 385 + }); 386 + 387 + it('should return to transient state after overlay if session was transient', () => { 388 + mockWindows.push(createMockWindow(1)); 389 + 390 + const coordinator = izuiState.getIzuiCoordinator(); 391 + coordinator.startSession('transient'); 392 + coordinator.enterOverlay(100, [1]); 393 + 394 + assert.strictEqual(coordinator.getState(), 'overlay'); 395 + 396 + coordinator.exitOverlay(); 397 + 398 + assert.strictEqual(coordinator.getState(), 'transient'); 399 + }); 400 + 401 + it('should not exit overlay when not in overlay mode', () => { 402 + const coordinator = izuiState.getIzuiCoordinator(); 403 + coordinator.startSession('active'); 404 + // Not in overlay mode 405 + 406 + coordinator.exitOverlay(); // Should not throw 407 + 408 + assert.strictEqual(coordinator.getState(), 'active'); 409 + }); 410 + 411 + it('should handle exitOverlay with no session', () => { 412 + const coordinator = izuiState.getIzuiCoordinator(); 413 + // No session 414 + 415 + coordinator.exitOverlay(); // Should not throw 416 + 417 + assert.strictEqual(coordinator.getSession(), null); 418 + }); 419 + 420 + it('should not restore destroyed windows on exitOverlay', () => { 421 + const destroyedWindow = createMockWindow(1, { destroyed: true }); 422 + destroyedWindow._visible = false; 423 + mockWindows.push(destroyedWindow); 424 + 425 + const coordinator = izuiState.getIzuiCoordinator(); 426 + coordinator.startSession('active'); 427 + coordinator.enterOverlay(100, [1]); 428 + 429 + coordinator.exitOverlay(); 430 + 431 + // Destroyed window should not have show() called (and therefore still not visible) 432 + assert.strictEqual(destroyedWindow._visible, false); 433 + }); 434 + }); 435 + 436 + describe('isOverlay', () => { 437 + it('should return false when not in overlay mode', () => { 438 + const coordinator = izuiState.getIzuiCoordinator(); 439 + assert.strictEqual(coordinator.isOverlay(), false); 440 + 441 + coordinator.startSession('active'); 442 + assert.strictEqual(coordinator.isOverlay(), false); 443 + }); 444 + 445 + it('should return true when in overlay mode', () => { 446 + const coordinator = izuiState.getIzuiCoordinator(); 447 + coordinator.startSession('active'); 448 + coordinator.enterOverlay(100, []); 449 + 450 + assert.strictEqual(coordinator.isOverlay(), true); 451 + }); 452 + }); 453 + 454 + describe('setFocusedWindow and clearFocusedWindow', () => { 455 + it('should track focused window in session', () => { 456 + const coordinator = izuiState.getIzuiCoordinator(); 457 + coordinator.startSession('active'); 458 + 459 + coordinator.setFocusedWindow(42); 460 + 461 + const session = coordinator.getSession()!; 462 + assert.strictEqual(session.focusedWindowId, 42); 463 + }); 464 + 465 + it('should update focused window', () => { 466 + const coordinator = izuiState.getIzuiCoordinator(); 467 + coordinator.startSession('active'); 468 + 469 + coordinator.setFocusedWindow(1); 470 + coordinator.setFocusedWindow(2); 471 + 472 + const session = coordinator.getSession()!; 473 + assert.strictEqual(session.focusedWindowId, 2); 474 + }); 475 + 476 + it('should not crash when setting focused window without session', () => { 477 + const coordinator = izuiState.getIzuiCoordinator(); 478 + // No session 479 + coordinator.setFocusedWindow(1); // Should not throw 480 + }); 481 + 482 + it('should clear focused window if it matches', () => { 483 + const coordinator = izuiState.getIzuiCoordinator(); 484 + coordinator.startSession('active'); 485 + coordinator.setFocusedWindow(42); 486 + 487 + coordinator.clearFocusedWindow(42); 488 + 489 + const session = coordinator.getSession()!; 490 + assert.strictEqual(session.focusedWindowId, null); 491 + }); 492 + 493 + it('should not clear focused window if it does not match', () => { 494 + const coordinator = izuiState.getIzuiCoordinator(); 495 + coordinator.startSession('active'); 496 + coordinator.setFocusedWindow(42); 497 + 498 + coordinator.clearFocusedWindow(99); // Different ID 499 + 500 + const session = coordinator.getSession()!; 501 + assert.strictEqual(session.focusedWindowId, 42); // Unchanged 502 + }); 503 + 504 + it('should not crash when clearing focused window without session', () => { 505 + const coordinator = izuiState.getIzuiCoordinator(); 506 + // No session 507 + coordinator.clearFocusedWindow(1); // Should not throw 508 + }); 509 + 510 + it('should return focused window ID via getFocusedWindowId', () => { 511 + const coordinator = izuiState.getIzuiCoordinator(); 512 + coordinator.startSession('active'); 513 + coordinator.setFocusedWindow(123); 514 + 515 + assert.strictEqual(coordinator.getFocusedWindowId(), 123); 516 + }); 517 + 518 + it('should return null from getFocusedWindowId when no session', () => { 519 + const coordinator = izuiState.getIzuiCoordinator(); 520 + assert.strictEqual(coordinator.getFocusedWindowId(), null); 521 + }); 522 + }); 523 + 524 + describe('getState', () => { 525 + it('should return idle initially', () => { 526 + const coordinator = izuiState.getIzuiCoordinator(); 527 + coordinator.endSession(); // Ensure clean state 528 + assert.strictEqual(coordinator.getState(), 'idle'); 529 + }); 530 + 531 + it('should return active when session started as active', () => { 532 + const coordinator = izuiState.getIzuiCoordinator(); 533 + coordinator.startSession('active'); 534 + assert.strictEqual(coordinator.getState(), 'active'); 535 + }); 536 + 537 + it('should return transient when session started as transient', () => { 538 + const coordinator = izuiState.getIzuiCoordinator(); 539 + coordinator.startSession('transient'); 540 + assert.strictEqual(coordinator.getState(), 'transient'); 541 + }); 542 + 543 + it('should return overlay when in overlay mode', () => { 544 + const coordinator = izuiState.getIzuiCoordinator(); 545 + coordinator.startSession('active'); 546 + coordinator.enterOverlay(1, []); 547 + assert.strictEqual(coordinator.getState(), 'overlay'); 548 + }); 549 + 550 + it('should return idle after session ends', () => { 551 + const coordinator = izuiState.getIzuiCoordinator(); 552 + coordinator.startSession('active'); 553 + coordinator.endSession(); 554 + assert.strictEqual(coordinator.getState(), 'idle'); 555 + }); 556 + }); 557 + 558 + describe('getEffectiveMode', () => { 559 + it('should return default for transient sessions', () => { 560 + const coordinator = izuiState.getIzuiCoordinator(); 561 + coordinator.startSession('transient'); 562 + 563 + assert.strictEqual(coordinator.getEffectiveMode(), 'default'); 564 + }); 565 + 566 + it('should return active for active sessions', () => { 567 + const coordinator = izuiState.getIzuiCoordinator(); 568 + coordinator.startSession('active'); 569 + 570 + assert.strictEqual(coordinator.getEffectiveMode(), 'active'); 571 + }); 572 + 573 + it('should return active when no session (isTransient returns false)', () => { 574 + const coordinator = izuiState.getIzuiCoordinator(); 575 + // No session - isTransient() returns false, so this returns 'active' 576 + assert.strictEqual(coordinator.getEffectiveMode(), 'active'); 577 + }); 578 + }); 579 + 580 + describe('Session persistence across windows', () => { 581 + it('should maintain transient mode when opening windows from transient session', () => { 582 + // Simulate: user invokes cmd bar via global hotkey (no window focused) 583 + mockWindows.push(createMockWindow(1, { focused: false })); 584 + 585 + const coordinator = izuiState.getIzuiCoordinator(); 586 + 587 + // Cmd bar evaluates on show - detects transient 588 + const entryMode = coordinator.evaluateOnShow(); 589 + assert.strictEqual(entryMode, 'transient'); 590 + 591 + // User opens a window from cmd bar 592 + coordinator.pushWindow(100); 593 + 594 + // Session is still transient 595 + assert.strictEqual(coordinator.isTransient(), true); 596 + 597 + // User opens another window from that window 598 + coordinator.pushWindow(101); 599 + 600 + // Session is still transient 601 + assert.strictEqual(coordinator.isTransient(), true); 602 + 603 + // ESC should close everything in transient mode 604 + // (behavior tested in integration, but state persists) 605 + assert.strictEqual(coordinator.getSession()!.entryMode, 'transient'); 606 + }); 607 + 608 + it('should maintain active mode when user is working within app', () => { 609 + // Simulate: user is working in a Peek window, opens cmd bar 610 + mockWindows.push(createMockWindow(50, { focused: true })); // User's active window 611 + 612 + const coordinator = izuiState.getIzuiCoordinator(); 613 + 614 + // Cmd bar evaluates on show - detects active (window 50 is focused) 615 + const entryMode = coordinator.evaluateOnShow(100); // Exclude cmd bar itself 616 + assert.strictEqual(entryMode, 'active'); 617 + 618 + coordinator.pushWindow(100); // cmd bar 619 + 620 + // Session is active 621 + assert.strictEqual(coordinator.isTransient(), false); 622 + 623 + // User opens a window from cmd bar 624 + coordinator.pushWindow(200); 625 + 626 + // Session is still active 627 + assert.strictEqual(coordinator.isTransient(), false); 628 + }); 629 + }); 630 + 631 + describe('Complex workflow scenarios', () => { 632 + it('should handle full transient workflow: invoke, pick, close', () => { 633 + const coordinator = izuiState.getIzuiCoordinator(); 634 + 635 + // 1. No windows focused - user invokes via global hotkey 636 + mockWindows.push(createMockWindow(1, { focused: false })); 637 + 638 + // 2. Cmd bar opens and evaluates state 639 + const entryMode = coordinator.evaluateOnShow(); 640 + assert.strictEqual(entryMode, 'transient'); 641 + coordinator.pushWindow(10); // cmd bar window 642 + 643 + // 3. User triggers windows picker (overlay) 644 + const hiddenWindow = createMockWindow(10); 645 + hiddenWindow._visible = false; 646 + mockWindows.length = 0; 647 + mockWindows.push(hiddenWindow); 648 + 649 + coordinator.enterOverlay(20, [10]); 650 + assert.strictEqual(coordinator.isOverlay(), true); 651 + assert.strictEqual(coordinator.isTransient(), true); // Still transient at entry 652 + 653 + // 4. User presses ESC - exit overlay 654 + coordinator.exitOverlay(); 655 + assert.strictEqual(coordinator.isOverlay(), false); 656 + assert.ok(hiddenWindow._visible, 'Cmd bar should be shown again'); 657 + 658 + // 5. User presses ESC again - close everything (in transient mode) 659 + coordinator.popWindow(10); 660 + assert.strictEqual(coordinator.getSession(), null); 661 + assert.strictEqual(coordinator.getState(), 'idle'); 662 + }); 663 + 664 + it('should handle full active workflow: work, invoke, navigate back', () => { 665 + const coordinator = izuiState.getIzuiCoordinator(); 666 + 667 + // 1. User has an active Peek window 668 + mockWindows.push(createMockWindow(50, { focused: true })); 669 + coordinator.startSession('active'); 670 + coordinator.pushWindow(50); 671 + 672 + // 2. User invokes cmd bar (window 50 still focused initially) 673 + const entryMode = coordinator.evaluateOnShow(100); // Exclude cmd bar 674 + assert.strictEqual(entryMode, 'active'); 675 + coordinator.pushWindow(100); 676 + 677 + // 3. User types and selects something, opening new window 678 + coordinator.pushWindow(200); 679 + 680 + // 4. ESC in active mode just navigates, doesn't close 681 + // (state remains active) 682 + assert.strictEqual(coordinator.isTransient(), false); 683 + assert.strictEqual(coordinator.getState(), 'active'); 684 + 685 + // 5. User manually closes windows one by one 686 + coordinator.popWindow(200); 687 + assert.ok(coordinator.getSession()); 688 + 689 + coordinator.popWindow(100); 690 + assert.ok(coordinator.getSession()); 691 + 692 + coordinator.popWindow(50); 693 + // Session ends when last window closes 694 + assert.strictEqual(coordinator.getSession(), null); 695 + }); 696 + }); 697 + 698 + describe('getIzuiCoordinator singleton', () => { 699 + it('should return the same instance', () => { 700 + const coordinator1 = izuiState.getIzuiCoordinator(); 701 + const coordinator2 = izuiState.getIzuiCoordinator(); 702 + assert.strictEqual(coordinator1, coordinator2); 703 + }); 704 + }); 705 + });
+345
backend/electron/izui-state.ts
··· 1 + /** 2 + * IZUI State Coordinator 3 + * 4 + * Centralized state machine for managing IZUI (Invocable Zoom User Interface) states. 5 + * See docs/izui.md for comprehensive documentation. 6 + * 7 + * Problems solved: 8 + * - Transient state frozen at creation for keepLive windows 9 + * - No session concept for unified state 10 + * - Stale focus trackers never cleaned up 11 + * 12 + * States: 13 + * - IDLE: No visible windows, app in background 14 + * - TRANSIENT: Invoked via global hotkey while unfocused 15 + * - ACTIVE: User working within focused Peek windows 16 + * - OVERLAY: Full-screen overlay (windows picker) 17 + * 18 + * Key Rules: 19 + * | State | Mode Display | ESC Behavior | 20 + * |-----------|----------------------|--------------------------------| 21 + * | TRANSIENT | Always "default" | Close immediately | 22 + * | ACTIVE | Target window's mode | Internal nav only, never close | 23 + * | OVERLAY | Inherit from previous| Always close, restore hidden | 24 + */ 25 + 26 + import { DEBUG } from './config.js'; 27 + 28 + // Lazy-load Electron modules to allow testing without Electron 29 + let BrowserWindow: typeof import('electron').BrowserWindow | null = null; 30 + 31 + try { 32 + const electron = await import('electron'); 33 + BrowserWindow = electron.BrowserWindow; 34 + } catch { 35 + // Electron not available (e.g., in unit tests) 36 + DEBUG && console.log('[izui-state] Running without Electron (test mode)'); 37 + } 38 + 39 + export type IzuiState = 'idle' | 'transient' | 'active' | 'overlay'; 40 + 41 + /** 42 + * Window provider interface for dependency injection (enables testing) 43 + */ 44 + export interface WindowProvider { 45 + getAllWindows(): Array<{ id: number; isDestroyed(): boolean; isFocused(): boolean }>; 46 + fromId(id: number): { show(): void; isDestroyed(): boolean } | null; 47 + } 48 + 49 + /** 50 + * Default window provider using Electron's BrowserWindow (or stub for testing) 51 + */ 52 + const defaultWindowProvider: WindowProvider = { 53 + getAllWindows: () => BrowserWindow?.getAllWindows() ?? [], 54 + fromId: (id: number) => BrowserWindow?.fromId(id) ?? null, 55 + }; 56 + 57 + export interface IzuiSession { 58 + id: string; 59 + entryMode: 'active' | 'transient'; 60 + windowStack: number[]; 61 + overlayWindowId: number | null; 62 + hiddenWindowIds: number[]; 63 + focusedWindowId: number | null; 64 + } 65 + 66 + /** 67 + * Generate a unique session ID 68 + */ 69 + function generateSessionId(): string { 70 + return `izui-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; 71 + } 72 + 73 + /** 74 + * IZUI State Coordinator singleton 75 + */ 76 + class IzuiStateCoordinator { 77 + private state: IzuiState = 'idle'; 78 + private session: IzuiSession | null = null; 79 + private windowProvider: WindowProvider = defaultWindowProvider; 80 + 81 + /** 82 + * Set custom window provider (for testing) 83 + */ 84 + _setWindowProvider(provider: WindowProvider): void { 85 + this.windowProvider = provider; 86 + } 87 + 88 + /** 89 + * Reset to default window provider 90 + */ 91 + _resetWindowProvider(): void { 92 + this.windowProvider = defaultWindowProvider; 93 + } 94 + 95 + /** 96 + * Start a new IZUI session when the first window opens 97 + * @param entryMode How the session was started (active if app was focused, transient if not) 98 + */ 99 + startSession(entryMode: 'active' | 'transient'): void { 100 + if (this.session) { 101 + DEBUG && console.log('[izui] Session already active, not starting new one'); 102 + return; 103 + } 104 + 105 + this.session = { 106 + id: generateSessionId(), 107 + entryMode, 108 + windowStack: [], 109 + overlayWindowId: null, 110 + hiddenWindowIds: [], 111 + focusedWindowId: null, 112 + }; 113 + 114 + this.state = entryMode; 115 + DEBUG && console.log('[izui] Started session:', this.session.id, 'entryMode:', entryMode); 116 + } 117 + 118 + /** 119 + * End the current IZUI session when the last window closes 120 + */ 121 + endSession(): void { 122 + if (!this.session) { 123 + DEBUG && console.log('[izui] No session to end'); 124 + return; 125 + } 126 + 127 + DEBUG && console.log('[izui] Ending session:', this.session.id); 128 + this.session = null; 129 + this.state = 'idle'; 130 + } 131 + 132 + /** 133 + * Re-evaluate transient state when a window is shown (for keepLive windows) 134 + * This is the key fix for the cmd bar stuck on "group" mode issue. 135 + * When a keepLive window is reused, we need to re-check if the app 136 + * was focused when it was invoked. 137 + * 138 + * IMPORTANT: BrowserWindow.getFocusedWindow() returns Electron's internally 139 + * tracked "focused window" which persists even when the app loses system focus. 140 + * Instead, we check if ANY window actually has system focus via isFocused(). 141 + * 142 + * @param excludeWindowId Optional window ID to exclude from focus check 143 + * (used when the querying window is already visible/focused) 144 + * @returns 'active' if a Peek window has system focus, 'transient' if not 145 + */ 146 + evaluateOnShow(excludeWindowId?: number): 'active' | 'transient' { 147 + // Check if ANY Peek window currently has actual system focus 148 + // Don't use getFocusedWindow() - it returns internal tracking, not system focus 149 + // Exclude the specified window (e.g., the cmd window asking if it's transient) 150 + const allWindows = this.windowProvider.getAllWindows(); 151 + 152 + const focusedWindow = allWindows.find(win => 153 + !win.isDestroyed() && 154 + win.isFocused() && 155 + win.id !== excludeWindowId 156 + ); 157 + const hasActiveFocus = !!focusedWindow; 158 + 159 + const result = hasActiveFocus ? 'active' : 'transient'; 160 + DEBUG && console.log('[izui] evaluateOnShow:', result, 'excludeWindowId:', excludeWindowId, 'focusedWindowId:', focusedWindow?.id); 161 + 162 + // If we don't have a session, start one with the detected mode 163 + if (!this.session) { 164 + this.startSession(result); 165 + } else { 166 + // Update the session's entry mode for this invocation 167 + this.session.entryMode = result; 168 + this.state = result === 'transient' ? 'transient' : (this.state === 'overlay' ? 'overlay' : 'active'); 169 + } 170 + 171 + return result; 172 + } 173 + 174 + /** 175 + * Enter overlay mode (e.g., windows picker) 176 + * @param windowId The overlay window's ID 177 + * @param hiddenIds IDs of windows that were hidden for the overlay 178 + */ 179 + enterOverlay(windowId: number, hiddenIds: number[]): void { 180 + if (!this.session) { 181 + DEBUG && console.log('[izui] Cannot enter overlay without active session'); 182 + return; 183 + } 184 + 185 + this.state = 'overlay'; 186 + this.session.overlayWindowId = windowId; 187 + this.session.hiddenWindowIds = hiddenIds; 188 + DEBUG && console.log('[izui] Entered overlay mode, windowId:', windowId, 'hidden:', hiddenIds.length); 189 + } 190 + 191 + /** 192 + * Exit overlay mode and restore hidden windows 193 + */ 194 + exitOverlay(): void { 195 + if (!this.session || this.state !== 'overlay') { 196 + DEBUG && console.log('[izui] Not in overlay mode'); 197 + return; 198 + } 199 + 200 + const hiddenIds = this.session.hiddenWindowIds; 201 + DEBUG && console.log('[izui] Exiting overlay, restoring', hiddenIds.length, 'windows'); 202 + 203 + // Restore hidden windows 204 + for (const id of hiddenIds) { 205 + const win = this.windowProvider.fromId(id); 206 + if (win && !win.isDestroyed()) { 207 + win.show(); 208 + } 209 + } 210 + 211 + // Return to previous state based on entry mode 212 + this.state = this.session.entryMode; 213 + this.session.overlayWindowId = null; 214 + this.session.hiddenWindowIds = []; 215 + } 216 + 217 + /** 218 + * Track when a window gains focus 219 + * @param windowId The window that gained focus 220 + */ 221 + setFocusedWindow(windowId: number): void { 222 + if (this.session) { 223 + this.session.focusedWindowId = windowId; 224 + } 225 + DEBUG && console.log('[izui] Focused window set:', windowId); 226 + } 227 + 228 + /** 229 + * Clear focus tracking when a window closes or loses focus 230 + * This prevents stale window IDs from being tracked 231 + * @param windowId The window to clear 232 + */ 233 + clearFocusedWindow(windowId: number): void { 234 + if (this.session && this.session.focusedWindowId === windowId) { 235 + this.session.focusedWindowId = null; 236 + DEBUG && console.log('[izui] Cleared focused window:', windowId); 237 + } 238 + } 239 + 240 + /** 241 + * Add a window to the session's window stack 242 + * @param windowId The window to add 243 + */ 244 + pushWindow(windowId: number): void { 245 + if (!this.session) { 246 + DEBUG && console.log('[izui] Cannot push window without active session'); 247 + return; 248 + } 249 + 250 + if (!this.session.windowStack.includes(windowId)) { 251 + this.session.windowStack.push(windowId); 252 + DEBUG && console.log('[izui] Pushed window:', windowId, 'stack size:', this.session.windowStack.length); 253 + } 254 + } 255 + 256 + /** 257 + * Remove a window from the session's window stack 258 + * If this was the last window, end the session 259 + * @param windowId The window to remove 260 + */ 261 + popWindow(windowId: number): void { 262 + if (!this.session) { 263 + return; 264 + } 265 + 266 + const index = this.session.windowStack.indexOf(windowId); 267 + if (index !== -1) { 268 + this.session.windowStack.splice(index, 1); 269 + DEBUG && console.log('[izui] Popped window:', windowId, 'remaining:', this.session.windowStack.length); 270 + } 271 + 272 + // End session if no more windows 273 + if (this.session.windowStack.length === 0) { 274 + this.endSession(); 275 + } 276 + } 277 + 278 + /** 279 + * Check if currently in transient mode 280 + * This is the primary query for determining mode display and ESC behavior 281 + */ 282 + isTransient(): boolean { 283 + if (!this.session) { 284 + return false; 285 + } 286 + // Transient means opened without app focus 287 + return this.session.entryMode === 'transient'; 288 + } 289 + 290 + /** 291 + * Get the current IZUI state 292 + */ 293 + getState(): IzuiState { 294 + return this.state; 295 + } 296 + 297 + /** 298 + * Get the current session info (for debugging) 299 + */ 300 + getSession(): IzuiSession | null { 301 + return this.session; 302 + } 303 + 304 + /** 305 + * Get the effective mode for display 306 + * @param targetWindowId Optional window to get mode for 307 + * @returns 'default' for transient sessions, actual mode otherwise 308 + */ 309 + getEffectiveMode(targetWindowId?: number): string { 310 + // In transient mode, always show "default" 311 + if (this.isTransient()) { 312 + return 'default'; 313 + } 314 + 315 + // For active sessions, return the window's actual mode 316 + // This will be resolved by the caller using the context API 317 + return 'active'; 318 + } 319 + 320 + /** 321 + * Check if currently in overlay mode 322 + */ 323 + isOverlay(): boolean { 324 + return this.state === 'overlay'; 325 + } 326 + 327 + /** 328 + * Get the focused window ID from the session 329 + */ 330 + getFocusedWindowId(): number | null { 331 + return this.session?.focusedWindowId ?? null; 332 + } 333 + } 334 + 335 + // Singleton instance 336 + const coordinator = new IzuiStateCoordinator(); 337 + 338 + /** 339 + * Get the IZUI State Coordinator singleton 340 + */ 341 + export function getIzuiCoordinator(): IzuiStateCoordinator { 342 + return coordinator; 343 + } 344 + 345 + export { IzuiStateCoordinator };
+13 -2
backend/electron/main.ts
··· 18 18 import { APP_DEF_WIDTH, APP_DEF_HEIGHT, WEB_CORE_ADDRESS, getPreloadPath, isTestProfile, isDevProfile, isHeadless, getProfile, DEBUG } from './config.js'; 19 19 import { addEscHandler, winDevtoolsConfig, closeOrHideWindow, getSystemThemeBackgroundColor, getPrefs } from './windows.js'; 20 20 import { getProfileSession, getPartitionString, getCurrentProfileId } from './session-partition.js'; 21 + import { getIzuiCoordinator } from './izui-state.js'; 21 22 22 23 // Configuration 23 24 export interface AppConfig { ··· 136 137 window.on('closed', () => { 137 138 const windowData = windowRegistry.get(windowId); 138 139 140 + // Clean up IZUI coordinator state 141 + const coordinator = getIzuiCoordinator(); 142 + coordinator.clearFocusedWindow(windowId); 143 + coordinator.popWindow(windowId); 144 + 139 145 if (windowData) { 140 146 // If this was an overlay window, restore the windows that were hidden 147 + // BUT: In transient mode, don't restore - just close and return to previous app 148 + // Use the window's own transient param (not coordinator - session may be ended by popWindow) 141 149 const hiddenWindowIds = windowData.params.overlayHiddenWindows as number[] | undefined; 142 - if (hiddenWindowIds && hiddenWindowIds.length > 0) { 143 - DEBUG && console.log('Overlay closed, restoring', hiddenWindowIds.length, 'windows'); 150 + const isTransient = windowData.params.transient === true; 151 + if (hiddenWindowIds && hiddenWindowIds.length > 0 && !isTransient) { 152 + DEBUG && console.log('[izui] Overlay closed (active mode), restoring', hiddenWindowIds.length, 'windows'); 144 153 for (const hiddenId of hiddenWindowIds) { 145 154 const hiddenWin = BrowserWindow.fromId(hiddenId); 146 155 if (hiddenWin && !hiddenWin.isDestroyed()) { 147 156 hiddenWin.show(); 148 157 } 149 158 } 159 + } else if (isTransient && hiddenWindowIds && hiddenWindowIds.length > 0) { 160 + DEBUG && console.log('[izui] Overlay closed in transient mode - not restoring', hiddenWindowIds.length, 'windows'); 150 161 } 151 162 152 163 publish(windowData.source, scopes.GLOBAL, 'window:closed', {
+17 -3
backend/electron/windows.ts
··· 23 23 getChildWindows, 24 24 } from './main.js'; 25 25 26 + import { 27 + getIzuiCoordinator, 28 + } from './izui-state.js'; 29 + 26 30 /** 27 31 * Get the appropriate background color based on system theme 28 32 * This helps prevent the "white flash" when opening windows in dark mode ··· 137 141 138 142 // For 'auto' mode, check if transient (no focused window when opened) 139 143 if (escapeMode === 'auto') { 140 - if (params.transient) { 141 - // Transient mode - close immediately (user invoked via global hotkey) 142 - console.log('[esc] Auto mode (transient) - closing directly'); 144 + // Use IZUI coordinator for consistent state management 145 + const coordinator = getIzuiCoordinator(); 146 + const isCoordinatorOverlay = coordinator.isOverlay(); 147 + 148 + // Overlay windows always close on ESC (they're full-screen temporary views) 149 + const isOverlay = isCoordinatorOverlay || params.overlay === true || params.overlayHiddenWindows; 150 + 151 + // Check transient from coordinator (re-evaluated) or window params (fallback) 152 + const isTransient = coordinator.isTransient() || params.transient === true; 153 + 154 + if (isTransient || isOverlay) { 155 + // Transient mode or overlay - close immediately 156 + console.log('[esc] Auto mode (transient or overlay) - closing directly, coordinator.isTransient:', coordinator.isTransient()); 143 157 } else { 144 158 // Active mode (Peek was focused when window opened) 145 159 // IZUI Policy: ESC only navigates internal state, does NOT close
+39
docs/api.md
··· 14 14 - [Commands (Command Palette)](#commands-command-palette) 15 15 - [Theme](#theme) 16 16 - [Escape Handling](#escape-handling) 17 + - [IZUI State](#izui-state) 17 18 - [Logging](#logging) 18 19 - [Debug Mode](#debug-mode) 19 20 - [App Control](#app-control) ··· 452 453 } 453 454 return { handled: false }; // Allow close 454 455 }); 456 + ``` 457 + 458 + --- 459 + 460 + ## IZUI State 461 + 462 + Query the IZUI state machine to determine if the current session is transient (invoked from another app) or active (invoked within Peek). See `docs/izui.md` for full details. 463 + 464 + ### `window.app.izui.isTransient()` 465 + 466 + Check if the current session was invoked transiently (app wasn't focused). 467 + 468 + ```javascript 469 + const isTransient = await window.app.izui.isTransient(); 470 + if (isTransient) { 471 + // User invoked from another app - show simplified UI 472 + currentMode = 'default'; 473 + } else { 474 + // User is working within Peek - show contextual UI 475 + currentMode = targetWindow.mode; 476 + } 477 + ``` 478 + 479 + ### `window.app.izui.getEffectiveMode()` 480 + 481 + Get the effective mode for display. Returns `'default'` for transient sessions, `'active'` otherwise. 482 + 483 + ```javascript 484 + const mode = await window.app.izui.getEffectiveMode(); 485 + ``` 486 + 487 + ### `window.app.izui.getState()` 488 + 489 + Get the current IZUI state. 490 + 491 + ```javascript 492 + const state = await window.app.izui.getState(); 493 + // Returns: 'idle' | 'transient' | 'active' | 'overlay' 455 494 ``` 456 495 457 496 ---
+225
docs/izui.md
··· 1 + # IZUI (Invocable Zoom User Interface) 2 + 3 + IZUI is a state machine that manages how Peek windows behave based on how they were invoked. It ensures consistent UX whether the user invokes Peek from within the app or from another application. 4 + 5 + ## Core Concept 6 + 7 + The key question IZUI answers: **Was the app focused when the user invoked a command?** 8 + 9 + - **Active mode**: User was working in Peek when they opened a window 10 + - **Transient mode**: User invoked Peek from another app (e.g., global hotkey) 11 + 12 + This distinction affects: 13 + 1. What mode the cmd bar displays 14 + 2. How ESC key behaves 15 + 3. Whether hidden windows are restored when overlays close 16 + 17 + ## States 18 + 19 + | State | Description | Mode Display | ESC Behavior | 20 + |-------|-------------|--------------|--------------| 21 + | `idle` | No visible windows, app in background | N/A | N/A | 22 + | `transient` | Invoked via global hotkey while unfocused | Always "default" | Close immediately, return to previous app | 23 + | `active` | User working within focused Peek windows | Target window's mode | Internal navigation only, never closes | 24 + | `overlay` | Full-screen overlay (e.g., windows picker) | Inherit from previous | Always close, restore hidden windows (if active) | 25 + 26 + ## Session Tracking 27 + 28 + IZUI uses sessions to track state across multiple windows opened in a single interaction. 29 + 30 + ``` 31 + User in Safari → Global hotkey → Cmd opens (transient session starts) 32 + → User runs "windows" command 33 + → Windows overlay opens (inherits transient) 34 + → User hits ESC 35 + → Everything closes, back to Safari 36 + ``` 37 + 38 + Key behaviors: 39 + - **Session persists** until all windows in the interaction are closed 40 + - **Child windows inherit** transient state from their parent session 41 + - **KeepLive windows** re-evaluate transient state each time they're shown 42 + 43 + ## Implementation 44 + 45 + ### State Coordinator 46 + 47 + The `IzuiStateCoordinator` class (`backend/electron/izui-state.ts`) is the central manager: 48 + 49 + ```typescript 50 + class IzuiStateCoordinator { 51 + // Query current state 52 + isTransient(): boolean; 53 + getState(): IzuiState; 54 + isOverlay(): boolean; 55 + 56 + // Evaluate focus state (excludes requesting window) 57 + evaluateOnShow(excludeWindowId?: number): 'active' | 'transient'; 58 + 59 + // Session lifecycle 60 + startSession(entryMode: 'active' | 'transient'): void; 61 + endSession(): void; 62 + 63 + // Window stack (auto-ends session when empty) 64 + pushWindow(windowId: number): void; 65 + popWindow(windowId: number): void; 66 + 67 + // Overlay mode 68 + enterOverlay(windowId: number, hiddenIds: number[]): void; 69 + exitOverlay(): void; 70 + 71 + // Focus tracking cleanup 72 + setFocusedWindow(windowId: number): void; 73 + clearFocusedWindow(windowId: number): void; 74 + } 75 + ``` 76 + 77 + ### Focus Detection 78 + 79 + Transient detection uses `BrowserWindow.isFocused()` (not `getFocusedWindow()`) to check actual system focus: 80 + 81 + ```typescript 82 + evaluateOnShow(excludeWindowId?: number): 'active' | 'transient' { 83 + const allWindows = BrowserWindow.getAllWindows(); 84 + const focusedWindow = allWindows.find(win => 85 + !win.isDestroyed() && 86 + win.isFocused() && 87 + win.id !== excludeWindowId // Exclude the window asking the question 88 + ); 89 + return focusedWindow ? 'active' : 'transient'; 90 + } 91 + ``` 92 + 93 + **Why exclude the requesting window?** When the cmd panel asks "am I transient?", it already has focus. We need to check if any *other* window had focus before cmd opened. 94 + 95 + ### Transient Propagation 96 + 97 + When opening a new window, transient state propagates from the session: 98 + 99 + ```typescript 100 + // In window-open IPC handler 101 + const sessionWasTransient = coordinator.isTransient(); 102 + const entryMode = coordinator.evaluateOnShow(); 103 + const isTransient = sessionWasTransient || entryMode === 'transient'; 104 + ``` 105 + 106 + This ensures windows opened FROM a transient session are also transient. 107 + 108 + ### Overlay Close Behavior 109 + 110 + When an overlay closes, it only restores hidden windows if NOT in transient mode: 111 + 112 + ```typescript 113 + // In window 'closed' handler 114 + const isTransient = windowData.params.transient === true; 115 + if (hiddenWindowIds && hiddenWindowIds.length > 0 && !isTransient) { 116 + // Active mode: restore hidden windows 117 + for (const id of hiddenWindowIds) { 118 + BrowserWindow.fromId(id)?.show(); 119 + } 120 + } 121 + // Transient mode: don't restore, just close and return to previous app 122 + ``` 123 + 124 + ## Renderer API 125 + 126 + Extensions can query IZUI state via the preload API: 127 + 128 + ```javascript 129 + // Check if current session is transient 130 + const isTransient = await api.izui.isTransient(); 131 + 132 + // Get the effective mode ('default' for transient, 'active' otherwise) 133 + const effectiveMode = await api.izui.getEffectiveMode(); 134 + 135 + // Get current IZUI state ('idle', 'transient', 'active', 'overlay') 136 + const state = await api.izui.getState(); 137 + ``` 138 + 139 + ### Usage in Cmd Panel 140 + 141 + The cmd panel uses `api.izui.isTransient()` to determine what mode to display: 142 + 143 + ```javascript 144 + const loadCommandContext = async () => { 145 + const isTransient = await api.izui.isTransient(); 146 + if (isTransient) { 147 + // Don't show target window's mode, show "default" 148 + currentMode = 'default'; 149 + updateModeIndicator(); 150 + return; 151 + } 152 + // Active mode: show target window's actual mode 153 + // ... 154 + }; 155 + ``` 156 + 157 + ## ESC Key Handling 158 + 159 + ESC behavior is determined by `escapeMode` parameter and transient state: 160 + 161 + | escapeMode | Transient | Behavior | 162 + |------------|-----------|----------| 163 + | `auto` | Yes | Close immediately | 164 + | `auto` | No | Ask renderer to handle (internal nav), never close | 165 + | `close` | Any | Always close | 166 + | `navigate` | Any | Ask renderer first, close if not handled | 167 + | `ignore` | Any | Do nothing | 168 + 169 + See `backend/electron/windows.ts` `addEscHandler()` for implementation. 170 + 171 + ## Testing 172 + 173 + The IZUI state coordinator has comprehensive unit tests in `backend/electron/izui-state.test.ts`: 174 + 175 + ```bash 176 + # Run IZUI tests 177 + yarn build && node --test dist/backend/electron/izui-state.test.js 178 + ``` 179 + 180 + Tests cover: 181 + - Transient detection with various focus states 182 + - Session lifecycle and persistence 183 + - Window exclusion in focus checks 184 + - Overlay mode and window restoration 185 + - Complex multi-window workflows 186 + 187 + ## Files 188 + 189 + | File | Purpose | 190 + |------|---------| 191 + | `backend/electron/izui-state.ts` | State coordinator singleton | 192 + | `backend/electron/izui-state.test.ts` | Unit tests (54 test cases) | 193 + | `backend/electron/ipc.ts` | IZUI IPC handlers, transient propagation | 194 + | `backend/electron/main.ts` | Window lifecycle hooks, overlay close | 195 + | `backend/electron/windows.ts` | ESC handler with IZUI integration | 196 + | `preload.js` | `api.izui.*` namespace for renderers | 197 + | `extensions/cmd/panel.js` | Mode display based on transient state | 198 + 199 + ## Common Scenarios 200 + 201 + ### Scenario 1: Transient Cmd Invocation 202 + 1. User is in Safari 203 + 2. Presses global hotkey to open cmd 204 + 3. Cmd opens, session starts as `transient` 205 + 4. Cmd shows "default" mode (not target window's mode) 206 + 5. User types command and presses Enter 207 + 6. User presses ESC 208 + 7. Everything closes, focus returns to Safari 209 + 210 + ### Scenario 2: Active Cmd Invocation 211 + 1. User is working in Peek (Groups window focused) 212 + 2. Presses Cmd+K to open cmd 213 + 3. Cmd opens, session starts as `active` 214 + 4. Cmd shows "group" mode (target window's mode) 215 + 5. User runs command 216 + 6. User presses ESC 217 + 7. Cmd closes, Groups window remains focused 218 + 219 + ### Scenario 3: Transient with Overlay 220 + 1. User is in Safari 221 + 2. Presses global hotkey, runs "windows" command 222 + 3. Windows overlay opens, hides other Peek windows 223 + 4. User presses ESC 224 + 5. Overlay closes, hidden windows NOT restored (transient mode) 225 + 6. Focus returns to Safari
+26 -2
extensions/cmd/panel.js
··· 112 112 */ 113 113 const loadCommandContext = async () => { 114 114 try { 115 + // IZUI Policy: If invoked transiently (app wasn't focused when invoked), 116 + // use "default" mode since we're not targeting a specific window context. 117 + // Use api.izui.isTransient() which re-evaluates on each call (not frozen at creation) 118 + // This fixes keepLive windows being stuck with stale transient state. 119 + const isTransient = await api.izui.isTransient(); 120 + if (isTransient) { 121 + log('cmd:panel', 'Transient invocation - using default mode'); 122 + currentMode = 'default'; 123 + currentModeMetadata = {}; 124 + updateModeIndicator(); 125 + // Still load command context for backwards compat, but don't use its mode 126 + const result = await api.modes.getCommandContext(); 127 + if (result.success) { 128 + commandContext = result.data; 129 + } 130 + return; 131 + } 132 + 115 133 // Use context API to get mode for the target window 116 134 // First get the focused visible window ID 117 135 const targetWindowId = await api.window.getFocusedVisibleWindowId(); ··· 207 225 async function initModeIndicator() { 208 226 // Use commandContext which has the target window's mode (not the cmd panel's mode) 209 227 // commandContext is loaded by loadCommandContext() before this is called 210 - if (commandContext?.mode?.major) { 228 + // BUT: Don't override if loadCommandContext already set 'default' (transient mode) 229 + if (currentMode === 'default') { 230 + // Already set by transient detection - don't override 231 + updateModeIndicator(); 232 + } else if (commandContext?.mode?.major) { 211 233 currentMode = commandContext.mode.major; 234 + updateModeIndicator(); 235 + } else { 236 + updateModeIndicator(); 212 237 } 213 - updateModeIndicator(); 214 238 215 239 // Set up click handler for cycling 216 240 const indicator = document.getElementById('mode-indicator');
+42
preload.js
··· 356 356 */ 357 357 getWindowId: () => { 358 358 return ipcRenderer.invoke('get-window-id'); 359 + }, 360 + /** 361 + * Check if this window was opened transiently (app wasn't focused when opened) 362 + * Used for IZUI policy: transient windows use different escape/mode behavior 363 + * @returns {Promise<boolean>} True if window is transient 364 + */ 365 + isTransient: () => { 366 + return ipcRenderer.invoke('window-is-transient'); 367 + } 368 + }; 369 + 370 + // ============================================================================ 371 + // IZUI State Coordinator API 372 + // ============================================================================ 373 + // Centralized IZUI state management. Preferred over api.window.isTransient() 374 + // because it re-evaluates state on each invocation, fixing keepLive windows 375 + // that would otherwise be stuck with stale transient state. 376 + api.izui = { 377 + /** 378 + * Check if the current IZUI session is in transient mode 379 + * Re-evaluates on each call (unlike window.isTransient which is frozen at creation) 380 + * @returns {Promise<boolean>} True if session is transient (app wasn't focused when invoked) 381 + */ 382 + isTransient: () => { 383 + return ipcRenderer.invoke('izui-is-transient'); 384 + }, 385 + 386 + /** 387 + * Get the effective mode for display 388 + * Returns 'default' for transient sessions, 'active' for focused sessions 389 + * @returns {Promise<string>} 'default' or 'active' 390 + */ 391 + getEffectiveMode: () => { 392 + return ipcRenderer.invoke('izui-get-effective-mode'); 393 + }, 394 + 395 + /** 396 + * Get the current IZUI state 397 + * @returns {Promise<string>} 'idle' | 'transient' | 'active' | 'overlay' 398 + */ 399 + getState: () => { 400 + return ipcRenderer.invoke('izui-get-state'); 359 401 } 360 402 }; 361 403