mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-08 07:17:39 +00:00
feat(web): session list status indicators (attention + scheduled) (#699)
This commit is contained in:
@@ -6,6 +6,7 @@ describe('useSSE scope handling', () => {
|
||||
expect(isGlobalScopedMessageStreamEvent('global', 'message-received')).toBe(true)
|
||||
expect(isGlobalScopedMessageStreamEvent('global', 'messages-consumed')).toBe(true)
|
||||
expect(isGlobalScopedMessageStreamEvent('global', 'message-cancelled')).toBe(true)
|
||||
expect(isGlobalScopedMessageStreamEvent('global', 'scheduled-matured')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not skip session lifecycle events on the global connection', () => {
|
||||
|
||||
+19
-4
@@ -26,7 +26,8 @@ export type SSEScope = 'global' | 'full'
|
||||
const MESSAGE_STREAM_EVENT_TYPES = new Set<SyncEvent['type']>([
|
||||
'message-received',
|
||||
'messages-consumed',
|
||||
'message-cancelled'
|
||||
'message-cancelled',
|
||||
'scheduled-matured'
|
||||
])
|
||||
|
||||
export function isGlobalScopedMessageStreamEvent(scope: SSEScope, eventType: SyncEvent['type']): boolean {
|
||||
@@ -299,9 +300,13 @@ export function useSSE(options: {
|
||||
return previous
|
||||
}
|
||||
|
||||
const summary = toSessionSummary(session)
|
||||
const existingIndex = previous.sessions.findIndex((item) => item.id === session.id)
|
||||
const existing = existingIndex >= 0 ? previous.sessions[existingIndex] : undefined
|
||||
const summary = {
|
||||
...toSessionSummary(session),
|
||||
futureScheduledMessageCount: existing?.futureScheduledMessageCount ?? 0
|
||||
}
|
||||
const nextSessions = previous.sessions.slice()
|
||||
const existingIndex = nextSessions.findIndex((item) => item.id === session.id)
|
||||
if (existingIndex >= 0) {
|
||||
nextSessions[existingIndex] = summary
|
||||
} else {
|
||||
@@ -336,6 +341,9 @@ export function useSSE(options: {
|
||||
thinking: patch.thinking ?? current.thinking,
|
||||
activeAt: patch.activeAt ?? current.activeAt,
|
||||
updatedAt: patch.updatedAt ?? current.updatedAt,
|
||||
backgroundTaskCount: Object.prototype.hasOwnProperty.call(patch, 'backgroundTaskCount')
|
||||
? patch.backgroundTaskCount ?? 0
|
||||
: current.backgroundTaskCount,
|
||||
model: Object.prototype.hasOwnProperty.call(patch, 'model') ? patch.model ?? null : current.model,
|
||||
effort: Object.prototype.hasOwnProperty.call(patch, 'effort') ? patch.effort ?? null : current.effort
|
||||
}
|
||||
@@ -440,7 +448,14 @@ export function useSSE(options: {
|
||||
}
|
||||
|
||||
if (scope === 'global' && MESSAGE_STREAM_EVENT_TYPES.has(event.type)) {
|
||||
if (event.type === 'message-received') {
|
||||
if (event.type === 'message-received' && event.message.scheduledAt != null) {
|
||||
queueSessionListInvalidation()
|
||||
}
|
||||
if (
|
||||
event.type === 'message-cancelled'
|
||||
|| event.type === 'messages-consumed'
|
||||
|| event.type === 'scheduled-matured'
|
||||
) {
|
||||
queueSessionListInvalidation()
|
||||
}
|
||||
onEventRef.current(event)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_SESSION_LIST_STATUS_MODE,
|
||||
getInitialSessionListStatusMode,
|
||||
getSessionListStatusModeOptions,
|
||||
} from './useSessionListStatusMode'
|
||||
|
||||
describe('useSessionListStatusMode helpers', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('returns the allowed session list status mode options', () => {
|
||||
expect(getSessionListStatusModeOptions()).toEqual([
|
||||
{ value: 'standard', labelKey: 'settings.display.sessionListStatus.standard' },
|
||||
{ value: 'detailed', labelKey: 'settings.display.sessionListStatus.detailed' },
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the default mode for missing or invalid storage values', () => {
|
||||
expect(getInitialSessionListStatusMode()).toBe(DEFAULT_SESSION_LIST_STATUS_MODE)
|
||||
|
||||
window.localStorage.setItem('hapi-session-list-status-mode', 'invalid')
|
||||
expect(getInitialSessionListStatusMode()).toBe(DEFAULT_SESSION_LIST_STATUS_MODE)
|
||||
})
|
||||
|
||||
it('reads a valid stored session list status mode', () => {
|
||||
window.localStorage.setItem('hapi-session-list-status-mode', 'detailed')
|
||||
|
||||
expect(getInitialSessionListStatusMode()).toBe('detailed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
export type SessionListStatusMode = 'standard' | 'detailed'
|
||||
|
||||
export const DEFAULT_SESSION_LIST_STATUS_MODE: SessionListStatusMode = 'standard'
|
||||
|
||||
export function getSessionListStatusModeOptions(): ReadonlyArray<{ value: SessionListStatusMode; labelKey: string }> {
|
||||
return [
|
||||
{ value: 'standard', labelKey: 'settings.display.sessionListStatus.standard' },
|
||||
{ value: 'detailed', labelKey: 'settings.display.sessionListStatus.detailed' },
|
||||
]
|
||||
}
|
||||
|
||||
function getSessionListStatusModeStorageKey(): string {
|
||||
return 'hapi-session-list-status-mode'
|
||||
}
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== 'undefined' && typeof document !== 'undefined'
|
||||
}
|
||||
|
||||
function safeGetItem(key: string): string | null {
|
||||
if (!isBrowser()) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function safeSetItem(key: string, value: string): void {
|
||||
if (!isBrowser()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
function safeRemoveItem(key: string): void {
|
||||
if (!isBrowser()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
function parseSessionListStatusMode(raw: string | null): SessionListStatusMode {
|
||||
if (raw === 'standard' || raw === 'detailed') {
|
||||
return raw
|
||||
}
|
||||
return DEFAULT_SESSION_LIST_STATUS_MODE
|
||||
}
|
||||
|
||||
export function getInitialSessionListStatusMode(): SessionListStatusMode {
|
||||
return parseSessionListStatusMode(safeGetItem(getSessionListStatusModeStorageKey()))
|
||||
}
|
||||
|
||||
export function useSessionListStatusMode(): {
|
||||
sessionListStatusMode: SessionListStatusMode
|
||||
setSessionListStatusMode: (mode: SessionListStatusMode) => void
|
||||
} {
|
||||
const [sessionListStatusMode, setSessionListStatusModeState] = useState<SessionListStatusMode>(getInitialSessionListStatusMode)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBrowser()) {
|
||||
return
|
||||
}
|
||||
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== getSessionListStatusModeStorageKey()) {
|
||||
return
|
||||
}
|
||||
setSessionListStatusModeState(parseSessionListStatusMode(event.newValue))
|
||||
}
|
||||
|
||||
window.addEventListener('storage', onStorage)
|
||||
return () => window.removeEventListener('storage', onStorage)
|
||||
}, [])
|
||||
|
||||
const setSessionListStatusMode = useCallback((mode: SessionListStatusMode) => {
|
||||
setSessionListStatusModeState(mode)
|
||||
|
||||
if (mode === DEFAULT_SESSION_LIST_STATUS_MODE) {
|
||||
safeRemoveItem(getSessionListStatusModeStorageKey())
|
||||
} else {
|
||||
safeSetItem(getSessionListStatusModeStorageKey(), mode)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { sessionListStatusMode, setSessionListStatusMode }
|
||||
}
|
||||
Reference in New Issue
Block a user