Skip to content

Commit 2e45e3e

Browse files
committed
update focus docs to reflect new layout simplification
1 parent 2b57abb commit 2e45e3e

2 files changed

Lines changed: 294 additions & 557 deletions

File tree

aiprompts/focus-layout.md

Lines changed: 96 additions & 176 deletions
Original file line numberDiff line numberDiff line change
@@ -1,254 +1,174 @@
11
# Wave Terminal Focus System - Layout State Flow
22

3-
This document explains how focus state changes in the layout system (`layoutState.focusedNodeId`) propagate through the application to update both the visual focus ring and physical DOM focus.
3+
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.
44

55
## Overview
66

7-
When layout operations modify `layoutState.focusedNodeId` (in ~10+ places throughout [`layoutTree.ts`](../frontend/layout/lib/layoutTree.ts)), a carefully orchestrated chain of updates occurs that ultimately results in:
7+
When layout operations modify focus state, a straightforward chain of updates occurs:
88
1. **Visual feedback** - The focus ring updates immediately
99
2. **Physical DOM focus** - The terminal (or other view) receives actual browser focus
1010

11-
This is a "house of cards" architecture that works through reactive atom updates and clever React hooks.
11+
The system uses local atoms as the source of truth with async persistence to the backend.
1212

13-
## The Complete Flow
13+
## The Flow
1414

15-
### 1. Setting focusedNodeId in LayoutTree
15+
### 1. Setting Focus in Layout Operations
1616

17-
Throughout [`layoutTree.ts`](../frontend/layout/lib/layoutTree.ts), operations set the focused node:
17+
Throughout [`layoutTree.ts`](../frontend/layout/lib/layoutTree.ts), operations directly mutate `layoutState.focusedNodeId`:
1818

19-
**Example from insertNode** ([`layoutTree.ts:283-294`](../frontend/layout/lib/layoutTree.ts:283-294)):
2019
```typescript
21-
} else {
22-
const insertLoc = findNextInsertLocation(layoutState.rootNode, DEFAULT_MAX_CHILDREN);
23-
addChildAt(insertLoc.node, insertLoc.index, action.node);
24-
if (action.magnified) {
25-
layoutState.magnifiedNodeId = action.node.id;
26-
layoutState.focusedNodeId = action.node.id; // ← Setting focusedNodeId
27-
}
20+
// Example from insertNode
21+
if (action.magnified) {
22+
layoutState.magnifiedNodeId = action.node.id;
23+
layoutState.focusedNodeId = action.node.id;
2824
}
2925
if (action.focused) {
30-
layoutState.focusedNodeId = action.node.id; // ← Or here
26+
layoutState.focusedNodeId = action.node.id;
3127
}
32-
layoutState.generation++; // ← CRITICAL: Triggers commit
3328
```
3429

35-
**Other locations that set focusedNodeId:**
36-
- [`layoutTree.ts:288`](../frontend/layout/lib/layoutTree.ts:288) - insertNode (magnified)
37-
- [`layoutTree.ts:292`](../frontend/layout/lib/layoutTree.ts:292) - insertNode (focused)
38-
- [`layoutTree.ts:313`](../frontend/layout/lib/layoutTree.ts:313) - insertNodeAtIndex (magnified)
39-
- [`layoutTree.ts:317`](../frontend/layout/lib/layoutTree.ts:317) - insertNodeAtIndex (focused)
40-
- [`layoutTree.ts:370-371`](../frontend/layout/lib/layoutTree.ts:370-371) - deleteNode (clearing)
41-
- [`layoutTree.ts:402`](../frontend/layout/lib/layoutTree.ts:402) - focusNode action
42-
- [`layoutTree.ts:419`](../frontend/layout/lib/layoutTree.ts:419) - magnifyNodeToggle
43-
- [`layoutTree.ts:427`](../frontend/layout/lib/layoutTree.ts:427) - clearTree (clearing)
44-
- [`layoutTree.ts:454`](../frontend/layout/lib/layoutTree.ts:454) - replaceNode
45-
- [`layoutTree.ts:498`](../frontend/layout/lib/layoutTree.ts:498) - splitHorizontal
46-
- [`layoutTree.ts:540`](../frontend/layout/lib/layoutTree.ts:540) - splitVertical
47-
48-
**The Critical Part:** Every time `focusedNodeId` is set, `generation++` follows. This increment is the signal that triggers the entire propagation chain.
30+
This happens in ~10 places: insertNode, insertNodeAtIndex, deleteNode, focusNode, magnifyNodeToggle, etc.
4931

50-
### 2. Generation Increment Commits to WaveObject
32+
### 2. Committing to Local Atom
5133

52-
The `treeStateAtom` in [`layoutAtom.ts`](../frontend/layout/lib/layoutAtom.ts) is a bidirectional atom that syncs with the backend WaveObject.
34+
The [`LayoutModel.treeReducer()`](../frontend/layout/lib/layoutModel.ts:547) commits changes:
5335

54-
**The Write Path** ([`layoutAtom.ts:37-56`](../frontend/layout/lib/layoutAtom.ts:37-56)):
5536
```typescript
56-
(get, set, value) => {
57-
if (get(generationAtom) < value.generation) { // ← Check if generation increased
58-
const stateAtom = getLayoutStateAtomFromTab(tabAtom, get);
59-
if (!stateAtom) return;
60-
const waveObjVal = get(stateAtom);
61-
if (waveObjVal == null) return;
62-
63-
// Write to backend WaveObject
64-
waveObjVal.rootnode = value.rootNode;
65-
waveObjVal.magnifiednodeid = value.magnifiedNodeId;
66-
waveObjVal.focusednodeid = value.focusedNodeId; // ← focusedNodeId commits here
67-
waveObjVal.leaforder = value.leafOrder;
68-
waveObjVal.pendingbackendactions = value?.pendingBackendActions?.length
69-
? value.pendingBackendActions
70-
: undefined;
71-
set(generationAtom, value.generation);
72-
set(stateAtom, waveObjVal); // ← Triggers WaveObject update
37+
treeReducer(action: LayoutTreeAction, setState = true): boolean {
38+
// Mutate tree state
39+
focusNode(this.treeState, action);
40+
41+
if (setState) {
42+
this.updateTree(); // Compute leafOrder, etc.
43+
this.setter(this.localTreeStateAtom, { ...this.treeState }); // Sync update
44+
this.persistToBackend(); // Async persistence
7345
}
7446
}
7547
```
7648

77-
**Without `generation++`, the changes stay local and never propagate!**
49+
The key is `{ ...this.treeState }` creates a new object reference, triggering Jotai reactivity.
7850

79-
### 3. WaveObject Update Triggers Atom Recalculation
51+
### 3. Derived Atoms Recalculate
8052

81-
When the WaveObject is updated via `set(stateAtom, waveObjVal)`, all atoms that depend on it recalculate.
53+
Each block's `NodeModel` has an `isFocused` atom:
8254

83-
**The Read Path** ([`layoutAtom.ts:24-35`](../frontend/layout/lib/layoutAtom.ts:24-35)):
84-
```typescript
85-
(get) => {
86-
const stateAtom = getLayoutStateAtomFromTab(tabAtom, get);
87-
if (!stateAtom) return;
88-
const layoutStateData = get(stateAtom); // ← Reads from WaveObject
89-
const layoutTreeState: LayoutTreeState = {
90-
rootNode: layoutStateData?.rootnode,
91-
focusedNodeId: layoutStateData?.focusednodeid, // ← Gets new focusedNodeId
92-
magnifiedNodeId: layoutStateData?.magnifiednodeid,
93-
pendingBackendActions: layoutStateData?.pendingbackendactions,
94-
generation: get(generationAtom),
95-
};
96-
return layoutTreeState;
97-
}
98-
```
99-
100-
The WaveObject acts as the "source of truth" that drives the reactive chain.
101-
102-
### 4. isFocused Atoms Recalculate
103-
104-
Each block's `NodeModel` has an `isFocused` atom that derives from `treeStateAtom`.
105-
106-
**NodeModel Creation** ([`layoutModel.ts:936-941`](../frontend/layout/lib/layoutModel.ts:936-941)):
10755
```typescript
10856
isFocused: atom((get) => {
109-
const treeState = get(this.treeStateAtom); // ← Depends on treeStateAtom
110-
const isFocused = treeState.focusedNodeId === nodeid; // ← Compare with this node's ID
57+
const treeState = get(this.localTreeStateAtom);
58+
const isFocused = treeState.focusedNodeId === nodeid;
11159
const waveAIFocused = get(atoms.waveAIFocusedAtom);
11260
return isFocused && !waveAIFocused;
11361
})
11462
```
11563

116-
When `treeStateAtom` updates, all `isFocused` atoms recalculate. Only the atom for the node matching `focusedNodeId` returns `true`.
64+
When `localTreeStateAtom` updates, all `isFocused` atoms recalculate. Only the matching node returns `true`.
11765

118-
### 5. Visual Focus Ring Updates
66+
### 4. React Components Re-render
11967

120-
React components consume the `isFocused` atom and re-render when it changes.
68+
**Visual Focus Ring** - Components subscribe to `isFocused`:
12169

122-
**Block Component** ([`block.tsx:142`](../frontend/app/block/block.tsx:142)):
12370
```typescript
124-
const isFocused = useAtomValue(nodeModel.isFocused); // ← Subscribes to isFocused atom
71+
const isFocused = useAtomValue(nodeModel.isFocused);
12572
```
12673

127-
The `isFocused` value is passed to child components and CSS classes, causing the focus ring to appear/disappear immediately.
128-
129-
### 6. Physical DOM Focus via Two-Step Effect
74+
CSS classes update immediately, showing the focus ring.
13075

131-
This is where it gets clever (and fragile). Physical DOM focus is achieved through a cascade of two `useLayoutEffect` hooks.
76+
**Physical DOM Focus** - Two-step effect chain:
13277

133-
**Step 1: isFocused Change Triggers blockClicked** ([`block.tsx:147-149`](../frontend/app/block/block.tsx:147-149)):
13478
```typescript
79+
// Step 1: isFocused → blockClicked
13580
useLayoutEffect(() => {
136-
setBlockClicked(isFocused); // When isFocused changes to true, trigger blockClicked
81+
setBlockClicked(isFocused);
13782
}, [isFocused]);
138-
```
13983

140-
**Step 2: blockClicked Triggers Physical Focus** ([`block.tsx:151-163`](../frontend/app/block/block.tsx:151-163)):
141-
```typescript
84+
// Step 2: blockClicked → physical focus
14285
useLayoutEffect(() => {
143-
if (!blockClicked) {
144-
return;
145-
}
146-
setBlockClicked(false); // Reset for next time
86+
if (!blockClicked) return;
87+
setBlockClicked(false);
14788
const focusWithin = focusedBlockId() == nodeModel.blockId;
14889
if (!focusWithin) {
149-
setFocusTarget(); // ← PHYSICAL DOM FOCUS HAPPENS HERE
150-
}
151-
if (!isFocused) {
152-
nodeModel.focusNode(); // Update layout state if needed
90+
setFocusTarget(); // Calls viewModel.giveFocus()
15391
}
15492
}, [blockClicked, isFocused]);
15593
```
15694

157-
**Why two effects?** This separates the visual update (immediate) from the physical focus (coordinated). It also provides a single point where focus granting can be controlled (e.g., skipped if there's a selection).
95+
The terminal's `giveFocus()` method grants actual browser focus:
15896

159-
**Step 3: setFocusTarget Delegates to ViewModel** ([`block.tsx:211-217`](../frontend/app/block/block.tsx:211-217)):
16097
```typescript
161-
const setFocusTarget = useCallback(() => {
162-
const ok = viewModel?.giveFocus?.(); // Try view-specific focus first
163-
if (ok) {
164-
return;
98+
giveFocus(): boolean {
99+
if (termMode == "term" && this.termRef?.current?.terminal) {
100+
this.termRef.current.terminal.focus();
101+
return true;
165102
}
166-
focusElemRef.current?.focus({ preventScroll: true }); // Fallback to dummy input
167-
}, []);
103+
return false;
104+
}
168105
```
169106

170-
**Step 4: Terminal's giveFocus Grants XTerm Focus** ([`term.tsx:414-427`](../frontend/app/view/term/term.tsx:414-427)):
107+
### 5. Background Persistence
108+
109+
While the UI updates synchronously, persistence happens asynchronously:
110+
171111
```typescript
172-
giveFocus(): boolean {
173-
if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpen)) {
174-
return true; // Search panel handles focus
175-
}
176-
let termMode = globalStore.get(this.termMode);
177-
if (termMode == "term") {
178-
if (this.termRef?.current?.terminal) {
179-
this.termRef.current.terminal.focus(); // ← XTerm gets actual browser focus
180-
return true;
181-
}
182-
}
183-
return false;
112+
private persistToBackend() {
113+
// Debounced (100ms) to avoid excessive writes
114+
setTimeout(() => {
115+
waveObj.rootnode = this.treeState.rootNode;
116+
waveObj.focusednodeid = this.treeState.focusedNodeId;
117+
waveObj.magnifiednodeid = this.treeState.magnifiedNodeId;
118+
waveObj.leaforder = this.treeState.leafOrder;
119+
this.setter(this.waveObjectAtom, waveObj);
120+
}, 100);
184121
}
185122
```
186123

124+
The WaveObject is used purely for persistence (tab restore, uncaching).
125+
187126
## The Complete Chain
188127

189128
```
129+
User action
130+
190131
layoutState.focusedNodeId = nodeId
191-
192-
layoutState.generation++
193-
194-
treeStateAtom setter (checks generation)
195-
196-
WaveObject.focusednodeid = nodeId (commit)
197-
198-
WaveObject update notification
199-
200-
treeStateAtom getter runs
201-
202-
All isFocused atoms recalculate
203-
204-
React components re-render
205-
206-
┌──────────────────────────┬──────────────────────────┐
207-
│ │ │
208-
│ Visual Focus Ring │ Physical DOM Focus │
209-
│ (immediate) │ (coordinated) │
210-
│ │ │
211-
│ CSS updates based on │ useLayoutEffect #1: │
212-
│ isFocused value │ isFocused → blockClicked│
213-
│ │ │
214-
│ │ useLayoutEffect #2: │
215-
│ │ blockClicked → setFocusTarget│
216-
│ │ │
217-
│ │ setFocusTarget() │
218-
│ │ ↓ │
219-
│ │ viewModel.giveFocus() │
220-
│ │ ↓ │
221-
│ │ terminal.focus() │
222-
│ │ │
223-
└──────────────────────────┴──────────────────────────┘
132+
133+
setter(localTreeStateAtom, { ...treeState })
134+
135+
isFocused atoms recalculate
136+
137+
React re-renders
138+
139+
┌────────────────────┬────────────────────┐
140+
│ Visual Ring │ Physical Focus │
141+
│ (immediate CSS) │ (2-step effect) │
142+
└────────────────────┴────────────────────┘
143+
144+
persistToBackend() (async, debounced)
224145
```
225146

226-
## User-Initiated Focus
147+
## Key Points
227148

228-
When a user clicks a block, the flow is slightly different (see [`focus.md`](./focus.md) for details):
149+
1. **Local atoms** - `localTreeStateAtom` is the source of truth during runtime
150+
2. **Synchronous updates** - UI changes happen immediately in one React tick
151+
3. **Async persistence** - Backend writes are fire-and-forget with debouncing
152+
4. **Two-step focus** - Separates visual (instant) from physical (coordinated) DOM focus
153+
5. **View delegation** - Each view implements `giveFocus()` for custom focus behavior
154+
155+
## User-Initiated Focus
229156

230-
1. **`onFocusCapture`** fires on mousedown → immediately calls `nodeModel.focusNode()`
231-
2. This updates the layout state (visual focus ring updates)
232-
3. **`onClick`** fires after click → sets `blockClicked = true`
233-
4. The two-step effect chain grants physical DOM focus
157+
When a user clicks a block:
234158

235-
This ensures the focus ring updates instantly on mousedown, while physical focus waits until after the click completes (protecting selections).
159+
1. **`onFocusCapture`** (mousedown) → calls `nodeModel.focusNode()` → visual focus ring appears
160+
2. **`onClick`** → sets `blockClicked = true` → two-step effect chain → physical DOM focus
236161

237-
## Key Takeaways
162+
This ensures visual feedback is instant while protecting selections.
238163

239-
1. **`generation++` is critical** - Without it, changes never commit to the WaveObject
240-
2. **WaveObject is the hub** - All state flows through the backend WaveObject, enabling persistence and sync
241-
3. **Reactive atoms propagate changes** - Jotai atoms automatically update when dependencies change
242-
4. **Two-step effect for physical focus** - Using `blockClicked` as a trigger separates visual from physical updates
243-
5. **View-specific focus** - Each view type (terminal, editor, etc.) implements its own `giveFocus()` method
164+
## Backend Actions
244165

245-
## Why This Architecture?
166+
On initialization or backend updates, queued actions are processed:
246167

247-
This seemingly complex flow provides several benefits:
248-
- **Persistence**: Changes automatically sync to the backend
249-
- **Consistency**: Single source of truth for focus state
250-
- **Flexibility**: Views can customize focus behavior via `giveFocus()`
251-
- **Performance**: Visual updates are immediate while physical focus is deferred
252-
- **Protection**: The two-step approach allows for conditional focus granting (e.g., preserving selections)
168+
```typescript
169+
if (initialState.pendingBackendActions?.length) {
170+
fireAndForget(() => this.processPendingBackendActions());
171+
}
172+
```
253173

254-
However, it is indeed a "house of cards" - each piece depends on the previous one working correctly. Understanding this flow is crucial for debugging focus-related issues.
174+
Backend can queue layout operations (create blocks, etc.) via `PendingBackendActions`.

0 commit comments

Comments
 (0)