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 as the web app over the same tRPC and WebSocket APIs, there’s no separate desktop backend.

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. That’s a deliberate memory tradeoff, per-tab webviews are the main source of Electron bloat, and Subspace avoids it because 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 is how you pull a reference doc, a related task, or a subtree of notes into view next to what you’re working on without leaving the page you’re 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. There’s no separate desktop file-tree widget, it’s the same component, just living next to a tab strip instead of a lone routed view.

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.*), zero server round trip for keystrokes, so typing in a desktop terminal is as responsive as any native terminal app.

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, so you don’t lose history on a window refresh.

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 full new tab, a full-viewport terminal bound to the same node id, same transport rules, same session and scrollback, just given the whole tab instead of sharing space with the rest of the page.

  • 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, so “my research layout” or “my inbox layout” is one keystroke away.
  • 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, so a notification looks the same whether you’re in an app window or the main shell.

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 itself travels inside the app bundle, updating the desktop app updates the server it talks to 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.