Skip to content

Workspaces

Workspaces are virtual desktops. Three ship out of the box; each open window is tagged with the workspace it was opened on. Switching workspaces only filters the window layer; every window keeps its bounds and internal state, so it’s there waiting when you come back.

Click the three pips next to the clock, or press Ctrl+Alt+→ Open in new tab ↗

The menu bar shows a row of pips next to the clock (one per workspace, the active one is wider and accent-colored). Click any pip to jump to that workspace; the keyboard chord moves at the speed of muscle memory.

ShortcutEffect
Ctrl + Alt + ←Previous workspace (wraps)
Ctrl + Alt + →Next workspace (wraps)
Ctrl + Alt + Shift + ←/→Same, and bring the focused window along
Right-click a window’s title barPick “Move to Workspace N” from the menu

Every binding bails when the event target is an <input>, <textarea>, or contenteditable element. Workspaces never hijack typing.

  • Focus follows you. Switching to a workspace restores the focus to whichever window was on top there. Returning to an empty workspace drops focus.
  • Opening across workspaces. Calling openWindow({ kind: "app", appId }) from anywhere, when the window already exists on another workspace, pulls you to that workspace and focuses the window. Mirrors what Cmd+Tab does on macOS.
  • Closing the focused window hands focus to the next-highest-z window on the current workspace, not across spaces.
  • appearsAsDesktopIcon predicates are evaluated against the storage adapter, not per-workspace. Desktop icons appear on every workspace consistently; this is the same model as macOS: folders and shortcuts live “on the desktop,” not “on Space 2.”

useWindowManager() now exposes four workspace actions:

const {
state, // includes workspaces[], activeWorkspaceId
switchWorkspace, // (workspaceId) => void
moveWindowToWorkspace, // (windowId, workspaceId) => void
addWorkspace, // (workspaceId?) => void, id auto-generated when omitted
removeWorkspace, // (workspaceId) => void, migrates windows to the first remaining
} = useWindowManager();

removeWorkspace refuses to drop the last workspace (a desktop needs at least one). It migrates windows on the removed workspace to the first remaining workspace rather than silently deleting them.

Replace the seed list by dispatching ADD_WORKSPACE and REMOVE_WORKSPACE from your app boot, or render your own menu-bar widget that calls addWorkspace() on click:

import { useWindowManager } from "@react-ui-os/core";
function AddWorkspaceButton() {
const { state, addWorkspace } = useWindowManager();
return (
<button
type="button"
onClick={() => addWorkspace()}
disabled={state.workspaces.length >= 9}
>
+ Workspace
</button>
);
}

(The library doesn’t ship this as a default: three felt like the right initial count, and many apps deliberately reduce to one to hide the feature.)

Adding the workspace field is a non-breaking change at the call-site level (every existing useWindowManager action still works) but the OpenWindow shape now includes a required workspaceId: string. Consumers persisting their own WM snapshots to disk will see the field on deserialization; treat missing values as the active workspace at load time.

  • The pip cluster in the menu bar is role="tablist" aria-label="Workspaces"; each pip is a <button role="tab"> with an aria-selected state.
  • The HUD shows the workspace number on switch so screen readers (with aria-live="polite") announce the change.
  • Keyboard shortcuts are independent of pointer interaction.