Skip to main content

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.tsx so 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 have id/title/component, so PanelHost resolves a view ID against registry.panels then registry.widgets and 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 layoutsLayoutPresets 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 (a layout.reset command 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 singleton flag: 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

  1. 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 synthesized widget.open:<id> command ("Open widget: <title>"). (The rail switches whole layouts; it no longer opens individual panes.)
  2. The shell switches to the workspace view and signals Workspace which pane to open (a bumped pendingOpen nonce, so repeats re-fire).
  3. Workspace renders every pane through one host (PanelHost) that resolves params.panelId from 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 panesapi.fromJSON(layout);
  • otherwise → seed from the matching LayoutPreset (via seedPreset, which replays preset.panes through addPane), 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:

Methoddockview 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_pane tools (relayed through tool-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 by PanelHost, 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 calls splitPane, 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.