diff --git a/src/blobbi/companion/hooks/useBlobbiAttention.ts b/src/blobbi/companion/hooks/useBlobbiAttention.ts index f797c4f9..8635b52c 100644 --- a/src/blobbi/companion/hooks/useBlobbiAttention.ts +++ b/src/blobbi/companion/hooks/useBlobbiAttention.ts @@ -49,6 +49,9 @@ interface TriggerOptions { source?: string; /** If true, uses shorter glance cooldown instead of default cooldown */ isGlance?: boolean; + /** If true, skip the cooldown check entirely (used by route reactions that + * keep the previous attention alive and need to override it immediately). */ + bypassCooldown?: boolean; } // Selectors for UI elements that should trigger full attention (overlays) @@ -130,6 +133,9 @@ export function useBlobbiAttention({ onAttentionEnd, }: UseBlobbiAttentionOptions): UseBlobbiAttentionResult { const [uiAttention, setUiAttention] = useState(null); + /** Ref mirror of uiAttention — lets triggerAttention read fresh state without + * capturing it in its closure (keeps the callback referentially stable). */ + const uiAttentionRef = useRef(null); const config = DEFAULT_COMPANION_CONFIG; const lastAttentionTimeRef = useRef(0); @@ -156,6 +162,7 @@ export function useBlobbiAttention({ // Reset cooldown so an immediate re-trigger is not blocked lastAttentionTimeRef.current = 0; + uiAttentionRef.current = null; setUiAttention(prev => { if (prev) { onAttentionEnd?.(prev.id); @@ -179,23 +186,29 @@ export function useBlobbiAttention({ priority = 'normal', source, isGlance = false, + bypassCooldown = false, } = options; // Respect cooldown to avoid spamming (shorter cooldown for glances) - const cooldown = isGlance ? config.attention.glanceCooldown : config.attention.cooldown; - if (timeSinceLastAttention < cooldown) { - return; + if (!bypassCooldown) { + const cooldown = isGlance ? config.attention.glanceCooldown : config.attention.cooldown; + if (timeSinceLastAttention < cooldown) { + return; + } } + // Read from ref instead of closure state — keeps this callback stable + const currentAttention = uiAttentionRef.current; + // If there's current UI attention, check priority - if (uiAttention) { + if (currentAttention) { const priorityOrder: Record = { low: 0, normal: 1, high: 2, }; - if (priorityOrder[priority] < priorityOrder[uiAttention.priority]) { + if (priorityOrder[priority] < priorityOrder[currentAttention.priority]) { return; // Current attention has higher priority } @@ -213,6 +226,7 @@ export function useBlobbiAttention({ }; lastAttentionTimeRef.current = now; + uiAttentionRef.current = target; setUiAttention(target); onAttentionStart?.(target); @@ -220,7 +234,7 @@ export function useBlobbiAttention({ attentionTimeoutRef.current = setTimeout(() => { clearAttention(); }, duration); - }, [config.attention, uiAttention, clearAttention, onAttentionStart]); + }, [config.attention, clearAttention, onAttentionStart]); /** * Handle overlay elements (modals, dialogs, sheets) - full attention. diff --git a/src/blobbi/companion/hooks/useRouteReaction.ts b/src/blobbi/companion/hooks/useRouteReaction.ts index e1e3ef4d..8dc36e46 100644 --- a/src/blobbi/companion/hooks/useRouteReaction.ts +++ b/src/blobbi/companion/hooks/useRouteReaction.ts @@ -30,6 +30,7 @@ interface TriggerAttentionFn { priority?: AttentionPriority; source?: string; isGlance?: boolean; + bypassCooldown?: boolean; }): void; } @@ -131,6 +132,7 @@ function genericRouteReaction(ctx: RouteReactionContext): void { duration, priority: 'normal', source: 'route:center', + bypassCooldown: true, // Previous attention was kept alive during the delay }); } @@ -147,14 +149,22 @@ export function useRouteReaction({ const prevPathnameRef = useRef(pathname); const timeoutsRef = useRef[]>([]); - /** Cancel pending timeouts and clear active attention. */ - const cancelReaction = useCallback(() => { + /** Cancel pending timeouts only — does NOT clear the active attention. + * Used during route transitions so the previous gaze target stays alive + * until the new one is ready (avoids a random-gaze gap). */ + const cancelPendingTimeouts = useCallback(() => { for (const tid of timeoutsRef.current) { clearTimeout(tid); } timeoutsRef.current = []; + }, []); + + /** Full cancel: pending timeouts + active attention. + * Used when dragging or when the reaction should fully stop. */ + const cancelReaction = useCallback(() => { + cancelPendingTimeouts(); clearAttention(); - }, [clearAttention]); + }, [cancelPendingTimeouts, clearAttention]); // Cancel fully on drag (pending timeouts + active attention) useEffect(() => { @@ -183,8 +193,11 @@ export function useRouteReaction({ const prevPathname = prevPathnameRef.current; prevPathnameRef.current = pathname; - // Cancel any in-flight reaction from a previous route change - cancelReaction(); + // Cancel pending timeouts from a previous route change but keep the + // current attention alive — the delayed reaction will override it. + // This prevents the gaze system from falling to random mode during + // the ROUTE_REACTION_DELAY gap. + cancelPendingTimeouts(); // Small delay to let the new page's DOM mount before querying positions const startTid = setTimeout(() => { @@ -208,8 +221,10 @@ export function useRouteReaction({ timeoutsRef.current.push(startTid); return () => { - cancelReaction(); + // Effect cleanup (re-fire or unmount): only cancel timeouts. + // Attention auto-clears via its own duration timeout. + cancelPendingTimeouts(); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- triggerAttention/clearAttention are stable callbacks - }, [pathname, hasEnteredOnce, isEntering, cancelReaction]); + }, [pathname, hasEnteredOnce, isEntering, cancelPendingTimeouts]); }