Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
129 changes: 129 additions & 0 deletions web/src/hooks/useAutoArchiveTimeout.ts
Original file line number Diff line number Diff line change
@@ -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<AutoArchiveTimeoutMs>(
() => 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<string>())

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])
}
7 changes: 7 additions & 0 deletions web/src/lib/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 7 additions & 0 deletions web/src/lib/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': '自动检测',
Expand Down
33 changes: 32 additions & 1 deletion web/src/routes/settings/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -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: () => [
Expand Down Expand Up @@ -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(<SettingsPage />)
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(<SettingsPage />)

fireEvent.click(screen.getAllByRole('button', { name: /Auto-Archive Timeout/i })[0])
fireEvent.click(screen.getByRole('option', { name: '15 minutes' }))

expect(setAutoArchiveTimeoutMs).toHaveBeenCalledWith(900000)
})
})
76 changes: 72 additions & 4 deletions web/src/routes/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[] = [
Expand Down Expand Up @@ -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<HTMLDivElement>(null)
const appearanceContainerRef = useRef<HTMLDivElement>(null)
const fontContainerRef = useRef<HTMLDivElement>(null)
const autoArchiveContainerRef = useRef<HTMLDivElement>(null)
const voiceContainerRef = useRef<HTMLDivElement>(null)
const { fontScale, setFontScale } = useFontScale()
const { appearance, setAppearance } = useAppearance()
const { autoArchiveTimeoutMs, setAutoArchiveTimeoutMs } = useAutoArchiveTimeout()

// Voice language state - read from localStorage
const [voiceLanguage, setVoiceLanguage] = useState<string | null>(() => {
Expand All @@ -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) => {
Expand All @@ -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) {
Expand All @@ -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)) {
Expand All @@ -135,31 +146,35 @@ 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)
}
}

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 (
<div className="flex h-full flex-col">
Expand Down Expand Up @@ -334,6 +349,59 @@ export default function SettingsPage() {
</div>
)}
</div>
<div ref={autoArchiveContainerRef} className="relative">
<button
type="button"
onClick={() => setIsAutoArchiveOpen(!isAutoArchiveOpen)}
className="flex w-full items-center justify-between px-3 py-3 text-left transition-colors hover:bg-[var(--app-subtle-bg)]"
aria-expanded={isAutoArchiveOpen}
aria-haspopup="listbox"
>
<div className="min-w-0">
<div className="text-[var(--app-fg)]">{t('settings.display.autoArchive')}</div>
<div className="mt-0.5 text-xs text-[var(--app-hint)]">
{t('settings.display.autoArchive.description')}
</div>
</div>
<span className="ml-3 flex shrink-0 items-center gap-1 text-[var(--app-hint)]">
<span>{t(currentAutoArchiveLabel)}</span>
<ChevronDownIcon className={`transition-transform ${isAutoArchiveOpen ? 'rotate-180' : ''}`} />
</span>
</button>

{isAutoArchiveOpen && (
<div
className="absolute right-3 top-full mt-1 min-w-[180px] rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] shadow-lg overflow-hidden z-50"
role="listbox"
aria-label={t('settings.display.autoArchive')}
>
{autoArchiveOptions.map((opt) => {
const isSelected = autoArchiveTimeoutMs === opt.value
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={isSelected}
onClick={() => handleAutoArchiveTimeoutChange(opt.value)}
className={`flex items-center justify-between w-full px-3 py-2 text-base text-left transition-colors ${
isSelected
? 'text-[var(--app-link)] bg-[var(--app-subtle-bg)]'
: 'text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]'
}`}
>
<span>{t(opt.labelKey)}</span>
{isSelected && (
<span className="ml-2 text-[var(--app-link)]">
<CheckIcon />
</span>
)}
</button>
)
})}
</div>
)}
</div>
</div>

{/* Voice Assistant section */}
Expand Down