fix(sync): prefer freshest session status and persist active-now list

This commit is contained in:
Bohdan Triapitsyn
2026-04-16 19:16:32 +03:00
parent 16ec1f605e
commit af7cf6ec9c
3 changed files with 114 additions and 11 deletions
@@ -57,7 +57,13 @@ import {
} from './sidebar/ConfirmDialogs'; } from './sidebar/ConfirmDialogs';
import { type SessionGroup, type SessionNode } from './sidebar/types'; import { type SessionGroup, type SessionNode } from './sidebar/types';
import { import {
type ActiveNowEntry,
addActiveNowSession,
deriveActiveNowSessions,
deriveLiveActiveNowSessions, deriveLiveActiveNowSessions,
persistActiveNowEntries,
pruneActiveNowEntries,
readActiveNowEntries,
} from './sidebar/activitySections'; } from './sidebar/activitySections';
import { import {
compareSessionsByPinnedAndTime, compareSessionsByPinnedAndTime,
@@ -129,6 +135,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
() => new Map(), () => new Map(),
); );
const safeStorage = React.useMemo(() => getSafeStorage(), []); const safeStorage = React.useMemo(() => getSafeStorage(), []);
const [activeNowEntries, setActiveNowEntries] = React.useState<ActiveNowEntry[]>(() => readActiveNowEntries(safeStorage));
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(new Set()); const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(new Set());
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map()); const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
@@ -1011,10 +1018,45 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}, [projectSections, homeDirectory]); }, [projectSections, homeDirectory]);
const activeNowSessions = React.useMemo( const activeNowSessions = React.useMemo(
() => deriveActiveNowSessions(activeNowEntries, new Map(sessions.map((session) => [session.id, session]))),
[activeNowEntries, sessions],
);
const liveActiveSessions = React.useMemo(
() => deriveLiveActiveNowSessions(sessions, liveSessionStatuses), () => deriveLiveActiveNowSessions(sessions, liveSessionStatuses),
[liveSessionStatuses, sessions], [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<string, Session>();
[...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. // Prefetch is wired below, after recentSessionIds is computed.
const activitySections = React.useMemo(() => { const activitySections = React.useMemo(() => {
@@ -58,6 +58,27 @@ describe('live aggregate', () => {
expect(findLiveSessionStatus(states, 'ses-2')?.type).toBe('retry') 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', () => { it('derives active-now sessions from live statuses instead of persisted history', () => {
const sessions = [ const sessions = [
session('ses-1', '/a', 20), session('ses-1', '/a', 20),
+51 -11
View File
@@ -47,6 +47,36 @@ const getStatusMessage = (status: SessionStatus | undefined): string | null => {
return typeof message === 'string' ? message : 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 => { export const areSessionListsEquivalent = (left: Session[], right: Session[]): boolean => {
if (left === right) { if (left === right) {
return true return true
@@ -116,17 +146,27 @@ export function aggregateLiveSessions(states: Iterable<LiveStateSlice>): Session
} }
export function aggregateLiveSessionStatuses(states: Iterable<LiveStateSlice>): Record<string, SessionStatus> { export function aggregateLiveSessionStatuses(states: Iterable<LiveStateSlice>): Record<string, SessionStatus> {
const statuses: Record<string, SessionStatus> = {} const candidates = new Map<string, StatusCandidate>()
for (const state of states) { for (const state of states) {
for (const [sessionId, status] of Object.entries(state.session_status ?? {})) { for (const sessionId of Object.keys(state.session_status ?? {})) {
const current = statuses[sessionId] const next = getStatusCandidate(state, sessionId)
if (!current || getStatusPriority(status) >= getStatusPriority(current)) { if (!next) {
statuses[sessionId] = status continue
}
const current = candidates.get(sessionId)
if (shouldReplaceStatusCandidate(current, next)) {
candidates.set(sessionId, next)
} }
} }
} }
const statuses: Record<string, SessionStatus> = {}
for (const [sessionId, candidate] of candidates) {
statuses[sessionId] = candidate.status
}
return statuses return statuses
} }
@@ -157,16 +197,16 @@ export function findLiveSessionStatus(
return undefined return undefined
} }
let match: SessionStatus | undefined let match: StatusCandidate | undefined
for (const state of states) { for (const state of states) {
const status = state.session_status?.[sessionID] const next = getStatusCandidate(state, sessionID)
if (!status) { if (!next) {
continue continue
} }
if (!match || getStatusPriority(status) >= getStatusPriority(match)) { if (shouldReplaceStatusCandidate(match, next)) {
match = status match = next
} }
} }
return match return match?.status
} }