experiments in a post-browser web
10
fork

Configure Feed

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

fix(cmd-panel): mode chip, blur-suppression, close-on-other-window-focus

Three cmd-panel regressions surfaced by manual testing on the
packaged build.

1. Mode chip rendered as `[object Object]`. `api.context.watchMode`'s
initial-value path in `tile-preload.cts` passed the full
ContextEntry as the `mode` arg instead of `entry.value` — matched
only the pubsub-driven path. Unwrap `.value` consistently so
consumers (cmd panel, page widget, HUD widget) get the mode string
in both branches. Also guard cmd panel's watchMode callback against
`null`/`undefined` so the initial fire against the panel's own
window (which has no mode entry) doesn't overwrite the correct
value loadCommandContext set via the focused visible window.

2. Cmd panel stayed visible when opened via global hotkey from outside
Peek, then focusing Peek. The `__lastShowTime` stamp + 200ms
blur-suppression + re-focus mechanism from commit 1b568d40 was lost
in the tiles v2 refactor, so the brief focus dance NSPanel +
`alwaysOnTop: 'floating'` emits during show() ran the blur handler
→ hide() → hit the NSPanel orderOut race and left the panel
rendered despite `isVisible()` reporting false. Restored: stamp in
the window-open reuse path and `window-show` IPC handler; skip
close-on-blur if we're within the 200ms settle window and refocus
instead.

3. Cmd panel stayed visible when user hit Cmd+` to cycle between Peek
windows. Panel-type + alwaysOnTop windows are skipped by macOS's
Cmd+` cycling, so `blur` never fires on the cmd panel and
`closeOrHideWindow` is never called. Not a regression — v1 had the
same hole. Cmd panel now subscribes to the existing `window:focused`
GLOBAL pubsub and calls `api.window.hide()` when a non-self,
non-utility window gains focus. Utility children (chain popup)
remain exempt so opening one doesn't dismiss the panel that spawned
it. Requires `window:focused` to include the focused window's role,
added to the publish in `ipc.ts`.

+86 -15
+35
app/cmd/panel.js
··· 127 127 } 128 128 }, api.scopes.GLOBAL); 129 129 130 + // Close the panel when the user focuses another Peek window. 131 + // 132 + // macOS Cmd+` cycles through "standard" windows only — panel-type + 133 + // alwaysOnTop windows (like this cmd panel) are skipped, so the panel 134 + // never fires `blur` in that flow and the modal blur handler can't 135 + // clean up. Subscribe to the global `window:focused` pubsub (published 136 + // by backend/electron/ipc.ts for every focus event) and hide the 137 + // panel when another non-utility window gains focus. Utility children 138 + // (chain popup, etc.) are excluded so opening one doesn't dismiss the 139 + // panel it spawned. 140 + let ownWindowId = null; 141 + api.window.getWindowId().then((result) => { 142 + ownWindowId = result?.id ?? result; 143 + }); 144 + api.subscribe('window:focused', async (msg) => { 145 + if (!msg || !msg.id) return; 146 + if (msg.id === ownWindowId) return; 147 + if (msg.role === 'utility') return; 148 + if (!ownWindowId) return; 149 + try { 150 + await api.window.hide(ownWindowId); 151 + } catch (err) { 152 + log.error('cmd:panel', 'Failed to hide panel on other-window focus:', err); 153 + } 154 + }, api.scopes.GLOBAL); 155 + 130 156 // Chain popup state (module-level for cleanup) 131 157 let chainPopupWindowId = null; 132 158 ··· 307 333 if (api.context) { 308 334 api.context.watchMode((mode, entry) => { 309 335 log('cmd:panel', 'Context mode changed:', mode, entry); 336 + // watchMode's initial fire reads the CURRENT window's mode (cmd 337 + // panel's own window), which has no context entry — `mode` comes 338 + // back null and would overwrite the correct 'page'/'group'/etc. 339 + // value that loadCommandContext already set via the focused 340 + // visible window. Ignore null/empty and let loadCommandContext 341 + // win on the cold read; real mode changes during panel lifetime 342 + // still flow through because they publish with their own non-null 343 + // value. 344 + if (mode === null || mode === undefined) return; 310 345 currentMode = mode || 'default'; 311 346 currentModeMetadata = entry?.metadata || {}; 312 347 updateModeIndicator();
+42 -12
backend/electron/ipc.ts
··· 963 963 DEBUG && console.log('Reused window transient from appFocused:', existingData.params.transient, 'appFocused:', coordinator.isAppFocused()); 964 964 965 965 if (!isHeadless()) { 966 + // Stamp show-time so the modal blur handler can suppress the 967 + // spurious blur that NSPanel + `alwaysOnTop:'floating'` emits 968 + // during the first ~200ms after show(). Without this, hitting 969 + // the global hotkey from another app produces: show → immediate 970 + // blur → hide(), and the NSPanel's floating-level order race 971 + // leaves the panel rendered even though Electron reports 972 + // isVisible=false. See commit 1b568d40 for original fix; this 973 + // was lost in the tiles v2 refactor. 974 + (existingWindow.window as { __lastShowTime?: number }).__lastShowTime = Date.now(); 966 975 existingWindow.window.show(); 967 976 existingWindow.window.focus(); 968 977 } ··· 2351 2360 const coordinator = getIzuiCoordinator(); 2352 2361 coordinator.setFocusedWindow(win.id); 2353 2362 } 2354 - // Notify HUD and other listeners that a window gained focus 2363 + // Notify HUD and other listeners that a window gained focus. 2364 + // Include `role` so subscribers (e.g. cmd panel's auto-hide-on- 2365 + // other-window-focus) can filter out utility children (chain 2366 + // popup, etc.) without another round-trip. 2355 2367 publish(getSystemAddress(), PubSubScopes.GLOBAL, 'window:focused', { 2356 - id: win.id 2368 + id: win.id, 2369 + role: winInfo?.params?.role as string | undefined, 2357 2370 }); 2358 2371 }); 2359 2372 // Also track immediately if this is a content/visible window (non-modal only) ··· 2375 2388 // Delay blur handler attachment to avoid race condition where focus events 2376 2389 // are still settling after window creation (can cause immediate close). 2377 2390 // 2378 - // In test profiles, skip the close-on-blur handler entirely. Under 2379 - // parallel Playwright workers (fullyParallel + workers > 1), focus 2380 - // events flicker between windows in ways they don't in real user 2381 - // sessions, causing modal cmd panels to close mid-test with 2382 - // "Target page, context or browser has been closed" errors on any 2383 - // subsequent .press() / .evaluate() call. OS-integration behavior 2384 - // for close-on-blur should be covered by a dedicated serial suite, 2385 - // not smoke. 2386 - if (options.modal === true && !isTestProfile()) { 2391 + // Under Playwright (E2E_TEST=true), skip the close-on-blur handler 2392 + // entirely. Parallel workers cause focus events to flicker between 2393 + // windows in ways they don't in real user sessions, closing modal 2394 + // cmd panels mid-test with "Target page, context or browser has 2395 + // been closed" errors on the next .press() / .evaluate(). Gating on 2396 + // `E2E_TEST` rather than `isTestProfile()` lets a developer run the 2397 + // packaged app with `PROFILE=test-sandbox` (required to bypass the 2398 + // single-instance lock for a manual smoke) and still see real 2399 + // close-on-blur behaviour. 2400 + if (options.modal === true && process.env.E2E_TEST !== 'true') { 2387 2401 setTimeout(() => { 2388 2402 if (!win.isDestroyed()) { 2389 2403 win.on('blur', () => { ··· 2396 2410 return; 2397 2411 } 2398 2412 } 2399 - // Skip blur events that fire shortly after the window was shown. 2413 + // Suppress spurious blurs that fire shortly after show. When a 2414 + // keepLive modal NSPanel is (re-)shown — especially triggered 2415 + // from another app via global hotkey — the macOS activation 2416 + // policy change plus the `alwaysOnTop: 'floating'` window level 2417 + // produces a brief focus dance that fires blur before focus 2418 + // actually settles. Hiding during this window leaves the 2419 + // NSPanel layer rendered even though `isVisible()` flips to 2420 + // false. Re-focus instead. Original fix: commit 1b568d40. 2421 + const showTime = (win as { __lastShowTime?: number }).__lastShowTime; 2422 + if (showTime && Date.now() - showTime < 200) { 2423 + DEBUG && console.log('window-open: blur for modal window suppressed — within show settle window', url); 2424 + if (!win.isDestroyed()) { 2425 + win.focus(); 2426 + } 2427 + return; 2428 + } 2400 2429 DEBUG && console.log('window-open: blur for modal window', url); 2401 2430 closeOrHideWindow(win.id); 2402 2431 }); ··· 2579 2608 return { success: false, error: 'Window not found' }; 2580 2609 } 2581 2610 2611 + (win as { __lastShowTime?: number }).__lastShowTime = Date.now(); 2582 2612 win.show(); 2583 2613 return { success: true }; 2584 2614 } catch (error) {
+9 -3
backend/electron/tile-preload.cts
··· 1555 1555 } 1556 1556 }); 1557 1557 // Fire initial value so caller doesn't have to do a separate getMode(). 1558 + // `tile:context:get` returns `{success, data: ContextEntry}` where the 1559 + // ContextEntry has `{value, metadata, ...}` — the pubsub-driven path 1560 + // below delivers `(value, entry)`, so match that shape here too 1561 + // (previously this passed the whole ContextEntry as `mode`, causing 1562 + // callers that did `textContent = mode` to render "[object Object]"). 1558 1563 const getPromise = contextStrict.get('mode', null); 1559 1564 (getPromise as Promise<unknown>).then((result: unknown) => { 1560 - const r = result as { success?: boolean; data?: unknown; value?: unknown } | null; 1561 - const value = r && typeof r === 'object' ? (('data' in r) ? (r as { data: unknown }).data : (r as { value: unknown }).value) : null; 1562 - callback(value ?? null, null); 1565 + const r = result as { success?: boolean; data?: { value?: unknown } | null } | null; 1566 + const entry = r && typeof r === 'object' && r.data && typeof r.data === 'object' ? r.data : null; 1567 + const value = entry ? (entry as { value?: unknown }).value ?? null : null; 1568 + callback(value, entry); 1563 1569 }).catch(() => { 1564 1570 callback(null, null); 1565 1571 });