Skip to main content

Workspace SDK

How the workspace shell is built: panels, groups, leaves and views, the one placement rule, the pane header every surface shares, history, persistence and the stable test ids. Kept in step with the code by a test.

The developer reference for the workspace shell: what the regions are called, how views are opened, closed, split and moved, what every pane header looks like, and where each rule lives. Read it before touching anything under app/w/[slug]/ or lib/workspace/views/. It is rendered publicly at /docs/workspace-sdk and is the single source of truth for this vocabulary — when the code changes, this page changes in the same PR (tests/ui/view-sdk-doc.test.ts fails when the registry and this page drift).

Vocabulary follows docs/desktop-lexicon.md. The verifiers that light up these surfaces (compiler, Lean, Claim Verifier, audit) have their own contract: Verification. One word per concept:

TermMeansIn code
WorkspaceOne project open in one window: its panels, its split tree, its focus.app/w/[slug]/page.tsx
PanelOne of the three fixed regions: left (rail), center, right (dock). Left and right never split.ViewPlacement: left, center, right
SplitA division of the center into rows or columns, recursively.SplitNode { dir, children }, dir is row or column
GroupA terminal cell of the split tree: a strip of leaves plus one active leaf. User-facing copy says pane.EditorPane { id, tabs, active }
LeafOne entry in a group's strip, hosting exactly one view. User-facing copy says tab.a leaf id (string)
ViewWhat a leaf (or a side panel) renders, keyed by view type.ViewTypeSpec in the registry
Pane headerThe one header row every center surface renders: ←/→ · identity · icons · ⋮ · ×.PaneHeader
ShellThe chrome variant: tabbed (desktop strips), no-tabs (web), or mobile.desktopTabs, isMobile

Code map: lib/workspace/views/registry.ts (view types) · lib/workspace/views/placement.ts (where an open lands) · lib/workspace/views/layout-tree.ts (the split tree) · lib/workspace/views/workspace-api.ts (the façade) · lib/workspace/editor-panes.ts (leaf-level transitions) · lib/workspace/editor-tabs.ts (leaf id helpers, history) · components/workspace/doc-pane-header.tsx (the header) · app/w/[slug]/_components/project-sidebar.tsx + lib/workspace/sidebar-sections.ts (left panel) · lib/workspace/layout.ts (persistence, mobile and embed boundaries) · app/w/[slug]/page.tsx (React state, rendering).

1. Anatomy

┌ left panel ─────┬ center ───────────────────────────────┬ right panel ─┐
│ ● ▢ ▣ ⌕         │ ← →   identity   [icons] ⋮ ×  ← header │ ⟲ ≡  strip   │
│ ▤ ▥ ⑂ ⌂ ⚡  strip│ ┌────────────┬─────────────┐           │              │
│ Files      + New│ │ group      │ group       │  split    │ History /    │
│  …tree…         │ │ (primary)  │ (chat)      │  tree     │ Outline /    │
│ Chats      + New│ │            │             │           │ Lean / graph │
│  …rows…         │ └────────────┴─────────────┘           │              │
│ Sundial Support │                                        │              │
│ ⌂ workspace ⚙ ← footer                  pinned top-right cluster ↗ (collaborators · Log in)     │
└─────────────────┴────────────────────────────────────────┴──────────────┘
  • Left panel (ProjectSidebar): the top row (Home, sidebar toggle, right-panel toggle, search — both panel switches sit together at the left), the section strip, the stacked sections (Modules first, then Files, Chats, Sync; Open with is a strip action), the support slot, and the footer holding the workspace identity, credits and Settings.
  • Center: the split tree of groups. panes[0] is the primary group (PRIMARY_PANE_ID), always the top-left cell, bound to selectedFilePath.
  • Right panel (right-dock): one view at a time from listViewTypes('right'), chosen on the dock's own strip; toggled from the left panel's top row (right-dock-toggle, beside the sidebar toggle).
  • Pinned top-right cluster (topbar-right): collaborator bubbles, Log in when signed out. It floats over the rightmost group's header, whose paddingRight mirrors its measured width.

2. Views

A view type is declared once, in lib/workspace/views/registry.ts, and every consumer reads the spec instead of switching on an id prefix:

registerView({
  type: 'terminal',
  scheme: 'sundial-terminal://',   // omit for path-keyed views (files)
  label: 'Terminal',
  icon: TerminalIcon,              // @phosphor-icons/react
  placements: ['center'],          // 'center' | 'left' | 'right'
  persist: 'never',                // 'validate' | 'always' | 'never'
  keep?: true,        // view-only: an open lands BESIDE it, never over it
  duplicable?: true,  // may show in two groups at once; else an open MOVES it
  overlay?: true,     // opens in front of the current leaf, returns to it on close
  transient?: true,   // consumed in place by the next open (the ⌘T chooser)
});

registerView returns an unregister function, so a type can be registered for the life of a document (the Lean and graph dock views exist only while a .tex is active). The flags drive the pane transitions (keep, transient), snapshot restore (persist), the tab glyph (icon), the tab context menu (duplicable gates Split right / down), the right dock's strip (placements), and the api (duplicable decides move vs copy).

typescheme / idplacementpersistflags
filethe workspace pathcentervalidateduplicable
chatsundial-chat://<chatId>centeralways
diffsundial-diff://<assistantMessageId>centerneverkeep
reviewsundial-review://chat/<chatId>centerneverkeep
historysundial-history://detailcenterneverkeep, overlay
commitsundial-commit://<repoId>/<sha>centerneveroverlay
changesundial-change://<repoId>/<path>centerneveroverlay
pdfsundial-pdf://<texPath>centervalidatekeep
leansundial-lean://<texPath>centervalidatekeep
graphsundial-graph://<texPath>centervalidatekeep
launchersundial-launcher://newcenternevertransient
supportsundial-support://threadcenterneveroverlay
modulessundial-modules://gallerycenterneveroverlay
projectleftalways
historyrightalways
outlinerightalways
leansundial-lean://<texPath>centervalidatekeep
graphsundial-graph://<texPath>centervalidatekeep

Leaf-id helpers live in lib/workspace/editor-tabs.ts: chatTab(id) / chatIdOfTab, diffTab / diffIdOfTab, reviewTab / reviewChatIdOfTab, pdfTab(texPath) / pdfTexPathOfTab, commitTab(repoId, sha) / changeTab(repoId, path) / sourceControlOfTab, LAUNCHER_TAB, SUPPORT_TAB, HISTORY_TAB, and isSpecialTab(id) (anything that is not a file: never path-remapped, never validated against the file list). resolveView(id) turns any id into { type, spec, param }; unclaimed ids are files.

3. Groups and the split tree

ViewLayoutState
├─ panes: EditorPane[]          groups in reading order; panes[0] is the primary
│     { id, tabs: LeafId[], active: LeafId }   // active === '' only for a sole, empty primary
├─ layout: LayoutNode           Split{dir:'row'|'column', children} | Group{id}
└─ focusedGroupId               the group shortcuts and rail opens act on
  • Tree order equals pane order. Every insertion lands its new group immediately before or after its target; reconcileLayout(prevTree, prevPanes, nextPanes) re-derives the tree for any transition that did not know about it and falls back to a plain row if the order ever disagrees, so a group can never be stranded.
  • Group cap MAX_EDITOR_PANES (3). At the cap every split affordance degrades to "open as a tab in that group", never a dropped open.
  • Ids are derived, never counted. Pane ids and split ids are max + 1 over the current state (a counter re-ran under StrictMode and looped).
  • Promotion. When the primary empties, the next group in reading order is promoted into the primary slot; reconcileLayout detects it by content, not by id.
  • One state. The page keeps panes and tree in one useState; every updater derives the next tree from the true previous panes, never from a ref snapshot, so two transitions in one tick cannot clobber each other.

4. Opening, closing, moving: the api

Every flow — rail click, keyboard, ⌘T pick, a file clicked inside a chat, a deep link, an agent, a plugin — goes through createWorkspaceApi(store, panelStore) from lib/workspace/views/workspace-api.ts. The api owns no state: the store hands it the current state and commits a transition. The page's store is applyPaneTransition (selection hand-off, live-chat re-point, ⌘W focus stamp); tests use createMemoryStore and run every flow without React (tests/ui/workspace-api.test.ts).

api.open(leaf)                          // replace-on-open in the focused group
api.open(leaf, 'tab')                   // new leaf beside the active one
api.open(leaf, 'aside')                 // the group beside the anchor (create once, then reuse)
api.open(leaf, { split: 'down' })       // new group below the focused one (a tab at the cap)
api.open(leaf, { group, mode: 'tab' })  // an explicit group — always wins
api.open(leaf, target, { origin })      // the group the action came from
api.open(leaf, 'aside', { origin, keepFocus: true })  // a companion: opens beside, focus stays on the source
api.activate(group, leaf)               // click a tab
api.close(leaf, group?) · closeOthers(group, keep) · closeToRight(group, from) · closeGroup(group) · closeEverywhere(leaf)
api.move(drag, { groupId, index })      // strip drop: reorder / adopt
api.swap(group, otherGroup)             // the no-tabs shell's drop-onto: the two groups trade views
api.split(drag, targetGroup, side)      // body-edge drop: 'left' | 'right' | 'up' | 'down'
api.focus(group) · focusedGroup() · leaves() · leavesOfType(type) · canSplit() · canDuplicate(leaf)
api.panels.views('right') · active('right') · open('right', 'outline') · close('right') · toggle('left')

The placement rule

lib/workspace/views/placement.ts decides where open lands:

  1. Already on screen → that group. Nothing moves.
  2. The anchor is origin (the group the action came from), else the focused group, else the primary.
  3. tab → a new leaf in the anchor.
  4. aside → the group right after the anchor when it shows the same kind (docs beside docs, chats beside chats), else a new group split right of the anchor; at the cap, a tab in the anchor.
  5. replace (the default) → the nearest group showing the same kind: the anchor, its right neighbour, its left neighbour, then reading order. With no group of that kind on screen a doc claims the primary and a chat there is displaced aside, never closed; a chat splits off the LAST group in reading order, not merely the anchor, so it never wedges between a document and a companion (PDF, Lean, graph) already open beside it. Files left, chats right of all of them.

Kind is doc or chat (diff, review, pdf and the utilities are docs). An empty group, or one showing the ⌘T chooser, accepts any kind. keep views are never replaced: the open lands beside them. An overlay (support, modules, history, commit, change) sits in front of the anchor's leaf and returns to it on close.

FlowEntry pointsApi call
Open a viewsidebar row / chat card, palette, deep link, wiki linkopen(leaf)
Open a file from a chat (edit card, mention, turn diff, View edits)transcript, ChatDiffPanel, TurnDiffPanelopen(leaf, 'replace', { origin: thatChatGroup })
Open in new tabrow ⋮, ⌘N, the chooser's Createopen(leaf, 'tab', { origin })
New tab⌘T, +open(LAUNCHER_TAB, 'tab')
New chat⌘⇧J, Chats ▸ New, the chooser, chat header +open(chatTab(id)) → the chat group if one is on screen, else a split off the rightmost group
Open to the siderow ⋮ / chat ⋮open(leaf, 'aside')
Deep-linked file?fileIdopen(path, { group: primary })
Deep-linked turn diff?diff=<messageId>&review=openopen(diffTab(messageId)) — the same Turn edits leaf a chat card opens (mobile keeps its full-screen overlay)
History entrya row in the sidebar's History sectionopen(HISTORY_TAB); the timeline portals the entry's detail into the leaf's body and closes it on All changes; the leaf's × is the same gesture
Source controla commit or a changed file in the Sync sectionopen(commitTab(repo, sha)) / open(changeTab(repo, path)) — one at a time: a pick closes the previous one; the rail's selection is read back from the leaf on screen
Close a view×, middle-click, ⌘Wclose(leaf, group) — the neighbour at the same index takes over; an emptied side group dissolves. No-tabs shell: an overlay closes normally to reveal its covered leaf; any other visible view closes the group, background tabs included (closeView in editor-panes.ts) — nothing hidden ever surfaces, and no chat is summoned: closing the last document leaves the empty state, or the chat already beside it at full width
Close others / to the right / alltab menucloseOthers · closeToRight · closeGroup
Split right / downtab menusplit({ rail, leaf }, group, side) (duplicates a file)
Drag to an edgetab / row / card over a group bodysplit(drag, group, zone) — outer-quarter, nearest-edge-wins zones with a preview
Panels⌘, ⌘⇧, dock strippanels.toggle('left') · panels.open('right', view)

Edge cases

  • Open a leaf already visible elsewhere. No second copy: the group showing it wins, so a rail click is a focus no-op.
  • Open a file while only chats are on screen. The doc claims the primary and the chat is displaced aside, never replaced.
  • Open a non-duplicable view held behind another tab. It moves (copies drop first), which can dissolve the group it left; an explicit { group } always wins.
  • A .tex and its companions. The compiled PDF, the Lean formalization and the statement graph are pdf / lean / graph leaves keyed by the .tex path, restored while the .tex exists. None lives in the right dock (editor-tabs.ts: companionTab, companionOfTab). The first opens beside the source; a second companion splits its own pane off the last one rather than tabbing over it (openCompanionLeaf aims the aside at the rightmost open companion), so e.g. Lean and the graph sit side by side (up to the three-pane cap, where it falls back to a tab). The header's PDF | Lean | Graph switch reads pressed for every companion on screen. Opening one keeps the focus on the source (keepFocus), so the next rail click replaces the source rather than landing as a hidden tab over the companion. The live pipeline (compile, statement pairing) follows the ACTIVE .tex; while another file is active a PDF leaf keeps showing its .tex's last compiled PDF (the tracked sibling) and a Lean leaf its Lean sibling — only the graph waits for its .tex to come back.
  • Close the primary's last leaf with side groups open. The next group is promoted into the primary slot; the tree follows. The selection mirror (syncPrimaryActive) clears a file view when the selection empties, never a PDF leaf, diff or chooser that was promoted there — that left a "Nothing open" pane beside the chat.
  • Close the sole primary's last leaf. The workspace empty state.
  • Left/up split of the first pane. The primary must stay panes[0], so it adopts the dragged tab and its previous content shifts into the new group.
  • Split a group's only tab against itself by drag. No-op; the menu's Split duplicates instead.
  • No-tabs shell. A pane's header row carries the same drag payload as a tab; dropping onto a pane swaps the two views (a background tab would have no way back); collapsePanesToActive keeps each pane's visible view when strips are turned off.

5. The pane header

Every center surface — documents (markdown, LaTeX, code), chats, review and turn-diff tabs, the New tab chooser, Support — renders the same PaneHeader (components/workspace/doc-pane-header.tsx). Seats, left to right, never reordered per surface:

[leading] [← →]        identity (icon · title · share status · crumbs)        [controls] [⋮] [×]
PropContract
nav?: PaneNavPropsThe ←/→ pair (PaneNavArrows). Undefined until the group has any history.
identity (required)Centered in the measured free band between the two clusters, asymmetric, so a narrow pane's title is never starved. titleAlign: 'start' for rows whose controls are wide (a .tex compile cluster).
title?Hover title for the band.
controls?(collapsed)Surface-specific icons before the ⋮ (Comments on documents). collapsed = the bar is under DOC_HEADER_COLLAPSE_WIDTH (460px) and always-on controls should fold into the ⋮.
menu?The ⋮ trigger and its dropdown.
onClose?, closeLabel, closeTestIdThe × (PaneCloseButton). Rendered in the no-tabs shell only — it closes the surface (closeSurfaceInPane); in the tabbed shell the tab carries its own ×, so headers pass no onClose.
leading?, leadingLeft?A cluster absolutely seated before the arrows (the floating Home/Sidebar cluster when the rail is collapsed).
mirrorLeft, mirrorRight, paddingRightObstructions floating over the row: the pinned top-right cluster's measured width, the left float. mirror* and padding/leading describe the same obstruction — the header takes the larger, never the sum. Only the top-right pane (topRightLeaf) reserves the pinned cluster, via pinnedRightFor(paneId) in page.tsx — every surface header passes it, so a collaborator bubble never covers the rightmost ⋮ / ×.
tall (required)h-10 when nothing sits above the row (no-tabs shell); min-h-[34px] pt-1 under a tab strip — the height a document's controls give the row, held as a floor so a controls-free view (Support, New tab) keeps its title instead of collapsing to the padding.
divider?Hairline when another chrome bar runs directly below (LaTeX toolbars).
testId, className, dataAttrstopbar-doc-controls for documents, chat-pane-header (+ data-chat-id) for chats, new-tab-header, support-header, diff-panel-header.

DocPaneHeader is the document flavour: it builds the identity from the path (folder crumbs that drop whole tiers as the bar tightens, then the name + share glyph as one never-wrapping item) and keeps the rest of the contract. The chat header (renderChatHeader), the launcher, support, modules, history and source-control headers (surfaceHeader) and the diff panels' PanelHeader are thin wrappers that only supply identity, menu and close. Add a surface by supplying those three; do not build a header from scratch.

6. History: the ←/→ arrows

Each group keeps a browser-style TabHistory { stack, index } of the leaves it showed (recordTabVisit in editor-tabs.ts: a plain activation truncates the forward entries and appends; an arrow step only moves the index). paneNavProps(groupId) turns it into PaneNavProps: canBack, canForward, onBack, onForward and entries (the stops behind and ahead, nearest first, labelled by tabLabel). A click steps once; holding an arrow (400ms) or right-clicking it lists that direction's stops (Chrome-style) and a pick jumps straight there. A step stays in the group and in its current tab, for every view type alike: a stop still open in the strip activates; one that was replaced or closed since takes the current tab's place (replaceActiveTab with keepViews: false — even a view-only diff or review tab is swapped, not kept beside). An arrow never creates a tab or a split. A chat's visits follow its draft→real id swap (retargetTabVisits), so ← after a first message lands on the same chat, not a ghost of the draft.

The standalone chat column of the no-tabs shell is not a group, so it keeps its own chatHistory of chat ids with the same bookkeeping: a new chat, then ←, returns to the previous chat.

7. The panels

Left panel

ProjectSidebar composes: the top chrome (Home, sidebar toggle, ⌘K search icon — nothing right-aligned; in the desktop shell the row holds 40 points at any page zoom, h-[max(2rem,calc(40px/var(--sd-zoom,1)))], so the natively-placed traffic lights stay centred on it), the section strip, the sections, the support slot and the footer — workspace identity, Settings, then the Share button, topbar-share: opens the share modal at once for the whole workspace, with the scope picker inside the modal to narrow to the focused file or its folder.

  • Sections (lib/workspace/sidebar-sections.ts): SidebarSection = 'modules' | 'files' | 'chats' | 'sync' | 'history' | 'openWith', each with a collapsed bit and a pinned bit; collapsed means hidden entirely — the strip icon is the way back; unpinned sections live only in the strip's overflow. openWith is an action, listed so its icon is pinnable. Defaults: files, chats and modules on, sync and history off. normalizeSidebarSections validates restored state.
  • Strip (sidebar-section-strip): one aria-pressed toggle per available section (sidebar-tab-files|chats|modules|sync; Sync with nothing linked opens the connect-a-repo flow instead), then one plain action: Open with (sidebar-tab-open-with, opens the modal at once). The Modules section lists the workspace's modules (add from the header, Remove on a row), then the picks under them — a Haiku pass suggests modules that fit this workspace (nothing when nothing stands out) — each openable in the gallery or added in place, and a Browse-all row into the full gallery at /modules.
  • Footer: the workspace identity (click opens the workspace menu, which opens upward; Log in beside it when signed out), credits, Settings.
  • The Files header reads "Files"; the workspace name lives only in the footer.
  • Sync repository selection: switching repositories discards the previous repository's pending commit/status responses. An uncertain Git action keeps being watched, but its completion cannot overwrite the selected repository.

Right panel

rightDockView: 'outline' | null (History moved into the left panel's sections). One view at a time; the dock's strip (right-dock-strip, dock-view-<type>) is generated from listViewTypes('right'), so registering a right-placed view adds it. openRightDock(view), closeRightDock(), toggleRightDock(); the toggle button (right-dock-toggle, filled while open) sits in the left panel's top row, right of the sidebar toggle (ShellNavControls). api.panels.* is the same state through the façade.

The launcher bar

A bottom bar of the shell — a 24px row below the panes with its own top hairline, so no leaf ever sits under it — carries a slim orange handle at its centre (LauncherPill, components/workspace/launcher-pill.tsx): the light way to reach what the left panel lists. Hover, tap, or the launcher shortcut (⌘.) grows one flat row of verbs up out of the strip (150ms): each installed module's primary action wired to the same handler its chip uses (Compile, Formalize, Verify claims), with the rail's status dot for the open document; New, which opens a second little row above it (New file / New folder / New chat — the palette's create actions, same handlers); Share; and Open…, which is the ⌘K palette. An action that cannot run right now stays in the row greyed, with the reason as its title, so the row never shifts. ←/→ walk the row, Enter runs, Esc collapses; leaving with the pointer keeps the row for a 400ms grace so the hand can travel from the handle up into it. Customize (the sliders glyph) picks what the row shows, per browser (sundial:launcher-hidden); Open… is always shown. Desktop shells only for now: the phone layout's composer owns the bottom edge. The item model is lib/workspace/launcher-pill.ts; the page builds the list (launcherItems).

8. Shells

ShellWhenWhat changes
TabbeddesktopTabs (desktop app, or Settings → Appearance)Each group has a tab strip (editor-tab-strip, editor-tab): the strip is a band in the sidebar's ground (stone-50) with a 6px inset and a stone-200 hairline along its bottom, inactive tabs are flat text with a hairline between neighbours, and the active tab is a round-topped cut of page colour outlined in that same stone-200, flaring into the hairline through concave Chrome-style feet (tab-foot, one SVG stroke each continuing the outline) and fusing with the surface below (the hairline is a background gradient so it pixel-snaps with the tabs at any zoom); headers are pt-1 under it and carry no ×; the tab's own × closes it.
No-tabsWeb defaultHeaders are the top row (h-10); a pane's header is the drag handle; dropping onto a pane swaps; the × closes the surface.
Mobilea touch device (coarse pointer) at width ≤ MOBILE_MAX_WIDTH (767) — MOBILE_MEDIA_QUERY. A mouse/trackpad window keeps the desktop shell at every width and collapses progressively.One surface at a time, the primary group only, no strips, the chat header hidden (the top bar carries it), the bottom surface switcher.

useDocStyle() is 'docs' | 'obsidian'; the Docs style is disabled (DOCS_STYLE_ENABLED = false), so docsPage paths are unreachable today.

9. Persistence

WhatKeyRead / write
Pane snapshot (tabs + serialized tree; ids are session-local and never saved)sundial:editor-panes:<projectId>readPaneSnapshot / persistPaneSnapshot (lib/workspace/layout.ts), restored through normalizePaneSnapshot, which applies each view's persist policy
Workspace layout (rail open, open panels, sidebar sections)sundial:workspace-layout:<projectId>applyStoredDesktopLayout / persistLayoutConfig (page)
Doc stylesundial:doc-stylelib/doc-style.ts
Chats box heightsundial:chats-box-heightProjectSidebar

Persistence is desktop-only: mobile never writes.

10. Stable test ids

SurfaceIds
Pane headerpane-header (default), topbar-doc-controls, chat-pane-header (+data-chat-id), new-tab-header, support-header, modules-header, history-detail-header, source-control-header, diff-panel-header; pane-nav-back, pane-nav-forward, pane-nav-list-back, pane-nav-list-forward; pane-close, close-file-view, chat-column-close, diff-panel-close; doc-file-identity, doc-file-path, doc-comments-toggle, doc-actions-menu, chat-header-title, chat-header-menu
Tabs and splitseditor-tab-strip (+data-pane-id), editor-tab (+data-active), tab-menu-split-right, tab-menu-split-down, tab-menu-close*, pane-split (+data-dir), pane-drop-overlay, pane-drop-preview-<zone>, new-tab-panel, modules-leaf, history-detail-leaf (+history-detail-column, the portal host), source-control-leaf
Left panelproject-left-rail, topbar-home, topbar-sidebar-toggle, sidebar-search-bar, sidebar-new-chat, sidebar-section-strip, sidebar-tab-<section>, sidebar-tab-modules, sidebar-tab-open-with, sidebar-chats-section, chats-new-button, workspace-identity-label, topbar-share, support-slot
Right panel and top-rightright-dock, right-dock-strip, dock-view-<type>, right-dock-toggle, topbar-right
Launcher barlauncher-pill (+data-open), launcher-pill-handle, launcher-pill-row, launcher-pill-item-<id>, launcher-pill-group-row, launcher-pill-customize, launcher-pill-toggle-<id>

11. Tests

Unit: tests/ui/view-registry.test.ts, view-layout-tree.test.ts, view-placement.test.ts, workspace-api.test.ts, editor-panes.test.ts, editor-tab-strip.test.tsx, doc-pane-header.test.tsx, pane-nav-arrows.test.tsx, pane-drop-overlay.test.tsx, sidebar-sections.test.ts, project-sidebar.test.tsx, panel-view-layout.test.tsx, view-sdk-doc.test.ts (this page vs the registry). Smokes: tests/smoke/editor-tabs-split.spec.ts, workspace-topbar.spec.ts, workspace-editor-layout.spec.ts, workspace-web-layout.spec.ts.

12. Adding a surface

  1. registerView its type (scheme, placement, persist policy, flags).
  2. Give it a leaf-id helper in editor-tabs.ts if agents or links build ids.
  3. Render it in the pane dispatch (renderPrimaryPane / renderSecondaryPane in page.tsx) inside a PaneHeader — identity, menu, close; the arrows come from paneNavProps(groupId).
  4. Open it through api.open; never reach into editorPanes directly.
  5. Add its test ids to §10 and its type to §2, and extend the specs in §11.

Ties

A tie is one thing seen from several views — a statement in the .tex, its declaration in the .lean, its node in the graph, its line in the PDF, the chat turn that wrote it; a claim and the chat verifying it. The SDK half is pure geometry (lib/workspace/views/ties.ts): every member answers one question, where is my block on screen right now (TieAnchor.rect), and tieChain orders the answers in reading order and connects consecutive blocks edge to edge — right edge to left edge across a horizontal gap, and along the left edges, bowing out through the gutter, across a vertical one, so the line never runs through the text between stacked blocks — and a chain lays out the same whether the groups sit in a row, a column, or a mix. TieConnectors draws the chain as one thin orange hairline (no endpoint dots), a pointer-transparent overlay that follows scrolling with one rAF loop.

Rules the page follows:

  • A double-click on a statement (either side) or a claim sets the tie; Esc, a double-click elsewhere, or switching files clears it.
  • A tie never opens panes: it connects what is on screen, and members whose pane is closed drop out of the chain. "Tie all" on the glyph card is the one action that brings the missing companions and the chat turn in first.
  • Anchors: editors answer through CodeEditorHandle.lineRangeRect (clamped to the editor's box, so a scrolled-out block still points at the pane edge); the PDF marks the tie's page point persistently (data-testid="pdf-tie-anchor"); the graph node carries data-tex-line; a chat turn is its data-message-id element inside its data-chat-id card.