Keep external sessions visible without reopening desktop

Desktop users can receive new sessions from IM adapters or scheduled tasks while the app stays open. The sidebar now refreshes on mount, visible focus, and a low-frequency visible-only interval, with a manual refresh control and in-flight request dedupe so the fix does not create avoidable polling pressure.

Constraint: Sessions can be created outside the desktop process by IM and scheduler entrypoints
Rejected: WebSocket push for this patch | broader server contract change than needed for the reported stale list
Confidence: high
Scope-risk: narrow
Directive: Keep session-list refresh visible-only and deduped before lowering intervals or adding more triggers
Tested: cd desktop && bunx vitest run src/components/layout/Sidebar.test.tsx src/i18n/index.test.tsx
Tested: cd desktop && bun run lint
Tested: bun run check:desktop
Tested: Browser smoke on isolated desktop backend/frontend with refresh button click
Not-tested: Full coverage gate is blocked by unrelated root test port/timeouts; changed lines coverage reported 100% (59/59)
This commit is contained in:
程序员阿江(Relakkes) 2026-05-13 12:45:25 +08:00
parent ee0fc2ad11
commit be01a64f1c
4 changed files with 137 additions and 4 deletions

View File

@ -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(<Sidebar />)
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(<Sidebar />)
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,
})
})
})

View File

@ -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<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const [lastSelectedSessionId, setLastSelectedSessionId] = useState<string | null>(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"
/>
</div>
<button
type="button"
onClick={() => void refreshSessionsNow()}
disabled={isLoading}
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[12px] border border-[var(--color-sidebar-search-border)] bg-[var(--color-sidebar-search-bg)] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)] disabled:cursor-default disabled:opacity-65 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
aria-label={t('sidebar.refreshSessions')}
title={t('sidebar.refreshSessions')}
>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} strokeWidth={1.9} aria-hidden="true" />
</button>
<button
type="button"
onClick={isBatchMode ? handleExitBatchMode : enterBatchMode}
@ -673,6 +683,60 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
)
}
function useSessionListAutoRefresh(fetchSessions: () => Promise<void>): () => Promise<void> {
const inFlightRef = useRef<Promise<void> | null>(null)
const lastStartedAtRef = useRef(0)
const refreshSessions = useCallback((force = false) => {
if (inFlightRef.current) return inFlightRef.current
const now = Date.now()
if (!force && now - lastStartedAtRef.current < SESSION_LIST_FOCUS_REFRESH_MIN_MS) {
return Promise.resolve()
}
lastStartedAtRef.current = now
const request = Promise.resolve()
.then(() => fetchSessions())
.catch(() => undefined)
.finally(() => {
if (inFlightRef.current === request) {
inFlightRef.current = null
}
})
inFlightRef.current = request
return request
}, [fetchSessions])
useEffect(() => {
void refreshSessions(true)
const refreshIfVisible = () => {
if (!isDocumentVisible()) return
void refreshSessions()
}
window.addEventListener('focus', refreshIfVisible)
document.addEventListener('visibilitychange', refreshIfVisible)
const timer = window.setInterval(() => {
if (!isDocumentVisible()) return
void refreshSessions(true)
}, SESSION_LIST_AUTO_REFRESH_MS)
return () => {
window.removeEventListener('focus', refreshIfVisible)
document.removeEventListener('visibilitychange', refreshIfVisible)
window.clearInterval(timer)
}
}, [refreshSessions])
return useCallback(() => refreshSessions(true), [refreshSessions])
}
function isDocumentVisible(): boolean {
return typeof document === 'undefined' || document.visibilityState !== 'hidden'
}
function groupByTime(sessions: SessionListItem[]): Map<TimeGroup, SessionListItem[]> {
const groups = new Map<TimeGroup, SessionListItem[]>()
const now = new Date()

View File

@ -24,6 +24,7 @@ export const en = {
'sidebar.noSessions': 'No sessions yet',
'sidebar.noMatching': 'No matching sessions',
'sidebar.sessionListFailed': 'Session list failed to load',
'sidebar.refreshSessions': 'Refresh sessions',
'sidebar.missingDir': 'missing dir',
'sidebar.confirmDelete': 'Delete this session? This cannot be undone.',
'sidebar.batchManage': 'Batch manage',

View File

@ -26,6 +26,7 @@ export const zh: Record<TranslationKey, string> = {
'sidebar.noSessions': '暂无会话',
'sidebar.noMatching': '没有匹配的会话',
'sidebar.sessionListFailed': '会话列表加载失败',
'sidebar.refreshSessions': '刷新会话列表',
'sidebar.missingDir': '目录缺失',
'sidebar.confirmDelete': '确定要删除这个会话吗?此操作不可撤销。',
'sidebar.batchManage': '批量管理',