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… │ │ (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 toselectedFilePath. 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, whosepaddingRightmirrors 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 portalledIconTooltips, 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).
| 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 |
terminal | sundial-terminal://<sessionId> | center | never | keep |
launcher | sundial-launcher://new | center | never | transient |
support | sundial-support://thread | center | never | overlay |
modules | sundial-modules://gallery | center | never | overlay |
checks | sundial-checks://<all|verifier id> | center | never | keep, 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, 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 + 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. - All panes are equal. A file renders through ONE path in every group —
renderDocumentPane(groupId, file, collabPath)inpage.tsx, which mountsSplitEditorPaneReviewBody(components/workspace/split-editor-pane.tsx) under the chromebuildSplitPaneChromebuilds 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'sWorkspaceCommentsEnvContext), the review gutter, the authorship lens, wiki navigation, image drop, peer reveal, the CSV / JSON / HTML viewers and the binary previews. Whatprimaryadds 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 ownsdata-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.tspins 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:
- 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, checks, 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 }) |
| 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 arrival | one 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 ids | the 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 | ⌘⇧O | the ⌘K palette narrowed to chats (files and actions hidden, placeholder "Open a chat…"); the pick is open(chatTab(id)) |
| Open to the side | row ⋮ / chat ⋮ | open(leaf, 'aside') |
| Deep-linked file | ?fileId | open(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=open | open(diffTab(messageId)) — the same Turn edits leaf a chat card opens (mobile keeps its full-screen overlay) |
| Checks page | the 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 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; ⌃⇧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 column | panels.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
.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), except local
terminals, which have no pane header. Seats, left to right, never reordered
per surface:
[leading] [← →] identity (icon · title · share pill · 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, 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, 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, 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, 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 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:
| Mode | What 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. |
| Permissive | Overleaf'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 (FilesTabPanellockedView: 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 inlib/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'strailingseat). 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'sopenTodayNote; selected, witharia-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 asdrive-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'ssubtitle): where it lives · who can open it, worded bydriveGreyLine(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 offernew-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 vialinkOriginFor, 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 samesidebar-chats-sectionand + 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'sonOpenWithgate: 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 acollapsedbit and apinnedbit;collapsedmeans 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 itspinnedbit counts.openWithis an action, listed so its icon is pinnable. Defaults: files, chats and modules on.normalizeSidebarSectionsvalidates 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 onearia-pressedtoggle 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; thesidebar-sectionrow 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 manifestverifies, 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'sopenDocument; 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 pickmoduleWords(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 (skillson 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 whenskills/<module>/SKILL.mdis there (folderChecks,lib/workspace/verifiers.ts): the check takes the row and its skill none, its words areFOLDER_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, fromuseWorkspaceAudienceProbe; 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.mdfrom 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.
- 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
-
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 itsMIN_EDITOR_COLUMN_WIDTH(200) floor alongside an open Drive panel, whose width also yields below 500px, because the rail isshrink-0and a window too narrow to seat both (a side panel, a split-screen half) pushed the editor past the right edge of anoverflow-hiddenrow 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 (ProjectSidebarisoverflow-hidden) rather than spilling over the editor. Menus and tooltips are portalled to the body, so the clip never catches them. -
Resize (
ResizeHandleon 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
| 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 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-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. |
| Drive frame | the /w2/<id> route, or /w2/local/<id> for a folder on this Mac (isDriveShellPath, drivePath, lib/workspace/drive-shell.ts), any of the shells above | The 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. |
| 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, 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
| 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) |
| Left rail width (stored, not the rendered width) | sundial:left-rail-width | handleLeftRailCommit (page); clamped to 200–480 on read, and a commit under 200 (a viewport-capped drag) is ignored |
| Doc style | sundial:doc-style | lib/doc-style.ts |
| LaTeX compile mode (per person, not per workspace) | sundial:latex-compile-mode | readStoredCompileMode / writeStoredCompileMode (lib/workspace/compile-mode.ts) |
| Chats box height | sundial:chats-box-height | ProjectSidebar |
| Rail view, Chats or Explorer (per person, not per workspace) | sundial:rail-view | coerceRailView (lib/workspace/rail-view.ts), read and written by the page (pickRailView) |
Shortcut overrides (per device; [] = unbound) | sundial:shortcuts | getShortcutOverrides / setShortcutOverride (lib/workspace/shortcut-bindings.ts) |
Comment margin closed on purpose (this tab only: sessionStorage), so a page with claim verdicts stops opening it | sundial:comment-lane-closed | useWorkspaceComments (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
| Surface | Ids |
|---|---|
| Pane header | pane-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 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, 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 panel | project-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 toolbars | latex-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-right | drive-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 body | drive frame: drive-today-hello (above the note's text, through the pane body's aboveText slot) |
| 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>, 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 → Shortcuts | shortcuts-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
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; a file view goes throughrenderDocumentPanefor every group) 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 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 carriesdata-tex-line; a chat turn is itsdata-message-idelement inside itsdata-chat-idcard.