diff --git a/web/src/App.tsx b/web/src/App.tsx index ca3d29217..e82d4a42d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,6 +10,8 @@ import { useSSE } from '@/hooks/useSSE' import { useSyncingState } from '@/hooks/useSyncingState' import { usePushNotifications } from '@/hooks/usePushNotifications' import { useVisibilityReporter } from '@/hooks/useVisibilityReporter' +import { useAutoArchive, useAutoArchiveTimeout } from '@/hooks/useAutoArchiveTimeout' +import { useSessions } from '@/hooks/queries/useSessions' import { queryKeys } from '@/lib/query-keys' import { AppContextProvider } from '@/lib/app-context' import { fetchLatestMessages } from '@/lib/message-window-store' @@ -45,6 +47,8 @@ function AppInner() { const { serverUrl, baseUrl, setServerUrl, clearServerUrl } = useServerUrl() const { authSource, isLoading: isAuthSourceLoading, setAccessToken } = useAuthSource(baseUrl) const { token, api, isLoading: isAuthLoading, error: authError, needsBinding, bind } = useAuth(authSource, baseUrl) + const { sessions } = useSessions(api) + const { autoArchiveTimeoutMs } = useAutoArchiveTimeout() const goBack = useAppGoBack() const pathname = useLocation({ select: (location) => location.pathname }) const matchRoute = useMatchRoute() @@ -124,6 +128,8 @@ function AppInner() { const pushPromptedRef = useRef(false) const { isSupported: isPushSupported, permission: pushPermission, requestPermission, subscribe } = usePushNotifications(api) + useAutoArchive(api, sessions, autoArchiveTimeoutMs) + useEffect(() => { if (baseUrlRef.current === baseUrl) { return diff --git a/web/src/hooks/useAutoArchiveTimeout.ts b/web/src/hooks/useAutoArchiveTimeout.ts new file mode 100644 index 000000000..44097896f --- /dev/null +++ b/web/src/hooks/useAutoArchiveTimeout.ts @@ -0,0 +1,129 @@ +import { useEffect, useRef, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import { queryKeys } from '@/lib/query-keys' +import type { SessionSummary } from '@/types/api' + +const AUTO_ARCHIVE_TIMEOUT_KEY = 'hapi:auto-archive-timeout-ms' + +export const AUTO_ARCHIVE_TIMEOUT_OPTIONS = [ + { value: 0, labelKey: 'settings.display.autoArchive.off' }, + { value: 15 * 60 * 1000, labelKey: 'settings.display.autoArchive.15m' }, + { value: 30 * 60 * 1000, labelKey: 'settings.display.autoArchive.30m' }, + { value: 60 * 60 * 1000, labelKey: 'settings.display.autoArchive.1h' }, + { value: 2 * 60 * 60 * 1000, labelKey: 'settings.display.autoArchive.2h' }, +] as const + +export type AutoArchiveTimeoutMs = (typeof AUTO_ARCHIVE_TIMEOUT_OPTIONS)[number]['value'] + +function isAutoArchiveTimeoutMs(value: number): value is AutoArchiveTimeoutMs { + return AUTO_ARCHIVE_TIMEOUT_OPTIONS.some((option) => option.value === value) +} + +function readAutoArchiveTimeoutPreference(): AutoArchiveTimeoutMs { + if (typeof window === 'undefined') return 0 + try { + const raw = localStorage.getItem(AUTO_ARCHIVE_TIMEOUT_KEY) + const parsed = raw === null ? 0 : Number(raw) + return isAutoArchiveTimeoutMs(parsed) ? parsed : 0 + } catch { + return 0 + } +} + +export function getAutoArchiveTimeoutOptions(): readonly { value: AutoArchiveTimeoutMs; labelKey: string }[] { + return AUTO_ARCHIVE_TIMEOUT_OPTIONS +} + +export function useAutoArchiveTimeout(): { + autoArchiveTimeoutMs: AutoArchiveTimeoutMs + setAutoArchiveTimeoutMs: (timeoutMs: AutoArchiveTimeoutMs) => void +} { + const [autoArchiveTimeoutMs, setAutoArchiveTimeoutMsState] = useState( + () => readAutoArchiveTimeoutPreference() + ) + + const setAutoArchiveTimeoutMs = (timeoutMs: AutoArchiveTimeoutMs) => { + setAutoArchiveTimeoutMsState(timeoutMs) + try { + localStorage.setItem(AUTO_ARCHIVE_TIMEOUT_KEY, String(timeoutMs)) + } catch { + // Ignore storage errors + } + } + + return { + autoArchiveTimeoutMs, + setAutoArchiveTimeoutMs + } +} + +function shouldAutoArchiveSession(session: SessionSummary, timeoutMs: number, now: number): boolean { + if (timeoutMs <= 0) return false + if (!session.active || session.thinking) return false + if (session.pendingRequestsCount > 0) return false + if (!Number.isFinite(session.updatedAt) || session.updatedAt <= 0) return false + return now - session.updatedAt >= timeoutMs +} + +export function useAutoArchive( + api: ApiClient | null, + sessions: SessionSummary[], + timeoutMs: AutoArchiveTimeoutMs +): void { + const queryClient = useQueryClient() + const inFlightRef = useRef(new Set()) + + useEffect(() => { + if (!api || timeoutMs <= 0) { + return + } + + let cancelled = false + + const run = async () => { + const now = Date.now() + const candidates = sessions.filter( + (session) => shouldAutoArchiveSession(session, timeoutMs, now) && !inFlightRef.current.has(session.id) + ) + + if (candidates.length === 0) { + return + } + + const archivedSessionIds: string[] = [] + + await Promise.all(candidates.map(async (session) => { + inFlightRef.current.add(session.id) + try { + await api.archiveSession(session.id) + archivedSessionIds.push(session.id) + } catch (error) { + console.error(`Failed to auto-archive session ${session.id}:`, error) + } finally { + inFlightRef.current.delete(session.id) + } + })) + + if (cancelled || archivedSessionIds.length === 0) { + return + } + + await queryClient.invalidateQueries({ queryKey: queryKeys.sessions }) + await Promise.all( + archivedSessionIds.map((sessionId) => queryClient.invalidateQueries({ queryKey: queryKeys.session(sessionId) })) + ) + } + + void run() + + const intervalId = window.setInterval(() => { + void run() + }, Math.min(timeoutMs, 30_000)) + + return () => { + cancelled = true + window.clearInterval(intervalId) + } + }, [api, queryClient, sessions, timeoutMs]) +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 9c67e8d25..235e7be42 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -254,6 +254,13 @@ export default { 'settings.display.appearance.dark': 'Dark', 'settings.display.appearance.light': 'Light', 'settings.display.fontSize': 'Font Size', + 'settings.display.autoArchive': 'Auto-Archive Timeout', + 'settings.display.autoArchive.description': 'Automatically archive active sessions after they stay idle for the selected time.', + 'settings.display.autoArchive.off': 'Off', + 'settings.display.autoArchive.15m': '15 minutes', + 'settings.display.autoArchive.30m': '30 minutes', + 'settings.display.autoArchive.1h': '1 hour', + 'settings.display.autoArchive.2h': '2 hours', 'settings.voice.title': 'Voice Assistant', 'settings.voice.language': 'Voice Language', 'settings.voice.autoDetect': 'Auto-detect', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index fa218ed91..aa04d9b7d 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -256,6 +256,13 @@ export default { 'settings.display.appearance.dark': '深色', 'settings.display.appearance.light': '浅色', 'settings.display.fontSize': '字体大小', + 'settings.display.autoArchive': '自动归档时间', + 'settings.display.autoArchive.description': '活动会话空闲达到所选时间后自动归档。', + 'settings.display.autoArchive.off': '关闭', + 'settings.display.autoArchive.15m': '15 分钟', + 'settings.display.autoArchive.30m': '30 分钟', + 'settings.display.autoArchive.1h': '1 小时', + 'settings.display.autoArchive.2h': '2 小时', 'settings.voice.title': '语音助手', 'settings.voice.language': '语音语言', 'settings.voice.autoDetect': '自动检测', diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx index 224868319..ad73c494e 100644 --- a/web/src/routes/settings/index.test.tsx +++ b/web/src/routes/settings/index.test.tsx @@ -1,10 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { I18nContext, I18nProvider } from '@/lib/i18n-context' import { en } from '@/lib/locales' import { PROTOCOL_VERSION } from '@hapi/protocol' import SettingsPage from './index' +vi.mock('@hapi/protocol', () => ({ + PROTOCOL_VERSION: '1' +})) + // Mock the router hooks vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn(), @@ -32,6 +36,17 @@ vi.mock('@/hooks/useTheme', () => ({ ], })) +const setAutoArchiveTimeoutMs = vi.fn() + +vi.mock('@/hooks/useAutoArchiveTimeout', () => ({ + useAutoArchiveTimeout: () => ({ autoArchiveTimeoutMs: 0, setAutoArchiveTimeoutMs }), + getAutoArchiveTimeoutOptions: () => [ + { value: 0, labelKey: 'settings.display.autoArchive.off' }, + { value: 900000, labelKey: 'settings.display.autoArchive.15m' }, + { value: 1800000, labelKey: 'settings.display.autoArchive.30m' }, + ], +})) + // Mock languages vi.mock('@/lib/languages', () => ({ getElevenLabsSupportedLanguages: () => [ @@ -121,4 +136,20 @@ describe('SettingsPage', () => { expect(calledKeys).toContain('settings.display.appearance') expect(calledKeys).toContain('settings.display.appearance.system') }) + + it('renders the auto-archive setting', () => { + renderWithProviders() + expect(screen.getAllByText('Auto-Archive Timeout').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('Automatically archive active sessions after they stay idle for the selected time.').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('Off').length).toBeGreaterThanOrEqual(1) + }) + + it('updates the auto-archive timeout when selecting an option', () => { + renderWithProviders() + + fireEvent.click(screen.getAllByRole('button', { name: /Auto-Archive Timeout/i })[0]) + fireEvent.click(screen.getByRole('option', { name: '15 minutes' })) + + expect(setAutoArchiveTimeoutMs).toHaveBeenCalledWith(900000) + }) }) diff --git a/web/src/routes/settings/index.tsx b/web/src/routes/settings/index.tsx index 3c7be6720..aaa7b9116 100644 --- a/web/src/routes/settings/index.tsx +++ b/web/src/routes/settings/index.tsx @@ -4,6 +4,7 @@ import { useAppGoBack } from '@/hooks/useAppGoBack' import { getElevenLabsSupportedLanguages, getLanguageDisplayName, type Language } from '@/lib/languages' import { getFontScaleOptions, useFontScale, type FontScale } from '@/hooks/useFontScale' import { useAppearance, getAppearanceOptions, type AppearancePreference } from '@/hooks/useTheme' +import { getAutoArchiveTimeoutOptions, useAutoArchiveTimeout, type AutoArchiveTimeoutMs } from '@/hooks/useAutoArchiveTimeout' import { PROTOCOL_VERSION } from '@hapi/protocol' const locales: { value: Locale; nativeLabel: string }[] = [ @@ -76,13 +77,16 @@ export default function SettingsPage() { const [isOpen, setIsOpen] = useState(false) const [isAppearanceOpen, setIsAppearanceOpen] = useState(false) const [isFontOpen, setIsFontOpen] = useState(false) + const [isAutoArchiveOpen, setIsAutoArchiveOpen] = useState(false) const [isVoiceOpen, setIsVoiceOpen] = useState(false) const containerRef = useRef(null) const appearanceContainerRef = useRef(null) const fontContainerRef = useRef(null) + const autoArchiveContainerRef = useRef(null) const voiceContainerRef = useRef(null) const { fontScale, setFontScale } = useFontScale() const { appearance, setAppearance } = useAppearance() + const { autoArchiveTimeoutMs, setAutoArchiveTimeoutMs } = useAutoArchiveTimeout() // Voice language state - read from localStorage const [voiceLanguage, setVoiceLanguage] = useState(() => { @@ -91,9 +95,11 @@ export default function SettingsPage() { const fontScaleOptions = getFontScaleOptions() const appearanceOptions = getAppearanceOptions() + const autoArchiveOptions = getAutoArchiveTimeoutOptions() const currentLocale = locales.find((loc) => loc.value === locale) const currentAppearanceLabel = appearanceOptions.find((opt) => opt.value === appearance)?.labelKey ?? 'settings.display.appearance.system' const currentFontScaleLabel = fontScaleOptions.find((opt) => opt.value === fontScale)?.label ?? '100%' + const currentAutoArchiveLabel = autoArchiveOptions.find((opt) => opt.value === autoArchiveTimeoutMs)?.labelKey ?? 'settings.display.autoArchive.off' const currentVoiceLanguage = voiceLanguages.find((lang) => lang.code === voiceLanguage) const handleLocaleChange = (newLocale: Locale) => { @@ -111,6 +117,11 @@ export default function SettingsPage() { setIsFontOpen(false) } + const handleAutoArchiveTimeoutChange = (timeoutMs: AutoArchiveTimeoutMs) => { + setAutoArchiveTimeoutMs(timeoutMs) + setIsAutoArchiveOpen(false) + } + const handleVoiceLanguageChange = (language: Language) => { setVoiceLanguage(language.code) if (language.code === null) { @@ -123,7 +134,7 @@ export default function SettingsPage() { // Close dropdown when clicking outside useEffect(() => { - if (!isOpen && !isAppearanceOpen && !isFontOpen && !isVoiceOpen) return + if (!isOpen && !isAppearanceOpen && !isFontOpen && !isAutoArchiveOpen && !isVoiceOpen) return const handleClickOutside = (event: MouseEvent) => { if (isOpen && containerRef.current && !containerRef.current.contains(event.target as Node)) { @@ -135,6 +146,9 @@ export default function SettingsPage() { if (isFontOpen && fontContainerRef.current && !fontContainerRef.current.contains(event.target as Node)) { setIsFontOpen(false) } + if (isAutoArchiveOpen && autoArchiveContainerRef.current && !autoArchiveContainerRef.current.contains(event.target as Node)) { + setIsAutoArchiveOpen(false) + } if (isVoiceOpen && voiceContainerRef.current && !voiceContainerRef.current.contains(event.target as Node)) { setIsVoiceOpen(false) } @@ -142,24 +156,25 @@ export default function SettingsPage() { document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, [isOpen, isAppearanceOpen, isFontOpen, isVoiceOpen]) + }, [isOpen, isAppearanceOpen, isFontOpen, isAutoArchiveOpen, isVoiceOpen]) // Close on escape key useEffect(() => { - if (!isOpen && !isAppearanceOpen && !isFontOpen && !isVoiceOpen) return + if (!isOpen && !isAppearanceOpen && !isFontOpen && !isAutoArchiveOpen && !isVoiceOpen) return const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { setIsOpen(false) setIsAppearanceOpen(false) setIsFontOpen(false) + setIsAutoArchiveOpen(false) setIsVoiceOpen(false) } } document.addEventListener('keydown', handleEscape) return () => document.removeEventListener('keydown', handleEscape) - }, [isOpen, isAppearanceOpen, isFontOpen, isVoiceOpen]) + }, [isOpen, isAppearanceOpen, isFontOpen, isAutoArchiveOpen, isVoiceOpen]) return (
@@ -334,6 +349,59 @@ export default function SettingsPage() {
)} +
+ + + {isAutoArchiveOpen && ( +
+ {autoArchiveOptions.map((opt) => { + const isSelected = autoArchiveTimeoutMs === opt.value + return ( + + ) + })} +
+ )} +
{/* Voice Assistant section */}