Windowing: the dockable workspace
The workspace view (see layout-shell.md) is a dockable window
manager: module panes open as windows that can be tabbed, split, resized, and
floated. This replaced the earlier single-panel switcher.
Decisions
- Engine: dockview, wrapped. dockview (MIT, React +
TS) provides tabs, resizable splits, floating groups, and layout
serialization. It lives behind
packages/ui/src/Workspace.tsxso the module registry stays the public API — modules never import dockview. We could swap the engine without touching modules. - One level: widgets and panels are views. A pane is a layout container that hosts a View (either a module panel (
PanelDecl) or a widget (WidgetDecl)). Both haveid/title/component, soPanelHostresolves a view ID againstregistry.panelsthenregistry.widgetsand renders either. There is no separate "widget board"; a widget docks, resizes, and floats in a pane exactly like a panel. (Earlier the dashboard had a fixed inner grid of widgets — that two-level model is gone.) - Workflow layouts (Blender-style). A workspace is a named, savable layout; you switch between them from the shell rail (the single switcher — there is no separate tab strip). The rail lists predefined workflow layouts —
LayoutPresets contributed via the module registry — e.g. Dashboard (a layout of common widget-panes) and Scripting (files · editor · REPL + terminal). A preset is the seed for a stable-id workspace: first activation lays out its panes (creating active pane instances for the preset's views), then the user's rearrangements persist per layout (alayout.resetcommand restores the preset). Custom workspaces (the rail's+) are just presetless layouts.
Model
A single workspace's layout is a serializable tree owned by the engine:
- Split — a resizable row/column of children (drag the divider).
- TabGroup — stacked panes sharing one rectangle (drag tabs to reorder, to another group, or out to split/float).
- Pane instance — an active container slot running a panel or widget view.
- FloatingLayer — windows not docked into the grid; move/resize freely.
View Types vs Pane Instances
A PanelDecl/WidgetDecl in the registry represents a View type (e.g. editor.buffer). The workspace layout creates active Pane instances (e.g. editor.buffer#1, terminal.new#2) to host these views:
- Widgets are singleton by view ID — only one Pane instance running that widget view is allowed per workspace; opening it again focuses the existing pane.
- Panels honor their
singletonflag:singleton: true(e.g.settings.home) focuses the existing pane; omitted/false (e.g.scratch.note) creates a new Pane instance each open (scratch.note#2, …) — supporting N simultaneous editor/terminal buffers.
defaultPlacement (left|center|right|bottom) on either view decl hints where a freshly opened pane docks; once the user rearranges, the persisted layout wins.
Same-direction opens tab, they don't keep splitting. The first pane opened
for a given direction splits a new group off the grid; every subsequent pane
with that same defaultPlacement (or explicit split direction) tabs onto that
existing group instead of splitting again — VS Code–style, so opening several
left-placed panels stacks them as tabs in one sidebar group rather than
tiling new columns. Workspace.tsx's findGroupForDirection resolves the
target group per dockview instance: a per-open cache first (recorded whenever
a pane is placed with an explicit direction, including preset-seeded panes),
falling back to scanning existing groups for a pane whose declared
defaultPlacement matches. Explicit user/agent splits (the split grip,
splitPane) are unaffected — this only governs automatic placement on open.
Changing a pane's view: each tab carries a ▾ switcher (a custom
defaultTabComponent, packages/ui/src/PaneTab.tsx, wrapping dockview's
DockviewDefaultTab) listing every registry view. Picking one calls
LayoutController.changePaneType(instanceId, viewId), which swaps the view content
in place — same pane instance id, so geometry and autosave are untouched.
PanelHost re-renders the new view by tracking panelId (view ID) from
api.onDidParametersChange. The menu is portaled to document.body and positioned
from the tab's viewport rect; since the view catalog grows long, its max-height is
capped to the space available below the tab (and it flips above the tab when
there's more room there) so the full list always fits on-screen and scrolls within
that height. A sticky filter input at the top narrows the list by title/id
substring (the command-palette match) and is keyboard-drivable — it auto-focuses on
open, ↑/↓ move the highlight, Enter picks it, Escape closes.
How panes reach the workspace
- A command calls
registry.openPanel(id)(works for panel or widget ids). Every panel and widget is openable from the command palette — panels via their module command, and every widget via a synthesizedwidget.open:<id>command ("Open widget: <title>"). (The rail switches whole layouts; it no longer opens individual panes.) - The shell switches to the
workspaceview and signalsWorkspacewhich pane to open (a bumpedpendingOpennonce, so repeats re-fire). Workspacerenders every pane through one host (PanelHost) that resolvesparams.panelIdfrom the registry — so the serialized layout stores only ids and restore just re-resolves them.
Workflow layouts, the rail & switching
The shell rail (in AppShell.tsx) is the workspace switcher. It renders every
registry.layouts preset (always, even before first open) plus any custom
workspace, highlighting the active one. The active id + workspace list come from a
small observable, workspace-store (packages/core), that Workspace publishes
on every change — so the rail (in packages/ui) stays in sync without owning the
state.
A rail click calls registry.switchWorkspace(id) — the existing
setWorkspaceSwitcher seam (AppShell enters the workspace view, then signals via a
pendingWorkspace nonce, mirroring pendingOpen). Workspace.switchTo flushes
the current layout (saveWorkspace), then loadInto:
- a saved layout with panes →
api.fromJSON(layout); - otherwise → seed from the matching
LayoutPreset(viaseedPreset, which replayspreset.panesthroughaddPane), or a blank dock for a presetless custom workspace.
All guarded by swappingRef so the programmatic swap doesn't trip autosave.
Activating a preset that has no workspace yet creates one with the preset's
stable id (saveWorkspace(preset.id, …)) then seeds it — so the rail (and the
agent's switch_workspace) can open a never-touched layout. layout.reset
re-seeds the active layout from its preset; workspace.new/workspace.delete
manage custom layouts (a preset resets rather than deletes).
On first run (no workspaces) the frontend seeds the default preset (Dashboard) under its stable id so the app always opens onto something.
The geometry seam: one set of operations, two callers
Splitting, resizing, moving, floating and maximizing panes are semantic
operations on LayoutController (packages/core/src/registry.ts), implemented
once in Workspace.tsx against the live dockview api:
| Method | dockview primitive |
|---|---|
splitPane(instanceId, direction, viewId) | addPanel({ position: { referencePanel, direction } }) |
resizePane(instanceId, {width?, height?}) | panel.api.setSize(...) |
movePane(instanceId, reference, direction) | panel.api.moveTo({ group, position }) |
setPaneFloating(instanceId, floating) | addFloatingGroup / moveTo back to a grid group |
maximizePane(instanceId, maximized) | panel.api.maximize() / exitMaximized() |
This is deliberately the only place these operations live, because two callers drive them:
- the agent, via the
split_pane/resize_pane/move_pane/float_pane/dock_pane/maximize_pane/restore_panetools (relayed throughtool-exec.ts), and - the user, via direct manipulation — the Blender-style split grip
(
packages/ui/src/SplitHandle.tsx), a grab tab in each pane's bottom-right corner (rendered byPanelHost, hidden until the pane is hovered). Dragging it picks the orientation from the dominant axis and the new region appears on the side you drag toward (drag left → new pane on the left, down → below); a live overlay previews the split, and on release it callssplitPane, duplicating the pane's content into the new region. Resizing and re-docking use dockview's native sashes and tab-drag (already Blender-like), so the custom layer only adds the split gesture the engine lacks.
So "the agent splits a pane" and "the user drags a split" are the same code path.
Because each method mutates the dockview tree, the existing onDidLayoutChange
autosave persists the result for free — no separate save path. Geometry verbs are
layout-only, so the orchestrator leaves them ungated (like open_pane).
Agent-driven layout
The agent orchestrator can arrange the workspace too (open/close panes, the
geometry verbs above, create and switch workspaces). The mutations it needs beyond
the existing openPanel/switchWorkspace seams go through
registry.layoutController (setLayoutController), which Workspace installs over
its dockview api. So the agent stays decoupled from the engine, same as every
module. See ../modules/agent-chat.md.
Persistence
Workspace serializes the active dockview layout on change (debounced ~600ms) to
PUT /api/workspaces/{id}, and restores on mount from GET /api/workspaces. The
backend (backend/modules/workspace/) stores the collection
({ active, workspaces: [{ id, name, layout }] }) but treats each layout
opaquely — it never interprets the engine's JSON shape (SerializedLayout in
packages/core/src/workspace.ts is Record<string, unknown>). Authoring the
preset arrangements is the frontend's job (engine-shaped); the backend just stores
the resulting blobs.
Browser vs desktop
The floating layer is where the window.multi capability will matter: in the
browser a floating window is an in-document pane (implemented today); on
desktop (Tauri) the same action can pop a pane out to a real OS window. The
seam is Workspace + the capability service; the OS-window path is not built yet.
Dev affordance
In dev builds (import.meta.env.DEV), Workspace exposes the live dockview API
as window.__horribleWorkspace for console/preview experimentation
(addPanel, toJSON, …). It is not set in production builds.
Status
Implemented: widgets-as-views; workflow-layout presets (registry.layouts) with
the rail as the single switcher; lazy stable-id seeding, per-layout
persistence/restore, and layout.reset; custom workspace create/delete; splits,
tab groups, floating windows; the view-singleton/multi-instance distinction; the
LayoutController geometry seam (split/resize/move/float/maximize), the
agent geometry tools that drive it, and the Blender-style split grip
(SplitHandle) that drives the same seam from direct manipulation; the per-tab
▾ pane-view switcher (changePaneType). Built-in presets: Dashboard and
Scripting. scratch.note is the reference non-singleton panel. Not yet: a
pane-view picker for the split target (it duplicates the current pane today),
edge-drag-to-split (native sashes cover
resize), real OS windows on desktop, per-instance pane state (scratch instances
share one store), multiple instances of the same widget in one workspace, and
workspace reorder/import-export.