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
```
---
## Proposed "Write Cache" Architecture
### Core Concept
```
User action
↓
Update LOCAL atom (immediate, synchronous)
↓
React re-renders (single tick, all atoms see new state)
↓
[async, fire-and-forget] Persist to WaveObject
```
### Key Principles
1. **Local atoms are source of truth** during runtime
2. **WaveObject is persistence layer** only (read on init, write async)
3. **Backend actions still work** via `PendingBackendActions`
4. **No generation tracking needed** (no need to trigger writes)
29 KiB
Wave Terminal Layout System - Simplification via Write Cache Pattern
Executive Summary
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.
Current Architecture Problems
The Unnecessary Round-Trip
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).
What the Backend Actually Does
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 viaQueueLayoutAction()
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 persistence
The key insight: Only LeafOrder needs to be synced to backend (for command resolution). The tree structure fields (rootnode, focusednodeid, magnifiednodeid) are pure persistence!
Complexity Symptoms
- Generation tracking:
layoutState.generation++appears in 10+ places, only to trigger atom writes - Bidirectional atoms:
withLayoutTreeStateAtomFromTab()has complex read/write logic - Timing coordination: The entire Section 8 of the WaveAI focus proposal exists only because of race conditions between focus updates and atom commits
- False reactivity: Changes to
focusedNodeIdtrigger full tree state propagation even though they're unrelated to tree structure
Proposed "Write Cache" Architecture
Core Concept
User action
↓
Update LOCAL atom (immediate, synchronous)
↓
React re-renders (single tick, all atoms see new state)
↓
[async, fire-and-forget] Persist to WaveObject
Key Principles
- Local atoms are source of truth during runtime
- WaveObject is persistence layer only (read on init, write async)
- Backend actions still work via
PendingBackendActions - No generation tracking needed (no need to trigger writes)
Implementation Design
1. New LayoutModel Structure
// 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
}
}
}
}
2. Simplified treeReducer
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
}
}
3. Simplified NodeModel isFocused
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
};
}
}
4. Remove Generation Tracking
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).
5. Simplified layoutAtom.ts
// 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!
Benefits
Immediate Benefits
- 10x simpler reactivity: Local atoms update synchronously, React sees complete state in one tick
- No generation tracking: Eliminate 10+
generation++calls and all related logic - No timing issues: Everything happens synchronously, no coordination needed
- Faster updates: No round-trip through WaveObject for every change
- Easier debugging: Clear separation between runtime state (local atoms) and persistence (WaveObject)
Impact on WaveAI Focus Proposal
The 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!
}
Code Deletion
Can delete:
generationfield and allgeneration++calls (~15 places)- Complex bidirectional atom logic in
layoutAtom.ts(~40 lines) lastTreeStateGenerationtracking inLayoutModel- All
generation > this.treeState.generationchecks
Total: ~200-300 lines of complex coordination code deleted
Edge Cases & Considerations
1. Rapid Changes
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);
}
2. Tab Switching
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.
3. Tab Uncaching (Electron Limit)
Current: Tab gets uncached, needs to reload layout from WaveObject.
After: Same - initializeFromWaveObject() reads persisted state. No change in behavior.
4. Backend Actions (New Blocks)
5. LeafOrder and CLI Commands
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:
- Commands are typed/run by users (human speed, not machine speed)
- Even if
LeafOrderis 100ms behind, no one will notice - By the time a user types
wsh block:1, the async write has long since completed - Worst case: User types command during a split operation and gets previous block - extremely rare and not breaking
Immutability and Jotai Atoms
Question: Do we need deep copies for Jotai to detect changes?
Answer: NO - shallow copy is sufficient! ✓
Current System (Already Uses Shallow Updates)
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.
Why Shallow Copy Works with Jotai
// In treeReducer after mutations
this.setter(this.localTreeStateAtom, { ...this.treeState });
This works because:
- Jotai checks reference equality on the atom value itself (the
LayoutTreeStateobject) { ...this.treeState }creates a NEW object with a different reference- Nested structures don't matter - Jotai doesn't do deep equality checks
Example:
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!
Tree Mutations Don't Need Immutability
All tree operations in layoutTree.ts mutate in place:
insertNode()- MutateslayoutState.rootNode
Derived Atoms Will Update Correctly ✓
Concern: Will derived atoms like isFocused and isMagnified update when we change to local atoms?
Answer: YES - they will work perfectly! ✓
How Derived Atoms Work
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;
}),
Why They'll Still Work with Local Atoms
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:
- User clicks block →
focusNode()called treeReducer()runs → mutatesthis.treeState.focusedNodeId = newIdthis.setter(this.localTreeStateAtom, { ...this.treeState })← New reference!- Jotai detects reference change in
localTreeStateAtom - All derived atoms that call
get(this.localTreeStateAtom)are notified - They re-run their getter functions
- They see the new
focusedNodeIdvalue - React components re-render with correct values ✓
Key Insight
We'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:
- No round-trip delay
- No generation checking
- Direct, synchronous update
- Same Jotai reactivity mechanism
What About Nested Fields?
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:
- We create new
LayoutTreeStateobject:{ ...this.treeState } - Jotai sees new reference → notifies subscribers
- Getter re-runs, calls
get(this.localTreeStateAtom) - Gets the new state object
- Accesses
newState.rootNode(same reference as before, but that's OK!) - Returns correct value
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.
Verification
All derived atoms in NodeModel:
- ✅
isFocused- depends ontreeState.focusedNodeId - ✅
isMagnified- depends ontreeState.magnifiedNodeId - ✅
blockNum- depends on separatethis.leafOrderatom (unaffected) - ✅
isEphemeral- depends on separatethis.ephemeralNodeatom (unaffected)
All will update correctly with the new local atom approach!
deleteNode()- Mutates parent's children arrayfocusNode()- MutateslayoutState.focusedNodeId
This is fine! We're not relying on immutability for change detection. We're relying on creating a new LayoutTreeState wrapper object via spread operator.
Backend Round-Trip
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.
Summary
✅ 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.
5. Write Failures
Concern: What if the async write to WaveObject fails?
Solution:
- The app continues working (local state is fine)
- On next persistence attempt, full state is written again
- On tab reload, worst case is state from last successful write
- Can add retry logic or error notification if needed
Migration Path
Phase 1: Preparation (No Breaking Changes)
- Add
localTreeStateAtomalongside existingtreeStateAtom - Keep both in sync
- Update a few
isFocusedatoms to use local atom - Test thoroughly
Phase 2: Switch Over
- Update
treeReducerto write to local atom + fire-and-forget persist - Update all
isFocusedand other computed atoms to use local atom - Remove generation checks and tracking
- Test all layout operations
Phase 3: Cleanup
- Delete bidirectional atom logic from
layoutAtom.ts - Remove
generationfield fromLayoutTreeState - Simplify
onTreeStateAtomUpdated()(only needed forpendingBackendActions) - Update documentation
Testing Checklist
- Split horizontal/vertical
- Close blocks (focused and unfocused)
- Focus changes via click, keyboard nav, tab switching
- Magnify/unmagnify
- Resize operations
- Drag & drop
- Tab switching (verify state persistence)
- App restart (verify state restore)
- Multiple windows
- Rapid operations (verify debouncing works)
Impact on Other Systems
Focus Manager
Before: Must coordinate timing with atom commits.
After: Can update focusType atom independently. Order doesn't matter since both updates happen synchronously.
Block Component
No change: Blocks still subscribe to nodeModel.isFocused, which still reacts correctly (faster now).
Keyboard Navigation
No change: Still calls layoutModel.focusNode(), which updates local state immediately.
Terminal/Views
No change: Views don't interact with layout atoms directly.
Performance Implications
Improved
- Faster reactivity: No round-trip through WaveObject (save ~1-2ms per operation)
- Fewer atom updates: Only local atom updates, not bidirectional propagation
- Batched writes: Debouncing reduces backend write frequency
No Change
- Tree operations: Same complexity (balance, walk, compute, etc.)
- React rendering: Same render triggers, just faster
- Memory usage: Same (local atom vs bidirectional atom is similar size)
Conclusion
The "write cache" pattern can simplify the layout system by ~70% while maintaining full functionality:
- Remove: Generation tracking, bidirectional atoms, timing coordination
- Keep: All tree logic, backend integration, persistence
- Gain: Simpler code, faster updates, easier debugging
This also makes the WaveAI focus integration trivial, eliminating the need for complex timing coordination.
Recommendation
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.
Wave Terminal Layout System - Simplification via Write Cache Pattern
Risk Assessment: LOW RISK, Well-Contained Change
Files to Modify: 4-5 files, all in frontend/layout/
-
frontend/layout/lib/layoutModel.ts(~150 lines changed)- Add
localTreeStateAtomfield - Modify
treeReducer()to update local atom + persist async - Add
initializeFromWaveObject()method - Add
persistToBackend()method - Update
getNodeModel()atoms to use local atom
- Add
-
frontend/layout/lib/layoutTree.ts(~15 line deletions)- Remove all
layoutState.generation++calls (appears 15 times) - No other changes needed
- Remove all
-
frontend/layout/lib/layoutAtom.ts(~40 lines deleted or simplified)- Can delete most of the bidirectional atom logic
- Keep only
getLayoutStateAtomFromTab()helper
-
frontend/layout/lib/types.ts(~1 line deletion)- Remove
generation: numberfromLayoutTreeState
- Remove
-
frontend/layout/tests/model.ts(~1 line change)- Remove generation from test fixtures
Total: ~5 files, all within frontend/layout/ directory. No changes outside layout system!
Why This is Low Risk
1. Fail-Fast Behavior ✓
If we break something, it will be immediately obvious:
- Split horizontal/vertical won't work → visible immediately
- Block focus won't work → obvious when clicking
- Close block won't work → obvious
- Magnify won't work → 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."
2. Well-Contained Scope ✓
- All changes in one directory:
frontend/layout/ - No changes to:
- Block components (unchanged)
- Terminal/views (unchanged)
- Keyboard navigation (unchanged)
- Focus manager (unchanged)
- Backend Go code (unchanged)
The interface to the layout system stays the same:
- Blocks still call
nodeModel.focusNode() - Blocks still subscribe to
nodeModel.isFocused - Keyboard nav still calls
layoutModel.focusNode() - Nothing outside the layout system needs to know about the change
3. No Data Corruption Risk ✓
This change affects reactive state propagation, not data storage:
- WaveObject still stores the same data
- Backend still queues actions the same way
- Blocks still have the same IDs
- Tab structure unchanged
Worst case: Layout stops working, we revert the code. No data loss, no corruption.
4. Incremental Implementation Possible ✓
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!
5. Easy to Test ✓
Every layout operation is user-visible and testable:
- Split horizontal → obvious if broken
- Split vertical → obvious if broken
- Close block → obvious if broken
- Focus block → obvious if broken
- Magnify/unmagnify → obvious if broken
- Drag & drop → obvious if broken
- Tab switch → obvious if broken
- App restart → obvious if broken
No subtle edge cases to hunt down. If it works in manual testing, it works.
Comparison to High-Risk Changes
This change is NOT:
- ❌ Touching 20+ files across the codebase
- ❌ Changing subtle timing in async operations
- ❌ Modifying data storage formats
- ❌ Affecting backend/frontend protocol
- ❌ Requiring coordinated backend changes
- ❌ Creating subtle race conditions
This change IS:
- ✅ Contained to 5 files in one directory
- ✅ Synchronous state updates (simpler than current!)
- ✅ Same data format, just different flow
- ✅ Frontend-only
- ✅ Backend unchanged
- ✅ Eliminating race conditions (not creating them)
What Could Go Wrong? (And How We'd Know)
| 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!
Difficulty Assessment
Conceptual Difficulty: LOW
- Replace bidirectional atom with simple atom
- Add async persist function
- Remove generation tracking
- Very straightforward refactor
Code Difficulty: LOW-MEDIUM
- Changes are localized and mechanical
- Most changes are deletions (always good!)
- New code is simpler than old code
- No complex algorithms to implement
Testing Difficulty: LOW
- All functionality is user-visible
- No need for complex test scenarios
- Manual testing catches everything
- Can test incrementally
Recommendation
This is a low-risk, high-reward change:
- Risk: LOW (contained, fail-fast, no corruption)
- Difficulty: LOW-MEDIUM (straightforward refactor)
- Reward: HIGH (70% less complexity, easier future work)
Suggested approach:
- Implement in a feature branch
- Add local atom alongside existing system
- Test thoroughly with both systems running
- Switch over gradually
- Remove old code
- Merge when confident
Total implementation time: 1-2 days for experienced developer, including thorough testing.