From 38f528756971ded9790283024057998d9a6e762e Mon Sep 17 00:00:00 2001 From: alpha Date: Thu, 13 Aug 2026 15:41:20 -0400 Subject: [PATCH 1/2] feat(window): hide taskbar/Dock and pin on top while running The taskbar button, the Dock icon and always-on-top were tied to stealth mode alone. Both are wrong for a running session outside stealth: a running assistant is exactly when a screen share is most likely live, and the window falls behind the call the moment the call takes focus, which is when the suggestions most need to be readable. Getting either behaviour used to mean entering full stealth, which also makes the window click-through, non-focusable and semi-transparent. Drive both from shouldHideSurfaces() - `_stealth || isAssistantRunning()` - instead. The two inputs are independent, not nested: leaving stealth mid-session no longer hands the taskbar button back or drops the pin. That is why always-on-top moved out of enableStealth/disableStealth and into applySurfaceVisibility, which is the one place that reads both. Three details that are easy to get wrong and fail silently: - Level is 'screen-saver'. Levels from 'floating' to 'status' put the window *below* the Dock and taskbar; only 'pop-up-menu' and above are actually on top. - setVisibleOnAllWorkspaces goes with it. On macOS an always-on-top window still vanishes when the user switches to a fullscreen Space, which is how most people run a call - so without visibleOnFullScreen the pin does nothing in the case it exists for. Now released symmetrically; entering stealth used to set it and never unset it. - Within applySurfaceVisibility the z-order call runs before setSkipTaskbar, since changing it re-registers the window with the shell. running-surface.test.mjs pins the ordering and fails if the two are swapped. macOS traffic lights deliberately still follow stealth alone: a merely running window is focusable and interactive, so removing its close and minimise buttons would strand the user. Verified both new mechanisms are load-bearing by breaking each in turn - removing the appStateService hook fails 8 checks, swapping the call order fails the ordering check. Closes #98 Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 6 +- SPEC.md | 4 + src/main/services/app-state.service.ts | 17 ++- src/main/services/window-control.service.ts | 130 ++++++++++++----- test/app-state.test.mjs | 7 +- test/run.mjs | 3 + test/running-surface.test.mjs | 146 ++++++++++++++++++++ test/stealth-dock.test.mjs | 27 ++++ test/stealth-surface.test.mjs | 16 ++- 9 files changed, 319 insertions(+), 37 deletions(-) create mode 100644 test/running-surface.test.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 1bcf70e..7d4a201 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,11 +75,13 @@ Hash-based router (required for Electron `file://` protocol). Routes: `/` (index The main window reference is passed to `windowControlService` and `zoomService` after creation. Window bounds persist to Electron Store on `close` and are restored on next launch with minimum-size clamping (`MIN_WIDTH` / `MIN_HEIGHT` from [src/main/consts.ts](src/main/consts.ts)). -The app keeps itself off the surfaces a screen share exposes, but only where it has to. There is never a desktop shortcut (the NSIS installer creates none, and `build/installer.nsh` deletes one left by an older install). The taskbar button and the macOS Dock icon belong to stealth mode: `applySurfaceVisibility()` in [src/main/services/window-control.service.ts](src/main/services/window-control.service.ts) drives both from the current stealth flag - `setSkipTaskbar(_stealth)` plus, on macOS, `app.setActivationPolicy('accessory')` + `app.dock.hide()` going in and `'regular'` + `app.dock.show()` coming out. There is deliberately no `LSUIElement` in the packaged Info.plist: it would pin the app to accessory from launch and there would be no Dock icon to give back. +The app keeps itself off the surfaces a screen share exposes, but only where it has to. There is never a desktop shortcut (the NSIS installer creates none, and `build/installer.nsh` deletes one left by an older install). The taskbar button and the macOS Dock icon are driven by `applySurfaceVisibility()` in [src/main/services/window-control.service.ts](src/main/services/window-control.service.ts) - `setSkipTaskbar(hidden)` plus, on macOS, `app.setActivationPolicy('accessory')` + `app.dock.hide()` going in and `'regular'` + `app.dock.show()` coming out. There is deliberately no `LSUIElement` in the packaged Info.plist: it would pin the app to accessory from launch and there would be no Dock icon to give back. + +`hidden` comes from `shouldHideSurfaces()`, which is `_stealth || isAssistantRunning()`. **The two inputs are independent, not nested.** A running assistant is when a screen share is most likely live, so it hides the same surfaces stealth does; leaving stealth mid-session must therefore *not* hand the taskbar button back. The macOS traffic lights are the deliberate exception - they follow `_stealth` alone, because a merely running window is still focusable and interactive and needs its close and minimise buttons. `test/running-surface.test.mjs` pins all of it. Two consequences. A window minimized *in stealth mode* has no button to click, so it can only be brought back by relaunching the app - the single instance lock routes to `restoreWindow()`. And `window-all-closed` quits on every platform including macOS, because a windowless process in stealth mode would otherwise sit there holding the global hotkeys unreachable. `test/stealth-surface.test.mjs` pins all of it. -Always-on-top belongs to stealth mode only (`'screen-saver'` level), and is dropped again on the way out. +Always-on-top follows the same `shouldHideSurfaces()` predicate and is owned by `applySurfaceVisibility()`, not by the stealth toggles - that is what keeps the pin when stealth is switched off mid-session. The level is `'screen-saver'`: levels from `'floating'` to `'status'` put the window *below* the Dock and taskbar, so only `'pop-up-menu'` and above are actually on top. `setVisibleOnAllWorkspaces(pinned, { visibleOnFullScreen: pinned })` goes with it, because on macOS an always-on-top window still vanishes when the user switches to a fullscreen Space - which is how most people run a video call. Within `applySurfaceVisibility()` the z-order call must come **before** `setSkipTaskbar`, since changing it re-registers the window with the shell. Hiding the taskbar button is *registration* state (`ITaskbarList::DeleteTab` on Windows), not a window style, so it does not survive `setFocusable` or z-order changes - the button reappears after a stealth toggle. `applySurfaceVisibility()` re-asserts the right state, is wired to the window's `show`/`restore`/`maximize`/`unmaximize` events, and must be called after anything that reshapes or re-shows the window - and after `_stealth` is updated, since it reads it. `test/stealth-toggle.test.mjs` pins that. diff --git a/SPEC.md b/SPEC.md index ff51cd4..a819c7d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -55,6 +55,10 @@ Streaming AI responses generated from the user's CV and job description, trigger Screenshot-based problem solving. Accepts up to 4 images, sends them to the LLM backend, returns syntax-highlighted code output. Service: [src/main/services/suggestion-action.service.ts](src/main/services/suggestion-action.service.ts). +### Session Window Behaviour + +While the assistant is running - or while stealth mode is on - the window is pinned above other windows (`screen-saver` level, and visible over a fullscreen call on macOS) and drops its taskbar button and Dock icon. The two conditions are independent: switching stealth off mid-session leaves both in place until the session actually stops. macOS traffic lights stay visible outside stealth, since the window is still interactive. Service: [src/main/services/window-control.service.ts](src/main/services/window-control.service.ts). + ### Interview Config Sync Full name, profile/CV, and context are stored on the user's backend account and pulled on login or a remembered session, so the setup follows the user across devices. Service: [src/main/services/account.service.ts](src/main/services/account.service.ts). The full values are kept in the main process and fetched on demand over `account:get`; the app-state broadcast carries only a `{ fullName, hasProfileData }` summary, since the profile and context can each run to 128,000 characters. diff --git a/src/main/services/app-state.service.ts b/src/main/services/app-state.service.ts index ca1af6e..58efcb2 100644 --- a/src/main/services/app-state.service.ts +++ b/src/main/services/app-state.service.ts @@ -12,7 +12,7 @@ import { Speaker, SuggestionState, } from '../types/app-state.js'; -import { getWindowReference } from './window-control.service.js'; +import { getWindowReference, refreshWindowSurfaces } from './window-control.service.js'; const DEFAULT_STATE: AppState = { isStealth: false, @@ -115,10 +115,25 @@ export class AppStateService { (key) => !Object.is(this.state[key], updates[key]) ); + const runningChanged = + updates.runningState !== undefined && updates.runningState !== this.state.runningState; + this.state = { ...this.state, ...updates }; if (changed) { this.notifyRenderer(); } + + // The taskbar button, the Dock icon and always-on-top follow the running state as well as + // stealth, and this is the only place a run starts or ends. Done after the state is written, + // since window-control reads it back. Never allowed to fail the update itself. + if (runningChanged) { + try { + refreshWindowSurfaces(); + } catch (e) { + console.warn('Failed to refresh window surfaces:', e); + } + } + return this.getState(); } diff --git a/src/main/services/window-control.service.ts b/src/main/services/window-control.service.ts index 3079238..e82b194 100644 --- a/src/main/services/window-control.service.ts +++ b/src/main/services/window-control.service.ts @@ -2,6 +2,7 @@ import { app, BrowserWindow, screen } from 'electron'; import { MIN_HEIGHT, MIN_WIDTH, OPACITY_LEVELS } from '../consts.js'; import { configStore } from '../store/config.store.js'; +import { RunningState } from '../types/app-state.js'; import { appStateService } from './app-state.service.js'; import { pushNotificationService } from './push-notification.service.js'; @@ -11,6 +12,12 @@ const isMac = process.platform === 'darwin'; // toggle can silently leave the icon in the wrong state. 1100ms is the documented workaround. const DOCK_RATE_LIMIT_MS = 1100; +// Levels from 'floating' to 'status' put the window *below* the Dock on macOS and below the +// taskbar on Windows, which is not what "on top" means here - the window has to sit over a +// video call that may itself be fullscreen. 'screen-saver' is the first level above both, and +// Apple only discourages going higher than one above it. +const ALWAYS_ON_TOP_LEVEL = 'screen-saver' as const; + // Global reference to the main window let win: BrowserWindow | null = null; let _stealth = configStore.getStealth(); @@ -88,10 +95,34 @@ export function getWindowReference(): BrowserWindow | null { return win; } +/** Whether the assistant is mid-session. Defaults to false if state is somehow unreadable. */ +function isAssistantRunning(): boolean { + try { + return appStateService.getState().runningState === RunningState.Running; + } catch (e) { + console.warn('Failed to read running state:', e); + return false; + } +} + /** - * Put the taskbar button and the macOS Dock icon in step with stealth mode: present in normal - * mode, gone in stealth, where a labelled button or Dock icon is the first thing a shared screen - * gives the app away with. + * Whether the app should be keeping itself off the surfaces a screen share exposes, and pinned + * above the call. + * + * Two independent inputs, either of which is enough. Stealth is the explicit request for it. + * A running assistant is the implicit one: that is precisely when a screen share is likely to be + * live, and when the suggestions are useless if the call window covers them. The two are not + * nested - leaving stealth mid-session must not hand the taskbar button back. + */ +function shouldHideSurfaces(): boolean { + return _stealth || isAssistantRunning(); +} + +/** + * Put the taskbar button, the macOS Dock icon and the window's z-order in step with + * `shouldHideSurfaces()`: present and unpinned when idle out of stealth, gone and pinned on top + * otherwise, where a labelled button or Dock icon is the first thing a shared screen gives the + * app away with. * * This cannot be set once and left alone. Hiding the taskbar button is registration state * (`ITaskbarList::DeleteTab` on Windows), not a window style, and the shell re-adds the button @@ -100,15 +131,24 @@ export function getWindowReference(): BrowserWindow | null { * has to call this afterwards. */ function applySurfaceVisibility(): void { + const hidden = shouldHideSurfaces(); + if (win && !win.isDestroyed()) { + // Z-order first. Changing it re-registers the window with the shell and hands the taskbar + // button back, so setSkipTaskbar below has to be the one that runs last of the two. + applyAlwaysOnTop(hidden); + try { - win.setSkipTaskbar(_stealth); + win.setSkipTaskbar(hidden); } catch (e) { console.warn('setSkipTaskbar failed:', e); } // `titleBarStyle: 'hidden'` draws the traffic lights as native chrome, independent of // setSkipTaskbar/the Dock icon - they stay on screen in stealth mode unless hidden here too. + // + // Keyed to stealth alone, not `hidden`. A merely running window is still focusable and + // interactive, so taking its close and minimise buttons away would strand the user. if (isMac) { try { win.setWindowButtonVisibility(!_stealth); @@ -121,6 +161,53 @@ function applySurfaceVisibility(): void { if (isMac) applyDockVisibility(); } +/** + * Pin the window above other windows, or release it. + * + * `setVisibleOnAllWorkspaces` is the other half on macOS: a window that is merely always-on-top + * still disappears when the user switches to a fullscreen Space, which is how most people run a + * video call - so without `visibleOnFullScreen` the pin does nothing in the case it exists for. + * Both are released together; leaving the window on every Space after a session is over would + * follow the user around their desktop. + */ +function applyAlwaysOnTop(pinned: boolean): void { + if (!win || win.isDestroyed()) return; + + try { + if (pinned) { + win.setAlwaysOnTop(true, ALWAYS_ON_TOP_LEVEL); + } else { + win.setAlwaysOnTop(false); + } + } catch (e) { + console.warn('setAlwaysOnTop with level failed:', e); + // Fall back to plain always-on-top if the level is not supported on this platform. + try { + win.setAlwaysOnTop(pinned); + } catch (e) { + console.warn('setAlwaysOnTop failed:', e); + } + } + + try { + if (typeof win.setVisibleOnAllWorkspaces === 'function') { + win.setVisibleOnAllWorkspaces(pinned, { visibleOnFullScreen: pinned }); + } + } catch (e) { + console.warn('setVisibleOnAllWorkspaces failed:', e); + } +} + +/** + * Re-apply the window surfaces after something other than a stealth toggle changed the inputs. + * + * Exported for `appStateService`, which owns the running state: the assistant starting or + * stopping moves `shouldHideSurfaces()` without going through `enableStealth`/`disableStealth`. + */ +export function refreshWindowSurfaces(): void { + applySurfaceVisibility(); +} + /** * macOS counterpart of the taskbar button. An accessory app has no Dock icon and no Cmd+Tab * entry, a regular one has both. The activation policy is what actually moves the app between @@ -133,7 +220,7 @@ function applySurfaceVisibility(): void { * than deferred with the Dock call - the icon goes away immediately even in the swallowed case. */ function applyDockVisibility(): void { - const wantVisible = !_stealth; + const wantVisible = !shouldHideSurfaces(); // Window events (show, restore, maximize) land here too. Skipping the no-op keeps them from // spending the one-second budget that a real stealth toggle needs. @@ -165,7 +252,8 @@ function applyDockVisibility(): void { dockRecheckTimer = setTimeout(() => { dockRecheckTimer = null; // Forget what we asked for so the re-assert is not skipped as a no-op, then apply whatever - // stealth is by now - the user may have toggled again while this was pending. + // the inputs say by now - the user may have toggled stealth again, or the assistant may + // have started or stopped, while this was pending. dockVisible = null; applyDockVisibility(); }, DOCK_RATE_LIMIT_MS); @@ -368,28 +456,8 @@ export function enableStealth(): void { if (!win || win.isDestroyed()) return; try { - // Ensure window stays always on top in stealth mode (use highest level) - try { - // Use a high z-order level so the overlay remains above other windows - win.setAlwaysOnTop(true, 'screen-saver'); - } catch (e) { - console.warn('setAlwaysOnTop with level failed:', e); - // Fallback to basic always-on-top if level not supported - try { - win.setAlwaysOnTop(true); - } catch (e) { - console.warn('setAlwaysOnTop failed:', e); - } - } - - // Make the window visible on all workspaces and in fullscreen - try { - if (typeof win.setVisibleOnAllWorkspaces === 'function') { - win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); - } - } catch (e) { - console.warn('setVisibleOnAllWorkspaces failed:', e); - } + // Always-on-top and the workspace flags are not set here: applySurfaceVisibility() below + // owns them, so that a session still running when stealth is switched off keeps the pin. // Ignore mouse events so clicks pass through the window // forward: true ensures underlying windows still receive events @@ -437,16 +505,14 @@ export function disableStealth(): void { win.setIgnoreMouseEvents(false); win.setFocusable(true); - // Restore previous always-on-top state - win.setAlwaysOnTop(false); - _stealth = false; // Restore full opacity win.setOpacity(1.0); // Last, after every other window mutation: setFocusable, the z-order change and dropping - // the layered style all reshuffle the taskbar registration. + // the layered style all reshuffle the taskbar registration. This also drops always-on-top, + // but only when no session is running - stopping stealth mid-interview keeps the pin. applySurfaceVisibility(); try { diff --git a/test/app-state.test.mjs b/test/app-state.test.mjs index 5cc0650..52ccce9 100644 --- a/test/app-state.test.mjs +++ b/test/app-state.test.mjs @@ -25,6 +25,9 @@ export async function run() { windowControl.setWindowReference({ isDestroyed: () => false, setSkipTaskbar: () => {}, + // Registering a window applies the surface state, which pins or releases the z-order too. + setAlwaysOnTop: () => {}, + setVisibleOnAllWorkspaces: () => {}, webContents: { send: (channel, payload) => sent.push({ channel, payload }) }, }); @@ -79,7 +82,9 @@ export async function run() { check('a coalesced broadcast reaches the renderer', sent.length === beforeCoalesced + 1); check('the coalesced broadcast carries the change', sent.at(-1).payload.isBackendLive === true); - appStateService.updateState({ interviewConfig: { fullName: 'Jane', profileData: ' ', context: '' } }); + appStateService.updateState({ + interviewConfig: { fullName: 'Jane', profileData: ' ', context: '' }, + }); check( 'whitespace-only profile is not reported as set', appStateService.getRendererState().interviewConfig.hasProfileData === false diff --git a/test/run.mjs b/test/run.mjs index 319a83a..818c754 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -20,6 +20,9 @@ for (const module of [ './stealth-surface.test.mjs', './stealth-toggle.test.mjs', './stealth-dock.test.mjs', + // After the stealth tests: it drives the shared appStateService singleton, and the dock test + // reads the same running state through its own copy of window-control. + './running-surface.test.mjs', './tools-export.test.mjs', './mac-update-util.test.mjs', ]) { diff --git a/test/running-surface.test.mjs b/test/running-surface.test.mjs new file mode 100644 index 0000000..bbb9c27 --- /dev/null +++ b/test/running-surface.test.mjs @@ -0,0 +1,146 @@ +/** + * The taskbar button, the Dock icon and always-on-top follow `stealth OR running`, not stealth + * alone. A running assistant is exactly when a screen share is most likely to be live, and when + * suggestions are useless if the call window covers them. + * + * The trap this pins is that the two inputs are independent, not nested. Leaving stealth while a + * session is still running used to be the only path through `disableStealth`, which dropped + * always-on-top and handed the taskbar button back unconditionally - so the window would surface + * itself mid-interview. Nothing about that fails loudly. + */ +import { createChecker, loadMain } from './helpers.mjs'; + +export async function run() { + const { check, failures } = createChecker('running-surface'); + + const windowControl = await loadMain('services/window-control.service.js'); + const { appStateService } = await loadMain('services/app-state.service.js'); + const { RunningState } = await loadMain('types/app-state.js'); + + const alwaysOnTop = []; + const skipTaskbar = []; + const workspaces = []; + // One ordered log across both calls, so the sequence between them can be asserted. + const order = []; + const handlers = {}; + windowControl.setWindowReference({ + isDestroyed: () => false, + on: (event, handler) => { + handlers[event] = handler; + }, + setAlwaysOnTop: (enabled, level) => { + alwaysOnTop.push({ enabled, level }); + order.push('alwaysOnTop'); + }, + setSkipTaskbar: (skip) => { + skipTaskbar.push(skip); + order.push('skipTaskbar'); + }, + setWindowButtonVisibility: () => {}, + setVisibleOnAllWorkspaces: (on, opts) => workspaces.push({ on, opts }), + setIgnoreMouseEvents: () => {}, + setFocusable: () => {}, + setOpacity: () => {}, + isMinimized: () => false, + isVisible: () => true, + show: () => {}, + showInactive: () => {}, + focus: () => {}, + restore: () => {}, + webContents: { send: () => {} }, + }); + + const start = () => appStateService.updateState({ runningState: RunningState.Running }); + const stop = () => appStateService.updateState({ runningState: RunningState.Idle }); + + check('idle out of stealth keeps the taskbar button', skipTaskbar.at(-1) === false); + check('idle out of stealth is not pinned on top', alwaysOnTop.at(-1)?.enabled === false); + + // 1. Running alone, no stealth involved. + skipTaskbar.length = 0; + alwaysOnTop.length = 0; + workspaces.length = 0; + start(); + check('starting the assistant takes the taskbar button away', skipTaskbar.at(-1) === true); + check('starting the assistant pins the window on top', alwaysOnTop.at(-1)?.enabled === true); + check( + 'and pins it above the Dock and taskbar, not below', + alwaysOnTop.at(-1)?.level === 'screen-saver' + ); + // Always-on-top alone does not show over a fullscreen Space, which is how a call usually runs. + check('and makes it visible over a fullscreen call', workspaces.at(-1)?.on === true); + check('with visibleOnFullScreen set', workspaces.at(-1)?.opts?.visibleOnFullScreen === true); + + // Z-order changes re-register the window with the shell and hand the taskbar button back, so + // setSkipTaskbar has to be the later of the two. Reversing them looks harmless and silently + // leaves the button on screen. + check( + 'the taskbar button is asserted after the z-order change', + order.lastIndexOf('skipTaskbar') > order.lastIndexOf('alwaysOnTop') + ); + + // 2. The window events that re-register the button must respect the running input too. + for (const event of ['show', 'restore', 'maximize', 'unmaximize']) { + skipTaskbar.length = 0; + handlers[event]?.(); + check(`the ${event} event keeps the button hidden while running`, skipTaskbar.at(-1) === true); + } + + skipTaskbar.length = 0; + windowControl.restoreWindow(); + check('restoring while running keeps the button hidden', skipTaskbar.at(-1) === true); + + // 3. Stealth on top of running, then off again while still running. This is the regression: + // disableStealth must not undo what the running session is asking for. + // (The macOS traffic lights are covered in stealth-dock.test.mjs, which runs as darwin - + // setWindowButtonVisibility is never reached on this platform.) + windowControl.enableStealth(); + + skipTaskbar.length = 0; + alwaysOnTop.length = 0; + windowControl.disableStealth(); + check('leaving stealth mid-session keeps the button hidden', skipTaskbar.at(-1) === true); + check( + 'leaving stealth mid-session keeps the window pinned', + alwaysOnTop.at(-1)?.enabled === true + ); + + // 4. Stopping is what releases both. + skipTaskbar.length = 0; + alwaysOnTop.length = 0; + workspaces.length = 0; + stop(); + check('stopping the assistant gives the taskbar button back', skipTaskbar.at(-1) === false); + check('stopping the assistant unpins the window', alwaysOnTop.at(-1)?.enabled === false); + check('and stops following the user across Spaces', workspaces.at(-1)?.on === false); + + // 5. Stealth still works on its own with no session running. + skipTaskbar.length = 0; + alwaysOnTop.length = 0; + windowControl.enableStealth(); + check('stealth alone still hides the button', skipTaskbar.at(-1) === true); + check('stealth alone still pins the window', alwaysOnTop.at(-1)?.level === 'screen-saver'); + windowControl.disableStealth(); + check('leaving stealth with no session gives the button back', skipTaskbar.at(-1) === false); + check('leaving stealth with no session unpins', alwaysOnTop.at(-1)?.enabled === false); + + // 6. Repeated identical updates must not thrash the window. Only transitions do work. + start(); + const settled = skipTaskbar.length; + appStateService.updateState({ runningState: RunningState.Running }); + appStateService.updateState({ runningState: RunningState.Running }); + check('re-reporting the same running state changes nothing', skipTaskbar.length === settled); + stop(); + + // 7. Intermediate states are not "running" - Starting and Stopping must not pin the window. + skipTaskbar.length = 0; + appStateService.updateState({ runningState: RunningState.Starting }); + check('Starting does not hide the button yet', skipTaskbar.at(-1) !== true); + appStateService.updateState({ runningState: RunningState.Running }); + skipTaskbar.length = 0; + appStateService.updateState({ runningState: RunningState.Stopping }); + check('Stopping releases the button', skipTaskbar.at(-1) === false); + appStateService.updateState({ runningState: RunningState.Idle }); + + return failures; +} diff --git a/test/stealth-dock.test.mjs b/test/stealth-dock.test.mjs index 4e672f0..40228c2 100644 --- a/test/stealth-dock.test.mjs +++ b/test/stealth-dock.test.mjs @@ -124,5 +124,32 @@ export async function run() { check('the re-assert stops once the state is stable', calls.length === afterSettle); check('and the Dock icon is still there', surfaceIs(calls, 'visible')); + // The Dock icon follows a running assistant as well as stealth. The traffic lights deliberately + // do not: a running window outside stealth is still focusable and interactive, so taking its + // close and minimise buttons away would strand the user. This is the only test that runs as + // darwin, so it is the only place either is reachable. + // + // The refresh is called directly rather than left to appStateService. That singleton is shared + // with this copy of the module, but its own import of window-control carries no query string, + // so its refresh drives the *default* instance - not this darwin one. That the state change + // triggers a refresh at all is covered in running-surface.test.mjs. + const { appStateService } = await import('../electron-dist/services/app-state.service.js'); + const { RunningState } = await import('../electron-dist/types/app-state.js'); + + await wait(1300); + windowButtonVisible.length = 0; + appStateService.updateState({ runningState: RunningState.Running }); + windowControl.refreshWindowSurfaces(); + check('starting the assistant drops the Dock icon', surfaceIs(calls, 'hidden')); + check( + 'starting the assistant leaves the traffic lights alone', + windowButtonVisible.at(-1) === true + ); + + await wait(1300); + appStateService.updateState({ runningState: RunningState.Idle }); + windowControl.refreshWindowSurfaces(); + check('stopping the assistant brings the Dock icon back', surfaceIs(calls, 'visible')); + return failures; } diff --git a/test/stealth-surface.test.mjs b/test/stealth-surface.test.mjs index af9455b..e8ada87 100644 --- a/test/stealth-surface.test.mjs +++ b/test/stealth-surface.test.mjs @@ -48,7 +48,21 @@ export async function run() { check('stealth makes the app an accessory app', /'accessory'/.test(windowControl)); check('normal mode brings the Dock icon back', /dock\?\.show\(\)/.test(windowControl)); check('normal mode makes the app a regular app', /'regular'/.test(windowControl)); - check('the taskbar button follows stealth', /setSkipTaskbar\(_?stealth\)/.test(windowControl)); + + // These surfaces follow stealth OR a running assistant, not stealth alone. Both inputs have to + // reach the same predicate, or one of them silently stops hiding anything. + check( + 'the taskbar button follows the surface predicate', + /setSkipTaskbar\(hidden\)/.test(windowControl) + ); + check( + 'the surface predicate covers stealth and running', + /_stealth \|\| isAssistantRunning\(\)/.test(windowControl) + ); + check( + 'the Dock icon follows the same predicate', + /wantVisible = !shouldHideSurfaces\(\)/.test(windowControl) + ); // In stealth mode there is no taskbar button and no Dock icon: without this, closing or // minimizing the window leaves a running process with no way back to it. From 86df446ba2f68abc951bf3d7c9d7b627b4f599d6 Mon Sep 17 00:00:00 2001 From: alpha Date: Thu, 13 Aug 2026 16:04:20 -0400 Subject: [PATCH 2/2] fix(window): skip redundant z-order calls on window events Self-review catch. applyAlwaysOnTop ran on every show/restore/maximize/ unmaximize with no no-op guard, so while a session was running each of those re-issued the pin. That is not free and not invisible: on Windows re-issuing HWND_TOPMOST re-raises the window to the front of the topmost band, shuffling it over whatever the user just brought forward, and on macOS Electron re-runs the whole level lookup and Cocoa call with no early return of its own. Guard it the way applyDockVisibility already guards the Dock, and reset the tracked value in setWindowReference - carrying it across windows would read the first real call on a new window as a no-op and leave it unpinned. The "leaving stealth mid-session" assertion moved from "last call was a pin" to "never issued a release". The old form was written against the unguarded code and would now demand exactly the redundant re-issue this removes; the window being pinned throughout is the thing that matters. Both halves verified load-bearing: dropping the guard fails the new window-event check, dropping the reset fails the startup check. Co-Authored-By: Claude Opus 5 --- src/main/services/window-control.service.ts | 16 ++++++++++++++++ test/running-surface.test.mjs | 14 ++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/main/services/window-control.service.ts b/src/main/services/window-control.service.ts index e82b194..846c03b 100644 --- a/src/main/services/window-control.service.ts +++ b/src/main/services/window-control.service.ts @@ -22,6 +22,10 @@ const ALWAYS_ON_TOP_LEVEL = 'screen-saver' as const; let win: BrowserWindow | null = null; let _stealth = configStore.getStealth(); +// Last z-order state we asked for. `null` means nothing has been applied to this window yet, +// which is what makes the first call after a window is registered always go through. +let alwaysOnTopApplied: boolean | null = null; + // Last Dock state we asked macOS for, and when. `null` means nothing has been applied yet. let dockVisible: boolean | null = null; let lastDockCallAt = 0; @@ -75,6 +79,10 @@ interface WindowBounds { export function setWindowReference(window: BrowserWindow): void { win = window; + // The tracked z-order belongs to the previous window, not this one. Carrying it over would + // read the first real call as a no-op and leave the new window unpinned. + alwaysOnTopApplied = null; + // The shell re-registers the taskbar button whenever the window is re-shown or re-shaped, and // most of those paths are not ours to intercept - Alt+Tab restoring a minimized window, for one. // Re-assert on the events instead of at every call site. @@ -169,9 +177,15 @@ function applySurfaceVisibility(): void { * video call - so without `visibleOnFullScreen` the pin does nothing in the case it exists for. * Both are released together; leaving the window on every Space after a session is over would * follow the user around their desktop. + * + * No-op calls are skipped, for the same reason `applyDockVisibility` skips them. Window events + * (show, restore, maximize) run through here too, and re-issuing the pin is not free or even + * invisible: on Windows it re-raises the window to the front of the topmost band, and on macOS + * Electron re-runs the whole level lookup and the Cocoa call with no early return of its own. */ function applyAlwaysOnTop(pinned: boolean): void { if (!win || win.isDestroyed()) return; + if (alwaysOnTopApplied === pinned) return; try { if (pinned) { @@ -196,6 +210,8 @@ function applyAlwaysOnTop(pinned: boolean): void { } catch (e) { console.warn('setVisibleOnAllWorkspaces failed:', e); } + + alwaysOnTopApplied = pinned; } /** diff --git a/test/running-surface.test.mjs b/test/running-surface.test.mjs index bbb9c27..c706e73 100644 --- a/test/running-surface.test.mjs +++ b/test/running-surface.test.mjs @@ -86,6 +86,13 @@ export async function run() { check(`the ${event} event keeps the button hidden while running`, skipTaskbar.at(-1) === true); } + // The taskbar button has to be re-asserted every time; the z-order does not, and re-issuing it + // is not invisible. On Windows it re-raises the window to the front of the topmost band, so a + // window event would shuffle the window over whatever the user just brought forward. + alwaysOnTop.length = 0; + for (const event of ['show', 'restore', 'maximize', 'unmaximize']) handlers[event]?.(); + check('window events do not re-issue an unchanged z-order', alwaysOnTop.length === 0); + skipTaskbar.length = 0; windowControl.restoreWindow(); check('restoring while running keeps the button hidden', skipTaskbar.at(-1) === true); @@ -100,9 +107,12 @@ export async function run() { alwaysOnTop.length = 0; windowControl.disableStealth(); check('leaving stealth mid-session keeps the button hidden', skipTaskbar.at(-1) === true); + // Asserted as "never released" rather than "last call was pin": the window is already pinned + // for the running session, so the correct behaviour is to issue no z-order call at all. A + // check on the last call would instead demand the redundant re-issue the guard exists to avoid. check( - 'leaving stealth mid-session keeps the window pinned', - alwaysOnTop.at(-1)?.enabled === true + 'leaving stealth mid-session never unpins the window', + !alwaysOnTop.some((call) => call.enabled === false) ); // 4. Stopping is what releases both.