Skip to content

Desktop shell

The desktop app is an Electron shell around the same web SPA everything else runs, plus a main process that gives you things a browser tab can’t: a real terminal, global hotkeys, native notifications, and windows with their own dock identity. It talks to the same self-hosted server over the same tRPC and WebSocket APIs; there is no separate desktop backend.

When SUBSPACE_SERVER_URL points at a claimed hosted cell, first run opens a pairing window instead of requiring SUBSPACE_DEVICE_TOKEN. The returned token is encrypted by Electron safeStorage and never exposed through the renderer preload. The environment token remains a non-interactive compatibility seam for test and managed deployments.

Each Electron BrowserWindow is a complete shell: one renderer per window, and inside that renderer, tabs and split panes are ordinary React state, not separate webviews. Per-tab webviews are the main source of Electron bloat; here a tab is just a routed view inside one page.

  • A tab strip sits above the routed content, alongside the regular sidebar. + (or ⌘T) opens a new workspace-root tab; × (or ⌘W) closes one. On macOS the app menu rebinds window-close to ⌘⇧W so ⌘W is free to close a tab instead.
  • The affordance on a tab splits right: two side-by-side panes, each an independently scrolled, independently routed view. The focused pane carries a light ring; clicking a pane focuses it, and navigation (sidebar clicks, page links) lands in whichever pane is focused. × on a pane folds the layout back to a single view.
  • Layout persists. The full shape, {tabs, active, layout, panes, activePane}, is written into view state keyed by the window, so reloading or relaunching a window restores exactly where you left it.
  • App presets seed a layout from a URL: /?desktop=1&tabs=slug,slug&layout=tabs|row opens a named set of tabs (or a two-pane split) in one shot, bound to a global hotkey or a slash command for “open my morning layout”.

In a plain browser (no desktop shell), the same navigation events just navigate, there’s no tab strip to promote into.

Two elements request promotion via cancelable window events the shell listens for:

  • subspace:open-tab, fired by a terminal’s ⇱ button or ⌥⇧↵ on a bullet, appends a new routed tab.
  • subspace:open-pane, fired by ⌥↵ (or shift-click) on a bullet, opens that bullet’s subtree, /p/<slug>?zoom=<nodeId>, in a pane beside the view you triggered it from. If the current tab is already split, it replaces the unfocused pane rather than forcing a third pane.

This pulls a reference doc, a related task, or a subtree of notes into view beside what you’re working on.

Every outline page and directory shows an open as app chip (shell-only, it keys off subspaceDesktop.apps). Clicking it spawns a dedicated headless window, no sidebar, no tab strip, no chrome, scoped to that page and its subtree (/p/<slug>?desktop=1&headless=1&app=<root-slug>). Reopening the same root focuses the existing window instead of spawning a duplicate.

This gives a page real app identity: the window title is pinned to the app’s name, so it gets its own taskbar entry on Windows/Linux and its own Mission Control / window-switcher entry on macOS, plus an entry in a dock menu that lists every open app for one-click focus. A per-app dock icon on macOS would need a separate OS process per app, which the single-process shell deliberately doesn’t do, so app identity is windows and menus, not icons.

All app windows remain views onto the same graph, with three consequences:

Cross-app links

Clicking a [[link]] or a backlink asks the main process to route it. If the target’s breadcrumb chain falls inside another open app’s subtree, that app’s window focuses and navigates there (the nearest open root wins). A link outside every open app’s subtree lands in the main shell window, opened as a new tab if the main window isn’t currently around. Links that stay inside the current app’s subtree just navigate in-window, the same as in a plain browser.

Centralized notifications

App windows never subscribe to the live toast topic themselves. All alerts flow through the main window plus the shell’s native OS notifications (one WebSocket subscription per app, held in the main process), and clicking a notification always focuses or creates the main window, never a standalone page app.

Sharing boundary

An app’s root subtree is also its ACL boundary. Sharing the root through the share dialog (explicit mode plus a grant) covers exactly the pages whose effective ACL source resolves to that root; other open apps’ subtrees stay invisible to whoever you shared with.

The sidebar renders the same directory tree as the web app: dragging a page onto a directory issues an ordinary movePage command, subject to the same write-on-both-directories check as dragging in a browser.

Inside the shell, “open terminal here” creates a terminal node with host: 'shell'. That element talks directly over IPC to the main process’s node-pty host (subspaceDesktop.pty.*), with zero server round trip for keystrokes.

The main process mirrors every frame of that session to the server’s relay, so a plain browser tab (or another window, or an agent watching output) attaches to the same session over the ordinary /pty WebSocket exactly as it would for a server-hosted terminal. Input and respawn events from the browser side relay back into the shell. Reattaching over IPC after a reload replays the main process’s scrollback buffer.

In a plain browser, without the shell, terminals are created with host: 'server' instead, running on the server host rather than the desktop machine.

VSCode-style: the action on a terminal element opens /terminal/<nodeId>?cwd=…&host=… as a new tab, a full-viewport terminal bound to the same node id, transport rules, session, and scrollback.

  • Global hotkeys: registered through Electron’s globalShortcut, reachable even when no Subspace window has focus.
  • Quick-capture palette: the default ⌘⇧Space hotkey opens a 560x88 always-on-top, headless palette window (/palette route). One input line with a leading blue dot and inline #tag chips as you type; the footer hints ↵ to inbox and ⇥ run as command. sends the text to ops.inbox.capture (the same inbox capture path as everywhere else) with a brief inline confirmation; invokes the default agent with the text as its instruction; esc dismisses. Either action hides the palette window afterward.
  • App presets: named window layouts (see above), triggered from a global hotkey or slash command.
  • AppleScript integration: the main process shells out via osascript for macOS-specific automation.
  • Desktop notifications: native OS notification bubbles, driven by the same centralized toast subscription described above.

The main process owns the server’s lifecycle: it spawns the embedded server, supervises it (including a launchd-managed restart on macOS), and health-checks it before considering the app ready. Updates ship through electron-updater, and the server travels inside the app bundle, so updating the app updates the server in the same step. This is the same server described in self-hosting; the desktop app is a supervised client of it, not a fork.