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..846c03b 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,10 +12,20 @@ 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(); +// 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; @@ -68,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. @@ -88,10 +103,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 +139,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 +169,61 @@ 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. + * + * 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) { + 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); + } + + alwaysOnTopApplied = pinned; +} + +/** + * 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 +236,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 +268,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 +472,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 +521,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..c706e73 --- /dev/null +++ b/test/running-surface.test.mjs @@ -0,0 +1,156 @@ +/** + * 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); + } + + // 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); + + // 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); + // 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 never unpins the window', + !alwaysOnTop.some((call) => call.enabled === false) + ); + + // 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.