From 0986d27e78903393baac01cf91b998306ad8c533 Mon Sep 17 00:00:00 2001 From: webwww123 <197701451+webwww123@users.noreply.github.com> Date: Tue, 10 Mar 2026 06:15:25 +0100 Subject: [PATCH] feat(web): add unread session indicators --- web/src/components/SessionList.tsx | 50 ++++++++++++++++++-- web/src/lib/locales/en.ts | 1 + web/src/lib/locales/zh-CN.ts | 1 + web/src/lib/sessionReadState.test.ts | 41 +++++++++++++++++ web/src/lib/sessionReadState.ts | 69 ++++++++++++++++++++++++++++ 5 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/sessionReadState.test.ts create mode 100644 web/src/lib/sessionReadState.ts diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 69c71c37b..d797a6497 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -8,6 +8,13 @@ import { SessionActionMenu } from '@/components/SessionActionMenu' import { RenameSessionDialog } from '@/components/RenameSessionDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { useTranslation } from '@/lib/use-translation' +import { + getSessionReadState, + isSessionUnread, + markSessionReadInState, + persistSessionReadState, + type SessionReadState, +} from '@/lib/sessionReadState' type SessionGroup = { directory: string @@ -163,13 +170,14 @@ function formatRelativeTime(value: number, t: (key: string, params?: Record void showPath?: boolean api: ApiClient | null selected?: boolean }) { const { t } = useTranslation() - const { session: s, onSelect, showPath = true, api, selected = false } = props + const { session: s, unread, onSelect, showPath = true, api, selected = false } = props const { haptic } = usePlatform() const [menuOpen, setMenuOpen] = useState(false) const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) @@ -242,6 +250,11 @@ function SessionItem(props: { {t('session.item.pending')} {s.pendingRequestsCount} ) : null} + {unread ? ( + + {t('session.item.unread')} + + ) : null} {formatRelativeTime(s.updatedAt, t)} @@ -323,6 +336,7 @@ export function SessionList(props: { }) { const { t } = useTranslation() const { renderHeader = true, api, selectedSessionId } = props + const [readState, setReadState] = useState(() => getSessionReadState()) const groups = useMemo( () => groupSessionsByDirectory(props.sessions), [props.sessions] @@ -333,7 +347,10 @@ export function SessionList(props: { const isGroupCollapsed = (group: SessionGroup): boolean => { const override = collapseOverrides.get(group.directory) if (override !== undefined) return override - return !group.hasActiveSession + const hasUnread = group.sessions.some( + session => session.id !== selectedSessionId && isSessionUnread(session, readState) + ) + return !group.hasActiveSession && !hasUnread } const toggleGroup = (directory: string, isCollapsed: boolean) => { @@ -360,6 +377,32 @@ export function SessionList(props: { }) }, [groups]) + useEffect(() => { + if (!selectedSessionId) return + const selectedSession = props.sessions.find(session => session.id === selectedSessionId) + if (!selectedSession) return + + setReadState(prev => { + const next = markSessionReadInState(prev, selectedSession.id, selectedSession.updatedAt) + if (next === prev) return prev + persistSessionReadState(next) + return next + }) + }, [props.sessions, selectedSessionId]) + + const handleSelect = (sessionId: string) => { + const target = props.sessions.find(session => session.id === sessionId) + if (target) { + setReadState(prev => { + const next = markSessionReadInState(prev, target.id, target.updatedAt) + if (next === prev) return prev + persistSessionReadState(next) + return next + }) + } + props.onSelect(sessionId) + } + return (
{renderHeader ? ( @@ -407,7 +450,8 @@ export function SessionList(props: { = {}): SessionSummary { + return { + id: overrides.id ?? 'session-1', + updatedAt: overrides.updatedAt ?? 100, + activeAt: overrides.activeAt ?? 100, + active: overrides.active ?? false, + pendingRequestsCount: overrides.pendingRequestsCount ?? 0, + thinking: overrides.thinking ?? false, + modelMode: overrides.modelMode ?? undefined, + todoProgress: overrides.todoProgress ?? null, + metadata: { + path: '/root/project-a', + ...overrides.metadata, + }, + } +} + +describe('sessionReadState', () => { + it('marks a session as unread when it has newer updates than the stored read timestamp', () => { + const session = createSession({ updatedAt: 200 }) + + expect(isSessionUnread(session, { [session.id]: 150 })).toBe(true) + }) + + it('clears unread once the stored read timestamp catches up', () => { + const session = createSession({ updatedAt: 200 }) + const next = markSessionReadInState({}, session.id, session.updatedAt) + + expect(isSessionUnread(session, next)).toBe(false) + }) + + it('does not move the read timestamp backwards', () => { + const next = markSessionReadInState({ 'session-1': 300 }, 'session-1', 200) + + expect(next['session-1']).toBe(300) + }) +}) diff --git a/web/src/lib/sessionReadState.ts b/web/src/lib/sessionReadState.ts new file mode 100644 index 000000000..1b96ec4c9 --- /dev/null +++ b/web/src/lib/sessionReadState.ts @@ -0,0 +1,69 @@ +import type { SessionSummary } from '@/types/api' + +const SESSION_READ_STATE_KEY = 'hapi-session-read-state' +const MAX_STORED_SESSIONS = 500 + +export type SessionReadState = Record + +function safeParseJson(value: string): unknown { + try { + return JSON.parse(value) as unknown + } catch { + return null + } +} + +export function getSessionReadState(): SessionReadState { + if (typeof window === 'undefined') return {} + try { + const raw = localStorage.getItem(SESSION_READ_STATE_KEY) + if (!raw) return {} + const parsed = safeParseJson(raw) + if (!parsed || typeof parsed !== 'object') return {} + + const result: SessionReadState = {} + for (const [key, value] of Object.entries(parsed as Record)) { + if (typeof key !== 'string' || key.trim().length === 0) continue + if (typeof value !== 'number' || !Number.isFinite(value)) continue + result[key] = value + } + return result + } catch { + return {} + } +} + +export function markSessionReadInState( + state: SessionReadState, + sessionId: string, + updatedAt: number +): SessionReadState { + if (!sessionId || !Number.isFinite(updatedAt)) return state + const current = state[sessionId] ?? 0 + if (current >= updatedAt) return state + + const next = { + ...state, + [sessionId]: updatedAt + } + + return Object.fromEntries( + Object.entries(next) + .sort((left, right) => right[1] - left[1]) + .slice(0, MAX_STORED_SESSIONS) + ) +} + +export function persistSessionReadState(state: SessionReadState): void { + if (typeof window === 'undefined') return + try { + localStorage.setItem(SESSION_READ_STATE_KEY, JSON.stringify(state)) + } catch { + // Ignore storage errors + } +} + +export function isSessionUnread(session: SessionSummary, readState: SessionReadState): boolean { + const readAt = readState[session.id] ?? 0 + return session.updatedAt > readAt +}