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:
| Term | Means | In code |
|---|---|---|
| Workspace | One project open in one window: its panels, its split tree, its focus. | app/w/[slug]/page.tsx |
| Panel | One of the three fixed regions: left (rail), center, right (dock). Left and right never split. | ViewPlacement: left, center, right |
| Split | A division of the center into rows or columns, recursively. | SplitNode { dir, children }, dir is row or column |
| Group | A terminal cell of the split tree: a strip of leaves plus one active leaf. User-facing copy says pane. | EditorPane { id, tabs, active } |
| Leaf | One entry in a group's strip, hosting exactly one view. User-facing copy says tab. | a leaf id (string) |
| View | What a leaf (or a side panel) renders, keyed by view type. | ViewTypeSpec in the registry |
| Pane header | The one header row every center surface renders: ←/→ · identity · icons · ⋮ · ×. | PaneHeader |
| Shell | The 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 toselectedFilePath. - Right panel (
right-dock): one view at a time fromlistViewTypes('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, whosepaddingRightmirrors 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).
| type | scheme / id | placement | persist | flags |
|---|---|---|---|---|
file | the workspace path | center | validate | duplicable |
chat | sundial-chat://<chatId> | center | always | |
diff | sundial-diff://<assistantMessageId> | center | never | keep |
review | sundial-review://chat/<chatId> | center | never | keep |
history | sundial-history://detail | center | never | keep, overlay |
commit | sundial-commit://<repoId>/<sha> | center | never | overlay |
change | sundial-change://<repoId>/<path> | center | never | overlay |
pdf | sundial-pdf://<texPath> | center | validate | keep |
lean | sundial-lean://<texPath> | center | validate | keep |
graph | sundial-graph://<texPath> | center | validate | keep |
launcher | sundial-launcher://new | center | never | transient |
support | sundial-support://thread | center | never | overlay |
modules | sundial-modules://gallery | center | never | overlay |
project | — | left | always | |
history | — | right | always | |
outline | — | right | always | |
lean | sundial-lean://<texPath> | center | validate | keep |
graph | sundial-graph://<texPath> | center | validate | keep |
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 + 1over 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
primaryslot;reconcileLayoutdetects 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:
- Already on screen → that group. Nothing moves.
- The anchor is
origin(the group the action came from), else the focused group, else the primary. tab→ a new leaf in the anchor.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.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.
| Flow | Entry points | Api call |
|---|---|---|
| Open a view | sidebar row / chat card, palette, deep link, wiki link | open(leaf) |
| Open a file from a chat (edit card, mention, turn diff, View edits) | transcript, ChatDiffPanel, TurnDiffPanel | open(leaf, 'replace', { origin: thatChatGroup }) |
| Open in new tab | row ⋮, ⌘N, the chooser's Create | open(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 side | row ⋮ / chat ⋮ | open(leaf, 'aside') |
| Deep-linked file | ?fileId | open(path, { group: primary }) |
| Deep-linked turn diff | ?diff=<messageId>&review=open | open(diffTab(messageId)) — the same Turn edits leaf a chat card opens (mobile keeps its full-screen overlay) |
| History entry | a row in the sidebar's History section | open(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 control | a commit or a changed file in the Sync section | open(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, ⌘W | close(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 / all | tab menu | closeOthers · closeToRight · closeGroup |
| Split right / down | tab menu | split({ rail, leaf }, group, side) (duplicates a file) |
| Drag to an edge | tab / row / card over a group body | split(drag, group, zone) — outer-quarter, nearest-edge-wins zones with a preview |
| Panels | ⌘, ⌘⇧, dock strip | panels.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
.texand its companions. The compiled PDF, the Lean formalization and the statement graph arepdf/lean/graphleaves keyed by the.texpath, restored while the.texexists. 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 (openCompanionLeafaims theasideat 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.texto 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);
collapsePanesToActivekeeps 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] [⋮] [×]
| Prop | Contract |
|---|---|
nav?: PaneNavProps | The ←/→ 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, closeTestId | The × (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, paddingRight | Obstructions 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, dataAttrs | topbar-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 acollapsedbit and apinnedbit;collapsedmeans hidden entirely — the strip icon is the way back; unpinned sections live only in the strip's overflow.openWithis an action, listed so its icon is pinnable. Defaults: files, chats and modules on, sync and history off.normalizeSidebarSectionsvalidates restored state. - Strip (
sidebar-section-strip): onearia-pressedtoggle 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
| Shell | When | What changes |
|---|---|---|
| Tabbed | desktopTabs (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-tabs | Web default | Headers are the top row (h-10); a pane's header is the drag handle; dropping onto a pane swaps; the × closes the surface. |
| Mobile | a 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
| What | Key | Read / 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 style | sundial:doc-style | lib/doc-style.ts |
| Chats box height | sundial:chats-box-height | ProjectSidebar |
Persistence is desktop-only: mobile never writes.
10. Stable test ids
| Surface | Ids |
|---|---|
| Pane header | pane-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 splits | editor-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 panel | project-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-right | right-dock, right-dock-strip, dock-view-<type>, right-dock-toggle, topbar-right |
| Launcher bar | launcher-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
registerViewits type (scheme, placement, persist policy, flags).- Give it a leaf-id helper in
editor-tabs.tsif agents or links build ids. - Render it in the pane dispatch (
renderPrimaryPane/renderSecondaryPaneinpage.tsx) inside aPaneHeader— identity, menu, close; the arrows come frompaneNavProps(groupId). - Open it through
api.open; never reach intoeditorPanesdirectly. - 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 carriesdata-tex-line; a chat turn is itsdata-message-idelement inside itsdata-chat-idcard.