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.
Windows, tabs, and split panes
Section titled “Windows, tabs, and split panes”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⌘⇧Wso⌘Wis 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|rowopens 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.
Opening a bullet as a pane or tab
Section titled “Opening a bullet as a pane or tab”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.
Open any page as its own app
Section titled “Open any page as its own app”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
Section titled “The sidebar”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.
The PTY host
Section titled “The PTY host”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.
Terminal promotion
Section titled “Terminal promotion”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.
OS integration
Section titled “OS integration”- Global hotkeys: registered through Electron’s
globalShortcut, reachable even when no Subspace window has focus. - Quick-capture palette: the default
⌘⇧Spacehotkey opens a 560x88 always-on-top, headless palette window (/paletteroute). One input line with a leading blue dot and inline#tagchips as you type; the footer hints↵ to inboxand⇥ run as command.↵sends the text toops.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;escdismisses. 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
osascriptfor macOS-specific automation. - Desktop notifications: native OS notification bubbles, driven by the same
centralized
toastsubscription described above.
Server supervision and auto-update
Section titled “Server supervision and auto-update”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.