diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index 353f6b16..73e79ed6 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -16,6 +16,7 @@ vi.mock('../../i18n', () => ({ 'sidebar.noSessions': 'No sessions', 'sidebar.noMatching': 'No matching sessions', 'sidebar.sessionListFailed': 'Session list failed', + 'sidebar.refreshSessions': 'Refresh sessions', 'common.retry': 'Retry', 'common.loading': 'Loading...', 'common.cancel': 'Cancel', @@ -102,6 +103,7 @@ describe('Sidebar', () => { }) afterEach(() => { + vi.useRealTimers() cleanup() useTabStore.setState({ tabs: [], activeTabId: null }) }) @@ -390,4 +392,69 @@ describe('Sidebar', () => { expect(screen.getByText('Loading...')).toBeInTheDocument() expect(screen.queryByText('No sessions')).not.toBeInTheDocument() }) + + it('refreshes sessions manually and through low-frequency visible polling', async () => { + vi.useFakeTimers() + + render() + await act(async () => { + await Promise.resolve() + }) + + expect(fetchSessions).toHaveBeenCalledTimes(1) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Refresh sessions' })) + await Promise.resolve() + }) + expect(fetchSessions).toHaveBeenCalledTimes(2) + + await act(async () => { + window.dispatchEvent(new Event('focus')) + await Promise.resolve() + }) + expect(fetchSessions).toHaveBeenCalledTimes(2) + + await act(async () => { + vi.advanceTimersByTime(30_000) + await Promise.resolve() + }) + expect(fetchSessions).toHaveBeenCalledTimes(3) + }) + + it('does not poll for session changes while the document is hidden', async () => { + vi.useFakeTimers() + const originalVisibility = document.visibilityState + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'hidden', + }) + + render() + await act(async () => { + await Promise.resolve() + }) + expect(fetchSessions).toHaveBeenCalledTimes(1) + + await act(async () => { + vi.advanceTimersByTime(30_000) + await Promise.resolve() + }) + expect(fetchSessions).toHaveBeenCalledTimes(1) + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }) + await act(async () => { + document.dispatchEvent(new Event('visibilitychange')) + await Promise.resolve() + }) + expect(fetchSessions).toHaveBeenCalledTimes(2) + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: originalVisibility, + }) + }) }) diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 3fdf4575..2ce5b470 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react' +import { RefreshCw } from 'lucide-react' import { useSessionStore } from '../../stores/sessionStore' import { useUIStore } from '../../stores/uiStore' import { useTranslation } from '../../i18n' @@ -10,6 +11,8 @@ import { useChatStore } from '../../stores/chatStore' const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window) const isWindows = typeof navigator !== 'undefined' && /Win/.test(navigator.platform) +const SESSION_LIST_AUTO_REFRESH_MS = 30_000 +const SESSION_LIST_FOCUS_REFRESH_MIN_MS = 5_000 type TimeGroup = 'today' | 'yesterday' | 'last7days' | 'last30days' | 'older' @@ -51,10 +54,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { const [renamingId, setRenamingId] = useState(null) const [renameValue, setRenameValue] = useState('') const [lastSelectedSessionId, setLastSelectedSessionId] = useState(null) - - useEffect(() => { - fetchSessions() - }, [fetchSessions]) + const refreshSessionsNow = useSessionListAutoRefresh(fetchSessions) useEffect(() => { if (!contextMenu) return @@ -376,6 +376,16 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { className="min-w-0 flex-1 bg-transparent pl-2 pr-0 text-[13px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none" /> +