Workspace pages: N switchable pane layouts per startup, migration-safe
The workspace ran out of horizontal room at ~3 panes. Added switchable pages per startup by making a PagedLayout the source of truth and deriving the old single layout from it — so every existing split/close/reset call site kept working untouched.
The problem
One workspace = one pane grid. Three terminal panes side by side and the width is gone — there's nowhere to put a fourth without each pane becoming unusably narrow. The founder wanted what every editor has: multiple pages (tabs) per workspace, switch between them, each holding its own pane layout.
The shape of the change
The naive way is to thread a pageIndex through every place that touches the
layout. That's a lot of call sites — splitFocused, closeFocused,
resetLayout, the <Mosaic onChange>, the load/save effects — and each is a
chance to get the cross-startup guard wrong.
Instead: make the new container the single source of truth and derive the old shape from it.
// pages.ts
export interface PagedLayout {
pages: (MosaicNode<PaneId> | null)[]; // each page = one pane-grid (or empty)
active: number; // which page is showing
}// App.tsx
const [pages, setPages] = useState<PagedLayout | null>(null);
const layout = pages ? (pages.pages[pages.active] ?? null) : null; // derived
const setLayout = (next) => setPages((p) => (p ? withActiveLayout(p, next) : p));Now layout and setLayout mean exactly what they used to — except scoped to
the active page. Every existing call site compiled and worked unchanged. The
only code that had to actually change was persistence and the two places that
walk all of a startup's panes.
The two traps
-
The cross-write guard (2026-06-09) had to survive verbatim. That bug — where switching startup A→B once wrote A's tree under B's key and the two workspaces permanently shared the same tmux sessions — is fixed by an "owner" stamp set in the same render batch as the layout, and a save effect that refuses to persist when the owner doesn't match the active startup. Porting it was mechanical (
saveLayoutFor(layout)→savePages(pages)), but getting it subtly wrong would have resurrected a week-old ghost. -
Two sites must walk every page, not just the active one. Registering pane→startup ownership with the Rust backend, and tearing down a deleted startup's panes, both used to read one layout. With pages, a startup owns panes across all its pages — so both now iterate
loadPageLayouts(id). Miss this and deleting a startup leaks the tmux sessions on its background pages.
Migration
Existing workspaces store a single layout under dalkkak.layout.<id>.v1. The new
loader reads dalkkak.pages.<id>.v1, and on first load (no pages key yet)
migrates that legacy layout into pages[0]. So everyone's current workspace
opens exactly as before — as page 1 — and pages 2, 3, … are theirs to add.
Verified
vitest run: 14 files / 102 tests pass, including 9 new ones covering the pure model (add / remove / switch / replace-active, null pages), persistence round-trip, the legacy→page-0 migration, and corrupt-blob safety.tsc --noEmit: clean.pnpm build(production vite): built.
Not yet app-tested — this is the static gate (types, tests, build). The dev-run /
install verification comes next, behind the founder's explicit go (pages touch
App.tsx, which other parallel sessions are also editing on main, so this stayed
worktree-isolated on feat/workspace-pages until merge).
Switching pages: ⌘⌥←/→ (and why not ⌘⇧-digit)
Pages needed a keyboard switch. The obvious idea — ⌘⇧+number — is a trap: ⌘⇧3/4/5
are macOS screenshots, an instant collision. And the digit chords were already taken
(Ctrl+1‥9 = startup, ⌘1‥9 = pane focus). The clean answer was the horizontal
arrow pair, mirroring the existing vertical ⌘⌥↑/↓ startup nav: ⌘⌥← / ⌘⌥→ =
prev / next page. The tab strip is horizontal, so the axis matches; ⌘ never reaches
xterm (so it can't leak into a terminal the way Ctrl+3=ESC once did), and arrows
aren't keyboard-layout-dependent the way ⌘⇧[ would be. Locked by a chord-guard plus
tests, and fully consumed per the rule that an intercepted chord must
preventDefault + stopPropagation — even on the no-op (fewer-than-two-pages) path.
One honest limit surfaced here: the entire keybinding layer is ⌘-based by design,
so it's macOS-only — a Windows port needs a platform keymap layer (no Cmd key; Ctrl is
terminal-reserved). Logged as a future decision, not built.
Shipped
App-confirmed working, code-signed build installed (v0.2.30), and merged to main
alongside the parallel widget work — one App.tsx conflict, resolved by dropping the
since-removed workspace abstraction; the pages feature itself rode through untouched.
The reusable lesson
To add a dimension to existing state (1 → N of something), don't fan the new index out across every consumer. Make the N-container the source of truth, derive the old singular shape from it, and wrap the old setter. The consumers don't know anything changed. Only persistence and "enumerate everything this entity owns" need to learn the new shape.