mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const STORAGE_KEY = 'hapi.sessionLastSeen.v1'
|
||||
const BASELINE_KEY = 'hapi.sessionLastSeenBaseline.v1'
|
||||
|
||||
type LastSeenStore = Record<string, number>
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
-2
@@ -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<string | null>(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() {
|
||||
</div>
|
||||
) : null}
|
||||
<SessionList
|
||||
key={initializedHub === baseUrl ? 'last-seen-ready' : 'last-seen-pending'}
|
||||
sessions={sessions}
|
||||
selectedSessionId={selectedSessionId}
|
||||
onSelect={(sessionId) => navigate({
|
||||
|
||||
Reference in New Issue
Block a user