mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
ee0fc2ad11
commit
be01a64f1c
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -26,6 +26,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'sidebar.noSessions': '暂无会话',
|
||||
'sidebar.noMatching': '没有匹配的会话',
|
||||
'sidebar.sessionListFailed': '会话列表加载失败',
|
||||
'sidebar.refreshSessions': '刷新会话列表',
|
||||
'sidebar.missingDir': '目录缺失',
|
||||
'sidebar.confirmDelete': '确定要删除这个会话吗?此操作不可撤销。',
|
||||
'sidebar.batchManage': '批量管理',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user