Skip to content

Multisite: Site Spaces — another admin opens on its own desktop - #736

Merged
epeicher merged 6 commits into
trunkfrom
multisite-site-spaces
Sep 2, 2026
Merged

Multisite: Site Spaces — another admin opens on its own desktop#736
epeicher merged 6 commits into
trunkfrom
multisite-site-spaces

Conversation

@epeicher

@epeicher epeicher commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Demo

OpenStation.Multisite.mp4

What it does

A cross-admin click now opens the target in that admin's own Space — a virtual desktop scoped to it. Click the Network Admin tile on a site's desktop and a "Network Admin" Space slides in (the desktops switcher's own animation) holding the network dashboard as a window; click a site's "Dashboard" row in the network Sites window and a Space named after that site slides in with its dashboard. One Space per admin, reused by every later click; Spaces and their windows persist across reloads, and closing one from overview closes its cross-admin windows. The dock follows the Space: inside the Network Admin Space it is the network menu, inside a site's Space it is that site's own menu, and back on your own desktop it is yours again. Your own desktop stays exactly where you left it.

The browser-tab behavior survives as the side-by-side gesture (cmd/ctrl/shift or middle click), and it remains the automatic fallback where a Space is impossible: a subdomain or mapped site's admin is another origin, and a window is an iframe WordPress refuses to serve cross-origin.

Supersedes #735 (closed unmerged): same clarified goal — "open the other admin in another Desktop with a subtle animation" — built on the Desktops feature instead of navigating between shells. The hop branch's machinery rides along: the modifier-gesture split, the MouseEvent threading through SystemDockItem.onOpen / SubmenuItem.onSelect, and the shell's cross-document view-transition opt-in (which still animates the shell-to-shell navigations that remain: typed URLs, bookmarks, a kept admin bar).

Rationale

#704 sent every cross-admin click to a new browser tab; the follow-up idea was Spaces. With per-admin sessions (#728) plus the Desktops plumbing that already existed (createDesktop, switchDesktop with slide animation, closeDesktop, the overview bar), the missing piece was small and precise: teach a desktop which admin it hosts, and teach the three subsystems that care — the session sanitizer, the desktop-close path, and the menu-payload consumer.

Implementation

  • Desktop.scope — an admin-scope path (/site2/wp-admin/, /wp-admin/network/). createSpaceOpener() in src/multisite/spaces.ts owns the whole opening rule (find-or-create the Space, slide, open the window there; tab fallbacks); the dock tile and the bridge route through the one opener so the two entry points cannot disagree. A target in the shell's own admin never gets a Space: its desktop is the primary one, so from inside a Space the opener slides home first and opens the window there. The bridge posts a new os-iframe-other-admin-link message for same-origin clicks that leave its admin (documented in bridge-protocol.md).
  • Sessions. The per-admin scoping from Multisite: drop deleted sites' tables, Core parity for site admins, per-site PWA, and a multisite CI job #728 makes exactly one exception: a window whose URL's admin scope equals its desktop's declared scope persists (openstation_session_window_url_ok()), same host required. The stored scope is validated as a normalized admin-scope path — its own openstation_admin_scope_of_path() fixed point, path-rooted (a full URL cannot sneak through). Enforced on write and on read; a window pointing at a dead desktop id loses the privilege with the remap.
  • Closing a Space closes its own admin's windows; windows of the shell's admin that wandered in still migrate to the neighbour, like on any desktop (migrating cross-admin windows would park them where the sanitizer drops them at the next save — a silent loss dressed as a move).
  • The dock follows the active desktop's admin. A plain admin page emits only a menu signature, never its dock menu, so a Space's menu is harvested lazily the first time you enter it — one hidden probe against that admin (the openstation_menu_refresh probe live refresh already uses, pointed at the Space's admin base) — cached, and swapped in on every switch through applyDockItems(), a dock-only repaint (createSpaceDockController() in src/multisite/space-dock.ts). Until the first harvest lands the previous dock stays (never an empty one); a harvest that lands after you've left is cached without repainting; a failed one retries on the next entry. Only the admin menu is swapped — system tiles stay on every desktop. A payload from the shell's own admin (a home-admin window living on a Space, or a probe spent while the user stood there) paints only while the dock shows the home menu; otherwise the fresh home items wait for the next switch home (applyHomeDockItems()). The harvest probe keeps the request's admin context: its placeholder screen is admin-network / admin-user (openstation_menu_refresh_probe_screen_id()), because WP_Screen reads a bare id's context off its suffix and a plain admin screen turned every network probe into a site request, so the harvested network dock pointed its tiles at site pages. This replaces the earlier "dock stays the booted admin" compromise, whose quarantine was also racy: a Space window loading plugins.php could leak the other admin's payload and repaint the shared dock.
  • Payload quarantine. frameSourceIsOtherAdmin() (in src/boot/menu-refresh.ts) drops os-plugins-changed payloads and os-menu-signature fingerprints from frames mounted on another admin — otherwise the first plugins.php opened in a Space repaints this dock with that admin's menu (the failure mode multisite.md documents), and every Space navigation spends a refresh probe. os-updates-changed deliberately passes: updates are network-wide, and its probe runs against this shell's own admin.
  • The admin-scope rule now has three deliberate copies — the bridge's inline adminScope() (the bridge stays a self-contained plain script), src/admin-scope.ts for the shell, and PHP's openstation_admin_scope_of_path() — pinned against one shared URL table (tests/vitest/admin-scope.test.ts and its row-for-row PHPUnit twin).

Known quirk, inherited from Spaces generally: the boot entry window opens on the ACTIVE desktop, so reloading while standing in a Space puts your own admin's dashboard there; it migrates safely if the Space is closed.

Testing instructions

npm run test:js             # site-spaces, admin-scope, multisite-dock-tiles, chromeless-bridge-links
npm run test:php            # scoped-desktop persistence round-trips
npm run test:php:multisite

On a subdirectory multisite (plugin network-activated):

  1. On a site desktop, click the Network Admin dock tile: a "Network Admin" Space slides in with the network dashboard as a window; your dock is unchanged. Hover the tile and pick Sites: the same Space gains the Sites window — no second Space.
  2. In the Sites window, click a site's Dashboard row: a Space named after that site slides in with its dashboard. Click your own site's row instead (the main site, from its own shell): it slides back to Desktop 1 and opens there, no Space.
  3. Inside a site Space the dock shows that site's menu (Posts, Media, Pages, …) a beat after first entry, instantly on re-entry; open Plugins there, then switch back to Desktop 1: the dock is your own admin's menu again, never the site's. In the Network Admin Space, Plugins on the dock opens the network plugins.php, and the dock stays the network menu after it loads.
  4. Reload: all Spaces come back, scopes, windows and all.
  5. Open overview and close a site Space: its cross-admin windows close with it; anything from your own admin migrates.
  6. Cmd/ctrl-click the tile or a Sites row: a browser tab, desktops untouched.
Open WordPress Playground Preview

…place

Cross-admin activations (the Network Admin tile, its flyout rows, and
same-origin links that leave the current admin inside a window) now
navigate THIS tab to the other admin's shell instead of forcing a
browser tab. Every admin keeps its own desktop under its own session
key, so each side restores exactly as it was left: hopping between the
network admin and a site reads as switching workspaces, spelled as a
navigation. The shell stylesheet opts into cross-document view
transitions, so on supporting browsers the two desktops crossfade (a
gentle zoom-through, instant under reduced motion) instead of
hard-cutting; chromeless iframes never opt in, so in-window
navigations never transition.

The browser-tab behavior survives as the side-by-side gesture: the
dock routes modifier and middle clicks through hopToAdmin()'s split,
and the bridge already yields modified clicks to the browser, whose
native new-tab handling is the same thing. Cross-origin links (a
subdomain or mapped site's admin) were never classed other-admin and
keep opening tabs.

SystemDockItem.onOpen and SubmenuItem.onSelect now receive the
originating MouseEvent when there is one, so any navigating handler
can honour the same gestures; handlers that ignore the argument are
unaffected.
A cross-admin activation (the Network Admin tile and its flyout rows,
a Sites-list Dashboard link inside a window) now opens the target in
that admin's own Space: a virtual desktop scoped to it, created on
first use and labelled after the site (or Network Admin), slid to
with the desktops switcher's own animation, and reused by every later
click for the same admin. The page is an ordinary iframe window on
that desktop, so this exists exactly where framing does — a
cross-origin admin opens a browser tab, and a modifier or middle
click opens one anywhere. createSpaceOpener() owns the whole rule;
the dock tile and the bridge route through the one opener.

The desktop persists its admin as Desktop.scope, and three behaviors
hang off it. Its windows persist: the per-admin session scoping makes
one exception for a window whose URL scope equals its desktop's
declared scope (same host required, and the stored scope must be a
normalized admin-scope path). Closing the Space closes them, while
windows of the shell's own admin still migrate to the neighbour. And
its payloads are quarantined: an os-plugins-changed payload or an
os-menu-signature fingerprint from a frame showing another admin is
dropped, so the first plugins.php opened in a Space cannot repaint
this dock with that admin's menu and Space navigations spend no
refresh probes — os-updates-changed stays through, since updates are
network-wide and its probe runs against this shell's admin.

The admin-scope rule now has three deliberate copies — the bridge's
inline one, src/admin-scope.ts for the shell, and PHP's
openstation_admin_scope_of_path() — pinned against one shared URL
table on both sides of the fence.

Folds in the workspace-hop branch it was built on: the modifier
gesture split, the event threading through onOpen/onSelect, and the
shell's cross-document view-transition opt-in, which still animates
the shell-to-shell navigations that remain (typed URLs, bookmarks).
Inside a Space the dock now shows THAT admin's menu — the network menu
in the Network Admin Space, a site's own menu in its Space — and the
shell's own menu on ordinary desktops, so a Space reads as that
admin's desktop, dock and all. Before, the dock stayed the booted
admin's menu on every desktop, and the foreign-payload quarantine
that kept it that way was racy: when a Space window loaded an
allowlisted page (plugins.php, themes.php) and the frame match missed
by a beat, the other admin's payload leaked and repainted the shared
dock — sometimes the network menu, sometimes the site's, never by
design.

A plain admin page emits only a menu signature, never its dock menu,
so a Space's menu is harvested lazily the first time the user enters
it: one hidden probe against that admin (the openstation_menu_refresh
probe live refresh already uses, pointed at the Space's admin base),
cached, and swapped in on every switch through applyDockItems() — a
dock-only repaint. createSpaceDockController() owns it; the switch
hook and a post-restore seed (a session can boot straight into a
Space) drive it. Until the first harvest lands the previous dock
stays, never an empty one; a harvest that lands after the user has
left is cached without repainting; a failed one is retried on the
next entry.

Only the admin MENU is swapped. System tiles stay on every desktop,
and the full-payload quarantine still stands: a foreign admin's
native windows, widgets and the rest never register in this shell —
its dock items are borrowed, and only to paint them.
From inside a Space, a click on a page of the shell's own admin (the
main site's Dashboard row in the network Sites list, seen from the main
site's shell) opened the window on the Space it was clicked from. The
home admin has no Space, its desktop is the primary one, so the opener
now slides home first when the active desktop is scoped, and opens the
window there.
…ever repaints a Space

Two faults behind "the Network Admin Space's dock turns into the site
menu when a window opens there".

The menu-refresh probe built its placeholder screen from a plain
`admin` id, and WP_Screen reads a bare id's admin context off its
suffix, so a network probe became a site request from that point on:
`self_admin_url()` resolved every network menu slug against the site
admin, and the dock harvested for a Space sent its Plugins tile to the
site's `plugins.php`. The screen now carries the request's context
(`admin-network`, `admin-user`).

And a payload from the shell's own admin repainted the dock whatever
desktop was showing. It now goes through the per-Space dock
controller, which paints it only while the dock shows the home menu
and otherwise keeps it for the next switch home.
# Conflicts:
#	docs/javascript-reference.md
#	includes/session.php
#	src/types.ts
@epeicher
epeicher merged commit f20ff5c into trunk Sep 2, 2026
5 checks passed
@epeicher
epeicher deleted the multisite-site-spaces branch September 2, 2026 16:03
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.

1 participant