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"
/>
+