waveterm/aiprompts/focus-layout.md
Mike Sawka d272a4ec03
New AIPanel (#2370)
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)
2025-10-07 13:32:10 -07:00

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:

  1. Visual feedback - The focus ring updates immediately
  2. 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

  1. Local atoms - localTreeStateAtom is the source of truth during runtime
  2. Synchronous updates - UI changes happen immediately in one React tick
  3. Async persistence - Backend writes are fire-and-forget with debouncing
  4. Two-step focus - Separates visual (instant) from physical (coordinated) DOM focus
  5. View delegation - Each view implements giveFocus() for custom focus behavior

User-Initiated Focus

When a user clicks a block:

  1. onFocusCapture (mousedown) → calls nodeModel.focusNode() → visual focus ring appears
  2. onClick → sets blockClicked = 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.