Massive PR, over 13k LOC updated, 128 commits to implement the first pass at the new Wave AI panel. Two backend adapters (OpenAI and Anthropic), layout changes to support the panel, keyboard shortcuts, and a huge focus/layout change to integrate the panel seamlessly into the UI. Also fixes some small issues found during the Wave AI journey (zoom fixes, documentation, more scss removal, circular dependency issues, settings, etc)
5.3 KiB
Wave Terminal Focus System - Layout State Flow
This document explains how focus state changes in the layout system propagate through the application to update both the visual focus ring and physical DOM focus.
Overview
When layout operations modify focus state, a straightforward chain of updates occurs:
- Visual feedback - The focus ring updates immediately
- Physical DOM focus - The terminal (or other view) receives actual browser focus
The system uses local atoms as the source of truth with async persistence to the backend.
The Flow
1. Setting Focus in Layout Operations
Throughout layoutTree.ts, operations directly mutate layoutState.focusedNodeId:
// Example from insertNode
if (action.magnified) {
layoutState.magnifiedNodeId = action.node.id;
layoutState.focusedNodeId = action.node.id;
}
if (action.focused) {
layoutState.focusedNodeId = action.node.id;
}
This happens in ~10 places: insertNode, insertNodeAtIndex, deleteNode, focusNode, magnifyNodeToggle, etc.
2. Committing to Local Atom
The LayoutModel.treeReducer() commits changes:
treeReducer(action: LayoutTreeAction, setState = true): boolean {
// Mutate tree state
focusNode(this.treeState, action);
if (setState) {
this.updateTree(); // Compute leafOrder, etc.
this.setter(this.localTreeStateAtom, { ...this.treeState }); // Sync update
this.persistToBackend(); // Async persistence
}
}
The key is { ...this.treeState } creates a new object reference, triggering Jotai reactivity.
3. Derived Atoms Recalculate
Each block's NodeModel has an isFocused atom:
isFocused: atom((get) => {
const treeState = get(this.localTreeStateAtom);
const isFocused = treeState.focusedNodeId === nodeid;
const waveAIFocused = get(atoms.waveAIFocusedAtom);
return isFocused && !waveAIFocused;
})
When localTreeStateAtom updates, all isFocused atoms recalculate. Only the matching node returns true.
4. React Components Re-render
Visual Focus Ring - Components subscribe to isFocused:
const isFocused = useAtomValue(nodeModel.isFocused);
CSS classes update immediately, showing the focus ring.
Physical DOM Focus - Two-step effect chain:
// Step 1: isFocused → blockClicked
useLayoutEffect(() => {
setBlockClicked(isFocused);
}, [isFocused]);
// Step 2: blockClicked → physical focus
useLayoutEffect(() => {
if (!blockClicked) return;
setBlockClicked(false);
const focusWithin = focusedBlockId() == nodeModel.blockId;
if (!focusWithin) {
setFocusTarget(); // Calls viewModel.giveFocus()
}
}, [blockClicked, isFocused]);
The terminal's giveFocus() method grants actual browser focus:
giveFocus(): boolean {
if (termMode == "term" && this.termRef?.current?.terminal) {
this.termRef.current.terminal.focus();
return true;
}
return false;
}
5. Background Persistence
While the UI updates synchronously, persistence happens asynchronously:
private persistToBackend() {
// Debounced (100ms) to avoid excessive writes
setTimeout(() => {
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);
}, 100);
}
The WaveObject is used purely for persistence (tab restore, uncaching).
The Complete Chain
User action
↓
layoutState.focusedNodeId = nodeId
↓
setter(localTreeStateAtom, { ...treeState })
↓
isFocused atoms recalculate
↓
React re-renders
↓
┌────────────────────┬────────────────────┐
│ Visual Ring │ Physical Focus │
│ (immediate CSS) │ (2-step effect) │
└────────────────────┴────────────────────┘
↓
persistToBackend() (async, debounced)
Key Points
- Local atoms -
localTreeStateAtomis the source of truth during runtime - Synchronous updates - UI changes happen immediately in one React tick
- Async persistence - Backend writes are fire-and-forget with debouncing
- Two-step focus - Separates visual (instant) from physical (coordinated) DOM focus
- View delegation - Each view implements
giveFocus()for custom focus behavior
User-Initiated Focus
When a user clicks a block:
onFocusCapture(mousedown) → callsnodeModel.focusNode()→ visual focus ring appearsonClick→ setsblockClicked = true→ two-step effect chain → physical DOM focus
This ensures visual feedback is instant while protecting selections.
Backend Actions
On initialization or backend updates, queued actions are processed:
if (initialState.pendingBackendActions?.length) {
fireAndForget(() => this.processPendingBackendActions());
}
Backend can queue layout operations (create blocks, etc.) via PendingBackendActions.