diff --git a/hub/src/store/messages.ts b/hub/src/store/messages.ts index bb850c0c3..2f4250cd4 100644 --- a/hub/src/store/messages.ts +++ b/hub/src/store/messages.ts @@ -64,6 +64,16 @@ export function addMessage( local_id: localId ?? null }) + db.prepare(` + UPDATE sessions + SET updated_at = CASE WHEN updated_at > @updated_at THEN updated_at ELSE @updated_at END, + seq = seq + 1 + WHERE id = @session_id + `).run({ + session_id: sessionId, + updated_at: now + }) + const row = db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as DbMessageRow | undefined if (!row) { throw new Error('Failed to create message') @@ -154,6 +164,20 @@ export function mergeSessionMessages( 'UPDATE messages SET session_id = ? WHERE session_id = ?' ).run(toSessionId, fromSessionId) + db.prepare(` + UPDATE sessions + SET updated_at = ( + SELECT MAX(value) + FROM ( + SELECT updated_at AS value FROM sessions WHERE id = ? + UNION ALL + SELECT COALESCE(MAX(created_at), 0) AS value FROM messages WHERE session_id = ? + ) + ), + seq = seq + 1 + WHERE id = ? + `).run(toSessionId, toSessionId, toSessionId) + db.exec('COMMIT') return { moved: result.changes, oldMaxSeq, newMaxSeq } } catch (error) { diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 8b68c560b..035c884c2 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -52,6 +52,65 @@ describe('session model', () => { expect(toSessionSummary(session).effort).toBe('high') }) + it('updates session timestamp when a message is stored', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-message-time', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + const before = store.sessions.getSession(session.id) + await new Promise((resolve) => setTimeout(resolve, 2)) + + const message = store.messages.addMessage(session.id, { role: 'assistant', content: 'hello' }) + const after = store.sessions.getSession(session.id) + + expect(after?.updatedAt).toBe(message.createdAt) + expect(after?.updatedAt ?? 0).toBeGreaterThan(before?.updatedAt ?? 0) + }) + + it('broadcasts a session update when a message is received', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + const events: SyncEvent[] = [] + const unsubscribe = engine.subscribe((event) => events.push(event)) + + try { + const session = engine.getOrCreateSession( + 'session-message-event-time', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + await new Promise((resolve) => setTimeout(resolve, 2)) + const message = store.messages.addMessage(session.id, { role: 'assistant', content: 'hello' }) + + engine.handleRealtimeEvent({ + type: 'message-received', + sessionId: session.id, + message + }) + + const sessionUpdated = events.find((event) => + event.type === 'session-updated' && event.sessionId === session.id + ) + expect(sessionUpdated).toBeDefined() + expect(engine.getSession(session.id)?.updatedAt).toBe(message.createdAt) + } finally { + unsubscribe() + engine.stop() + } + }) + it('persists explicit model reasoning effort on Codex sessions', () => { const store = new Store(':memory:') const events: SyncEvent[] = [] diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index b4d391005..a3a623912 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -182,9 +182,7 @@ export class SyncEngine { } if (event.type === 'message-received' && event.sessionId) { - if (!this.getSession(event.sessionId)) { - this.sessionCache.refreshSession(event.sessionId) - } + this.sessionCache.refreshSession(event.sessionId) } this.eventPublisher.emit(event) diff --git a/web/src/App.tsx b/web/src/App.tsx index 7ab0798c2..d7289b6fe 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -239,30 +239,43 @@ function AppInner() { }) }, [addToast]) - const eventSubscription = useMemo(() => { - if (selectedSessionId) { - return { sessionId: selectedSessionId } - } - return { all: true } - }, [selectedSessionId]) + const globalEventSubscription = useMemo(() => ({ all: true }), []) + const sessionEventSubscription = useMemo( + () => (selectedSessionId ? { sessionId: selectedSessionId } : undefined), + [selectedSessionId] + ) const { subscriptionId } = useSSE({ enabled: Boolean(api && token), token: token ?? '', baseUrl, - subscription: eventSubscription, + subscription: globalEventSubscription, onConnect: handleSseConnect, onDisconnect: handleSseDisconnect, onEvent: handleSseEvent, onToast: handleToast }) + const { subscriptionId: sessionSubscriptionId } = useSSE({ + enabled: Boolean(api && token && selectedSessionId), + token: token ?? '', + baseUrl, + subscription: sessionEventSubscription, + onEvent: handleSseEvent + }) + useVisibilityReporter({ api, subscriptionId, enabled: Boolean(api && token) }) + useVisibilityReporter({ + api, + subscriptionId: sessionSubscriptionId, + enabled: Boolean(api && token && selectedSessionId) + }) + // Loading auth source if (isAuthSourceLoading) { return ( diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 115cc266e..128cc8ecf 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -152,6 +152,39 @@ function groupByMachine( }) } +const SESSION_LIST_LAST_SEEN_KEY = 'hapi.session-list.last-seen.v1' + +function loadLastSeenUpdatedAt(): Record { + if (typeof window === 'undefined') { + return {} + } + try { + const raw = window.localStorage.getItem(SESSION_LIST_LAST_SEEN_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) as Record + const next: Record = {} + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === 'number' && Number.isFinite(value)) { + next[key] = value + } + } + return next + } catch { + return {} + } +} + +function persistLastSeenUpdatedAt(value: Record) { + if (typeof window === 'undefined') { + return + } + try { + window.localStorage.setItem(SESSION_LIST_LAST_SEEN_KEY, JSON.stringify(value)) + } catch { + // Ignore storage failures. + } +} + function CopyPathButton({ path, className }: { path: string; className?: string }) { const [copied, setCopied] = useState(false) const timerRef = useRef>(undefined) @@ -181,6 +214,16 @@ function CopyPathButton({ path, className }: { path: string; className?: string ) } +function UnreadDot({ className }: { className?: string }) { + return ( + + ) +} + function PlusIcon(props: { className?: string }) { return ( ({ x: 0, y: 0 }) @@ -399,6 +443,9 @@ function SessionItem(props: {
{sessionName}
+ {unread ? ( + + ) : null} {s.active && s.thinking ? ( ) : null} @@ -489,9 +536,71 @@ export function SessionList(props: { () => groupSessionsByDirectory(deduplicateSessionsByAgentId(props.sessions, selectedSessionId)), [props.sessions, selectedSessionId] ) + const [lastSeenUpdatedAtBySession, setLastSeenUpdatedAtBySession] = useState>( + () => loadLastSeenUpdatedAt() + ) const [collapseOverrides, setCollapseOverrides] = useState>( () => new Map() ) + + useEffect(() => { + setLastSeenUpdatedAtBySession((prev) => { + const next: Record = {} + let changed = false + + for (const session of props.sessions) { + const previousValue = prev[session.id] + if (typeof previousValue === 'number' && Number.isFinite(previousValue)) { + next[session.id] = previousValue + } else { + next[session.id] = session.updatedAt + changed = true + } + } + + if (!changed && Object.keys(prev).length !== Object.keys(next).length) { + changed = true + } + + return changed ? next : prev + }) + }, [props.sessions]) + + useEffect(() => { + if (!selectedSessionId) return + const selectedSession = props.sessions.find((session) => session.id === selectedSessionId) + if (!selectedSession) return + + setLastSeenUpdatedAtBySession((prev) => { + const previousValue = prev[selectedSession.id] ?? 0 + if (previousValue >= selectedSession.updatedAt) { + return prev + } + return { + ...prev, + [selectedSession.id]: selectedSession.updatedAt + } + }) + }, [props.sessions, selectedSessionId]) + + useEffect(() => { + persistLastSeenUpdatedAt(lastSeenUpdatedAtBySession) + }, [lastSeenUpdatedAtBySession]) + + const unreadSessionIds = useMemo(() => { + const next = new Set() + for (const session of props.sessions) { + if (session.id === selectedSessionId) { + continue + } + const lastSeenUpdatedAt = lastSeenUpdatedAtBySession[session.id] ?? session.updatedAt + if (session.updatedAt > lastSeenUpdatedAt) { + next.add(session.id) + } + } + return next + }, [lastSeenUpdatedAtBySession, props.sessions, selectedSessionId]) + const isGroupCollapsed = (group: SessionGroup): boolean => { const override = collapseOverrides.get(group.key) if (override !== undefined) return override @@ -524,6 +633,26 @@ export function SessionList(props: { [groups, machineLabelsById] // eslint-disable-line react-hooks/exhaustive-deps ) + const groupHasUnread = useMemo(() => { + const map = new Map() + for (const group of groups) { + map.set(group.key, group.sessions.some((session) => unreadSessionIds.has(session.id))) + } + return map + }, [groups, unreadSessionIds]) + + const machineHasUnread = useMemo(() => { + const map = new Map() + for (const machineGroup of machineGroups) { + const key = machineGroup.machineId ?? UNKNOWN_MACHINE_ID + map.set( + key, + machineGroup.projectGroups.some((group) => groupHasUnread.get(group.key)) + ) + } + return map + }, [groupHasUnread, machineGroups]) + const isMachineCollapsed = (mg: MachineGroup): boolean => { const key = `machine::${mg.machineId ?? UNKNOWN_MACHINE_ID}` const override = collapseOverrides.get(key) @@ -611,6 +740,7 @@ export function SessionList(props: {
{machineGroups.map((mg) => { const machineCollapsed = isMachineCollapsed(mg) + const hasUnreadInMachine = machineHasUnread.get(mg.machineId ?? UNKNOWN_MACHINE_ID) === true return (
{/* Level 1: Machine */} @@ -622,6 +752,9 @@ export function SessionList(props: { {mg.label} + {hasUnreadInMachine ? ( + + ) : null} ({mg.totalSessions}) @@ -631,6 +764,7 @@ export function SessionList(props: {
{mg.projectGroups.map((group) => { const isCollapsed = isGroupCollapsed(group) + const hasUnreadInGroup = groupHasUnread.get(group.key) === true return (
{group.displayName} + {hasUnreadInGroup ? ( + + ) : null} ({group.sessions.length}) @@ -660,6 +797,7 @@ export function SessionList(props: { showPath={false} api={api} selected={s.id === selectedSessionId} + unread={unreadSessionIds.has(s.id)} /> ))}
diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index 7b43260c0..7f4787098 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -499,6 +499,9 @@ export function useSSE(options: { if (event.type === 'message-received') { ingestIncomingMessages(event.sessionId, [event.message]) + const patch = { updatedAt: event.message.createdAt } + patchSessionDetail(event.sessionId, patch) + patchSessionSummary(event.sessionId, patch) } if (event.type === 'session-added' || event.type === 'session-updated' || event.type === 'session-removed') {