Skip to content

feat(window): support multiple application windows for multi-monitor workspaces - #692

Merged
Juliusolsson05 merged 8 commits into
mainfrom
feat/multi-window
Aug 30, 2026
Merged

Juliusolsson05 merged 8 commits into
mainfrom
feat/multi-window

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

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.ts held
one module-scoped BrowserWindow and said so in a comment; WorkflowBridge and
OrchestrationBridge both carried // a future multi-window product must make this target-aware notes; the app menu shipped a bare { role: 'windowMenu' }
with no New Window anywhere.

docs/archive/issue-investigations/147-multi-instance-policy.md had already
studied 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-code with no merge semantics and could resume the
same provider session twice. That conclusion is adopted wholesale; both instance
locks are untouched.

What this delivers

  • File → New Window, the new-window palette command, and ⌘⇧N. Each window
    is its own workspace: own tabs, panes, Dispatch, and agents.
  • Windows persist. workspace.json gains a window dimension; every window
    restores with its own layout, bounds, display, and full-screen state.
  • Closing a window is not destructive. Its agents stay alive and the
    surviving window takes over its tabs, sessions, detached records, pins, and
    drafts, where they appear in Dispatch under their own projects.
  • Session IPC is routed to the window that owns the session instead of being
    broadcast to every window.

The hazard this had to close

useAutoSave does not merely serialize the workspace — it prunes. Any
session 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.json is now composed by main from per-window slices, and a window's
save 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, stored
and returned inside the same { workspace } envelope, so rehydrate.ts never
learns windows exist.

Ownership is claimed at id-mint time, inside spawn(). Claiming from the
resolved result is too late: spawn() awaits the provider start, and the
provider emits started, its first screen snapshot, and its first semantic
events during that await. The hook is a callback rather than an ownerWindowId
option so SessionManager stays 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.root is a TileNode whose
only terminal form is a leaf, so a tab with no panes cannot exist. It also buys
nothing — buildDispatchGroups already lists grid-placed sessions alongside
detached ones, so an adopted agent shows up in Dispatch either way.

Tabs travel with their sessions. collectOwnedSessionIds drops a detached
record whose projectTabId names no tab. Handing a survivor bare records would
delete 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 loaded
    nothing 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.
  • Session ownership stayed on the dead window across the handoff's setImmediate
    and 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 is
imageAttachment.test.ts > every fixture is traceable to … a real session, which
asserts existsSync on a Claude transcript under
~/.claude/projects/-Users-juliusolsson-Desktop-Development-klay/ that no longer
exists on this machine. Verified pre-existing: it fails identically on main
with no changes applied.

npm run typecheck, npm run test:contract, and npm run check:keybindings all
pass.

New coverage:

  • workspaceFile.test.ts — one window's save cannot rewrite another's (by
    reference); 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 with
    the behavior from the old ipc/workspace.test.ts, plus on-disk per-window
    isolation, 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 is
    acknowledged 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; unowned
    broadcasts; 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 the
    survivor'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 window
    parked 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 screen would assert our
own 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:

  1. New Window (⌘⇧N), drag it to a second display, add agents, quit, relaunch —
    both windows return to their displays with their own workspaces.
  2. Close the second window — its agents stay alive and appear in the first
    window's Dispatch under their own project tabs, with drafts intact.
  3. Quit with two windows open — both come back, not collapsed into one.
  4. Unplug the second monitor and relaunch — that window opens centred on the
    remaining display rather than off-screen.
  5. Run an orchestration/agent-management MCP tool from an agent in window 2 —
    it acts on window 2's workspace.

Known limitations

  • Settings drift. Both windows share one localStorage origin, so a settings
    change in one is not observed by the other until it reloads. Benign and
    self-healing; a settings:changed broadcast is the fix if it becomes annoying.
  • Moving an agent between windows is out of scope (feat(window): support multiple application windows for multi-monitor workspaces #688). Detach in one
    window, reattach in the other.
  • will-prevent-unload now prompts per window during a quit with unsaved
    editor 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.
  • The quit flag is cleared on window focus because Electron has no
    "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

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
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Review round 1 — two orchestrated adversarial reviewers

Two 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 d04c5181.

Blockers

  1. A vetoed close bricked the window. Electron emits close before the renderer's beforeunload veto, so a window surviving "Keep Editing" was already marked closing — and a closing window is skipped by every main→renderer send. Panes froze and the menu stopped working until restart; ⌘Q + Keep Editing did it to every window at once. The veto now disarms the flag (onCloseVetoed).
  2. 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 — clearing the flag mid-quit and letting the remaining windows collapse into one. And the unsaved-changes prompt is a sheet, so a vetoed quit never fires focus and the flag latched forever, silently disabling the handoff. Now cleared only by the two paths that actually know the quit failed.
  3. 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. The hook also mounted before bootstrap, and rehydrate's first publish replaces workspace state wholesale, so an adoption during restore was erased after main had deleted the original. A partial-restore needs no race at all: autosave stays off for that renderer's whole life. Confirmation now comes from the autosave success path; pre-bootstrap adoptions are queued.

Majors

  • The store composed its read-modify-write outside its own queue — which the plan asserted it did not. Window B could compose against the pre-A document and revert A's slice: a tab closed in one window reappearing after an unrelated save in the other, the exact resurrection class pruneSessionOwnership exists to prevent.
  • Adopted parked/buried sessions got no runtime. ensureSessionLive no-ops every write without one, so Attach to Grid on an adopted Dispatch agent opened an empty feed with no transcript and no way to report failure — the exact case this feature exists to rescue.
  • No backend-snapshot seeding. setInputReadiness dedupes in main, so every adopted pane read "starting agent" forever and its first send detoured through a full recoverSession.
  • A refusal stranded live sessions on a window that would never display them. Refusals now roll ownership back through window:adoption-refused.
  • Refuse-to-write mode was invisible — the app looked normal while nothing persisted. It now says so in a startup dialog.

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; setFullScreen moved after show(); dead adoptedTabIds removed; test casts dropped; stale sendToMainWindow comments fixed.

Verification

New coverage: useWorkspaceAdoption.renderer.test.tsx (7 cases over the property that authorizes main to delete user data), a two-window store race test, a late-save-after-adoption test, and registry veto/focus/tombstone cases. Mutation-checked — reverting the bootstrap gate, the deferred confirmation, or the in-queue composition each turns a new test red.

npm test: 2229 pass. Local failures are environment-only and unrelated: imageAttachment.test.ts cites a transcript missing from this machine (fails identically on main), and 5 others are 5s-timeout tests that all pass with --testTimeout=30000 — an artifact of this worktree's symlinked node_modules. CI has a real node_modules and was green before these fixes.

@Juliusolsson05
Juliusolsson05 merged commit e6dbd8a into main Aug 30, 2026
1 check passed
@Juliusolsson05
Juliusolsson05 deleted the feat/multi-window branch August 30, 2026 21:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(window): support multiple application windows for multi-monitor workspaces

1 participant