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
This commit is contained in:
@@ -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<AttentionTarget | null>(null);
|
||||
/** Ref mirror of uiAttention — lets triggerAttention read fresh state without
|
||||
* capturing it in its closure (keeps the callback referentially stable). */
|
||||
const uiAttentionRef = useRef<AttentionTarget | null>(null);
|
||||
|
||||
const config = DEFAULT_COMPANION_CONFIG;
|
||||
const lastAttentionTimeRef = useRef<number>(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<AttentionPriority, number> = {
|
||||
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.
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
/** 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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user