feat(web): session list status indicators (attention + scheduled) (#699)

This commit is contained in:
HeavyGee
2026-05-29 15:43:21 +08:00
committed by GitHub
parent 7457a8fc32
commit ec3722aba9
33 changed files with 910 additions and 37 deletions
+1
View File
@@ -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
View File
@@ -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')
})
})
+99
View File
@@ -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 }
}