aiprompts/waveai-focus-updates.md
Wave AI focus handling is fragile compared to blocks:
window.blur() incorrectly assumes user wants to leave Wave AIExtend the block focus system pattern to Wave AI:
graph TB
User[User Interaction]
FM[Focus Manager]
Layout[Layout System]
WaveAI[Wave AI Panel]
User -->|click/key| FM
FM -->|node focus| Layout
FM -->|waveai focus| WaveAI
Layout -->|request focus back| FM
WaveAI -->|request focus back| FM
FM -->|focusType atom| State[Global State]
Layout -.->|checks| State
WaveAI -.->|checks| State
File: frontend/app/store/focusManager.ts
Add selection-aware focus methods:
class FocusManager {
// Existing
focusType: PrimitiveAtom<"node" | "waveai">; // Single source of truth
blockFocusAtom: Atom<string | null>;
// NEW: Selection-aware focus checking
waveAIFocusWithin(): boolean;
nodeFocusWithin(): boolean;
// NEW: Focus transitions (INTENTIONALLY not defensive)
requestNodeFocus(): void; // from Wave AI → node (BREAKS selections - that's the point!)
requestWaveAIFocus(): void; // from node → Wave AI
// NEW: Get current focus type
getFocusType(): FocusStrType;
// ENHANCED: Smart refocus based on focusType
refocusNode(): void; // already handles both types
}
Critical Design Decision: requestNodeFocus() is NOT defensive
When requestNodeFocus() is called (e.g., Cmd+n, explicit focus change), it MUST take focus even if there's a selection in Wave AI. This is intentional - the user explicitly requested a focus change. Losing the selection is the correct behavior.
Focus Manager as Source of Truth
The focusType atom is the single source of truth. The old waveAIFocusedAtom will be kept in sync during migration but should eventually be removed. All components should read focusManager.focusType directly (via useAtomValue) to determine focus ring state - this ensures synchronized, reactive focus ring updates.
New File: frontend/app/aipanel/waveai-focus-utils.ts
Similar to focusutil.ts but for Wave AI:
// Find if element is within Wave AI panel
export function findWaveAIPanel(element: HTMLElement): HTMLElement | null {
let current: HTMLElement = element;
while (current) {
if (current.hasAttribute("data-waveai-panel")) {
return current;
}
current = current.parentElement;
}
return null;
}
// Check if Wave AI panel has focus or selection (like focusedBlockId())
export function waveAIHasFocusWithin(): boolean {
// Check if activeElement is within Wave AI panel
const focused = document.activeElement;
if (focused instanceof HTMLElement) {
const waveAIPanel = findWaveAIPanel(focused);
if (waveAIPanel) return true;
}
// Check if selection is within Wave AI panel
const sel = document.getSelection();
if (sel && sel.anchorNode && sel.rangeCount > 0 && !sel.isCollapsed) {
let anchor = sel.anchorNode;
if (anchor instanceof Text) {
anchor = anchor.parentElement;
}
if (anchor instanceof HTMLElement) {
const waveAIPanel = findWaveAIPanel(anchor);
if (waveAIPanel) return true;
}
}
return false;
}
// Check if there's an active selection in Wave AI
export function waveAIHasSelection(): boolean {
const sel = document.getSelection();
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
return false;
}
let anchor = sel.anchorNode;
if (anchor instanceof Text) {
anchor = anchor.parentElement;
}
if (anchor instanceof HTMLElement) {
return findWaveAIPanel(anchor) != null;
}
return false;
}
File: frontend/app/aipanel/aipanel.tsx
Add capture phase and selection protection:
// ADD: Capture phase handler (like blocks)
const handleFocusCapture = useCallback((event: React.FocusEvent) => {
console.log("Wave AI focus capture", getElemAsStr(event.target));
focusManager.requestWaveAIFocus(); // Sets visual state immediately
}, []);
// MODIFY: Click handler with selection protection
const handleClick = (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
const isInteractive = target.closest('button, a, input, textarea, select, [role="button"], [tabindex]');
if (isInteractive) {
return;
}
// NEW: Check for selection protection
const hasSelection = waveAIHasSelection();
if (hasSelection) {
// Just update visual focus, don't move DOM focus
focusManager.requestWaveAIFocus();
return;
}
// No selection, safe to move DOM focus
setTimeout(() => {
if (!waveAIHasSelection()) { // Double-check after timeout
model.focusInput();
}
}, 0);
};
// Add data attribute and onFocusCapture to the div
<div
data-waveai-panel="true"
className={...}
onFocusCapture={handleFocusCapture}
onClick={handleClick}
// ... rest
>
File: frontend/app/aipanel/aipanelinput.tsx
Smart blur handling:
// MODIFY: handleFocus - advisory only
const handleFocus = useCallback(() => {
focusManager.requestWaveAIFocus();
}, []);
// MODIFY: handleBlur - simplified with waveAIHasFocusWithin()
const handleBlur = useCallback((e: React.FocusEvent) => {
// Window blur - preserve state
if (e.relatedTarget === null) {
return;
}
// Still within Wave AI (focus or selection) - don't revert
if (waveAIHasFocusWithin()) {
return;
}
// Focus truly leaving Wave AI, revert to node focus
focusManager.requestNodeFocus();
}, []);
Note: waveAIHasFocusWithin() checks both:
relatedTarget is within Wave AI panel (handles context menus, buttons)This combines both checks from the original implementation into a single utility call.
File: frontend/app/block/block.tsx
No changes needed in block.tsx - the block code works perfectly as-is!
How it works:
When a block child gets focus (input field, terminal click, tab navigation):
1. handleChildFocus fires (capture phase)
↓
2. nodeModel.focusNode()
↓
3. layoutModel.focusNode(nodeId)
↓
4. treeReducer(FocusNodeAction)
↓
5. focusManager.requestNodeFocus() (see Layout Focus Coordination section)
↓
6. Updates localTreeStateAtom (synchronous)
↓
7. isFocused recalculates (sees focusType = "node")
↓
8. Two-step effect grants physical DOM focus
The focus manager update happens automatically in the treeReducer for all focus-claiming operations.
File: frontend/layout/lib/layoutModel.ts
The isFocused atom already checks Wave AI state:
isFocused: atom((get) => {
const treeState = get(this.localTreeStateAtom);
const isFocused = treeState.focusedNodeId === nodeid;
const waveAIFocused = get(atoms.waveAIFocusedAtom);
return isFocused && !waveAIFocused;
});
Update to use focus manager:
isFocused: atom((get) => {
const treeState = get(this.localTreeStateAtom);
const isFocused = treeState.focusedNodeId === nodeid;
const focusType = get(focusManager.focusType);
return isFocused && focusType === "node";
});
This single change coordinates the entire system:
focusedNodeId freelyisFocused returns false if focus manager says "waveai"File: frontend/layout/lib/layoutModel.ts
Critical Integration: When layout operations claim focus, they must update the focus manager synchronously.
treeReducer(action: LayoutTreeAction, setState = true): boolean {
// Process the action (mutates this.treeState)
switch (action.type) {
case LayoutTreeActionType.InsertNode:
insertNode(this.treeState, action);
// If inserting with focus, claim focus from Wave AI
if ((action as LayoutTreeInsertNodeAction).focused) {
focusManager.requestNodeFocus();
}
break;
case LayoutTreeActionType.InsertNodeAtIndex:
insertNodeAtIndex(this.treeState, action);
if ((action as LayoutTreeInsertNodeAtIndexAction).focused) {
focusManager.requestNodeFocus();
}
break;
case LayoutTreeActionType.FocusNode:
focusNode(this.treeState, action);
// Explicit focus change always claims focus
focusManager.requestNodeFocus();
break;
case LayoutTreeActionType.MagnifyNodeToggle:
magnifyNodeToggle(this.treeState, action);
// Magnifying also focuses the node
focusManager.requestNodeFocus();
break;
// ... other cases don't affect focus
}
if (setState) {
this.updateTree();
this.setter(this.localTreeStateAtom, { ...this.treeState });
this.persistToBackend();
}
return true;
}
Why This Works:
focusManager.requestNodeFocus() updates focusType synchronouslylocalTreeStateAtom commits, isFocused sees the new focusTypeOrder of Operations:
Cmd+n pressed
↓
treeReducer() executes
↓
1. insertNode() mutates layoutState.focusedNodeId
2. focusManager.requestNodeFocus() updates focusType
3. setter(localTreeStateAtom) commits tree state
↓
[All synchronous - single call stack]
↓
React re-renders with both updates applied
↓
isFocused sees: focusedNodeId = newNode AND focusType = "node"
↓
Two-step effect grants physical focus
File: frontend/app/store/keymodel.ts
Use focus manager instead of direct atom checks:
function switchBlockInDirection(tabId: string, direction: NavigateDirection) {
const layoutModel = getLayoutModelForTabById(tabId);
const focusType = focusManager.getFocusType();
if (direction === NavigateDirection.Left) {
const numBlocks = globalStore.get(layoutModel.numLeafs);
if (focusType === "waveai") {
return;
}
if (numBlocks === 1) {
focusManager.requestWaveAIFocus();
return;
}
}
// For right navigation, switch from Wave AI to blocks
if (direction === NavigateDirection.Right && focusType === "waveai") {
focusManager.requestNodeFocus();
return;
}
// Rest of navigation logic...
}
User presses Cmd+n
↓
treeReducer() called
↓
1. insertNode(focused: true) - SYNCHRONOUS
- layoutState.focusedNodeId = newNode
↓
2. setter(localTreeStateAtom, { ...treeState }) - SYNCHRONOUS
- Atom updated immediately
↓
3. persistToBackend() - ASYNC (fire-and-forget)
↓
[All in same tick - no intermediate renders]
↓
React re-renders (batched update)
↓
isFocused recalculates:
- get(localTreeStateAtom) → focusedNodeId = newNode ✓
- get(focusType) → checks current focus type
- Returns TRUE if focusType === "node"
↓
useLayoutEffect #1: setBlockClicked(true)
↓
useLayoutEffect #2: setFocusTarget()
↓
Physical DOM focus granted ✓
Why there's no flash:
handleBlurrelatedTarget is null → detected as window blurwaveAIHasSelection() returns truerelatedTarget within Wave AI panelhandleBlur detects this, doesn't revert focusglobalRefocus()focusTypefocusManager.ts with new methodswaveai-focus-utils.ts with selection utilitiesonFocusCapture to Wave AI panelhandleBlur with simplified waveAIHasFocusWithin() checkhandleClick with selection awarenessfocusManager.focusType directly via useAtomValue for focus ring displayisFocused atom to check focusManager.focusTypefocusManager.requestNodeFocus() calls in treeReducer for focus-claiming operationsfocusManager.getFocusType()frontend/app/aipanel/waveai-focus-utils.ts - Focus utilities for Wave AIfrontend/app/store/focusManager.ts - Enhanced with new methodsfrontend/app/aipanel/aipanel.tsx - Add capture phase, improve click handlerfrontend/app/aipanel/aipanelinput.tsx - Smart blur handlingfrontend/layout/lib/layoutModel.ts - Update isFocused atom AND add focus manager calls in treeReducerfrontend/app/store/keymodel.ts - Use focus manager for navigationThe changes can be broken into safe, independently testable phases. Each phase can be shipped and tested before proceeding to the next.
Add focus manager methods WITHOUT changing existing code
// In focusManager.ts - ADD these methods
class FocusManager {
// NEW methods that ALSO update the old waveAIFocusedAtom during migration
requestWaveAIFocus(): void {
globalStore.set(this.focusType, "waveai");
globalStore.set(atoms.waveAIFocusedAtom, true); // ← Keep old atom in sync during migration!
}
requestNodeFocus(): void {
// NO defensive checks - when called, we TAKE focus (selections may be lost)
globalStore.set(this.focusType, "node");
globalStore.set(atoms.waveAIFocusedAtom, false); // ← Keep old atom in sync during migration!
}
getFocusType(): FocusStrType {
return globalStore.get(this.focusType);
}
waveAIFocusWithin(): boolean {
return waveAIHasFocusWithin();
}
nodeFocusWithin(): boolean {
return focusedBlockId() != null;
}
}
Why this is safe:
focusType AND old waveAIFocusedAtom during migrationfocusType directly via useAtomValue for reactive updatesTesting:
Add utilities and improve Wave AI focus handling
waveai-focus-utils.ts with selection checking utilitiesaipanel.tsx:
data-waveai-panel attributeonFocusCapture handlerfocusManager.requestWaveAIFocus() instead of setting atom directlyaipanelinput.tsx:
focusManager.requestNodeFocus() instead of setting atom directlyWhy this is safe:
waveAIFocusedAtom directly - still works!Testing:
User-visible improvements:
Update isFocused atom to use focus manager
// In layoutModel.ts - CHANGE isFocused atom
isFocused: atom((get) => {
const treeState = get(this.localTreeStateAtom);
const isFocused = treeState.focusedNodeId === nodeid;
const focusType = get(focusManager.focusType); // ← Use focus manager
return isFocused && focusType === "node";
});
Why this is safe:
waveAIFocusedAtom in sync (Phase 1)focusType but it's always consistent with old atomTesting:
No user-visible changes - just internal refactoring
Add focus manager calls to treeReducer
// In layoutModel.ts treeReducer - ADD focus manager calls
case LayoutTreeActionType.FocusNode:
focusNode(this.treeState, action);
focusManager.requestNodeFocus(); // ← NEW
break;
case LayoutTreeActionType.InsertNode:
insertNode(this.treeState, action);
if ((action as LayoutTreeInsertNodeAction).focused) {
focusManager.requestNodeFocus(); // ← NEW
}
break;
case LayoutTreeActionType.MagnifyNodeToggle:
magnifyNodeToggle(this.treeState, action);
focusManager.requestNodeFocus(); // ← NEW
break;
Why this is safe:
Testing:
User-visible improvements:
Use focus manager in keyboard navigation, remove old atom usage
keymodel.ts to use focusManager.getFocusType()atoms.waveAIFocusedAtom usage throughout codebasewaveAIFocusedAtom in focus manager - can be deprecatedWhy this is safe:
Testing:
Phase 1 is the enabler: By having the focus manager update BOTH the new focusType atom AND the old waveAIFocusedAtom, we create a safe transition period where:
This dual-sync approach eliminates the "all or nothing" problem. You can stop at any phase and have a working, tested system.
After each phase, you can ship and test:
Each phase builds on the previous one but can be independently verified.