From 7fa856224e4e47259b3e2eb7dfcdebba8cfb111b Mon Sep 17 00:00:00 2001 From: filemon Date: Sun, 12 Apr 2026 19:25:47 -0300 Subject: [PATCH] Fix Blobbi looking at sidebar during route transitions Keep previous attention alive during the 250ms route-reaction delay instead of clearing it immediately. This prevents the gaze system from falling to random mode (which could point toward the sidebar) while waiting for the new page's DOM to mount. - Split cancelReaction into cancelPendingTimeouts (timeouts only) and full cancel (timeouts + attention); route changes use the former - Add bypassCooldown option to triggerAttention so the delayed reaction can override the kept-alive attention without being blocked by cooldown - Stabilize triggerAttention via uiAttentionRef instead of stale closure --- .../companion/hooks/useBlobbiAttention.ts | 26 +++++++++++++---- .../companion/hooks/useRouteReaction.ts | 29 ++++++++++++++----- 2 files changed, 42 insertions(+), 13 deletions(-) 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]); }