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…         │ │ (doc)      │ (chat)      │  tree     │ Outline /    │
│ Chats      + New│ │            │             │           │ Lean / graph │
│  …rows…         │ └────────────┴─────────────┘           │              │
│ Sunny Support   │                                        │              │
│ ⌂ workspace ⚙ ← footer                  pinned top-right cluster ↗ (collaborators · Log in)     │
└─────────────────┴────────────────────────────────────────┴──────────────┘
  • Left panel (ProjectSidebar): the top row (Home, sidebar toggle, search, and the view pill at its right end), then one of two shapes: the Explorer (the section strip and the stacked sections — Modules, Files, Chats; the strip's tools open the side column; Open with is a strip action) or the Chats view (a Search field, the sections as labeled rows, one body); then 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. Primary is a placement slot, never a richer editor: every group renders the same document surface (see §3, "All panes are equal").
  • Side column (DriveSidePanel, drive-side-panel): the rail's tools, one at a time and full height, right of the panes — Contents, History, Source control, Share (§7, "Side column").
  • 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. Under a tab strip it paints the strip's band at the strip's exact height (tabStripHeight), so the band's bottom hairline runs level under the bubbles; bubble labels are portalled IconTooltips, clamped inside the viewport.

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
terminalsundial-terminal://<sessionId>centerneverkeep
launchersundial-launcher://newcenternevertransient
supportsundial-support://threadcenterneveroverlay
modulessundial-modules://gallerycenterneveroverlay
checkssundial-checks://<all|verifier id>centerneverkeep, overlay
project—leftalways
history—rightalways
outline—rightalways
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, checksTab(filter) / checksFilterOfTab, 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.

Local terminals

New terminal in the New tab chooser and command palette opens a shell in the local workspace's root folder. It is available for local workspaces only. Each leaf owns an independent PTY through the owner's authenticated sidecar connection; shared links never grant terminal access. The terminal fills its pane directly beneath the tab strip, with no separate pane header. Tabs can move or split without restarting their shell, and background tabs keep running. Closing the leaf, leaving the workspace, reloading, or stopping the sidecar ends the session. Terminals are not restored from layout snapshots.

terminal-pane is the interactive surface; new-tab-terminal opens it. The sidecar ships its native PTY dependency with the desktop app. A standalone serve.mjs without that dependency reports that the desktop app is required.

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.
  • All panes are equal. A file renders through ONE path in every group — renderDocumentPane(groupId, file, collabPath) in page.tsx, which mounts SplitEditorPaneReviewBody (components/workspace/split-editor-pane.tsx) under the chrome buildSplitPaneChrome builds per file. The body owns the whole surface: the formatting toolbar, raw view, inline title, word count, the comment lane and its Comments toggle (usePaneComments, one hook per group over the page's WorkspaceCommentsEnvContext), the review gutter, the authorship lens, wiki navigation, image drop, peer reveal, the CSV / JSON / HTML viewers and the binary previews. What primary adds is placement only: it is the top-left cell (clears the window's floating chrome), mirrors the selection (URL, outline, offline banner) and is the pane that prints. Only that pane owns data-print-root: print CSS removes the surrounding chrome and releases its ancestors' scroll/height constraints so the whole document, including rendered HTML embeds, paginates in normal flow. Page margins repeat on every sheet; comment highlights, comment lanes and Accept/Reject controls stay off paper. Never add a capability to one group that the others do not get; tests/ui/pane-parity.test.ts pins the single mount site. The one surface still rendered inline by the page is the LaTeX workbench (compile / PDF / Lean chrome) for the selection's .tex.
  • 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, checks, 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 })
Today's note⌘⇧D (today-note), the palette action today-note, the launcher verb Today, File ▸ Today's Note in the shell, a ?today=1 arrivalone handler (openTodayNote, lib/daily-notes/today.ts): today's daily note by the machine clock, created empty when the tree lacks it, then open(leaf) — replace-on-open in the focused group. Off → Settings ▸ Workspace (the card); another workspace is the home → navigate there with ?today=1; a folder at the path → a notice, nothing overwritten
Previous / next daily note⌘⇧[ / ⌘⇧] (prev-daily-note / next-daily-note, desktop app), the palette actions of the same idsthe same handler with a direction (resolveAdjacentNote): the nearest existing daily note before / after the open one (from today when the open file is not a daily note); never creates. None that way → a notice
New tab⌘T, +open(LAUNCHER_TAB, 'tab')
Next / previous tab, go to tab⌃⇥ / ⌃⇧⇥ cycle; ⌘1–9 jumps to tab N of the focused group and 9 is always the last (tabbed shell)activate(group, leaf)
Back / forward⌘[ / ⌘], the header's ←/→the focused group's TabHistory (§6); the chat column's own history in the no-tabs shell. Monaco keeps the pair for indent/outdent while a code file has focus
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 a chat⌘⇧Othe ⌘K palette narrowed to chats (files and actions hidden, placeholder "Open a chat…"); the pick is open(chatTab(id))
Open to the siderow ⋮ / chat ⋮open(leaf, 'aside')
Deep-linked file?fileIdopen(path, { group: primary })
Deep-linked folder?folder=<share ref> (a shared folder's address)no open — no document opens, not even the last one; once the saved layout lands, the rail opens (not on a phone), Files expands, and so do the folder and its parents
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)
Checks pagethe Modules header's See all (modules-see-all), a check row's ⋮ See all runs (sidebar-verifier-runs-<id>, filtered to that module)openChecks(filter) → open(checksTab(filter)): one at a time, another filter replaces it. Its rows open the document at the checked text (a thread's run through the comment lane's openWorkspaceCommentThread), and See the work opens the run's trace beside it: a Fix my LaTeX fix's turn (handleJumpToTurn), any other run's chat (openChatById(id, { sidePanel: true })), an AI-written? result's card
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 strip; ⌃⇧1–6 (Ctrl+Shift+1–6 off Mac) in strip order: 1–3 show or hide Modules, Files, Chats (opening the rail first if it is closed), 4–6 open or close Source control, History, Contents in the side columnpanels.toggle('left') · panels.open('right', view) · toggleSectionCollapsed

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), except local terminals, which have no pane header. Seats, left to right, never reordered per surface:

[leading] [← →]        identity (icon · title · share pill · 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, squeeze)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 ⋮. With minTitle (px), squeeze counts the tiers the controls shed (up to squeezeLevels, default 3) so the title keeps that room: a .tex header's check chips keep just their names, then go to their pose (the plain ▶ build button is always wordless, "Build the PDF" on hover), then the view switches go, then the idle chips, so paper.tex never clips to p.
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, a tiny window); 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.
dragWindow?The desktop app's tiny window (§8), where this row is the title bar: data-tauri-drag-region="deep", so its empty space drags the window and its buttons keep their clicks.
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 pill as one never-wrapping item — doc-share-status, always rendered: "Private" on a private doc ("Local" in a folder on this computer), "Shared" with the people or globe glyph once it is out there; one click opens the share window at file scope) 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.

The drive frame's panel buttons (§8; DriveSideButtons and DriveSidePanel, components/workspace/drive-side-panel.tsx). The Drive view has no sections, so a document's controls lead with them, before Comments: Contents (drive-contents-button, the primary pane's markdown note in a split layout, or the shown markdown note in one-tab mode) and History (drive-history-button). The .tex header seats History after its compile cluster. They stay when the bar collapses (no ⋮ item holds them yet). Source control opens from the folder's ⋮ in the Drive list (drive-menu-source, only while repos or the local folder mirror are linked, the rail's tools.sync condition), as the spec puts it. Each opens the side column (§7), the same one the /w rail's strip opens. Contents and History read the pane whose header opened the panel; in one-tab mode they follow the shown pane: MarkdownTOC over the outline (outline-lane), ReviewPanel with the rail's props (its detail still portals into the History leaf), CommitsRail. The pressed button closes it again, and so does each body's close: drive-side-close on Contents' header, review-column-close on the review bar, commits-rail-close (CommitsRail's onClose).

The .tex compile cluster (components/workspace/latex-editor-toolbar.tsx) leads the row: Compile (latex-compile-button, spinner + "Compiling…" while busy, disabled while a fix run is live) with the compile-mode picker welded to its right edge as a caret (latex-compile-mode-toggle, one rounded group). The caret opens a two-item radio menu (latex-compile-mode-menu, latex-compile-mode-hard / latex-compile-mode-permissive, checkmark on the current one), because the mode decides whether a build counts as a success and so belongs on the button that produces the verdict:

ModeWhat it does
Hard (default)A build latexmk -f recovered from — a PDF exists, the log carries an error — is called the failure it is: the last-good PDF stays mounted and flagged stale, the status chip goes red. The verdict belongs to whatever layer promotes the artifact, not to the browser: the request carries compileMode, and a refused build is never published — the cloud route writes no files row, the desktop sidecar writes no PDF next to the source — so no collaborator, reload or realtime listener can mount it. Sunny's background compiles (agent-ts/src/latex/autocompile.ts) carry no browser and so follow the default: a recovered build gets no files row either, and the auto-fix loop reports it as a failure rather than publishing a PDF the readers' hard mode would refuse.
PermissiveOverleaf's behaviour: the recovered PDF mounts and the amber latex-compile-status chip carries the errors.

The choice is a working habit, not a document property: it is stored per browser under sundial:latex-compile-mode (§9) and read through lib/workspace/compile-mode.ts. A squeezed header (compact) — or a host that passes no onCompileModeChange — drops the caret and keeps the bare Compile button.

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. ⌘[ and ⌘] (rows back / forward in Settings → Shortcuts) step the focused group the same way. 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, and — the one right-aligned thing — the view pill; in the desktop shell the row holds 44 points at any page zoom, h-[calc(44px/var(--sd-zoom,1))] with no floor, so the natively-placed traffic lights stay centred on it, and its controls hold their zoom-1 size past zoom 1 rather than outgrow it, top-row-fit), the view the pill picks (the section strip and the sections, or the Chats view), the support slot and the footer — workspace identity, Settings, then the Share button, topbar-share: opens the share window at once for the focused file (the whole workspace on chat surfaces); Share the whole folder instead, under the title, switches scope. One window for every scope (_components/share-modal.tsx), shaped like Google Docs' and frosted like the tour's welcome card: Add people or agents (an email, or one of My agents, or New agent…; focused on open), People and agents (each agent tied to its person, "my agent on MacBook"; role and Remove in one menu; a file lists what its folder grants as one Everyone in “…” row), General access (Only people and agents added or Anyone with the link at a role), and Copy link + Done. Advanced (top right) holds Stop sharing and the Claude and ChatGPT chat apps. The link is the thing's own address; copying never changes access.

  • Home is the drive (components/launcher/home.tsx): the home page lists every folder; a click selects, a double-click (or Enter) steps in, and the big Open “…” button (drive-open) opens exactly what is picked. Rows are flat (LauncherRow), and the picked one lifts off the page. Home in the top row links back to the drive, opened on the folder you're in (/dashboard?in=<id>&path=<folder>, lib/workspace/drive-place.ts).

  • One folder on its own (?scope=<folder>): the workspace opened on a subfolder from the drive. The Files tree is that folder alone (FilesTabPanel lockedView: no Show all, no Everything else), the Chats list keeps only chats started in it, new chats start in it, and its last file and tabs are kept apart from the whole workspace's.

  • View pill (RailViewSwitch, rail-view-switch, model in lib/workspace/rail-view.ts): the shape of the Claude desktop app's view switch — two icon segments on a grey track, the active one a raised white tile — with glyphs that name the layout, not the content (rail-view-chats, a list; rail-view-explorer, a tree; radio semantics, the label is the tooltip). Not Claude's chat-bubble | </> pair: that read as a chat product beside a code product. It sits at the right end of the top chrome (SidebarTopChrome's trailing seat). Explorer is the rail described in the rest of this section, and the default. Chats (rail-chats-view) is the app's shape: a Search field (rail-nav-search, the ⌘K palette — the chrome's search icon is left out in this view, and in the desktop shell it also yields to the pill below 272px, @max-[272px]/chrome:hidden), then the rail's icons as labeled rows — + New (rail-nav-new, starts a chat), Chats, Files, Modules, History, and More (rail-nav-more) unfolding Source control, Contents, Share, Schedules, Open with and Docs (rail-nav-<id>) — and ONE body under the rows (rail-nav-body-<section>): the chat list until a section row is picked (aria-current), then that section exactly as it renders in the stack, own header included. Picking Files or Modules asks the page to expand the section (onShowSection), since Files renders nothing while its bit is collapsed. A tool row opens the side column as the strip does (marked while open) and leaves the body as it is; Sync with nothing linked opens the connect flow. The pick and More are session state. New file in this view creates and opens at once (the ⌘T path, createFileAndOpen) because the tree may be off screen, and a folder draft brings the Explorer back first. The view is a per-browser habit (sundial:rail-view, §9), Explorer by default.

  • Drive view (rail-drive-view), the rail of the /w2/<id> frame (§8): no pill and no strip. One scroller holds the Drive list (DriveList, components/workspace/drive-list.tsx, drive-list): Today (drive-today, the page's openTodayNote; selected, with aria-current, while today's daily note is the file in the front pane), a "Drive" header with + New (drive-new-folder, /new?shell=w2, never prefetched), every workspace you own A to Z as a folder row (drive-folder, left mark cloud or laptop, a people mark when others can open it), with the folders on this Mac among them when the sidecar is running (laptop mark, /w2/local/<id>), and "Shared with me" (the owner's initial). The folder you are in renders in its place as drive-current: the page's own Files panel, headed by the folder (DriveFolderTitle, drive-current-folder) instead of "Files", with one grey line under its name (drive-grey-line, the Files panel's subtitle): where it lives · who can open it, worded by driveGreyLine (lib/workspace/drive-grey-line.ts) from the page's sharing ("In the cloud · You and Jo", "On this Mac · Only you", "From Florent · You can suggest"; only where until sharing loads). Every row's ⋮ (drive-folder-actions) shows on hover or focus, takes no room otherwise (the folder title's stays in the tab order, faded), and a right-click on the row opens it too (AnchoredDropdown, drive-menu-<id>; not while renaming): the folder you are in keeps the workspace's own actions (share "Share…" when you may share, history "See changes" (History for the whole folder, no note needed), source "Source control" when something is linked, settings "Folder settings", rename "Rename" in place, the identity header's input); other rows offer new-tab "Open in a new tab" ("Open in your browser" for a cloud folder in the desktop app, whose webview drops new tabs; nothing for a Mac folder there) and, for cloud folders, copy-link "Copy link" (the public /w2/<id> URL via linkOriginFor, never the desktop's loopback, and a toast; it never changes sharing). A rename re-sorts the list at once (currentName). Its chats sit below the scroller ("Chats in <folder>", the same sidebar-chats-section and + New menu), with Modules docked above them, and under them the strip's two actions in words (drive-tools): Open with… (drive-open-with, the rail's onOpenWith gate: never on a Mac folder or for a link visitor) and Docs (drive-docs). A row click is a route change to that folder's /w2/<id>; the list is cached across switches. History and Outline (as Contents) move to the note header, and Source control to the folder's ⋮ (drive-menu-source), each opening a panel beside the notes (§5).

  • Sections (lib/workspace/sidebar-sections.ts): SidebarSection = 'modules' | 'files' | 'chats' | 'sync' | 'history' | 'outline' | 'share' | '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. Only Modules, Files and Chats stack. The tools (TOOL_SECTIONS: sync, history, outline, share) never do (Belinda 2026-10-11, "it all seems so cramped": they are about the doc in front of you and used in bursts): each opens the side column, and only its pinned bit counts. openWith is an action, listed so its icon is pinnable. Defaults: files, chats and modules on. normalizeSidebarSections validates restored state. Stacking (Belinda 2026-10-09): Files takes the free height (else the top section does); Modules and Chats hug their content up to 45%, so one switched on under Files docks at the bottom, small, and rises into place (skipped under reduced motion, and for a layout restored on load). Each boundary drags the section on its far side from the filler, growing it only into room the others can give up; a dragged height holds until double-clicked. When even every section's 7rem floor doesn't fit, the stack scrolls, so the footer is never pushed off.

  • Strip (sidebar-section-strip): New file (sidebar-new-file) leads for anyone who can create there, then one aria-pressed toggle per available section (sidebar-tab-files|chats|modules|sync|history|outline|share): a section's shows or hides it; a tool's opens its column, swapping out any other, and reads pressed while it is open (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 Outline tool reads Contents, as its column does. ⌃⇧1–6 (Ctrl+Shift+1–6 off Mac) presses the same toggles from the keyboard, in strip order (1 Modules, 2 Files, 3 Chats, 4 Source control, 5 History, 6 Contents), opening the rail first for a section when it is closed; the sidebar-section row in Settings → Shortcuts rebinds the chord for all six. The Docs link (sidebar-docs-link) sits left of the Views caret, which closes the strip. The Modules section is one list, one line per row (Belinda 2026-09-28, "looks messy"): each check, each module and skill, then the checks this workspace could add and the other picks (a Haiku pass's suggestions, nothing when nothing stands out), each with a visible Add and openable in the gallery. Daily notes is not a row (Belinda 2026-09-29): it is a setting in Settings → Workspace (daily-notes-card), used through the launcher's Today and ⌘⇧D. Every check row has one shape, drawn by one component (VerifierRowView, components/workspace/verifier-rows.tsx): a mark, the check's registry name, one line of words, one action, in the same type, spacing and hover whatever its state. The rail is calm (Belinda 2026-09-30): no verdict colors, the mark (RailMark) is a plain stone dot that glows softly while its check runs (steady under reduced motion). Added (sidebar-verifier-<id>, every check an installed module's manifest verifies, as the page reads them): its words for the open document, the running words and a clock while it runs, else its last result ("2 of 4 proved") or "Not checked yet", Remove on hover over the row's end. Clicking it takes you to the document the check is checking or last checked (the row's openDocument; a running check's is the file its run started on): that file, or when it is already open its results (the open claim card, the Lean or Graph view beside the paper), never the chat. A row saying "Try again · …" tries again. Not added (sidebar-verifier-recommended-<id>: the checks its files would use, and the picks that switch on a check not on yet; a pick whose checks are all on is not offered): an empty ring and its pitch, Add always showing. Built-in checks (Fix my LaTeX, AI-written?) take no row. Every other row says what it does for you, muted after its name (Belinda 2026-09-29, "tough to know what modules to add/remove"): a module or pick moduleWords (app/modules/discover-model.ts: a paper template "Sunny follows its rules", a featured module its goal, else its first sentence), a skill its description. A module's own skills (skills on each installed module: the ones it seeds and the ones its instructions name, so Math Research brings four and Lean Blueprint brings lean4, which its setup installs) list under it, not as rows of their own; a check module has no row of its own, its checks are its rows. With every check on, the checks that cannot help with the open file (activeVerifiers) fold into one line of the same shape, sidebar-checks-away ("2 mods · For LaTeX and Lean files", its dot the worst of theirs), whose Show opens them. In a folder on this Mac, which keeps no install list, a check's module counts as added when skills/<module>/SKILL.md is there (folderChecks, lib/workspace/verifiers.ts): the check takes the row and its skill none, its words are FOLDER_CHECK_WORDS ("Only in a Sundial drive for now"), and it has no ⋮ (no model runs it there; removing it is deleting the folder). + Add says the same words on check modules. Everything that brings a module in lives in the header's + Add menu (modules-add-button): the catalog with a filter, in the gallery's groups (Checks your work, Skills and assistants, then Paper templates by name alone), each check and helper saying what it does, then New module (sidebar-modules-new), Add skill (sidebar-modules-add-skill) and Browse all modules (sidebar-modules-browse, the full gallery at /modules). A viewer gets a Browse header link instead. See all (modules-see-all, left of + Add) opens the Checks page, every module run in one place, and each check's ⋮ See all runs (sidebar-verifier-runs-<id>) opens it filtered to that check. The chats those runs made are not in Chats (Chats is for talking, Checks is for runs; docs/verification-sdk.md). No Invite row here: Share covers it.

  • Share (share-panel; Belinda 2026-09-22, reworked 2026-09-29): the who-has-access panel, a tool: its icon opens it in the side column, headed "Share" with a close; the footer's Share is the main door. One flat action row leads the body: Add people or agents (share-panel-manage) opens the share window with its add box focused (agents join from there). Then one row per member (share-panel-member, role on the right, "(you)" on yours, "Someone" when there's no name), each person it was sent to who hasn't joined yet (share-panel-invite, "Invited"), "Only you so far" (share-panel-nobody) when nobody else can open it, the org (share-panel-org), the link lane (share-panel-link, "Anyone with the link") and each AI app here now (share-panel-agent, "Here now", from presence). Cloud workspaces and Mac folders (the backing workspace's people and link, from useWorkspaceAudienceProbe; a folder not shared yet is only you), never for guests who came in on a file, folder or chat link — the same gate as the footer's Share.

  • Add skill (sidebar-modules-add-skill) sits in the Modules header's

    • Add menu — a skill is a module, so it is added where modules are, not from the Files header's + New menu where it used to live. It opens the Add skill modal (install a SKILL.md from a URL or an upload) and expands the Files section first, since the installed skill is revealed in the file tree. Absent — the entry is not rendered — for local workspaces (the sidecar has no skills route) and for viewers without workspace-wide write.
  • Suggestion dots (suggestion-dot): a file row holding pending suggestions carries a small dot in the editor's own suggestion ink (--diff-add-text), labelled for screen readers, so a review waiting in an unopened file is visible from the tree. A collapsed folder rolls its descendants' dots up — a pending review never hides behind a closed caret; an expanded folder stays clean, since the rows carrying the dots are on screen. The paths come from the files payload (suggestionPaths), which both the cloud route and the local (sidecar) shim answer, and refresh with it.

  • Width (project-left-rail): the stored width (200–480, default 260, sundial:left-rail-width) is the user's choice and is never rewritten by the layout. Only the RENDER yields: renderedLeftRailWidth(stored, viewportWidth, sidePanelWidth) caps the rail so the editor column keeps its MIN_EDITOR_COLUMN_WIDTH (200) floor alongside an open Drive panel, whose width also yields below 500px, because the rail is shrink-0 and a window too narrow to seat both (a side panel, a split-screen half) pushed the editor past the right edge of an overflow-hidden row with no scroll to reach it. Widening the window restores the stored width; an unmeasured viewport (SSR, first paint) passes through. The rail never renders under its own 200px minimum: below 400px, where the cap would put it there, the window shows one tab instead (§8) and the rail is hidden. At its minimum the section strip stays ONE row and scrolls sideways (overflow-x-auto, left-aligned so its start is always reachable), and whatever still doesn't fit stops at the rail's edge (ProjectSidebar is overflow-hidden) rather than spilling over the editor. Menus and tooltips are portalled to the body, so the clip never catches them.

  • Resize (ResizeHandle on the rail's right edge): the drag runs from the 200px minimum up to that responsive cap, which never falls under it while the rail shows. Dragging well past the minimum still collapses the rail (bounds don't apply to the collapse preview), and collapsing leaves the stored width untouched.

  • Footer: the workspace identity (click opens the workspace menu, which opens upward; Log in beside it when signed out), credits, Settings. On a local folder with .git, a read-only git pill ("main · 3 changed", local-git-pill) sits beside the identity; commits stay in the terminal.

  • 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.

Side column

The rail's tools open here, ONE at a time, full height, right of the panes (DriveSidePanel, drive-side-panel, DRIVE_SIDE_WIDTH 300px, yielding below 500px; data-kind = contents | history | source | share; the page's drivePanel): the /w rail's strip, the Chats view's rows, ⌃⇧4–6 and the /w2 note header all open it, each tool through TOOL_SIDE. It holds the bodies built once in the page: MarkdownTOC over the outline (outline-lane), ReviewPanel (its detail still portals into the History leaf), CommitsRail, SharePanel. Contents and Share get their header here (drive-side-close); History and Source control head themselves (review-column-close, commits-rail-close). Contents and History read the primary pane, or the pane whose /w2 header opened it; in one-tab mode they follow the shown pane. Every "open history" path (openHistorySection), the add-repo modals' "Open Sync panel" and a Source control leaf open it too. Its top row is an empty strip of the window's band, where the pinned top-right cluster floats (a drag region in the desktop app); while it is open the pane beside it reserves only what the cluster needs beyond the column's width.

The launcher bar

A small orange tab sits flush on the window's right edge, at its vertical centre (LauncherPill, components/workspace/launcher-pill.tsx) — no bar, no hairline, nothing in the layout, so the shell keeps its full width. It wears a 3px page-colour halo and touches the edge so that, over scrolled text, it reads as chrome and never as a stray rule: the light way to reach what the left panel lists. It is not on the bottom edge on purpose: hovering there reveals the Mac Dock, and dictation overlays (Wispr Flow) park at the bottom centre, so a handle there fought both. Hover, tap, or the launcher shortcut (⌘.) grows one stacked column of verbs leftward out of the handle (150ms), one verb per line: each installed module's primary action wired to the same handler its chip uses (Fix my LaTeX, only while a .tex is open, runs its whole flow: build, fix as suggestions, build again; Check with Lean; Is it true?; AI-written?, only on a markdown doc, which checks the whole document), each worn in the check registry's words (label, with its before-you-start line, beforeYouStart: what Sunny will do · how long · what it costs you, as its black hover label beside the column, where a reason it can't run wins; lib/workspace/verifiers.ts), with the rail's status dot for the open document; New, which opens a second little column beside it (New file / New folder / New chat — the palette's create actions, same handlers); Today (today, only while daily notes are on: today's note by ⌘⇧D's handler); Share; Open…, which is the ⌘K palette; and Customize, last. Is it true? is live for any prose file, not only LaTeX: the click first asks a model how the open document splits into claims (verbatim spans, up to 12, skipping sentences an open card already checks or is still checking, read from the file's own threads, while those words are unedited; a toast says Finding facts to check… at once and the verb reads the same meanwhile) and verifies that split. While the checks run, the verb shows their progress muted after its label, in the rail row's words (Checking facts…, then 2 of 3 done, then A source disagrees · 1 of 3), with a ticking clock from the oldest running check (since: Checking facts… · 0:48). The deterministic statement list (lib/workspace/claim-statements.ts: paragraphs and list items, a .tex's prose included; a .tex counts the larger of that and its theorem-like blocks) only sizes the count on the verb and runs when the split call fails. When a run cannot start it says so once and creates nothing; once a start was refused for credits the verb says Out of AI credits in that muted slot (and the bubble's Verify dims, with the reason as its title), a hint rather than a block: a click asks the server again, and returning to the tab or closing Settings clears it. An action that cannot run right now stays in the column greyed, with the reason as its title, so the column never shifts. ↑/↓ (or ←/→) walk it, Enter runs, Esc collapses; leaving with the pointer keeps the column for a 400ms grace so the hand can travel from the handle into it. Hold to peek: holding ⌘ alone for 200ms peeks the column open with no focus change and a digit badge on each verb (launcher-pill-badge-<id>); ⌘1–9 runs one. ⌘↑/⌘↓ step into the column as ⌘. would open it (↓ onto the first verb, ↑ onto the last row) and it stays once ⌘ is let go; Enter runs, ⌘ still down or not. Otherwise releasing ⌘, clicking, blurring the window or any other key folds it away. The hold modifier is the "Peek the launcher bar" row in Settings → Shortcuts (unbound turns the peek off), and the peek never arms while a dialog is open or Settings is recording a chord. The peek's two tensions: while it is up, digits pick verbs, not tabs, and ⌘↑/⌘↓ walk the column, not the caret to the document's start or end (⌘←/⌘→ stay the editor's). In a folder on this Mac each check its modules brought (folderChecks) sits in the column greyed, its reason FOLDER_CHECK_WORDS. Customize (the sliders glyph, worded) picks what the column shows, per browser (sundial:launcher-hidden): every verb, hidden ones unticked; Open… is always shown. Under it, Add more (launcher-pill-add-<id>) lists each check not added yet, one per module (addableChecks), its pitch on hover: Add puts its module in through installTourChecks / connectWorkspaceModule (Adding… meanwhile) and shows it if it was hidden. In a folder on this Mac it adds the same way (the module's files are copied in, as the rail's + Add does) and the hover says FOLDER_CHECK_WORDS; the added check then sits greyed. 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).

On /w3 the handle is the buddy (WorkspaceBuddy, components/workspace/buddy/): Sunny in one of its looks (Pip, Mochi, Ink, Page, Classic) peeks in from the edge in place of the orange tab, through LauncherPill's handle and place (which edge, and how far down). The strip stays 14px wide and only the buddy's still 48px box takes the pointer (its pose moves inside it, so it never slides out from under a resting pointer and shivers). Hover (200ms) or ⌘. opens the launcher as frosted glass beside it, kept 12px inside the window at any height, with New and Customize opening their second column on the side away from the buddy; ⌘. or Esc hands the keyboard back where it was. A click opens the talk card instead of pinning the column (onHandleClick), and the handle's accessible name is handleLabel. A drag (past 6px) picks it up where you grabbed it, lifted and leaning into the motion. Dropped with the pointer within 56px of the left or right edge (a glow on that edge says so), it peeks from that edge, 12–88% down; anywhere else it stands on the page, its centre at place.x%, place.y% (kept 8px inside the window), only its painted body takes the pointer, and the column opens on its roomier side. Saved in localStorage sundial:buddy:v1 (spot: { side, y, x? }, no x = an edge); it springs into place on landing, and while it is carried (or has just landed under the pointer) held keeps the column shut. Its words show beside it (buddy-line), and the talk card (buddy-card) opens next to it. The card is where you talk: what you say goes to the current chat without opening it, and the replies stream into the card (shown there, they count as read: onRead). Open chat opens the full chat and closes the card. While the current chat is on screen (chatOnScreen) there is no card (an open one is gone the moment the chat arrives): a click on the buddy goes to the chat's composer (the column stays shut), and the bubble skips what the chat already shows (your words, Sunny's reply, while its voice and mouth still play) but keeps the buddy's own lines; Esc there hushes the reply and still stops the run. New chat from the card starts the draft without opening it. A click on its mic help turns the mic back on.

Shortcuts

Settings → Shortcuts (ShortcutsSection, components/workspace/preferences-section.tsx) lists every row of workspaceShortcuts (lib/workspace/shortcuts.ts) under flat group headers (shortcuts-group-<group>). Every row is rebindable except Monaco's two reference rows (fixed); rows with menu also move the desktop shell's native accelerator (set_menu_accelerators). Click a key (shortcut-edit-<id>, -<n> for the second key onward) to change it, Add (shortcut-add-<id>) for a second key, delete while recording removes that key, Reset per row (shortcut-reset-<id>) and Reset all (shortcuts-reset-all). A filter by name or keys (shortcuts-filter), and Record keys (shortcuts-filter-keys) to press a chord and see who holds it. A chord another row owns is refused (shortcut-error) with "Move it here" (shortcut-move), which takes it from that row; a Monaco-owned chord is assigned with a note (shortcut-note: code files keep it). Digit rows (⌘1–9, ⌃⇧1–6) record one chord for the whole family, stored with the digit normalised to 1; the hold row records a tapped modifier (Alt refused off Mac). Overrides live per device under localStorage sundial:shortcuts ({ [id]: string[] }, an empty array means unbound); useShortcutBindings (lib/workspace/shortcut-bindings.ts) resolves them for the page's keydown handlers and the palette's hints.

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 and fusing with the surface below — feet, sides and top corners are one SVG path (tab-outline, sized to the tab by a ResizeObserver; the path only masks bg-white / bg-stone-200 layers so dark mode's palette remap reaches them), never a CSS border stitched to separate feet, and the hairline is a background gradient, so the joins survive 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.
Drive framethe /w2/<id> route, or /w2/local/<id> for a folder on this Mac (isDriveShellPath, drivePath, lib/workspace/drive-shell.ts), any of the shells aboveThe rail is the Drive view (§7). Home leaves the top chrome (the list is home), Share moves from the footer to the top-right cluster (topbar-right), the footer shows you (drive-you) instead of the workspace, and Today, sign-in returns and "+ New" folders stay inside /w2. /w2 alone opens your last folder (driveEntryPath, lib/workspace/drive-entry.ts): the one you visited last and can still open, else the one you own that changed last, else a new one; signed out, sign-in first. The note header gains Contents and History, and the folder's ⋮ Source control, each opening the panel beside the notes (drive-side-panel, §5). When Today makes today's note, Sunny greets you above its text, never in it (TodayHello, components/workspace/today-hello.tsx, drive-today-hello, through the pane body's aboveText slot): a "jump" pose, "Good morning, <first name>." by the local clock, and up to 3 chats here with replies waiting, each a click away. It is decided once, when the note opens, so typing never moves it and the next visit is just the note. On a Sunny chat (not Claude Code or Codex) the live line shows Sunny at work where the spinner was (sunnyPoses, SunnyPose in components/sunny-pose.tsx): the running tool picks the pose (sunnyPoseForTool: reading a file is "book", searching files "search", the web "telescope", Bash and making a PDF "laptop", writing or editing "files", anything else "spin"). Everything else is the same page.
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, down to one tab (below).One surface at a time, the primary group only, no strips, the chat header hidden (the top bar carries it), the bottom surface switcher.

One tab (showsSingleTab, lib/workspace/layout.ts). A mouse/trackpad window too narrow to seat the left panel at its minimum beside the editor's floor (200 + 200, so under 400px; or under 700px when Drive's side panel is open; the desktop app's window floor is 267×200) shows one tab in any shell, as just the note: the focused group's active leaf (shownPaneId) under its own header, which is the window's top row (stripsShown is false, so no strip renders; the header takes the no-tabs tall height and, in the desktop app, clears the traffic lights and drags the window, dragWindow). The left panel and every other group stay mounted but hidden; an open Drive panel and its buttons remain visible. The panel narrows to leave 200px for the editor below 500px. Nothing reloads or is saved differently: widening brings back the rail, the strips, the splits and the background tabs as they were. Everything else steps aside until there is room: the formatting bar and its ⋮ toggle, the launcher tab, the pinned top-right cluster, Home and the sidebar toggle (the desktop app keeps topbar-float-nav as a bare traffic-light pad and drag strip), closed Drive panel buttons, and the ←/→ pair until there is somewhere to go. The share pill leaves the header so the name keeps the row, and Share moves into the ⋮. A header-less terminal gets a bare title-bar strip. Drive Contents and History follow the shown group's active note. The group shown is the one focused last (a click, a tab, an open that landed there), so an open that lands in another group brings that group forward.

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)
Left rail width (stored, not the rendered width)sundial:left-rail-widthhandleLeftRailCommit (page); clamped to 200–480 on read, and a commit under 200 (a viewport-capped drag) is ignored
Doc stylesundial:doc-stylelib/doc-style.ts
LaTeX compile mode (per person, not per workspace)sundial:latex-compile-modereadStoredCompileMode / writeStoredCompileMode (lib/workspace/compile-mode.ts)
Chats box heightsundial:chats-box-heightProjectSidebar
Rail view, Chats or Explorer (per person, not per workspace)sundial:rail-viewcoerceRailView (lib/workspace/rail-view.ts), read and written by the page (pickRailView)
Shortcut overrides (per device; [] = unbound)sundial:shortcutsgetShortcutOverrides / setShortcutOverride (lib/workspace/shortcut-bindings.ts)
Comment margin closed on purpose (this tab only: sessionStorage), so a page with claim verdicts stops opening itsundial:comment-lane-closeduseWorkspaceComments (app/w/[slug]/_components/workspace-comments.tsx): its Comments toggle and the margin's close write it, opening it again clears it

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, checks-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; drive frame: drive-contents-button, drive-history-button
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, checks-leaf (checks-view, checks-filters, checks-filter-<all|id>, checks-list, checks-row (+data-module, data-running), checks-row-open, checks-row-work, checks-empty), 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, rail-view-switch, rail-view-<chats|explorer>, rail-chats-view, rail-drive-view, drive-list, drive-today, drive-new-folder, drive-folder, drive-folder-actions, drive-menu-<share|history|source|settings|rename|new-tab|copy-link>, drive-tools, drive-open-with, drive-docs, drive-current, drive-current-folder, drive-grey-line, drive-you, rail-nav-search, rail-nav-new, rail-nav-<section>, rail-nav-more, rail-nav-body-<section>, sidebar-section-strip, sidebar-tab-<section>, sidebar-tab-modules, sidebar-tab-open-with, sidebar-tab-share, share-panel, share-panel-copy-ai, share-panel-manage, sidebar-chats-section, chats-new-button, workspace-identity-label, topbar-share, support-slot, suggestion-dot
Document toolbarslatex-compile-button, latex-compile-mode-toggle, latex-compile-mode-menu, latex-compile-mode-<hard|permissive>, latex-compile-status, latex-view-<mode>
Side column and top-rightdrive-side-panel (+data-kind), drive-side-close, outline-lane, share-panel, review-column-close, commits-rail-close, topbar-right; /w2 note header: drive-contents-button, drive-history-button
Document bodydrive frame: drive-today-hello (above the note's text, through the pane body's aboveText slot)
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>, launcher-pill-add-<id>, launcher-pill-badge-<id>; /w3: workspace-buddy (+data-mood), buddy-line, buddy-card, buddy-picker, buddy-pick-<look>, buddy-sound-<sound>
Settings → Shortcutsshortcuts-section, shortcuts-filter, shortcuts-filter-keys, shortcuts-group-<group>, shortcut-edit-<id>[-<n>], shortcut-add-<id>, shortcut-reset-<id>, shortcuts-reset-all, shortcut-error, shortcut-note, shortcut-move

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, rail-view-switch.test.tsx, sidebar-top-chrome.test.tsx, panel-view-layout.test.tsx, files-tab-panel-suggestion-dots.test.tsx, latex-compile-mode-picker.test.tsx, use-latex-compile-mode.test.ts, workspace-shortcuts.test.ts, shortcut-bindings.test.ts, shortcuts-section.test.tsx, launcher-pill.test.tsx, drive-side-panel.test.tsx, today-hello.test.tsx, sunny-pose.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; a file view goes through renderDocumentPane for every group) 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 PDF double-click that resolves to another source waits for that source's editor, statement scan, and available Lean mapping before syncing any linked pane. PDF, graph, citations, and chat ties use the resolved source together; another navigation cancels the pending jump and its editor retries. Existing companion leaves follow the child in the same groups, keeping source focus. A failed Lean lookup does not prevent the other panes from following.
  • 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.