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 { 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')
|
||||||
|
|||||||
@@ -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
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user