Desktops are kernel state: implementation notes #
Requirements, rules, model and acceptance checks: specs/kernel-desktops.md. This file holds what
is specific to building it in this repo: motivation, the baseline, the decisions to pull into the
kernel, the order of work and probe findings.
Motivation #
Peek item a0ea0c2d; parked track close-raises-other-desktop. Defect on main: with another app
in front and two macOS desktops, a page opened from the palette on desktop 2 can follow the user to
desktop 1, drop behind the other app, and vanish from desktop 2, because the window kernel knows
nothing about desktops and every desktop rule in docs/design/window-rules.md is decided by
native-call timing.
Baseline on main #
electron-window-backend.ts attachSpaceChangeListeneronly logsspace-changed(macOS only); no event reaches the kernel.window-kernel.ts Statehas no desktop.WindowRecord.desktopMembershiprecords intent only ('follow'/'anchor'), set for canvas windows by role (ipc.tswindow-open handler,kernelSetDesktopMembership(…, desktopMembershipForRole(role))) and on key-reuse; page hosts fromipc.ts assembleHybridPageHostnever get it and land on the desktop current at first show.ElectronWindowBackend.setDesktopMembershiprealizes'anchor'as the all-desktops-on-then-off pair, moving the window to the current desktop; no-op off macOS.- A transient page-host window is built with
type: 'panel'(ipc.ts,pageOpensTransient()branch,options.type = 'panel'); Electron keeps such windows on every desktop (electron_ns_panel.mm setCollectionBehavior:). isTransientPageHostreads onlyappActive,keyand the record; a desktop switch replaces a transient page-host window only via theOsFocusLostfromElectronWindowBackend.armBlurSettle→kernelOsFocusSettled, so 5c and 5d are indistinguishable.reactemitsReopenAsOrdinaryWindow { id };ipc.ts reopenWindowAsOrdinary()opens the reopened window then closes the transient page-host window via a promise chain; the reopened window lands on the desktop switched to.successorOnHide()(WindowHidden/WindowClosedarms ofevolve) picks the prior key window, elsetopmost(state, isContentWindow), ignoring desktops. Raising a window on another desktop switches desktop (raise-realized id=wm-3thenspace-changed).- Switcher selection (
tile-ipc.ts→kernelArmSelectionFocus) is spent in theWindowClosedarm as alastFocusAssert, which carries no user intent. - Hybrid URL dedup in the
ipc.tswindow-open handler (findHybridWindowsByUrl→kernelShowRequested(reusedId, true)) runs before thepageOpensTransient()check and reuses a window on any desktop.
Known gap: modal browser windows are also built with type: 'panel' on macOS (ipc.ts,
options.modal === true branch) but are not nonActivating, so the backend does not declare them
on every desktop. The macOS helper's placement read reports them; revisit before the F9 gate.
Out-of-kernel decisions to move #
One commit each, order per "Order of work":
- Dropped: 5e now closes the page, so there is no reopen to order.
ipc.tshybrid URL dedup → kernel predicateadmitsReuse(state, record); thepageOpensTransient()check moves ahead of the dedup. The rule check watches for a hidden window re-shown on a desktop that is not current.window-kernel-executor.tsRaiseWindow/planRaise→reactstops emitting what the'suppressed'plan (app inactive, no user intent) drops, together with F9;planRaisekeeps headless and non-activating realization choices.electron-window-backend.tsfocus filters (win.on('focus')drops reports onpendingStackingFocus,pendingHideProvokedFocus,activationFocusTarget) → the kernel records each expectation when it emits the causing effect (stacking replay, hide of the key window, activation withpendingColdStartFocus) and classifies the report; the backend reports every focus with its raw cause.hideProvokesFocusReassignment()becomes a kernel fact likewise.electron-window-backend.ts appResignSettleTimer(reads izui overlay cooldowns, drops the settle) → the timer stays; the overlay transition becomes a kernel fact that makesAppResignSettledinert inevolve.main.tsdevtools-opened / devtools-closed (kernelAssertFocus()on the topmost content window, possibly on another desktop) → retargeted to the host whose devtools opened or closed by commit5f888241of trackclose-raises-other-desktop, cherry-picked here; F9 also refuses a machine focus elsewhere.
Stays outside: armBlurSettle (translates Electron's event order into "focus left every window";
decides no rule).
Relationship to close-raises-other-desktop #
- One commit of the parked track carries over:
5f888241(the devtools-opened / devtools-closed handlers inmain.tsassert focus on the devtools host, not the topmost content window). It is cherry-picked into this track at commit 12 below (decision 6). Its lines indocs/design/window-rules.mdanddocs/design/window-rules-audit.mdthat describe the parked track's F7 are dropped in favour of the spec's F7 when resolving the pick. - Everything else in the parked track is superseded by the spec's F7 and F9 design: its transient / application split, its removal of the successor choice for application-window closes, and its focusing of the reopened window on activation (covered by the spec's close and focus ordering).
- The parked track is retired once this track lands; it is not rebased.
Order of work #
The macOS helper and its probe come before the kernel reshape because everything rests on the helper's reads; if they fail, nothing has yet been built on them.
-
Landed as
250051ad(minted ids,DesktopChanged); reshaped by commit 5.- Survives:
DesktopId;WindowRecord.desktop;WindowRecord.onEveryDesktopwith its registration plumbing (WindowRegisteredevent andRegisterWindowcommand fields,window-machine.ts registerWindowWithMachine,ElectronWindowBackend.registerWindowreturning it for windows built withtype: 'panel'on macOS);isOnEveryDesktop; the provisional stamp on a genuineWindowShown(desktopOnShow, changed to read the window's screen's current desktop instead ofState.desktop); theattachSpaceChangeListenersubscription as switch trigger; thenative-blurandos-focus-left-applog lines; desktop-state tests not depending on minting. - Removed by commit 5:
State.desktop,State.desktopCounter,mintedDesktopId,DesktopChangedand itsevolvearm,kernelDesktopChangedinwindow-kernel-dispatch.ts, thefocusedread inattachSpaceChangeListener, the stamp in theOsFocusGainedarm, and tests for minting,name,focusedstamping and focus stamping. - Changed by commit 5:
onCurrentDesktopkeeps name and meaning over the new state; thespace-changedlog line prints each screen's current desktop and each window'sid:screen/desktop|*|?:v|hfrom the new fields.
- Survives:
-
Rename the old identifiers, log lines and prose to the transient-window names, no behaviour change, own commit. The code and docs carry an older, retired name for the transient page-host window. In the table,
⟨old⟩stands for that retired two-word term in the case form of the name it sits in: camelCase or PascalCase in identifiers, upper snake case in constants, hyphenated in log lines, file names, profile names and rule labels.grep -rn -i 'side.\?task' apps/desktop docs specslists every occurrence and returns nothing once the step is done.Old New Kind is⟨old⟩isTransientPageHostkernel predicate ( window-kernel.ts)pageOpensAs⟨old⟩pageOpensTransientopen-time predicate ( izui-roles.ts)⟨old⟩SetuptransientPageHostSetupexecutor test fixture ⟨old⟩InFlighttransientPageHostHoldingKeykernel test fixture ⟨old⟩_REOPEN_STRIPTRANSIENT_PAGE_REOPEN_STRIPconstant ( ipc.ts)⟨old⟩-reopentransient-page-reopenlog line, and the […]prefix of twoconsole.errorlines⟨old⟩-reopen-droppedtransient-page-reopen-droppedlog line page-opens-as-⟨old⟩page-opens-transientlog line ⟨old⟩-standin-misplacedtransient-placeholder-misplacedplanned log line, since dropped with the placeholder window; only docs carried the old name G13 ⟨old⟩-endsG13 transient-page-endsrule label in window-rules.mdspecs/⟨old⟩-ends-on-blur.mdspecs/transient-page-ends-on-blur.mdspec file, moved with git mv⟨old⟩Escape(type),⟨old⟩_ESCAPE,⟨old⟩Escape(parameter)TransientPageEscape,TRANSIENT_PAGE_ESCAPE,transientPageEscapealready removed from code; named in the docs' history agent-⟨old⟩-1agent-transient-page-1profile name in a live-check recipe the retired term in prose "transient page-host window" for the window; "replaced by the reopened window" for its end comments, test titles, assertion messages, doc prose Files, with what each carries:
apps/desktop/main/window-kernel.ts:is⟨old⟩(definition and its use inreact), comments.apps/desktop/main/window-kernel.test.ts:is⟨old⟩import and uses,⟨old⟩InFlight, describe and test titles.apps/desktop/main/window-kernel-executor.test.ts:⟨old⟩Setupand its comment.apps/desktop/main/izui-roles.ts:pageOpensAs⟨old⟩and its comment.apps/desktop/main/izui-roles.test.ts:pageOpensAs⟨old⟩import and uses, describe and test titles.apps/desktop/main/ipc.ts:pageOpensAs⟨old⟩import and call,⟨old⟩_REOPEN_STRIP, the log lines⟨old⟩-reopen,⟨old⟩-reopen-droppedandpage-opens-as-⟨old⟩, theconsole.errorprefixes, comments onreopenWindowAsOrdinary()and inregisterWindowHandlers.apps/desktop/main/window-backend.ts,apps/desktop/main/electron-window-backend.ts,apps/desktop/main/window-escape.ts: comments.apps/desktop/main/window-escape.test.ts: one test title.apps/desktop/tests/desktop/cmd-url-unfocused.spec.ts: header and inline comments, one test title, one assertion message.docs/design/window-rules.md: prose, the G13 label, the removed⟨old⟩_ESCAPEconstant.specs/⟨old⟩-ends-on-blur.md: moved as above; prose, log lines, the three removed Escape names, the profile name.specs/cmd-url-linux-failures.md: prose.
Verify: the
tracks.jsonverify,node scripts/check-platform-leak.mjs, and the grep above returning nothing. -
Done after the live checks: the
PEEK_DESKTOP_PROBEhooks (installDesktopProbeShortcut,probeRead,probeWriteTestand their test windows) are removed fromelectron-window-backend.ts.MacDesktopProvider.probePlaceand the helper'splaceop stay. -
Providers and the macOS helper, no kernel change:
desktop-provider.ts,desktop-provider-fallback.ts,main.swift,build-desktop-helper.mjs, theextraResourcesentry,desktop-provider-mac.ts, the fail-safe, thecheck-platform-leak.mjsadditions, and temporary probe hooks behindPEEK_DESKTOP_PROBE=1. The kernel has no desktop events yet, so the probe hooks are the provider's only report sink: they log its reads directly. Then the probe (the spec's macOS provider acceptance check) on a real Mac with a person. Stop and report if ids are not stable across returns; never fall back to letting the page follow the user. Two backend fixes found by the probe follow here, one commit each: the shared page chrome window on every desktop (hybrid-overlay.ts ensureOverlay()), andthe transient page-host window pinned to the desktop it is shown on— not possible for a panel, which Electron keeps on every desktop; done instead by building it as an ordinary window flagged non-activating (4e6ada21), later every transient window (52e4f46a,26b5be31). -
Kernel model reshape. Kernel:
State.screens/State.desktops,WindowRecord.screen, the three spec events plusDesktopSwitchObserved,isCurrentDesktop, reshapedonCurrentDesktop. Backend: the report → event loop (electron-window-backend.ts onDesktopTrigger/syncDesktops, reads serialized), fed by whichever provider started. No rule reads the new state; behaviour unchanged on every platform (pinned by the property test "the fallback provider changes no effect").DesktopSwitchObservedcarries the switch-closes-the-page behaviour of96fe4006, which lived in the removedDesktopChangedarm: it is sent synchronously from the switch notification because the provider's read that yieldsCurrentDesktopChangedis asynchronous and an activation can arrive first. -
Dropped: 5e now closes the page, so there is no reopen to order.
-
Done ahead of the reshape,
84a123ee: a transient page-host window that loses focus with the app inactive (click-away, desktop switch) closes instead of being reopened, because Electron panels are on every desktop (see "Research: Electron panels and desktops"). The earlier plan (defer the reopen until the user returns,reopenPending) is dropped. -
Split in two:
- 8a Done: F9 gate + F7 rewrite.
successorOnHide()appliesonCurrentDesktopto both branches, and every raisereactemits without user intent (successor, parent refocus, key move on an OS focus report, focus assert and its content focus, raise request, activation latch) passes one predicate,admitsMachineRaise(), whichevolvealso asks where it stamps a token, so an ungated raise mints none. TheWindowClosedarm already chose a successor for any key holder, application windows included. The switcher selection spend is exempt: a pick is an explicit choice and rule 1 takes the user to the picked window's desktop, so it is gated onadmitsFocusAssert()only, marked byFocusAssertRequest.switcherPick. Until 8b it still carries nointent: 'user', and noShowDesktopprecedes it. - 8b-1 Done: one desktop rule.
admitsFocus(state, record, requester)with requestersMACHINE_REQUEST,USER_REQUEST,SWITCHER_PICKreplacesadmitsMachineRaiseandadmitsFocusAssertat every site inevolveandreact, includingsuccessorOnHide;withinReachis its desktop half. With it:- user intent alone no longer reaches another desktop (rule 5); only a switcher pick does;
- a switcher pick's
RaiseWindowcarriesintent: 'user', preceded byShowDesktopwhen the window is not on a current desktop (pushRaise); the executor only logsShowDesktop, since no backend can switch desktops; FocusAssertRequestedandFocusRaiseRequestedstamplastOpSeqonly when they issue an op;- the hold above other apps (and its cold-start latch) needs the target within reach. Kept as it was: a key move the OS reports is still gated on the desktop, because a late report for a window on a desktop the user just left must not pull the user back.
- 8b-2: the app-active half. App inactive and no user intent → not admitted, so
reactstops emitting whatplanRaisesuppresses (decision 3), andplanRaisekeeps headless and non-activating choices only. The hold above other apps askswithinReachonly, since it stands in for exactly that raise. Also: the cold-start latch never names a window dismissed in the same transition; the property test of spec section 4 "Enforcement". - Settled:
successorOnHidereturns nothing when the window that hid or closed took key from nothing, so a transient window opened over another app returns the user to that app; spec rule 9 and F7 now say so. Four kernel tests pin it. - 8c: one desktop stamp on show, chrome on this desktop. Every write flipping
visibleto true goes through one helper that stamps the desktop (today the re-show on app activation and the overlay reveal skip it).topmostBaseLayerPageHostpicks only a page-host window on a current desktop.
- 8a Done: F9 gate + F7 rewrite.
-
Choosing a window moves into the kernel.
reusableHereandfrontWindowHere(spec section 4 "Queries"), then every site outside the kernel that picks a window uses them, one commit per group:- 9a Done — reuse: the finders return every match (
findHybridWindowsByUrl,findHybridWindowsByKey,main.ts findWindowsByKey/findWindowsByUrl) andwindow-machine.ts pickReusableHeretakes the kernel'sreusableHereanswer. A keyed BrowserWindow found only off every current desktop is closed there (closeHostWindow, logwindow-open-closed-elsewhere); a page-host window found there stays. Workspace restore reaches reuse through the same handler, so it is covered too. Was:ipc.ts windowOpenHandler(findHybridWindowsByUrl,findHybridWindowsByKey,main.ts findWindowsByKey/findWindowsByUrl),chrome-extensions.ts openChromeExtensionPage, and workspace restore through the same handler. ThepageOpensTransient()check moves ahead of reuse. A one-per-app window found only off every current desktop is closed there and opened here. Replaces decision 2. - 9b the window the user is looking at:
window-machine.ts getTopmostContentWindowIdand its consumers (tile:window:get-focused-visible-id,tile:dialogs:save/open,tileResolveWebWindow, the fullscreen and colour-scheme defaults,tile:window:devtools),windows.ts resolveFocusedWindowIdForClose, the Cmd+W devtools guard inentry.ts, andipc.ts openNewPageWindow. The devtools-opened / devtools-closed handlers inmain.tstake5f888241from trackclose-raises-other-desktop(decision 6). - 9c requests by id:
tile:window:focusandtile:window:showcarrynamed, false unless the request is a switcher pick (features/windows/windows.js). - 9d the static check of spec section 4 "Enforcement", with the finders and
topmostas its listed names and an allowlist that only shrinks.
- 9a Done — reuse: the finders return every match (
-
Decision 4 (focus filters). Done:
State.expectedPlatformFocusholds the three expectations.window-kernel.ts expectPlatformFocusrecords them from the effectsreactemits: a hide of the key window while the app is active and windows hold real focus, and a restack where the backend'srestackingTakesFocussays restacking takes focus.AppActivatedrecords the activation's asked-for window.platformChoseFocusclassifies eachOsFocusGained, and a matched world-caused report is spent and otherwise ignored.AppResignedclears all three, which also retires a hide the platform made itself (the app hidden), recorded like the kernel's own because both arrive asWindowHidden. The backend reports every focus with its raw cause. -
Decision 5 (resign settle). Done: the switcher opening (
ipc.ts windowOpenHandler, besideenterOverlay) and closing (main.ts, besideexitOverlay) dispatchOverlayTransitionObserved, which setsState.overlayTransitionSinceResign;AppResignedresets it, andAppResignSettledis inert while it holds. The backend's settle timer no longer reads the coordinator's cooldowns. With the 500 ms settle, those cooldowns (500 ms after entry, 300 ms after exit) only ever held for a transition after the resign, so the kernel rule needs no clock; it is slightly wider for an exit in the first 200 ms after a resign. -
Folded into 9b.
-
Linux on X11, separate unscheduled track: EWMH provider with reads and writes.
Open, not scheduled: DesktopSwitchObserved carries no screen, so a switch on another screen still
ends a transient page-host window (spec rule 3 says it changes nothing); and whether the stacking
replay's move-to-top of a window on another desktop can switch desktop is unverified.
The macOS provider acceptance check runs after commit 4; the rule acceptance check after commit 8.
Running the live checks #
Run from the kernel-desktops worktree with PROFILE=kernel-desktops PEEK_PROFILE=kernel-desktops TMPDIR=<worktree>/.tmp (create .tmp first). The agent builds, starts the app, reads main.log,
and asks the person only for the gestures, stdout to <worktree>/.tmp/<name>.log. The
PEEK_DESKTOP_PROBE=1 mode that logged the helper's placements is removed (step 3).
Done when #
Kernel, property, provider and executor tests pass; the tracks.json verify and the platform-leak
check are green; probe answers are recorded under "Probe findings"; the rule check behaves as
listed in the spec; the full gate (yarn test:desktop:electron, only with an explicit OK) shows no
failure outside apps/desktop/tests/gate-baseline.md.
Linux verification #
apps/desktop/CLAUDE.md lists desktops among what Linux runs do not verify. X11 behaviour is
checkable on the remote Linux dev box's virtual X server; whether its window manager implements EWMH
desktops is unverified. Wayland cannot be checked there.
Research: Electron panels and desktops #
Web and history research, 2026-09-28, Electron 43.0.0. Nothing here was run.
Electron cannot keep a type: 'panel' window on one desktop. ElectronNSPanel
(shell/browser/ui/cocoa/electron_ns_panel.mm, unchanged since PR #34388, Electron 20) overrides
the setter:
- (void)setCollectionBehavior:(NSWindowCollectionBehavior)collectionBehavior {
NSWindowCollectionBehavior panelBehavior =
(NSWindowCollectionBehaviorCanJoinAllSpaces |
NSWindowCollectionBehaviorFullScreenAuxiliary);
[super setCollectionBehavior:collectionBehavior | panelBehavior];
}
NativeWindowMac::SetVisibleOnAllWorkspaces only sets or clears join-all-Spaces and full-screen
auxiliary through that setter, so on a panel the clear has no effect; so do setFullScreenable
and setHiddenInMissionControl. Electron's docs (base-window-options.md, type: 'panel') say
the panel "will appear on all spaces (desktops)". No API changes a window's type after creation.
ElectronNSPanel is an ElectronNSWindow subclass whose styleMask getter adds the
non-activating bit, not a real NSPanel. No Electron issue asks to pin a panel to one desktop.
Links: https://github.com/electron/electron/blob/v43.0.0/shell/browser/ui/cocoa/electron_ns_panel.mm,
https://github.com/electron/electron/pull/34388, https://github.com/electron/electron/pull/52914
(open, keeps the override), https://github.com/electron/electron/issues/53889 (macOS 27: clicking
a panel activates the app; workaround _setPreventsActivation:).
Repo history. Ordinary windows stayed on their desktop through the on-then-off
setVisibleOnAllWorkspaces pair right after construction and before any show (6b3651b9,
2026-05-15, the ancestor of desktop membership anchor); TRANSIENT_ROLES windows kept
join-all-Spaces. Pages opened from the palette became panels in e77f6cdc (2026-09-20); an
attempt to open them as ordinary windows outright (41b76804) was reverted minutes later
(e5046f79). The reopen path (a2137355, ipc.ts reopenWindowAsOrdinary()) creates a new
window through the generic open, so it lands on the desktop the user is on when it runs.
docs/macos-spaces-analysis.md records the earlier Spaces root causes.
AppKit facts. A managed window (default collection behaviour) belongs to the desktop active
when it is first ordered on screen and stays bound to it after orderOut; showing it again takes
the user back there (electron#8734). Only one of managed, join-all-Spaces and move-to-active-Space
may be set. Full-screen auxiliary does not mean every desktop; appearing over another app's
full-screen desktop needs join-all-Spaces or move-to-active-Space as well. A non-activating window
that is managed is legal AppKit. Toggling the non-activating style bit after creation leaves the
window server's prevents-activation tag stale; _setPreventsActivation: (private) re-syncs it
(https://philz.blog/nspanel-nonactivating-style-mask-flag/,
https://github.com/mitchchn/FunWithPanels). Moving a window to a given desktop needs private
SkyLight calls that broke in macOS 14.5, 15 and 27 (Hammerspoon libspaces.m, issue #3698,
PR #3889).
Prior art. No shipped app or library was found doing the whole flow (transient non-activating page → persistent window pinned to its desktop). Pieces:
@akiflow/electron-panel-window(maintained, shipped in Akiflow, Electron 43.1.0):makePanelswaps the window's class to a panel class withobject_setClassand calls_setPreventsActivation:YES;makeWindowswaps it back and callsNO. Its panel class overrides thecollectionBehaviorgetter (join-all-Spaces). Warnings: never callsetVisibleOnAllWorkspaces(true)on it; callmakeWindowbefore close. Its desktop behaviour aftermakeWindowis undocumented. https://github.com/akiflow/electron-panel-windowgoabstract/electron-panel-window: class swap with move-to-active-Space instead of join-all-Spaces. https://github.com/goabstract/electron-panel-window
Candidate realizations for a transient page-host window that stays on its desktop, none verified:
- An ordinary window (not
type: 'panel') plus a small native addon calling_setPreventsActivation:YESand making it key,NOto promote it to an ordinary window. Being managed, it stays on the desktop it opened on, and promotion replaces nothing. Unverified: keyboard focus without activating the app from a plain window on current macOS. Cost: cannot show over another app's full-screen desktop. - akiflow's class swap, with the desktop set while the user is still on the original desktop (managed behaviour, then swap back on loss of focus or activation, before any switch).
- Stock Electron: keep the panel, and create the reopened window at open time on the same desktop, ordered on screen once (transparent or behind) and then out, so it stays bound there; show it at reopen time. Showing it later may take the user back to that desktop.
- Record the desktop at open and move the reopened window back with private SkyLight calls. Most fragile.
The panel travelling with the user during the switch gesture is fixed only by 1 or 2; 3 and 4 fix only where the reopened window lands.
Chosen for now (84a123ee): none of the four. Losing focus with the app inactive closes the
transient page-host window, and nothing is reopened. The panel was since replaced by an ordinary
window flagged non-activating (4e6ada21), which stays on its own desktop, so the page no longer
shows on both desktops during a switch gesture. Keeping a page open across a click-away is not
planned: a transient window ends when the user leaves it.
Probe findings #
First probe (inferred ids):
- macOS order for a switch away from a transient page-host window:
space-changed, thennative-blurandos-focus-left-app~20ms later, thenOsFocusLostand the reopen. The switch always preceded the blur. The design does not depend on this order. - Inferred ids fail: every switch minted a new id (two desktops →
desktop-2…desktop-15), so returns were never recognised. Electron exposes no desktop identity on macOS. Hence real desktop ids read through a provider. - Behaviour matched
main(page reopened on desktop 1).
Second probe, native helper (2026-09-28, stopped after gesture 3): one screen, two desktops,
build c3647846, log kept only locally.
- Precondition met:
desktop-provider started name=mac;desktop-probe-hellowith all four symbols resolved, includingCGSMoveWindowsToManagedSpace. - Gesture 1 passed: the transient page-host window read
*(every desktop); layout1@1,3@1, desktop 2 (id3) current. - Gesture 2 passed: current went
3→1→3; ids are stable across returns (the oldspace-changedline kept mintingdesktop-Nin the same run). - Gesture 3 passed: the reopened window read
1/3(desktop 2) on every read, matching what was on screen. - Gestures 4–6 not run, blocked by a new defect: the page chrome window (role
overlay, one shared window) sits on desktop 1. Arriving on desktop 2, where the reopened window is the front Peek window, activates Peek; the overlay is shown for that window from desktop 1 and macOS switches back to desktop 1 about 0.4s later, on every arrival. Clicking the page on desktop 2 does the same. Thespace-changedlines show each arrival on desktop 2 followed by a switch withappActive=true key=wm-9andwm-1(the overlay) turning visible. - Consequence for the spec: rule 1's
TRANSIENT_ROLESexception (they appear on the desktop the user is on) needs a realization for the overlay window, for example declaring itonEveryDesktopin the Electron backend, before any gesture that visits a desktop holding a page-host window.
Third check, overlay on every desktop (2026-09-28, after 31c4ac84): one screen, two desktops.
- The overlay reads
*from start. Switching onto desktop 2 while it holds a page-host window no longer pulls the user back to desktop 1: the person stayed on desktop 2. - Remaining defect, seen by the person and in the log: after opening a URL from the palette on
desktop 2 and switching to desktop 1, the page is visible on desktop 1. Log order:
space-changed(the transient page-host windowwm-9read*),native-blurandos-focus-left-app7ms later, theneffect-reopen-as-ordinaryandtransient-page-reopen; the reopened windowwm-10then reads1/1, desktop 1. Two causes: the panel is on every desktop, so it travels with the switch, and the notification arrives only after the switch, too late to hide it; and the reopened window is created on the desktop the user is on. - Decision taken from it: the transient page-host window is pinned to the desktop it is shown on, and a switch away defers the reopen until the user returns to that desktop (spec rules 2 and 3). The placeholder window and its probe gestures (the helper write test, the Mission Control check) are dropped.
Fourth probe, a kept-alive flagged window re-shown on another desktop (2026-09-29). Throwaway
Electron script (log kept only locally): an ordinary window flagged non-activating
(nonactivating-window.ts flagNonActivating), floating level, Finder frontmost; shown and made
key on desktop 1, hidden, desktop switched right, then shown and made key again, as the palette
and the switcher are.
- With AppKit's move-to-active-desktop collection behaviour set on the window: the re-show appears on desktop 2, no desktop switch follows, Finder stays frontmost, the window is key and receives typing. Switching back to desktop 1 while it is visible leaves it on desktop 2: it moves only when shown, never follows a switch.
- Without it: the re-show stays on desktop 1, invisible from desktop 2, yet becomes key, so typing lands in a window the user cannot see. No desktop switch follows either, since the app is not activated.
- Consequence: the palette and the switcher can be flagged ordinary windows kept alive, with the move behaviour set by the addon; the close-and-rebuild fallback is not needed.
Fifth probe: the page chrome overlay on a switch (live, macOS) #
- As a window on every desktop the overlay was drawn on the new desktop during a switch, until the switch notice arrived.
- Anchoring it and re-applying Electron's
setVisibleOnAllWorkspacespair while it was hidden did not move it: showing it over a page on another desktop made macOS switch back to the overlay's old desktop, about 0.4 s after each show, in the log as an unrequestedspace-changed. - Setting only the move-to-active-desktop collection behaviour through the addon
(
setMoveToActiveDesktop, applied inElectronWindowBackend.showWindowto the anchored overlay before each show) fixed both: no bar on the new desktop during a switch, no switch back, the bar shown on both desktops, and a page opened over another app kept that app frontmost with the bar taking key. Log lineoverlay-move-to-active.
Default-browser prompt during a gate run #
A packaged gate run raised the OS prompt to make Peek the default browser. The system log
(/usr/bin/log show, predicate on setting handler for scheme) showed a packaged Peek process
asking about 0.6 s after launch, then exiting while the dialog stayed up. The source has one
registration call, the Settings button handler; no spec reaches it and single specs did not
reproduce it, so the trigger is unknown. default-protocol-guard.ts refuses any registration in a
headless run and logs the caller (default-protocol-client-requested); the handler also logs the
requesting page (set-default-browser-requested).