aiprompts/focus-layout.md
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.
When layout operations modify focus state, a straightforward chain of updates occurs:
The system uses local atoms as the source of truth with async persistence to the backend.
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.
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.
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.
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;
}
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).
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)
localTreeStateAtom is the source of truth during runtimegiveFocus() for custom focus behaviorWhen a user clicks a block:
onFocusCapture (mousedown) → calls nodeModel.focusNode() → visual focus ring appearsonClick → sets blockClicked = true → two-step effect chain → physical DOM focusThis ensures visual feedback is instant while protecting selections.
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.