fix(web): initialize session unread baseline (#1346)

* fix(web): initialize session unread baseline

* fix: complete unread baseline migration

* test: restore standard CLI coverage

* fix(web): scope unread baseline by hub
This commit is contained in:
SSU-WEI HUANG
2026-08-04 08:05:34 +08:00
committed by GitHub
parent 99f4ca471d
commit cc8cc914bc
3 changed files with 67 additions and 3 deletions
+33 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, beforeEach, vi } from 'vitest' import { describe, expect, it, beforeEach, vi } from 'vitest'
import { getSessionLastSeenAt, markSessionSeen } from './sessionLastSeen' import { getSessionLastSeenAt, initializeSessionLastSeen, markSessionSeen } from './sessionLastSeen'
describe('sessionLastSeen', () => { describe('sessionLastSeen', () => {
beforeEach(() => { beforeEach(() => {
@@ -18,6 +18,38 @@ describe('sessionLastSeen', () => {
expect(getSessionLastSeenAt('session-a')).toBe(5000) 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', () => { it('ignores localStorage write failures', () => {
const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota exceeded') throw new Error('quota exceeded')
+23
View File
@@ -1,4 +1,5 @@
const STORAGE_KEY = 'hapi.sessionLastSeen.v1' const STORAGE_KEY = 'hapi.sessionLastSeen.v1'
const BASELINE_KEY = 'hapi.sessionLastSeenBaseline.v1'
type LastSeenStore = Record<string, number> type LastSeenStore = Record<string, number>
@@ -50,6 +51,28 @@ export function getSessionLastSeenAt(sessionId: string): number {
return readStore()[sessionId] ?? 0 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 { export function markSessionSeen(sessionId: string, seenAt: number): void {
if (!sessionId) { if (!sessionId) {
return return
+11 -2
View File
@@ -43,7 +43,7 @@ import { useTranslation } from '@/lib/use-translation'
import { seedMessageWindowFromSession, syncTailMessages } from '@/lib/message-window-store' import { seedMessageWindowFromSession, syncTailMessages } from '@/lib/message-window-store'
import { clearDraftsAfterSend } from '@/lib/clearDraftsAfterSend' import { clearDraftsAfterSend } from '@/lib/clearDraftsAfterSend'
import { inactiveSessionCanResume } from '@/lib/sessionResume' import { inactiveSessionCanResume } from '@/lib/sessionResume'
import { markSessionSeen } from '@/lib/sessionLastSeen' import { initializeSessionLastSeen, markSessionSeen } from '@/lib/sessionLastSeen'
import { useSessionBrowserTitle } from '@/hooks/useSessionBrowserTitle' import { useSessionBrowserTitle } from '@/hooks/useSessionBrowserTitle'
import { clearCodexImportedSession } from '@/lib/codexImportedSessions' import { clearCodexImportedSession } from '@/lib/codexImportedSessions'
import { getSupersedingSessionId, shouldFollowSupersedingSession } from '@/routes/sessions/followSupersedingSession' import { getSupersedingSessionId, shouldFollowSupersedingSession } from '@/routes/sessions/followSupersedingSession'
@@ -147,13 +147,14 @@ function SettingsIcon(props: { className?: string }) {
} }
function SessionsPage() { function SessionsPage() {
const { api } = useAppContext() const { api, baseUrl } = useAppContext()
const navigate = useNavigate() const navigate = useNavigate()
const pathname = useLocation({ select: location => location.pathname }) const pathname = useLocation({ select: location => location.pathname })
const matchRoute = useMatchRoute() const matchRoute = useMatchRoute()
const { t } = useTranslation() const { t } = useTranslation()
const { addToast } = useToast() const { addToast } = useToast()
const { sessions, isLoading, error, refetch } = useSessions(api) const { sessions, isLoading, error, refetch } = useSessions(api)
const [initializedHub, setInitializedHub] = useState<string | null>(null)
const { machines } = useMachines(api, true) const { machines } = useMachines(api, true)
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
return (async () => { return (async () => {
@@ -190,6 +191,13 @@ function SessionsPage() {
() => selectedSessionId ? sessions.find((session) => session.id === selectedSessionId) ?? null : null, () => selectedSessionId ? sessions.find((session) => session.id === selectedSessionId) ?? null : null,
[selectedSessionId, sessions] [selectedSessionId, sessions]
) )
useEffect(() => {
if (isLoading || error) {
return
}
initializeSessionLastSeen(baseUrl, sessions)
setInitializedHub(baseUrl)
}, [baseUrl, error, isLoading, sessions])
useEffect(() => { useEffect(() => {
if (!selectedSessionId || !selectedSession) { if (!selectedSessionId || !selectedSession) {
return return
@@ -221,6 +229,7 @@ function SessionsPage() {
</div> </div>
) : null} ) : null}
<SessionList <SessionList
key={initializedHub === baseUrl ? 'last-seen-ready' : 'last-seen-pending'}
sessions={sessions} sessions={sessions}
selectedSessionId={selectedSessionId} selectedSessionId={selectedSessionId}
onSelect={(sessionId) => navigate({ onSelect={(sessionId) => navigate({