Skip to content

Commit 0da197c

Browse files
alpha5611331claude
andauthored
feat: make taskbar/Dock icon hiding during live interviews optional (#142)
* Add hideTaskbarWhileRunning setting and UI Refs #141 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Gate taskbar hiding, pin and minimize on the setting Re-raise the window on an interval while hidden. Refs #141 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Update tests and docs for the taskbar setting Refs #141 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Apply minimizable before skipTaskbar and only on change Also remove a stray comment and fix comment placement. Refs #141 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Remove dead Dock re-assert Refs #141 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Test Dock stays visible on macOS with the setting off Refs #141 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Parent save dialogs to the main window An unowned dialog sits under the window while it is pinned on top. Refs #141 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pin save dialogs to the main window in tests Refs #141 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Restore a minimized window before the save prompt A close from the Dock or taskbar while minimized showed the prompt inside a window nobody could see. Refs #141 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Restore the window on a repeat close too Refs #141 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 26b048f commit 0da197c

24 files changed

Lines changed: 316 additions & 48 deletions

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -722,7 +722,7 @@ The outer container keeps `overflow-auto` as a safety valve for large zoom facto
722722

723723
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.
724724

725-
`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.
725+
`hidden` comes from `shouldHideSurfaces()`, which is `_stealth || (_hideTaskbarWhileRunning && isAssistantRunning())`. `hideTaskbarWhileRunning` is an opt-in config setting, off by default: with it off a running assistant leaves the icon, z-order and minimize alone. **The two inputs are independent, not nested.** With the setting on, leaving stealth mid-session must *not* hand the taskbar button back. While hidden the window is also re-raised with `moveTop()` every `RAISE_INTERVAL_MS`, and `canMinimize()` (false in stealth and whenever the setting is on) drives `setMinimizable`, the `window:minimize` handler and the titlebar's minimize button. 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.
726726

727727
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.
728728

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Stay on top of the conversation with live ASR:
6565

6666
### Staying Out of Sight
6767

68-
The app keeps itself off the surfaces a screen share exposes. There is no desktop shortcut, and stealth mode hides the window from screen capture while also removing the Windows taskbar button and the macOS Dock icon and Cmd+Tab entry. Outside stealth mode the app behaves like any other window, taskbar button and Dock icon included.
68+
The app keeps itself off the surfaces a screen share exposes. There is no desktop shortcut, and stealth mode hides the window from screen capture while also removing the Windows taskbar button and the macOS Dock icon and Cmd+Tab entry. Outside stealth mode the app behaves like any other window, taskbar button and Dock icon included, unless you turn on "Hide taskbar icon during live interviews" in Configuration.
6969

7070
**Lost the window?** Launch Power Interview again. It does not start a second copy - the running app brings its window back and focuses it.
7171

‎SPEC.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ Nothing in it is a trap: Skip is on every step, every setting has a working defa
105105

106106
### Session Window Behaviour
107107

108-
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).
108+
While stealth mode is on - or while the assistant is running with the opt-in "Hide taskbar icon during live interviews" setting enabled - the window is pinned above other windows (`screen-saver` level, visible over a fullscreen call on macOS), re-raised every 2 seconds, cannot be minimized, and drops its taskbar button and Dock icon. With the setting off (the default) a running assistant leaves the window alone. The two conditions are independent: switching stealth off mid-session with the setting on leaves everything in place until the session 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).
109109

110110
### Interview Config Sync
111111

‎src/main/consts.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ export const SUGGESTION_STALL_MS = 15_000;
117117
// above the longest legitimate action suggestion.
118118
export const ACTION_LOCK_MAX_HOLD_MS = 180_000;
119119

120+
// How often a hidden-surface window is moved back to the top of the z-order.
121+
export const RAISE_INTERVAL_MS = 2000;
122+
120123
// Stealth mode opacity levels (cycles on each toggle; default = second highest)
121124
export const OPACITY_LEVELS = [0.2, 0.5, 0.73, 0.9] as const;
122125
export const OPACITY_DEFAULT = OPACITY_LEVELS[OPACITY_LEVELS.length - 2]; // 0.73

‎src/main/ipc/config.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ipcMain } from 'electron';
22

33
import { appStateService } from '../services/app-state.service.js';
4+
import { setHideTaskbarWhileRunning } from '../services/window-control.service.js';
45
import { configStore, RuntimeConfig } from '../store/config.store.js';
56

67
export function registerConfigHandlers(): void {
@@ -25,6 +26,10 @@ export function registerConfigHandlers(): void {
2526
// changing, which is enough: the re-seed is a no-op once a real interview has started.
2627
if (updates.uiLanguage !== undefined) appStateService.refreshPlaceholderLanguage();
2728

29+
if (updates.hideTaskbarWhileRunning !== undefined) {
30+
setHideTaskbarWhileRunning(config.hideTaskbarWhileRunning);
31+
}
32+
2833
return config;
2934
} catch (error) {
3035
console.error('Failed to update config:', error);

‎src/main/ipc/tools.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { dialog, ipcMain } from 'electron';
1+
import { ipcMain } from 'electron';
22
import fs from 'fs/promises';
33

44
import { toolsService } from '../services/tools.service.js';
5+
import { showSaveDialog } from '../services/window-control.service.js';
56
import { ExportFormat } from '../types/export.js';
67
import { uiStrings } from '../utils/ui-strings.js';
78

@@ -22,7 +23,7 @@ export function registerToolsHandlers(): void {
2223
'tools:save-image',
2324
async (_event, { filename, data }: { filename: string; data: number[] }) => {
2425
const strings = uiStrings();
25-
const { canceled, filePath } = await dialog.showSaveDialog({
26+
const { canceled, filePath } = await showSaveDialog({
2627
title: strings.saveImageTitle,
2728
defaultPath: filename,
2829
filters: [{ name: strings.pngFilter, extensions: ['png'] }],

‎src/main/ipc/window.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export function registerWindowHandlers(): void {
1919

2020
ipcMain.on('window:minimize', () => {
2121
const win = window();
22-
if (win && !win.isDestroyed()) win.minimize();
22+
if (win && !win.isDestroyed() && windowControls.canMinimize()) win.minimize();
2323
});
2424

2525
ipcMain.on('window:maximize', () => {

‎src/main/services/app-state.service.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -273,9 +273,9 @@ export class AppStateService {
273273
this.notifyRenderer();
274274
}
275275

276-
// The taskbar button, the Dock icon and always-on-top follow the running state as well as
277-
// stealth, and this is the only place a run starts or ends. Done after the state is written,
278-
// since window-control reads it back. Never allowed to fail the update itself.
276+
// The taskbar button, the Dock icon and always-on-top follow the running state (when the
277+
// hideTaskbarWhileRunning setting is on) as well as stealth, and this is the only place a
278+
// run starts or ends. Done after the state is written, since window-control reads it back. Never allowed to fail the update itself.
279279
if (runningChanged) {
280280
try {
281281
refreshWindowSurfaces();

‎src/main/services/tools.service.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { convertMarkdownToDocx } from '@mohtasham/md-to-docx';
2-
import { dialog } from 'electron';
32
import fs from 'fs/promises';
43

54
import { LLMApi } from '../api/llm.js';
@@ -14,6 +13,7 @@ import { mockInterviewService } from './mock-interview.service.js';
1413
import { actionSuggestionService } from './suggestion-action.service.js';
1514
import { liveSuggestionService } from './suggestion-live.service.js';
1615
import { transcriptService } from './transcript.service.js';
16+
import { showSaveDialog } from './window-control.service.js';
1717

1818
/**
1919
* The Word style sheet both exports are rendered with.
@@ -81,7 +81,7 @@ class ToolsService {
8181
// The document inside follows the interview language; this window is being read by the
8282
// person at the keyboard, so it follows the chrome language.
8383
const strings = uiStrings();
84-
const { canceled, filePath } = await dialog.showSaveDialog({
84+
const { canceled, filePath } = await showSaveDialog({
8585
title: strings.saveTranscriptTitle,
8686
defaultPath: generateExportFilename(format),
8787
filters: isMarkdown
@@ -125,7 +125,7 @@ class ToolsService {
125125
const isMarkdown = format === 'md';
126126

127127
const strings = uiStrings();
128-
const { canceled, filePath } = await dialog.showSaveDialog({
128+
const { canceled, filePath } = await showSaveDialog({
129129
title: strings.saveMockReportTitle,
130130
defaultPath: generateExportFilename(format, 'mock-interview'),
131131
filters: isMarkdown

‎src/main/services/window-control.service.ts‎

Lines changed: 100 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { app, BrowserWindow, screen } from 'electron';
1+
import { app, BrowserWindow, dialog, screen } from 'electron';
22

3-
import { MIN_HEIGHT, MIN_WIDTH, OPACITY_LEVELS } from '../consts.js';
3+
import { MIN_HEIGHT, MIN_WIDTH, OPACITY_LEVELS, RAISE_INTERVAL_MS } from '../consts.js';
44
import { configStore } from '../store/config.store.js';
55
import { RunningState } from '../types/app-state.js';
66
import { isMockInterviewSessionActive } from '../types/mock-interview.js';
@@ -24,6 +24,17 @@ const ALWAYS_ON_TOP_LEVEL = 'screen-saver' as const;
2424
let win: BrowserWindow | null = null;
2525
let _stealth = configStore.getStealth();
2626

27+
// Cached so the window events that run applySurfaceVisibility do not hit the disk each time.
28+
let _hideTaskbarWhileRunning = readHideTaskbarSetting();
29+
30+
// While surfaces are hidden the window is re-raised on this interval, since something else
31+
// (a call app, a fullscreen share) can still end up above it.
32+
let raiseTimer: NodeJS.Timeout | null = null;
33+
34+
// Last minimizable state we asked for. Changing it alters the window style, which on Windows
35+
// makes the shell re-add the taskbar button, so it is only touched on a real change.
36+
let minimizableApplied: boolean | null = null;
37+
2738
// Last z-order state we asked for. `null` means nothing has been applied to this window yet,
2839
// which is what makes the first call after a window is registered always go through.
2940
let alwaysOnTopApplied: boolean | null = null;
@@ -33,6 +44,15 @@ let dockVisible: boolean | null = null;
3344
let lastDockCallAt = 0;
3445
let dockRecheckTimer: NodeJS.Timeout | null = null;
3546

47+
function readHideTaskbarSetting(): boolean {
48+
try {
49+
return configStore.getConfig().hideTaskbarWhileRunning === true;
50+
} catch (e) {
51+
console.warn('Failed to read hideTaskbarWhileRunning:', e);
52+
return false;
53+
}
54+
}
55+
3656
// helper: return the display the window mostly occupies (fallback to primary)
3757
function getCurrentDisplay(): Electron.Display {
3858
if (win && !win.isDestroyed()) {
@@ -75,6 +95,17 @@ interface WindowBounds {
7595
height?: number;
7696
}
7797

98+
/**
99+
* A save dialog owned by the main window. Unowned, it opens as an ordinary window and sits under
100+
* the main window whenever that is pinned on top, out of reach until the pin is released.
101+
*/
102+
export function showSaveDialog(
103+
options: Electron.SaveDialogOptions
104+
): Promise<Electron.SaveDialogReturnValue> {
105+
if (win && !win.isDestroyed()) return dialog.showSaveDialog(win, options);
106+
return dialog.showSaveDialog(options);
107+
}
108+
78109
/**
79110
* Set the window reference for all control functions
80111
*/
@@ -84,6 +115,8 @@ export function setWindowReference(window: BrowserWindow): void {
84115
// The tracked z-order belongs to the previous window, not this one. Carrying it over would
85116
// read the first real call as a no-op and leave the new window unpinned.
86117
alwaysOnTopApplied = null;
118+
minimizableApplied = null;
119+
stopRaiseTimer();
87120

88121
// The shell re-registers the taskbar button whenever the window is re-shown or re-shaped, and
89122
// most of those paths are not ours to intercept - Alt+Tab restoring a minimized window, for one.
@@ -93,6 +126,7 @@ export function setWindowReference(window: BrowserWindow): void {
93126
window.on('restore', applySurfaceVisibility);
94127
window.on('maximize', applySurfaceVisibility);
95128
window.on('unmaximize', applySurfaceVisibility);
129+
window.on('closed', stopRaiseTimer);
96130
}
97131

98132
applySurfaceVisibility();
@@ -120,12 +154,67 @@ function isAssistantRunning(): boolean {
120154
* above the call.
121155
*
122156
* Two independent inputs, either of which is enough. Stealth is the explicit request for it.
123-
* A running assistant is the implicit one: that is precisely when a screen share is likely to be
124-
* live, and when the suggestions are useless if the call window covers them. The two are not
125-
* nested - leaving stealth mid-session must not hand the taskbar button back.
157+
* A running assistant is the implicit one, but only when the user opted in with
158+
* `hideTaskbarWhileRunning`: otherwise the window is an ordinary one, icon and z-order included.
159+
* The two are not nested - leaving stealth mid-session with the setting on must not hand the
160+
* taskbar button back.
126161
*/
127162
function shouldHideSurfaces(): boolean {
128-
return _stealth || isAssistantRunning();
163+
return _stealth || (_hideTaskbarWhileRunning && isAssistantRunning());
164+
}
165+
166+
/**
167+
* Minimize is unavailable whenever the setting is on (even at idle, so there is no gap around a
168+
* session) and in stealth: with the taskbar button gone there is nothing to restore from.
169+
*/
170+
export function canMinimize(): boolean {
171+
return !_stealth && !_hideTaskbarWhileRunning;
172+
}
173+
174+
/** Apply a changed `hideTaskbarWhileRunning` setting to the live window. */
175+
export function setHideTaskbarWhileRunning(value: boolean): void {
176+
_hideTaskbarWhileRunning = value;
177+
refreshWindowSurfaces();
178+
}
179+
180+
function applyMinimizable(): void {
181+
if (!win || win.isDestroyed()) return;
182+
const allowed = canMinimize();
183+
if (minimizableApplied === allowed) return;
184+
try {
185+
win.setMinimizable(allowed);
186+
minimizableApplied = allowed;
187+
// Only the setting, not stealth: restore() activates the window, which must not pull focus
188+
// out of a call. A window minimized before stealth is brought back by restoreWindow().
189+
if (!allowed && !_stealth && win.isMinimized()) win.restore();
190+
} catch (e) {
191+
console.warn('setMinimizable failed:', e);
192+
}
193+
}
194+
195+
function stopRaiseTimer(): void {
196+
if (!raiseTimer) return;
197+
clearInterval(raiseTimer);
198+
raiseTimer = null;
199+
}
200+
201+
function syncRaiseTimer(hidden: boolean): void {
202+
if (!hidden) {
203+
stopRaiseTimer();
204+
return;
205+
}
206+
if (raiseTimer) return;
207+
raiseTimer = setInterval(() => {
208+
if (!win || win.isDestroyed() || !win.isVisible() || win.isMinimized()) return;
209+
try {
210+
// moveTop does not take focus and does not re-register the taskbar button, unlike
211+
// re-issuing setAlwaysOnTop.
212+
win.moveTop();
213+
} catch (e) {
214+
console.warn('moveTop failed:', e);
215+
}
216+
}, RAISE_INTERVAL_MS);
217+
raiseTimer.unref?.();
129218
}
130219

131220
/**
@@ -144,6 +233,9 @@ function applySurfaceVisibility(): void {
144233
const hidden = shouldHideSurfaces();
145234

146235
if (win && !win.isDestroyed()) {
236+
// Style change, so before setSkipTaskbar for the same reason as the z-order below.
237+
applyMinimizable();
238+
147239
// Z-order first. Changing it re-registers the window with the shell and hands the taskbar
148240
// button back, so setSkipTaskbar below has to be the one that runs last of the two.
149241
applyAlwaysOnTop(hidden);
@@ -154,6 +246,8 @@ function applySurfaceVisibility(): void {
154246
console.warn('setSkipTaskbar failed:', e);
155247
}
156248

249+
syncRaiseTimer(hidden);
250+
157251
// `titleBarStyle: 'hidden'` draws the traffic lights as native chrome, independent of
158252
// setSkipTaskbar/the Dock icon - they stay on screen in stealth mode unless hidden here too.
159253
//

0 commit comments

Comments
 (0)