aiprompts/layout-simplification.md
The current layout system uses a complex bidirectional atom architecture that forces every layout change to round-trip through the backend WaveObject, even though the backend never reads this data - it only queues actions via PendingBackendActions. By switching to a "write cache" pattern where local atoms are the source of truth and backend writes are fire-and-forget, we can eliminate ~70% of the complexity while maintaining full persistence.
Every layout change (split, close, focus, magnify) currently follows this flow:
User action
↓
treeReducer() mutates layoutState
↓
layoutState.generation++ ← Only purpose: trigger the write
↓
Bidirectional atom setter (checks generation)
↓
Write to WaveObject {rootnode, focusednodeid, magnifiednodeid}
↓
WaveObject update notification
↓
Bidirectional atom getter runs
↓
ALL dependent atoms recalculate (every isFocused, etc.)
↓
React re-renders with updated state
The critical insight: The backend reads ONLY leaforder from the WaveObject (for block number resolution in commands like wsh block:1). The rootnode, focusednodeid, and magnifiednodeid fields exist only for persistence (tab restore, uncaching).
Backend Reads (from pkg/wshrpc/wshserver/resolvers.go):
LeafOrder - Used to resolve block numbers in commands (e.g., wsh block:1 → blockId lookup)Backend Writes (from pkg/wcore/layout.go):
PendingBackendActions - Queued layout actions via QueueLayoutAction()Backend NEVER touches:
RootNode - Never read, only written by frontend for persistenceFocusedNodeId - Never read, only written by frontend for persistenceMagnifiedNodeId - Never read, only written by frontend for persistenceThe key insight: Only LeafOrder needs to be synced to backend (for command resolution). The tree structure fields (rootnode, focusednodeid, magnifiednodeid) are pure persistence!
layoutState.generation++ appears in 10+ places, only to trigger atom writeswithLayoutTreeStateAtomFromTab() has complex read/write logicfocusedNodeId trigger full tree state propagation even though they're unrelated to tree structureUser action
↓
Update LOCAL atom (immediate, synchronous)
↓
React re-renders (single tick, all atoms see new state)
↓
[async, fire-and-forget] Persist to WaveObject
PendingBackendActions// frontend/layout/lib/layoutModel.ts
class LayoutModel {
// BEFORE: Bidirectional atom with generation tracking
// treeStateAtom: WritableLayoutTreeStateAtom
// AFTER: Simple local atom (source of truth)
private localTreeStateAtom: PrimitiveAtom<LayoutTreeState>;
// Keep reference to WaveObject atom for persistence
private waveObjectAtom: WritableWaveObjectAtom<LayoutState>;
constructor(tabAtom: Atom<Tab>, ...) {
this.waveObjectAtom = getLayoutStateAtomFromTab(tabAtom);
// Initialize local atom (starts empty)
this.localTreeStateAtom = atom<LayoutTreeState>({
rootNode: undefined,
focusedNodeId: undefined,
magnifiedNodeId: undefined,
leafOrder: undefined,
pendingBackendActions: undefined,
generation: 0 // Can be removed entirely or kept for debugging
});
// Read from WaveObject ONCE during initialization
this.initializeFromWaveObject();
}
private async initializeFromWaveObject() {
const waveObjState = this.getter(this.waveObjectAtom);
// Load persisted state into local atom
const initialState: LayoutTreeState = {
rootNode: waveObjState?.rootnode,
focusedNodeId: waveObjState?.focusednodeid,
magnifiedNodeId: waveObjState?.magnifiednodeid,
leafOrder: undefined, // Computed by updateTree()
pendingBackendActions: waveObjState?.pendingbackendactions,
generation: 0
};
// Set local state
this.treeState = initialState;
this.setter(this.localTreeStateAtom, initialState);
// Process any pending backend actions
if (initialState.pendingBackendActions?.length) {
await this.processPendingBackendActions();
}
// Initialize tree (compute leafOrder, etc.)
this.updateTree();
}
// Process backend-queued actions (startup only)
private async processPendingBackendActions() {
const actions = this.treeState.pendingBackendActions;
if (!actions?.length) return;
this.treeState.pendingBackendActions = undefined;
for (const action of actions) {
// Convert backend action to frontend action and run through treeReducer
// This code already exists in onTreeStateAtomUpdated()
switch (action.actiontype) {
case LayoutTreeActionType.InsertNode:
this.treeReducer({
type: LayoutTreeActionType.InsertNode,
node: newLayoutNode(undefined, undefined, undefined, {
blockId: action.blockid
}),
magnified: action.magnified,
focused: action.focused
}, false);
break;
// ... other action types
}
}
}
}
class LayoutModel {
treeReducer(action: LayoutTreeAction, setState = true): boolean {
// Run the tree operation (mutates this.treeState)
switch (action.type) {
case LayoutTreeActionType.InsertNode:
insertNode(this.treeState, action);
break;
case LayoutTreeActionType.FocusNode:
focusNode(this.treeState, action);
break;
case LayoutTreeActionType.DeleteNode:
deleteNode(this.treeState, action);
break;
// ... all other cases unchanged
}
if (setState) {
// Update tree (compute leafOrder, validate, etc.)
this.updateTree();
// Update local atom IMMEDIATELY (synchronous)
this.setter(this.localTreeStateAtom, { ...this.treeState });
// Persist to backend asynchronously (fire and forget)
this.persistToBackend();
}
return true;
}
// Fire-and-forget persistence
private async persistToBackend() {
const waveObj = this.getter(this.waveObjectAtom);
if (!waveObj) return;
// Update WaveObject fields
waveObj.rootnode = this.treeState.rootNode; // Persistence only
waveObj.focusednodeid = this.treeState.focusedNodeId; // Persistence only
waveObj.magnifiednodeid = this.treeState.magnifiedNodeId; // Persistence only
waveObj.leaforder = this.treeState.leafOrder; // Backend reads this for command resolution!
// Write to backend (don't await - fire and forget)
this.setter(this.waveObjectAtom, waveObj);
// Optional: Debounce if rapid changes are a concern
}
}
class LayoutModel {
getNodeModel(node: LayoutNode): NodeModel {
return {
// BEFORE: Complex dependency on bidirectional treeStateAtom
// isFocused: atom((get) => {
// const treeState = get(this.treeStateAtom); // Triggers on any tree change
// ...
// })
// AFTER: Simple dependency on local atom
isFocused: atom((get) => {
const treeState = get(this.localTreeStateAtom); // Simple read
const focusType = get(focusManager.focusType);
return treeState.focusedNodeId === node.id && focusType === "node";
}),
// All other atoms similarly simplified...
isMagnified: atom((get) => {
const treeState = get(this.localTreeStateAtom);
return treeState.magnifiedNodeId === node.id;
}),
// ... rest unchanged
};
}
}
The generation field can be removed entirely from LayoutTreeState:
// frontend/layout/lib/types.ts
export interface LayoutTreeState {
rootNode?: LayoutNode;
focusedNodeId?: string;
magnifiedNodeId?: string;
leafOrder?: LayoutLeafEntry[];
pendingBackendActions?: LayoutActionData[];
// generation: number; ← DELETE THIS
}
And remove all generation++ calls from layoutTree.ts (appears in 10+ places).
// frontend/layout/lib/layoutAtom.ts
// BEFORE: Complex bidirectional atom (60 lines)
// AFTER: Can be deleted entirely or simplified to just helper for WaveObject access
export function getLayoutStateAtomFromTab(
tabAtom: Atom<Tab>,
get: Getter
): WritableWaveObjectAtom<LayoutState> {
const tabData = get(tabAtom);
if (!tabData) return;
const layoutStateOref = WOS.makeORef("layout", tabData.layoutstate);
return WOS.getWaveObjectAtom<LayoutState>(layoutStateOref);
}
// No more withLayoutTreeStateAtomFromTab() - not needed!
generation++ calls and all related logicThe entire Section 8 ("Layout Model Focus Integration - CRITICAL TIMING") becomes unnecessary:
BEFORE (complex timing coordination):
treeReducer(action: LayoutTreeAction) {
insertNode(this.treeState, action); // generation++
// CRITICAL: Must update focus manager BEFORE atom commits
if (action.focused) {
focusManager.requestNodeFocus(); // Synchronous!
}
// Then atom commits
this.setter(this.treeStateAtom, ...);
// Now isFocused sees correct focusType
}
AFTER (trivial):
treeReducer(action: LayoutTreeAction) {
insertNode(this.treeState, action); // Just mutates local state
// Update local atom (synchronous)
this.setter(this.localTreeStateAtom, { ...this.treeState });
// Update focus manager (order doesn't matter - both updated synchronously)
if (action.focused) {
focusManager.setBlockFocus();
}
// Both updates happen in same tick, no race condition possible!
}
Can delete:
generation field and all generation++ calls (~15 places)layoutAtom.ts (~40 lines)lastTreeStateGeneration tracking in LayoutModelgeneration > this.treeState.generation checksTotal: ~200-300 lines of complex coordination code deleted
Concern: Many layout changes in quick succession could cause many backend writes.
Solution: Debounce the persistToBackend() call (e.g., 100ms). Users won't notice the delay in persistence.
private persistDebounceTimer: NodeJS.Timeout | null = null;
private persistToBackend() {
if (this.persistDebounceTimer) {
clearTimeout(this.persistDebounceTimer);
}
this.persistDebounceTimer = setTimeout(() => {
const waveObj = this.getter(this.waveObjectAtom);
if (!waveObj) return;
waveObj.rootnode = this.treeState.rootNode;
waveObj.focusednodeid = this.treeState.focusedNodeId;
waveObj.magnifiednodeid = this.treeState.magnifiedNodeId;
waveObj.leaforder = this.treeState.leafOrder;
this.setter(this.waveObjectAtom, waveObj);
this.persistDebounceTimer = null;
}, 100);
}
Current: Each tab has its own treeStateAtom in a WeakMap.
After: Each tab has its own localTreeStateAtom in the LayoutModel instance. No change needed - already isolated per tab.
Current: Tab gets uncached, needs to reload layout from WaveObject.
After: Same - initializeFromWaveObject() reads persisted state. No change in behavior.
Concern: The backend reads LeafOrder for CLI command resolution (e.g., wsh block:1). What if it's not synced yet?
Solution: Fire-and-forget is perfectly fine! CLI commands aren't time-sensitive:
LeafOrder is 100ms behind, no one will noticewsh block:1, the async write has long since completedAnswer: NO - shallow copy is sufficient! ✓
Looking at the current code in layoutModel.ts:587:
setTreeStateAtom(bumpGeneration = false) {
if (bumpGeneration) {
this.treeState.generation++;
}
this.lastTreeStateGeneration = this.treeState.generation;
this.setter(this.treeStateAtom, this.treeState); // ← Sets same object!
}
The current system doesn't create new objects either! It relies on generation changing to trigger the bidirectional atom's setter.
// In treeReducer after mutations
this.setter(this.localTreeStateAtom, { ...this.treeState });
This works because:
LayoutTreeState object){ ...this.treeState } creates a NEW object with a different referenceExample:
const oldState = { rootNode: someTree, focusedNodeId: "node1" };
const newState = { ...oldState };
oldState === newState // FALSE - different objects!
oldState.rootNode === newState.rootNode // TRUE - same tree reference
// But Jotai only checks the first comparison, so it detects the change!
All tree operations in layoutTree.ts mutate in place:
insertNode() - Mutates layoutState.rootNodeConcern: Will derived atoms like isFocused and isMagnified update when we change to local atoms?
Answer: YES - they will work perfectly! ✓
The NodeModel creates derived atoms that depend on treeStateAtom:
// From layoutModel.ts:936-946
isFocused: atom((get) => {
const treeState = get(this.treeStateAtom); // Subscribe to treeStateAtom
const isFocused = treeState.focusedNodeId === nodeid;
const waveAIFocused = get(atoms.waveAIFocusedAtom);
return isFocused && !waveAIFocused;
}),
isMagnified: atom((get) => {
const treeState = get(this.treeStateAtom); // Subscribe to treeStateAtom
return treeState.magnifiedNodeId === nodeid;
}),
After the change:
isFocused: atom((get) => {
const treeState = get(this.localTreeStateAtom); // Subscribe to localTreeStateAtom
const isFocused = treeState.focusedNodeId === nodeid;
const waveAIFocused = get(atoms.waveAIFocusedAtom);
return isFocused && !waveAIFocused;
}),
The update flow:
focusNode() calledtreeReducer() runs → mutates this.treeState.focusedNodeId = newIdthis.setter(this.localTreeStateAtom, { ...this.treeState }) ← New reference!localTreeStateAtomget(this.localTreeStateAtom) are notifiedfocusedNodeId valueWe're not mutating fields inside the atom - we're replacing the entire state object:
// OLD way (current):
// 1. Mutate this.treeState.focusedNodeId = newId
// 2. Bump this.treeState.generation++
// 3. Set bidirectional atom (checks generation, writes to WaveObject, reads back, updates)
// 4. Derived atoms see new state from the round-trip
// NEW way (proposed):
// 1. Mutate this.treeState.focusedNodeId = newId (same!)
// 2. this.setter(localTreeStateAtom, { ...this.treeState }) (new object reference!)
// 3. Derived atoms immediately see new state (no round-trip!)
Both approaches create a new state object that triggers Jotai's reactivity!
The new way is actually MORE reliable because:
Question: What if derived atoms access nested fields like treeState.rootNode.children?
Answer: Still works! Example:
// Hypothetical derived atom
someAtom: atom((get) => {
const treeState = get(this.localTreeStateAtom);
return treeState.rootNode.children.length; // Nested access
})
This works because:
LayoutTreeState object: { ...this.treeState }get(this.localTreeStateAtom)newState.rootNode (same reference as before, but that's OK!)The derived atom doesn't care that rootNode is the same object - it just cares that the STATE object changed and it needs to re-evaluate.
All derived atoms in NodeModel:
isFocused - depends on treeState.focusedNodeIdisMagnified - depends on treeState.magnifiedNodeIdblockNum - depends on separate this.leafOrder atom (unaffected)isEphemeral - depends on separate this.ephemeralNode atom (unaffected)All will update correctly with the new local atom approach!
deleteNode() - Mutates parent's children arrayfocusNode() - Mutates layoutState.focusedNodeIdThis is fine! We're not relying on immutability for change detection. We're relying on creating a new LayoutTreeState wrapper object via spread operator.
When reading from WaveObject on initialization:
const waveObjState = this.getter(this.waveObjectAtom);
const initialState: LayoutTreeState = {
rootNode: waveObjState?.rootnode, // New reference from backend
focusedNodeId: waveObjState?.focusednodeid,
// ...
};
This creates a completely new object with new references, which is even more immutable than necessary. No issues here.
✅ We're covered - Shallow copy via spread operator is sufficient
✅ Same as current system - We're not making it worse, just simpler
✅ Jotai only checks reference equality on the atom value, not deep equality
✅ Tree mutations are fine - They've always worked this way
Current: Backend queues actions via QueueLayoutAction(), frontend processes via pendingBackendActions.
After: Same - initializeFromWaveObject() processes pending actions. No change needed.
Concern: What if the async write to WaveObject fails?
Solution:
localTreeStateAtom alongside existing treeStateAtomisFocused atoms to use local atomtreeReducer to write to local atom + fire-and-forget persistisFocused and other computed atoms to use local atomlayoutAtom.tsgeneration field from LayoutTreeStateonTreeStateAtomUpdated() (only needed for pendingBackendActions)Before: Must coordinate timing with atom commits.
After: Can update focusType atom independently. Order doesn't matter since both updates happen synchronously.
No change: Blocks still subscribe to nodeModel.isFocused, which still reacts correctly (faster now).
No change: Still calls layoutModel.focusNode(), which updates local state immediately.
No change: Views don't interact with layout atoms directly.
The "write cache" pattern can simplify the layout system by ~70% while maintaining full functionality:
This also makes the WaveAI focus integration trivial, eliminating the need for complex timing coordination.
Implement this simplification before adding WaveAI focus features. The cleaner foundation will make the focus work much easier and the codebase more maintainable long-term.
frontend/layout/frontend/layout/lib/layoutModel.ts (~150 lines changed)
localTreeStateAtom fieldtreeReducer() to update local atom + persist asyncinitializeFromWaveObject() methodpersistToBackend() methodgetNodeModel() atoms to use local atomfrontend/layout/lib/layoutTree.ts (~15 line deletions)
layoutState.generation++ calls (appears 15 times)frontend/layout/lib/layoutAtom.ts (~40 lines deleted or simplified)
getLayoutStateAtomFromTab() helperfrontend/layout/lib/types.ts (~1 line deletion)
generation: number from LayoutTreeStatefrontend/layout/tests/model.ts (~1 line change)
Total: ~5 files, all within frontend/layout/ directory. No changes outside layout system!
If we break something, it will be immediately obvious:
No subtle corruption: This change affects reactive state flow, not data persistence. If it breaks, the UI breaks obviously. We won't get "sometimes it works, sometimes it doesn't."
frontend/layout/The interface to the layout system stays the same:
nodeModel.focusNode()nodeModel.isFocusedlayoutModel.focusNode()This change affects reactive state propagation, not data storage:
Worst case: Layout stops working, we revert the code. No data loss, no corruption.
Can be done in safe phases:
Phase 1: Add alongside existing (no breaking changes)
class LayoutModel {
treeStateAtom: WritableLayoutTreeStateAtom; // Keep old
localTreeStateAtom: PrimitiveAtom<LayoutTreeState>; // Add new
// Keep both in sync temporarily
}
Phase 2: Switch consumers one at a time
// Change this gradually
isFocused: atom((get) => {
// const treeState = get(this.treeStateAtom); // Old
const treeState = get(this.localTreeStateAtom); // New
...
})
Phase 3: Remove old code once everything uses new atoms
Can test thoroughly at each phase before proceeding!
Every layout operation is user-visible and testable:
No subtle edge cases to hunt down. If it works in manual testing, it works.
This change is NOT:
This change IS:
| Potential Issue | How We'd Detect | Recovery |
|---|---|---|
| Local atom doesn't update | Layout frozen, nothing responds | Immediately obvious, revert |
| Persistence fails silently | State doesn't survive restart | Caught in testing, add logging |
| isFocused calculation wrong | Wrong focus ring | Immediately obvious, fix calculation |
| Missing generation++ somewhere | Old code path tries to use generation | Compile error or immediate runtime error |
| Tab switching breaks | Tabs don't load correctly | Immediately obvious |
All failure modes are immediate and obvious!
Conceptual Difficulty: LOW
Code Difficulty: LOW-MEDIUM
Testing Difficulty: LOW
This is a low-risk, high-reward change:
Suggested approach:
Total implementation time: 1-2 days for experienced developer, including thorough testing.