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