Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 4 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 16 additions & 1 deletion src/main/services/app-state.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
}

Expand Down
146 changes: 114 additions & 32 deletions src/main/services/window-control.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion test/app-state.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) },
});

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]) {
Expand Down
Loading