cmux NEXT spaces: each space owns its pins and groups

Merged
TE
1d ago
+1234-33

Refs #28701 (follow-up)

Leo, 2026-10-29: "could u guys also now work on the per space pins and groups? rn anything u make in teh default space goes across to new ones"

What was wrong

The sidebar document is one per home session, shared by every space. A window drew a space's own workspaces, and pinned workspaces were already scoped to their space (spaces-overhaul-DESIGN.md rule 1). Two kinds of item still drew in every space:

  • Groups that list no workspace of the window, such as an empty group or a Pinned or custom group whose workspaces closed or sit in a space no window shows. Each one drew its header in every space.
  • Links, tabs and apps pinned to Pinned drew in every space.

So a new space started with the default space's groups and pins.

What changed

Each space owns its pins and groups (leo-workspace design/spaces.md):

  • A new group stores the space it was made in, taken from the acting window's state, as the document group's profile. The reducer has carried that field since the personal-group migration; the client never sent or read it.
  • A link, tab or app pinned to Pinned stores the acting window's space as extra.room. The reducer keeps unknown extras.
  • A window on space S draws such a group or Pinned item only when it belongs to S. A group still draws wherever one of its open workspaces draws, so the workspace's own space wins and no row hides in a group that isn't drawn.
  • Migration is a reading, not a rewrite. Anything made before items stored a space reads as the default space's. A group with no stored space but an open workspace elsewhere follows that workspace. Old personal groups already carry their room. No stored data changes, and nothing is deleted.
  • Unchanged: the top band (Home, App Store, built-ins), custom sections (they keep their own space scope toggle), and a window with no space (a home daemon without profiles-v1), which draws everything.
  • The band's change check (bandGroupsNotListedHere) includes the space filter, so a space switch redraws the groups it leaves out.
  • No item is left in no space. An item or group whose stored space no longer exists reads as the default space's (SidebarDocItem.owningRoom). That covers a deleted space, a space not mirrored back yet, and an imported document. A space that comes back (Reopen) gets its items back. Until the home store mirrors its spaces, every stored space counts, so nothing hides at launch.
  • Delete Space with moveTo moves the space's Pinned links, tabs and apps (item.update {room}) and its groups (group.update {profile}) to the target, in one change after the daemon's delete (SidebarDocStore.moveSpace). Move Group to Space stores the target on the document group too (SidebarDocStore.moveGroup), so the group stays there once it has no open workspace.
  • A pinned group whose live workspace lists in this window keeps its link-only child groups (bandGroupsNotListedHere(listedHere:)).
  • A nested group goes with its parent (Group.followsParent), whatever space it stores: only a top-level group's own space counts. So no group lists in no space, whatever an op leaves stored. A top-level group always has a space that exists (its own, else Default), and everything under it lists wherever it lists. This is the safety net for the whole class: it does not depend on each op getting the stored spaces right. Move Group to Space also carries the nested groups that stored the old space (SidebarDocStore.groupSpaceOps), so they keep the right space if they leave the parent.

No daemon change; both fields already exist in cmux-layout-reducer.

Risk

Low to normal. It is client-side and touches only the projection of groups and Pinned items. The worst case is a group or pin drawing in the wrong space. Undo: stored data is unchanged, so reverting brings back the old drawing.

Overlap

https://github.com/manaflow-ai/cmux/pull/26649 scopes the same band items per window and saves them in window records. This PR scopes them per space, which is Leo's bug: one window switching to a new space still showed the default space's groups. Since New Window opens on a new space (overhaul rule 3), a new window is clean under this rule too. The two can coexist (an item draws when its window and its space both allow it), but they edit the same lines in SidebarDocBridging.swift and SidebarDocListing.swift, so whichever lands second needs a rebase.

Tests

SidebarSpaceOwnsPinsAndGroupsTests (new) covers:

  • a group draws only in the space it was made in
  • with no space filter, every group draws
  • a group lists where its open workspace lists
  • a Pinned link draws only in its space, and an old one is the default space's
  • the change check follows the window's space
  • the wire reads the space from profile and from extra.room
  • New Group stores the window's space
  • a Pinned link stores the acting window's space, and Add to Top stores none

Neighbouring suites run alongside: SidebarNewWindowGroupsTests, SidebarDocPinnedGroupTests, SidebarDocPinInGroupTests, SidebarDocAppSectionAddTests, SidebarDocNewWorkspaceTopTests, SidebarDocArrangeIdempotenceTests, PinnedRowsLookLikeRowsTests, PinnedWorkspaceShortcutsTests, SidebarIncrementalMatchesRebuildGapsTests.

SidebarSpaceReachabilityTests (new) is the invariant. One document has band and machine groups nested across Default, Work and Play, with Pinned links, tabs and apps. A table of ops runs through the store's own planning where it has one: Move Group to Space (band and machine), Delete Space with and without moveTo, rename, pin, unpin, a workspace moving to another space, New Subgroup, and nesting under another space's group. After each op, every group and every link, tab or app must list in at least one space. Red at the test-only commit 442bef09 (cmux-ci step b2e2b0c500d2a929726f42b0) found three such orphans: Move Group to Space left the nested Work group and its link in no space, a workspace moving to Play left Work's empty nested group in none, and a Default group nested under Work's group listed nowhere.

Review rounds

  • gpt-6-astra (high) at e379b9bf: a link-only child of a pinned group with a live workspace disappeared in Default. Fixed in 07f752da. Red: aLinkOnlyChildOfAListedPinnedGroupStays at fcc079da (cmux-ci step 95d1ca7f0382d4b96b1be1ac).
  • gpt-6.1-sol (xhigh) at da461fce: a link pinned in a space deleted with moveTo showed in no space. Fixed in 19f34716. Red: aGoneSpacesPinsAndGroupsListInDefault, deletingASpaceIntoAnotherMovesItsPinsAndGroups and movingAGroupToASpaceStoresItsSpace at 8280f4cc (cmux-ci step 02a6983d60f7e1153a61c535).
  • The audit of other paths that could leave a space reference dangling: rename keeps the id; delete without moveTo and import or restore are covered by the default-space reading; Move Group to Space now stores the target; a workspace's space is its own; an item unpinned out of Pinned is not filtered by space; and an incognito space deleted when its window closes has its items read as Default's.
  • gpt-6-astra (high) at 80d23843: a link-only child or a New Subgroup (no stored space) disappeared outside Default. Red at e4422e58 (cmux-ci step 2881ee1d6f5ccf42fdc02cde); fixed by the parent rule.
  • gpt-6-astra (high) at 30be5363: nested groups disappeared from every space after Move Group to Space. Fixed for the whole class by a nested group going with its parent, plus the invariant table above.
  • Green at d6afb982: SidebarSpaceOwnsPinsAndGroupsTests plus the 12 sidebar suites and SidebarDocPerAppTests (cmux-ci step 4172ec6b57458ca467e0d409).

Gate notes

  • Sidebar-Look:: 28d1f36386e carried no trailer. It is logged in plans/cmux-next/sidebar-owner.md as no visual change, and every later commit carries the trailer.
  • app_lines (prepush arch-ratchet, not a CI check): CmuxNextApp grows by 4 lines. That is the space wiring: SidebarRows.visibleLayout's room/spaces, SidebarDocWiring's room/spaces and New Group's newItemRoom, and the Delete Space and Move Group to Space calls. The logic lives in CmuxNextSidebar and CmuxNextBridge.

Before / after

Tour scripts/cmux-next/media/tours/space-pins-groups.json (synthetic data, 2x). In the default space: workspace Notes goes into a new group Design, the group is pinned, Notes closes, and Design stays in Pinned. Then a space Work is made and the window switches to it. Before is base a3f620e, after is the PR head.

lightdark
Before (base a3f620e): Work shows the default space's pinned Designlightdark
After (6825464): Work shows only its ownlightdark

Viewed after image: yes
Visual verdict: In the Work space the sidebar shows only Home and its own workspace with no leaked Design group, clean in light and dark and fit to sit next to ChatGPT or Claude.

Agent session: agent session

šŸ¤– Generated with Claude Code