Repository navigation
feat(window): support multiple application windows for multi-monitor workspaces - #692
Conversation
Records the design before implementation. Two findings shape it and are worth stating outside the diff: Autosave prunes any session it cannot trace to an owner, by design. Two windows each serializing the whole workspace would therefore delete each other's agents from workspace.json every 400ms, in both directions. That makes per-window save slices a correctness requirement, and it is why main has to compose the file from N authors instead of accepting opaque bytes. Every session IPC handler materializes `emptyRuntime()` for an unrecognized id, so a stray event does not get ignored — it grows a ghost runtime in the wrong window. Routing in main is therefore paired with a refusal guard in the renderer rather than trusted alone. Refs #688
…istry `sendToMainWindow` made "send to the window" a well-formed thought, and with more than one window it is not one. Three different questions were hiding behind that one helper: session traffic belongs to the window that owns the session, app-wide state belongs to every window, and a user gesture belongs to the window the user is in. All 30 call sites are reclassified accordingly. Getting that wrong would not be cosmetic. Every session handler in useIpcSubscriptions reads `prev[sessionId] ?? emptyRuntime()`, so an event delivered to a window that does not own the session materializes a runtime for a pane that window will never show, rather than being ignored. The three bridges that carried "a future multi-window product must make this target-aware" comments now do: WorkflowBridge addresses the renderer whose interest it is answering (it already keyed interests by rendererId), while the orchestration and agent-management bridges address the window owning the caller session and fail closed when no window owns it — answering an MCP mutation from the wrong window's workspace model is worse than not answering. No alias is left behind for `sendToMainWindow`, deliberately: an alias would let a new call site keep the ambiguity this split removes. Still exactly one window; behavior is unchanged. Refs #688
Routing a session's events needs to know which window owns it, and the obvious source — the persisted workspace main already parses at save time — is wrong by 400ms. A freshly spawned session would be unrouted for exactly the autosave debounce, which is the interval its first paint lands in. The claim therefore happens inside spawn(), through a callback invoked with the id the moment it is minted. That is earlier than it first appears to need to be: spawn() awaits the provider start, and the provider emits `started`, its first screen snapshot, and its first semantic events during that await, so claiming from the resolved result would still leave a hole. The hook is a callback rather than an ownerWindowId option so SessionManager stays ignorant of windows — it hands out an id and does not care who claims it. session:recover needs no hook because the renderer supplies the durable id it is restoring. Ownership ends on an explicit kill, not on a natural exit: an exited pane is still on screen, still owned, and can be reloaded in place. An unowned session broadcasts rather than drops, and now leaves a metadata-only breadcrumb so the fallback is visible in a freeze log instead of silent. The symmetrical renderer-side "ignore sessions I don't own" guard was designed and rejected — recorded in the plan and in the routing comment because it reads as an oversight otherwise. The renderer cannot distinguish "not mine" from "mine, but not registered yet": a pane's first events legitimately precede the spawn IPC response, so that guard would reject the first frames of every new pane in its own window. Refs #688
Main used to refuse to interpret workspace.json — "the renderer is the source
of truth for the tile tree. Main just reads / writes bytes." That refusal
cannot survive multi-window, for a physical rather than stylistic reason:
window 1 cannot serialize window 2's slice because it has never seen it.
Something must compose one file from N authors, and main is the only party
that can see all of them.
So the refusal narrows instead of disappearing. Main learns exactly one new
thing — which window a blob belongs to — and the blob stays `unknown`, stored
and returned verbatim inside the same `{ workspace }` envelope, so rehydrate.ts
never learns that windows exist.
This is what closes the hazard that shapes the whole feature. useAutoSave
prunes any session it cannot prove is owned; two windows each writing the whole
file would classify the other's agents as orphans and delete them every 400ms,
in both directions, with the file's only writer doing the deleting. Slices make
that impossible by construction rather than by agreement, and the test asserts
the untouched slice survives by REFERENCE so a future deep-cloning refactor
cannot quietly reopen the door.
A file this build cannot represent — a newer version, or truncated JSON — puts
the store in a refuse-to-write mode rather than resetting. Both obvious
alternatives destroy something: throwing makes the app unlaunchable over a file
the user cannot easily repair, and starting fresh silently discards every tab,
agent, and pin. Refusing costs a session instead of the workspace, which makes
launching an older build a survivable mistake.
Startup now restores every persisted window, skipping saved bounds that no
longer land on an attached display. That is the common case, not an edge case:
the entire point of the feature is a window on a second monitor, and laptops
get undocked daily.
Refs #688
New Window is reachable three ways — the File menu, the command palette, and ⌘⇧N. It lives under File rather than Window because that is the macOS convention every native app follows: the Window menu lists and arranges the windows that exist, the File menu creates things. That also leaves `role: 'windowMenu'` intact, which is what supplies the standard window list. Main does the work directly instead of dispatching a command id to the renderer, for the same reason Close Window keeps its native role: a window has no tabs to consult and no tile tree to mutate, so there is nothing for the renderer to decide. The menu item carries no accelerator — the renderer binds ⌘⇧N, and a menu accelerator would open two windows per press, which is the rule appMenu.ts already states for every dispatched item. Geometry is persisted on move, resize, and full-screen rather than only riding along with the next workspace save. Dragging a window to the other monitor changes nothing the renderer knows about and therefore triggers no autosave, so without this the feature's central promise — it comes back where you left it — would depend on the user touching a pane before quitting. A window whose saved bounds no longer land on an attached display gets default placement instead, because an undocked laptop is the common case for a feature whose entire point is a second monitor. Refs #688
Closing a window no longer costs you its agents. They stay alive in SessionManager, and the surviving window takes over the closed window's tabs, sessions, detached records, pins, and drafts — where they appear in its Dispatch under their own projects. The survivor performs the merge, not main. Main deliberately treats a window's workspace payload as opaque, and this merge reasons about tabs, tile leaves, detached records, and pins; implementing it in main would be a second opinion about session ownership, free to disagree with sessionOwnership.ts — the exact failure that module's header warns about. Two findings shaped the merge itself. Converting every adopted tile leaf into a Dispatch row — the obvious reading of "they show up in Dispatch" — is not representable: Tab.root is a TileNode whose only terminal form is a leaf, so a tab with no panes cannot exist. It also buys nothing, because buildDispatchGroups already lists grid-placed sessions alongside detached ones. And tabs have to travel WITH their sessions: collectOwnedSessionIds drops a detached record whose projectTabId names no tab, so bare records would be deleted by the survivor's very next autosave. Adopted sessions are seeded from a backend snapshot rather than recovered. recoverSession exists to reconcile a persisted id with a backend that may not exist any more, at the cost of a generation fence and a 30-second deadline; here every backend is alive and already owned by main, and the only thing missing is the renderer's view of it. Deletion of the closed slice is confirmed, never assumed: until the adopting renderer's next autosave lands, that slice is the only durable record of those sessions. A renderer that refuses the merge, cannot parse it, or dies mid-handoff never confirms, and the workspace comes back as its own window next launch. An id collision refuses the whole adoption for the same reason — dropping a colliding tab would strand its sessions alive, owned by no window, and unkillable from the UI. Quit is excluded: on quit every window closes, and collapsing them into whichever one dies last would destroy the layout the user arranged. Refs #688
# Conflicts: # src/renderer/src/features/command-palette/catalog.test.ts
Two orchestrated adversarial reviews found three blockers and five majors, all of them defects in invariants this change had claimed for itself. **A vetoed close bricked the window.** Electron emits `close` before the renderer's beforeunload veto, so a window that survives "Keep Editing" had already been marked as closing — and a closing window is skipped by every main→renderer send. Its panes froze and its menu stopped working until restart; ⌘Q + Keep Editing did it to every window at once. The veto now disarms the flag. **The quit flag was wrong in both directions.** Clearing it on `browser-window-focus` was a guess at "the quit did not happen". During a real quit, destroying the first window promotes the next to key and fires focus, which cleared the flag mid-quit and let the remaining windows collapse into one. And the unsaved-changes prompt is a sheet, so a vetoed quit never fires focus at all and the flag latched forever, silently disabling the handoff. It is now cleared only by the two paths that know the quit failed. **Adoption confirmed the wrong thing.** Main deletes the closed window's slice on confirmation, and the renderer sent it the moment the merge landed in memory — ~400ms before any save could carry it. Worse, the hook mounts before bootstrap, and rehydrate's first publish replaces workspace state wholesale, so an adoption during restore was erased after main had already deleted the original. A `partial-restore` — which this codebase's comments call recurring — leaves autosave off entirely, needing no race at all. Confirmation now comes from the autosave success path, and adoptions arriving before bootstrap are queued. **The store composed its read-modify-write outside its own queue**, which the plan claimed it did not. Window B could compose against the document as it was before window A's in-flight write and revert A's slice — a tab closed in one window reappearing after an unrelated save in the other, which is exactly the resurrection class pruneSessionOwnership exists to prevent. Also: adopted parked and buried sessions now get runtimes (the wake path no-ops without one, so Attach to Grid opened an empty feed); adopted panes are seeded from a backend snapshot (readiness dedupes in main, so every adopted pane read "starting agent" forever and its first send detoured through a full recovery); refusals roll ownership back instead of stranding live sessions on a window that will never show them; a refuse-to-write workspace file now tells the user instead of silently discarding a day's work; a destroyed window's final unload save still resolves to its slice; ownership is released when a session is removed; the unowned-route breadcrumb records which channel was misrouted. The two regressions with the worst consequences are mutation-checked: reverting the bootstrap gate, the deferred confirmation, or the in-queue composition each turns a new test red. Refs #688
Review round 1 — two orchestrated adversarial reviewersTwo agents reviewed this branch independently (main-process / data-safety, and renderer / tests / conventions). They returned 3 blockers and 5 majors, and every one was a defect in an invariant this PR had claimed for itself. All are fixed in Blockers
Majors
Plus: a destroyed window's final unload save resolves to its slice again (tombstone lookup); ownership is released on session removal; the unowned-route breadcrumb records which channel was misrouted; VerificationNew coverage:
|
Fixes #688
The problem
Agent Code is built for running many agents at once and then confines all of
them to a single window. On a two-monitor desk the second display is unusable —
there is no way to put five agents on one screen and another set on the other.
The single-window assumption was explicit and load-bearing.
mainWindow.tsheldone module-scoped
BrowserWindowand said so in a comment;WorkflowBridgeandOrchestrationBridgeboth carried// a future multi-window product must make this target-awarenotes; the app menu shipped a bare{ role: 'windowMenu' }with no New Window anywhere.
docs/archive/issue-investigations/147-multi-instance-policy.mdhad alreadystudied this and concluded that multi-window must mean more windows in the one
main process, never more main processes — two Electron mains would put two
writers on
~/.config/agent-codewith no merge semantics and could resume thesame provider session twice. That conclusion is adopted wholesale; both instance
locks are untouched.
What this delivers
File → New Window, thenew-windowpalette command, and ⌘⇧N. Each windowis its own workspace: own tabs, panes, Dispatch, and agents.
workspace.jsongains a window dimension; every windowrestores with its own layout, bounds, display, and full-screen state.
surviving window takes over its tabs, sessions, detached records, pins, and
drafts, where they appear in Dispatch under their own projects.
broadcast to every window.
The hazard this had to close
useAutoSavedoes not merely serialize the workspace — it prunes. Anysession it cannot trace to a tile leaf, a detached record, or a buried pane is
dropped, deliberately, so orphan metadata cannot make itself durable.
Correct for one writer, catastrophic for two. If each window wrote the whole
file, each would classify the other's agents as orphans and delete them — every
400 ms, in both directions, with the file's only writer doing the deleting. So
workspace.jsonis now composed by main from per-window slices, and a window'ssave can only ever rewrite its own subtree.
The regression test asserts the untouched slice survives by reference, so a
future refactor that deep-clones windows on save cannot quietly reopen the door.
Design decisions worth reviewing
Main now interprets one thing about workspace.json, and only one. The old
handler took opaque bytes on principle. That could not survive: window 1 cannot
serialize window 2's slice because it has never seen it. Main learns which
window a blob belongs to and nothing else — the payload stays
unknown, storedand returned inside the same
{ workspace }envelope, sorehydrate.tsneverlearns windows exist.
Ownership is claimed at id-mint time, inside
spawn(). Claiming from theresolved result is too late:
spawn()awaits the provider start, and theprovider emits
started, its first screen snapshot, and its first semanticevents during that await. The hook is a callback rather than an
ownerWindowIdoption so
SessionManagerstays ignorant of windows.There is no renderer-side "ignore sessions I don't own" guard, and that is
deliberate rather than an oversight. The renderer cannot distinguish "not mine"
from "mine, but not registered yet" — a pane's first events legitimately precede
the spawn IPC response — so such a guard would reject the first frames of every
new pane in its own window. Routing is made exact instead, and the unowned
fallback (a broadcast, because a dropped event silently freezes a pane) leaves a
metadata-only breadcrumb so it is visible in a freeze log.
Adopted tabs keep their tile trees. Converting every leaf into a Dispatch row
was the first design and is not representable:
Tab.rootis aTileNodewhoseonly terminal form is a leaf, so a tab with no panes cannot exist. It also buys
nothing —
buildDispatchGroupsalready lists grid-placed sessions alongsidedetached ones, so an adopted agent shows up in Dispatch either way.
Tabs travel with their sessions.
collectOwnedSessionIdsdrops a detachedrecord whose
projectTabIdnames no tab. Handing a survivor bare records woulddelete them on its very next autosave.
A file this build cannot represent is not overwritten. A newer version or
truncated JSON puts the store in refuse-to-write mode rather than resetting.
Throwing would make the app unlaunchable over a file the user cannot easily
repair; starting fresh would silently discard every tab, agent, and pin.
Adoption deletion is confirmed, never assumed. Until the adopting renderer's
next autosave lands, the closed slice is the only durable record of those
sessions. A renderer that refuses, cannot parse, or dies mid-handoff never
confirms, and the workspace returns as its own window next launch.
Two defects found in self-review and fixed
app.on('activate')minted a fresh window id, which would have loadednothing and orphaned the persisted slice of the window the user had just
closed. Startup and activate now share one restore closure so they cannot
drift again.
setImmediateand slice read, so events in that gap fell back to a broadcast. Ownership now
transfers synchronously, before anything is awaited.
Tests
npm test— 2221 passed, 1 failed. The failure isimageAttachment.test.ts > every fixture is traceable to … a real session, whichasserts
existsSyncon a Claude transcript under~/.claude/projects/-Users-juliusolsson-Desktop-Development-klay/that no longerexists on this machine. Verified pre-existing: it fails identically on
mainwith no changes applied.
npm run typecheck,npm run test:contract, andnpm run check:keybindingsallpass.
New coverage:
workspaceFile.test.ts— one window's save cannot rewrite another's (byreference); v1 migration preserves tabs, sessions, detached, buried, pins and
drafts; newer/truncated files are refused rather than reset; duplicate window
ids and payload-less records are dropped.
workspaceFileStore.test.ts— the ordering and temp-cleanup cases moved withthe behavior from the old
ipc/workspace.test.ts, plus on-disk per-windowisolation, refuse-to-write, and "a failed write does not advance the in-memory
document".
ipc/workspace.test.ts— payloads land in the sender's slice, ownership isacknowledged as the union across windows and in admission order, and a sender
owning no window is rejected rather than guessed at.
windowRegistry.test.ts— session events reach only the owner; unownedbroadcasts; transfer moves routing; ownership survives a natural exit and ends
on an explicit kill; a closing window is skipped; the closed-window observer
runs after the registry has dropped it.
adoptWorkspace.test.ts— adopted detached records are still owned by thesurvivor's own ownership rules (the assertion proving tabs had to travel with
sessions); tile arrangement, pins, drafts and buried panes survive; the
Dispatch project ordinal is re-derived; an id collision refuses the whole
adoption.
windowGeometry.test.ts— bounds on a detached display are rejected, a windowparked half off-screen is kept, a sliver too small to grab is not.
Not automated
Dragging a window to a second physical monitor and relaunching. Electron cannot
fake a second display deterministically, and a mocked
screenwould assert ourown fake rather than macOS behavior. The predicate that decides it is split out
and unit-tested against work-area rectangles.
Manual verification worth doing before merge:
both windows return to their displays with their own workspaces.
window's Dispatch under their own project tabs, with drafts intact.
remaining display rather than off-screen.
it acts on window 2's workspace.
Known limitations
localStorageorigin, so a settingschange in one is not observed by the other until it reloads. Benign and
self-healing; a
settings:changedbroadcast is the fix if it becomes annoying.window, reattach in the other.
will-prevent-unloadnow prompts per window during a quit with unsavededitor files in more than one window. That is correct rather than merely
tolerable — each dialog names work the user can actually see — but it is a
visible change.
"quit cancelled" event. A quit vetoed by the unsaved-editor dialog leaves the
flag set until the next focus event.
🤖 Generated with Claude Code
https://claude.ai/code/session_0177WqFRiFYVpkNmj2h22YgU