From aa3cf3b027fd5d164639cc45c7ccb0cefc09f799 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:27:47 -0800 Subject: [PATCH] refactor(native-chat): tidy the rail wake hook and cover it with tests (#39477) Store absolute deadlines instead of two last-seen timestamps, and let scroll events with no wake pending return before reading the clock. Behaviour is unchanged. --- .../use-native-chat-rail-wake.test.ts | 67 +++++++++++++++++++ .../native-chat/use-native-chat-rail-wake.ts | 21 ++++-- 2 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-native-chat-rail-wake.test.ts diff --git a/src/renderer/src/components/native-chat/use-native-chat-rail-wake.test.ts b/src/renderer/src/components/native-chat/use-native-chat-rail-wake.test.ts new file mode 100644 index 00000000000..dae6cd93b4f --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-rail-wake.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useNativeChatRailWake } from './use-native-chat-rail-wake' + +function setup() { + const hook = renderHook(() => useNativeChatRailWake()) + const rail = document.createElement('div') + hook.result.current.railRef.current = rail + return { ...hook.result.current, rail, unmount: hook.unmount } +} + +describe('useNativeChatRailWake', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('lights on input and goes dark after the wake window', () => { + const { rail, wake } = setup() + wake() + expect(rail.dataset.awake).toBe('') + vi.advanceTimersByTime(299) + expect('awake' in rail.dataset).toBe(true) + vi.advanceTimersByTime(1) + expect('awake' in rail.dataset).toBe(false) + }) + + it('stays lit while scroll events follow a recent input', () => { + const { rail, wake, sustain } = setup() + wake() + for (let i = 0; i < 10; i++) { + vi.advanceTimersByTime(100) + sustain() + } + expect('awake' in rail.dataset).toBe(true) + vi.advanceTimersByTime(300) + expect('awake' in rail.dataset).toBe(false) + }) + + it('stops sustaining once the input is too old', () => { + const { rail, wake, sustain } = setup() + wake() + for (let i = 0; i < 20; i++) { + vi.advanceTimersByTime(100) + sustain() + } + // 1.5 s after input, scroll events no longer extend the wake. + expect('awake' in rail.dataset).toBe(false) + }) + + it('ignores scroll events with no wake pending', () => { + const { rail, sustain } = setup() + sustain() + vi.advanceTimersByTime(1000) + expect('awake' in rail.dataset).toBe(false) + }) + + it('clears its timer on unmount', () => { + const { wake, unmount } = setup() + wake() + unmount() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-rail-wake.ts b/src/renderer/src/components/native-chat/use-native-chat-rail-wake.ts index f78bfb6356e..97f9617135e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-rail-wake.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-rail-wake.ts @@ -17,12 +17,15 @@ export function useNativeChatRailWake(): { } { const railRef = useRef(null) const timer = useRef | null>(null) - const lastWake = useRef(0) - const lastInput = useRef(0) + /** When the rail may go dark again. */ + const sleepAt = useRef(0) + /** Scroll events before this still belong to the reader's last input. */ + const sustainUntil = useRef(0) const wake = useCallback(() => { - lastWake.current = performance.now() - lastInput.current = lastWake.current + const now = performance.now() + sleepAt.current = now + NATIVE_CHAT_RAIL_WAKE_MS + sustainUntil.current = now + NATIVE_CHAT_RAIL_SUSTAIN_MS const rail = railRef.current // Why: a gesture fires wheel events every frame; write the DOM and arm a timer once per gesture. if (rail && !('awake' in rail.dataset)) { @@ -32,7 +35,7 @@ export function useNativeChatRailWake(): { return } const settle = (): void => { - const remaining = lastWake.current + NATIVE_CHAT_RAIL_WAKE_MS - performance.now() + const remaining = sleepAt.current - performance.now() if (remaining > 0) { timer.current = setTimeout(settle, remaining) return @@ -46,9 +49,13 @@ export function useNativeChatRailWake(): { }, []) const sustain = useCallback(() => { + // Why: most scroll events (streaming, auto-follow) arrive with no wake pending; skip the clock read. + if (timer.current === null) { + return + } const now = performance.now() - if (timer.current !== null && now - lastInput.current < NATIVE_CHAT_RAIL_SUSTAIN_MS) { - lastWake.current = now + if (now < sustainUntil.current) { + sleepAt.current = now + NATIVE_CHAT_RAIL_WAKE_MS } }, [])