diff --git a/web/src/lib/sessionLastSeen.test.ts b/web/src/lib/sessionLastSeen.test.ts index 2ce40c20..07cbad6d 100644 --- a/web/src/lib/sessionLastSeen.test.ts +++ b/web/src/lib/sessionLastSeen.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach, vi } from 'vitest' -import { getSessionLastSeenAt, markSessionSeen } from './sessionLastSeen' +import { getSessionLastSeenAt, initializeSessionLastSeen, markSessionSeen } from './sessionLastSeen' describe('sessionLastSeen', () => { beforeEach(() => { @@ -18,6 +18,38 @@ describe('sessionLastSeen', () => { expect(getSessionLastSeenAt('session-a')).toBe(5000) }) + it('uses the first session list as the unread baseline', () => { + initializeSessionLastSeen('hub-a', [ + { id: 'session-a', updatedAt: 1000 }, + { id: 'session-b', updatedAt: 2500 }, + ]) + + expect(getSessionLastSeenAt('session-a')).toBe(1000) + expect(getSessionLastSeenAt('session-b')).toBe(2500) + }) + + it('preserves existing watermarks while completing a legacy partial baseline', () => { + markSessionSeen('session-a', 1000) + + initializeSessionLastSeen('hub-a', [ + { id: 'session-a', updatedAt: 2500 }, + { id: 'session-b', updatedAt: 2500 }, + ]) + + expect(getSessionLastSeenAt('session-a')).toBe(1000) + expect(getSessionLastSeenAt('session-b')).toBe(2500) + + initializeSessionLastSeen('hub-a', [{ id: 'session-c', updatedAt: 3000 }]) + expect(getSessionLastSeenAt('session-c')).toBe(0) + }) + + it('initializes each hub independently', () => { + initializeSessionLastSeen('hub-a', [{ id: 'session-a', updatedAt: 1000 }]) + initializeSessionLastSeen('hub-b', [{ id: 'session-b', updatedAt: 2000 }]) + + expect(getSessionLastSeenAt('session-b')).toBe(2000) + }) + it('ignores localStorage write failures', () => { const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('quota exceeded') diff --git a/web/src/lib/sessionLastSeen.ts b/web/src/lib/sessionLastSeen.ts index ccdcbed9..8bfcabd0 100644 --- a/web/src/lib/sessionLastSeen.ts +++ b/web/src/lib/sessionLastSeen.ts @@ -1,4 +1,5 @@ const STORAGE_KEY = 'hapi.sessionLastSeen.v1' +const BASELINE_KEY = 'hapi.sessionLastSeenBaseline.v1' type LastSeenStore = Record @@ -50,6 +51,28 @@ export function getSessionLastSeenAt(sessionId: string): number { return readStore()[sessionId] ?? 0 } +export function initializeSessionLastSeen(scope: string, sessions: Iterable<{ id: string; updatedAt: number }>): void { + const storage = getLocalStorage() + if (!storage) { + return + } + + try { + const baselineKey = `${BASELINE_KEY}:${scope}` + if (storage.getItem(baselineKey) === '1') { + return + } + const store = readStore() + for (const session of sessions) { + store[session.id] ??= session.updatedAt + } + storage.setItem(STORAGE_KEY, JSON.stringify(store)) + storage.setItem(baselineKey, '1') + } catch { + // Ignore storage errors + } +} + export function markSessionSeen(sessionId: string, seenAt: number): void { if (!sessionId) { return diff --git a/web/src/router.tsx b/web/src/router.tsx index 9526e4d8..99ccd152 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -43,7 +43,7 @@ import { useTranslation } from '@/lib/use-translation' import { seedMessageWindowFromSession, syncTailMessages } from '@/lib/message-window-store' import { clearDraftsAfterSend } from '@/lib/clearDraftsAfterSend' import { inactiveSessionCanResume } from '@/lib/sessionResume' -import { markSessionSeen } from '@/lib/sessionLastSeen' +import { initializeSessionLastSeen, markSessionSeen } from '@/lib/sessionLastSeen' import { useSessionBrowserTitle } from '@/hooks/useSessionBrowserTitle' import { clearCodexImportedSession } from '@/lib/codexImportedSessions' import { getSupersedingSessionId, shouldFollowSupersedingSession } from '@/routes/sessions/followSupersedingSession' @@ -147,13 +147,14 @@ function SettingsIcon(props: { className?: string }) { } function SessionsPage() { - const { api } = useAppContext() + const { api, baseUrl } = useAppContext() const navigate = useNavigate() const pathname = useLocation({ select: location => location.pathname }) const matchRoute = useMatchRoute() const { t } = useTranslation() const { addToast } = useToast() const { sessions, isLoading, error, refetch } = useSessions(api) + const [initializedHub, setInitializedHub] = useState(null) const { machines } = useMachines(api, true) const handleRefresh = useCallback(() => { return (async () => { @@ -190,6 +191,13 @@ function SessionsPage() { () => selectedSessionId ? sessions.find((session) => session.id === selectedSessionId) ?? null : null, [selectedSessionId, sessions] ) + useEffect(() => { + if (isLoading || error) { + return + } + initializeSessionLastSeen(baseUrl, sessions) + setInitializedHub(baseUrl) + }, [baseUrl, error, isLoading, sessions]) useEffect(() => { if (!selectedSessionId || !selectedSession) { return @@ -221,6 +229,7 @@ function SessionsPage() { ) : null} navigate({