From be01a64f1cda6ecf74098f90e40bca4280a64fed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 13 May 2026 12:45:25 +0800 Subject: [PATCH] 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) --- .../src/components/layout/Sidebar.test.tsx | 67 +++++++++++++++++ desktop/src/components/layout/Sidebar.tsx | 72 +++++++++++++++++-- desktop/src/i18n/locales/en.ts | 1 + desktop/src/i18n/locales/zh.ts | 1 + 4 files changed, 137 insertions(+), 4 deletions(-) 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" /> +