fix(window): make a backgrounded window reachable again - #270
fix(window): make a backgrounded window reachable again#270miadisabelle wants to merge 2 commits into
Conversation
"Keep them alive in the background" hides the window instead of closing it, which is the point — the agents keep running. Getting back to it is where it breaks down, in two ways. Nothing owns a second launch. Clicking the icon, or running the binary again, starts a whole second process. It reads the same state file, restores every persisted session, and spawns a duplicate agent for each one — on top of the PTYs the first instance is still holding. The hidden window is not raised, because nothing is listening for the launch. On Linux, where there is no dock to click, that launch is the only gesture a user has, so the app answers "come back" by cloning itself over its own running terminals. `show()` is also not enough on its own. It is a no-op on a minimized window, and does nothing for a window that is visible but buried behind another app. Both states reach the same handlers. - Take the single-instance lock in packaged builds. A second launch now arrives at the running instance as `second-instance` and restores its window instead of starting a process. Dev runs skip the lock, so `npm run dev` still starts while an installed build is running. - Add `restoreWindow()`: show if hidden, restore if minimized, focus either way, no-op if the window is missing or destroyed. Typed structurally so it is testable without an Electron runtime. - Route all three call sites through it — `activate` (the existing dock handler), the new `second-instance`, and the `before-quit` prompt, whose comment already said a quit must not prompt where the user cannot see it. 7 unit tests over the state matrix. Full suite passes.
johannesjo
left a comment
There was a problem hiding this comment.
Verified locally: npm run typecheck, npm run check:static (tsc + eslint + knip + depcruise) and the 7 new unit tests all pass on this branch. The premise holds — before this, nothing listened for second-instance, and activate called only show(). The if (!gotTheLock) quit() else { on('second-instance'); whenReady() } shape matches Electron's canonical example, and adding show() on top of the documented isMinimized() → restore(); focus() is the right extension for an app that actually hides its window.
Four things, one of which I'd fix before merge.
1. fixEnv() runs before the lock is requested — main.ts:84 vs main.ts:240
The second, doomed process still executes fixEnv() at module scope: an interactive+login shell (-ilc) that sources .zshrc, so nvm/conda/compinit all run before it reaches line 240 and finds out it should quit. That's dead time sitting directly in the path this PR exists to fix — the user clicks the icon and nothing happens until a throwaway shell resolves.
I timed the exact probe from fixEnv() on my machine: ~0.53s, bounded at 5s by the existing timeout. Not fatal, but it's a half-second of "did my click register?" on the icon-relaunch path, which per your Note is the whole story on the headless Linux box.
Electron's guidance is to take the lock as early as possible. Hoisting the check above fixEnv() and moving fixEnv() into the primary branch would fix it — the resolved environment only matters for spawning PTYs, which only the primary does.
2. The visible-but-buried case may not be deliverable on Wayland (note, not a change request)
Electron's own docs for win.focus(): "On Wayland (Linux), the desktop environment may show a notification or flash the app icon if the window or app is not already focused." So of the three states the PR enumerates, the third one — visible but behind another app — may resolve to an icon flash rather than a raise on Wayland, since the compositor generally won't let a client raise itself.
I don't think there's a code fix here (show() isn't demonstrably better under Wayland, and deviating from the canonical pattern to chase it isn't worth it). But the restoreWindow doc comment states the buried case as solved, and on one of the two Linux display servers it likely isn't. Worth a sentence in the comment so the next person debugging "focus doesn't work on my desktop" doesn't go looking for a bug in this function.
I'd also drop the "show() is a no-op on a minimized window" claim in the header comment — Electron's docs don't actually say that either way, and the test file's comment ("minimized windows report themselves as not visible on some platforms") tells a different story from the source comment. The code calls both and is correct regardless; only the narration is over-specified.
3. restoreWindow's doc comment names a tray entry point that doesn't exist — window-restore.ts:8
No Electron Tray anywhere in the repo (the only hit is a renderer component, NeedsInputTray). Either drop it or the next reader goes hunting for a tray handler.
4. The dev exemption sanctions a config that cross-wires agent hooks (pre-existing; probably its own PR)
Not introduced here, but the new comment promotes "dev alongside an installed build" to a documented, supported configuration, so it's worth knowing what that configuration does today: persistence.ts:6-12 gives dev its own -dev state dir, but agent-hooks/runtime.ts:18 uses raw userData. Both instances write agent-hooks/endpoint (port + token) to the same path, and the hook script re-sources that file on every event (per hook-script.test.ts:20) — so whichever instance started last receives hook status events for both instances' agents. A one-line fix would mirror the dev suffix from persistence.ts.
Minor
second-instancedropscommandLine/workingDirectory. Correct today —main.tsreads no argv (the onlyprocess.argvis inelectron/mcp/server.ts, a separate process). Flagging it becausesecond-instanceis also where deep links land on Linux/Windows, so this is the hook to remember if aparallel-code <dir>or protocol handler ever ships.'asks whether the window is destroyed before touching it'asserts a mock was called, which the no-op test already covers. Harmless, adds nothing.
On your question about the third call site
Keep it here. One line, same failure mode, and the existing comment already asserted a property the code didn't have — splitting it would leave that comment lying for another cycle.
Caveat on coverage
The lock only engages in packaged builds, so neither CI nor npm run dev exercises the second-instance path at all — the unit tests cover restoreWindow in isolation, not the wiring. Worth one manual packaged-build check (launch, hide, relaunch) before merge.
…onment Review follow-up. fixEnv() spawns an interactive login shell at module scope, and the lock was requested 150 lines later — so the second, doomed instance paid for a full shell startup before finding out it should quit, directly on the icon-relaunch path the lock exists to make instant. The lock decision now happens before that call, and fixEnv() moves into the primary branch: only the primary spawns PTYs, so only the primary needs the resolved environment. Measured on a packaged build (3 runs each, headless Linux, nvm + conda in the rc file): the second instance went from 7.86s to 1.25s, with the login-shell probe no longer appearing at all while it runs. Also from review: - Drop the claim that show() is a no-op on a minimized window. Electron's docs do not say that either way, and the test file's comment told a different story from the source comment. The function calls both and is correct regardless; only the narration was over-specified. - Note the Wayland caveat on the visible-but-buried case: a client generally cannot raise itself there, and Electron's own docs say focus() may flash the icon instead. The doc comment stated that case as solved. - Drop 'tray' from the list of entry points. There is no Electron Tray in this repo. - Drop the test that only asserted isDestroyed() was called; the no-op test already covers it.
|
Thanks — the 1. Lock before
|
| second instance | login-shell probes observed while it ran | |
|---|---|---|
| before | 7.86s (7.80 / 7.75 / 8.04) | 63 |
| after | 1.25s (1.24 / 1.23 / 1.27) | 0 |
Your 0.53s is the floor, not the ceiling. This box is the headless Linux one from the Note, and its rc file has nvm and conda in it — so the probe costs 6.6s here, and every bit of it landed on the click.
2. Wayland — noted in the comment, no code change
Agreed on both halves. The doc comment now says the visible-but-buried case may end at an icon flash rather than a raise on Wayland, with the reason (a client generally cannot raise itself; Electron's docs say focus() may notify or flash instead), and that hidden and minimized are unaffected.
I also dropped the "show() is a no-op on a minimized window" claim. You were right that the source comment and the test comment were telling different stories, and the docs back neither. The comment now says only what the code relies on: the two states are not exclusive, so both questions get asked and each answer gets acted on.
3. tray — dropped
No Electron Tray in the repo. Gone.
4. agent-hooks/endpoint cross-wiring — separate PR, ready when you want it
Confirmed the shape you describe: persistence.ts:6-12 gives dev its own -dev state dir, agent-hooks/runtime.ts:18 uses raw userData, and the hook script re-sources the endpoint file per event — so the last instance to start collects hook status for both. Happy to send the one-line mirror of the dev suffix as its own PR, since as you say it predates this one. Say the word and it's up.
Minor
- Dropped the
isDestroyed()mock assertion. You're right, the no-op test already covers it. commandLine/workingDirectory: understood, leaving the parameters off rather than accepting and ignoring them. Noted that this is where deep links would land.
Third call site
Kept, per your call.
Packaged-build check
Done, since neither CI nor npm run dev reaches this path. Packaged build, Xvfb + kwin, both instances on the same --user-data-dir:
A launched → window state: Normal, 2 processes on the profile
xdotool windowminimize → window state: Iconic
B launched → exit 0 after 1.14s
A still running → YES
A window state → Normal (restored by second-instance)
processes on profile → 2 (unchanged)
So the wiring is exercised, not just restoreWindow in isolation: B declines to become a second app, and the minimized window comes back. I used minimize rather than "Keep in Background" because driving the UI headless is awkward, and minimize is the stricter of the two — it needs restore(), where hide only needs show().
npm run check:static and the full suite (2219 passing) are green on the branch.
"Keep them alive in the background" hides the window instead of closing it, which is the point — the agents keep running. Getting back to it is where it breaks down, in two ways.
Nothing owns a second launch. Clicking the icon, or running the binary again, starts a whole second process. It reads the same state file, restores every persisted session, and spawns a duplicate agent for each one — on top of the PTYs the first instance is still holding. The hidden window is not raised, because nothing is listening for the launch. On Linux, where there is no dock to click, that launch is the only gesture a user has, so the app answers "come back" by cloning itself over its own running terminals.
show()is not enough on its own. It is a no-op on a minimized window, and does nothing for a window that is visible but buried behind another app. Both states reach the same handlers as the hidden one.Changes
second-instanceand restores its window instead of starting a process. Dev runs skip the lock, sonpm run devstill starts while an installed build is running.restoreWindow()inelectron/window-restore.ts: show if hidden, restore if minimized, focus either way, no-op if the window is missing or destroyed. Typed structurally against aRestorableWindowshape rather thanBrowserWindow, so it is testable without an Electron runtime — following themenu-template.tspattern.activatehandler, the newsecond-instance, and thebefore-quitprompt.On that third call site
before-quitcurrently callsmainWindow.show()and its comment already says a quit from the menu "must not prompt invisibly". A minimized window is exactly that case, so I included it. Happy to drop it to a separate PR if you'd rather keep this one to the lock.Testing
7 unit tests covering hidden, minimized, both at once, visible-but-unfocused, destroyed, and missing.
npm run check:staticand the full suite pass.Note
Found while running a fork of this app on a headless Linux box, where the icon-relaunch path is the whole story. Not fork-specific — the duplicate-spawn happens on any platform once the window is hidden.
🤖 Generated with Claude Code
https://claude.ai/code/session_01MoJfWuU93BeL2W48mz9bC6