diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 604a7ac9..fcbbf19c 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -57,7 +57,13 @@ import { } from './sidebar/ConfirmDialogs'; import { type SessionGroup, type SessionNode } from './sidebar/types'; import { + type ActiveNowEntry, + addActiveNowSession, + deriveActiveNowSessions, deriveLiveActiveNowSessions, + persistActiveNowEntries, + pruneActiveNowEntries, + readActiveNowEntries, } from './sidebar/activitySections'; import { compareSessionsByPinnedAndTime, @@ -129,6 +135,7 @@ export const SessionSidebar: React.FC = ({ () => new Map(), ); const safeStorage = React.useMemo(() => getSafeStorage(), []); + const [activeNowEntries, setActiveNowEntries] = React.useState(() => readActiveNowEntries(safeStorage)); const [collapsedProjects, setCollapsedProjects] = React.useState>(new Set()); const [projectRepoStatus, setProjectRepoStatus] = React.useState>(new Map()); @@ -1011,10 +1018,45 @@ export const SessionSidebar: React.FC = ({ }, [projectSections, homeDirectory]); const activeNowSessions = React.useMemo( + () => deriveActiveNowSessions(activeNowEntries, new Map(sessions.map((session) => [session.id, session]))), + [activeNowEntries, sessions], + ); + + const liveActiveSessions = React.useMemo( () => deriveLiveActiveNowSessions(sessions, liveSessionStatuses), [liveSessionStatuses, sessions], ); + React.useEffect(() => { + if (liveActiveSessions.length === 0) { + return; + } + + setActiveNowEntries((prev) => { + const next = liveActiveSessions.reduce((entries, session) => addActiveNowSession(entries, session.id), prev); + if (next === prev) { + return prev; + } + persistActiveNowEntries(safeStorage, next); + return next; + }); + }, [liveActiveSessions, safeStorage]); + + React.useEffect(() => { + const allKnownSessionsById = new Map(); + [...sessions, ...archivedSessions].forEach((session) => { + allKnownSessionsById.set(session.id, session); + }); + + const pruned = pruneActiveNowEntries(activeNowEntries, allKnownSessionsById); + if (pruned.length === activeNowEntries.length && pruned.every((entry, index) => entry.sessionId === activeNowEntries[index]?.sessionId)) { + return; + } + + setActiveNowEntries(pruned); + persistActiveNowEntries(safeStorage, pruned); + }, [activeNowEntries, archivedSessions, safeStorage, sessions]); + // Prefetch is wired below, after recentSessionIds is computed. const activitySections = React.useMemo(() => { diff --git a/packages/ui/src/sync/__tests__/live-aggregate.test.js b/packages/ui/src/sync/__tests__/live-aggregate.test.js index bfa83daa..2ba1a1d1 100644 --- a/packages/ui/src/sync/__tests__/live-aggregate.test.js +++ b/packages/ui/src/sync/__tests__/live-aggregate.test.js @@ -58,6 +58,27 @@ describe('live aggregate', () => { expect(findLiveSessionStatus(states, 'ses-2')?.type).toBe('retry') }) + it('lets a fresher idle snapshot override a stale busy status', () => { + const states = [ + { + session: [session('ses-1', '/a', 10)], + session_status: { + 'ses-1': { type: 'busy' }, + }, + }, + { + session: [session('ses-1', '/a', 30)], + session_status: { + 'ses-1': { type: 'idle' }, + }, + }, + ] + + const statuses = aggregateLiveSessionStatuses(states) + expect(statuses['ses-1']?.type).toBe('idle') + expect(findLiveSessionStatus(states, 'ses-1')?.type).toBe('idle') + }) + it('derives active-now sessions from live statuses instead of persisted history', () => { const sessions = [ session('ses-1', '/a', 20), diff --git a/packages/ui/src/sync/live-aggregate.ts b/packages/ui/src/sync/live-aggregate.ts index bbd65860..50358286 100644 --- a/packages/ui/src/sync/live-aggregate.ts +++ b/packages/ui/src/sync/live-aggregate.ts @@ -47,6 +47,36 @@ const getStatusMessage = (status: SessionStatus | undefined): string | null => { return typeof message === 'string' ? message : null } +type StatusCandidate = { + status: SessionStatus + sessionUpdatedAt: number +} + +const getStatusCandidate = (state: LiveStateSlice, sessionId: string): StatusCandidate | null => { + const status = state.session_status?.[sessionId] + if (!status) { + return null + } + + const session = state.session.find((candidate) => candidate.id === sessionId) + return { + status, + sessionUpdatedAt: session ? getSessionUpdatedAt(session) : -1, + } +} + +const shouldReplaceStatusCandidate = (current: StatusCandidate | undefined, next: StatusCandidate): boolean => { + if (!current) { + return true + } + + if (next.sessionUpdatedAt !== current.sessionUpdatedAt) { + return next.sessionUpdatedAt > current.sessionUpdatedAt + } + + return getStatusPriority(next.status) >= getStatusPriority(current.status) +} + export const areSessionListsEquivalent = (left: Session[], right: Session[]): boolean => { if (left === right) { return true @@ -116,17 +146,27 @@ export function aggregateLiveSessions(states: Iterable): Session } export function aggregateLiveSessionStatuses(states: Iterable): Record { - const statuses: Record = {} + const candidates = new Map() for (const state of states) { - for (const [sessionId, status] of Object.entries(state.session_status ?? {})) { - const current = statuses[sessionId] - if (!current || getStatusPriority(status) >= getStatusPriority(current)) { - statuses[sessionId] = status + for (const sessionId of Object.keys(state.session_status ?? {})) { + const next = getStatusCandidate(state, sessionId) + if (!next) { + continue + } + + const current = candidates.get(sessionId) + if (shouldReplaceStatusCandidate(current, next)) { + candidates.set(sessionId, next) } } } + const statuses: Record = {} + for (const [sessionId, candidate] of candidates) { + statuses[sessionId] = candidate.status + } + return statuses } @@ -157,16 +197,16 @@ export function findLiveSessionStatus( return undefined } - let match: SessionStatus | undefined + let match: StatusCandidate | undefined for (const state of states) { - const status = state.session_status?.[sessionID] - if (!status) { + const next = getStatusCandidate(state, sessionID) + if (!next) { continue } - if (!match || getStatusPriority(status) >= getStatusPriority(match)) { - match = status + if (shouldReplaceStatusCandidate(match, next)) { + match = next } } - return match + return match?.status }