refactor(recent): replace active-now tracking with recent session window

Replace persisted 'active now' tracking with 48-hour recency window
Remove Zustand ActiveNowStore and localStorage persistence
Simplify session sidebar data flow
This commit is contained in:
Bohdan Triapitsyn
2026-06-12 14:34:33 +03:00
parent 28aeb4950b
commit c281937406
5 changed files with 50 additions and 212 deletions
@@ -7,7 +7,7 @@ import { isDesktopShell } from '@/lib/desktop';
import { sessionEvents } from '@/lib/sessionEvents';
import { formatDirectoryName, cn } from '@/lib/utils';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useAllLiveSessions, useAllSessionStatuses } from '@/sync/sync-context';
import { useAllLiveSessions } from '@/sync/sync-context';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSync } from '@/sync/use-sync';
import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch';
@@ -59,11 +59,9 @@ import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore'
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { type SessionGroup, type SessionNode } from './sidebar/types';
import {
deriveActiveNowSessions,
deriveLiveActiveNowSessions,
deriveRecentSessions,
getSessionUpdatedAtMs,
} from './sidebar/activitySections';
import { useActiveNowStore } from '@/stores/useActiveNowStore';
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
import {
compareSessionsByPinnedAndTime,
@@ -187,9 +185,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
() => new Map(),
);
const safeStorage = React.useMemo(() => getSafeStorage(), []);
const activeNowEntries = useActiveNowStore((state) => state.entries);
const addActiveNowSessionToStore = useActiveNowStore((state) => state.addSession);
const pruneActiveNowEntriesInStore = useActiveNowStore((state) => state.prune);
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(new Set());
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
@@ -315,7 +310,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const sync = useSync();
const liveSessions = useAllLiveSessions();
const liveSessionStatuses = useAllSessionStatuses();
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
@@ -1025,9 +1019,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return [];
}
return deriveActiveNowSessions(activeNowEntries, new Map(sessions.map((session) => [session.id, session])))
return deriveRecentSessions(sessions)
.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
}, [activeNowEntries, isVSCode, pinnedSessionIds, sessions, showRecentSection]);
}, [isVSCode, pinnedSessionIds, sessions, showRecentSection]);
const vscodeSharedSessions = React.useMemo(() => {
if (!isVSCode) {
@@ -1044,35 +1038,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
});
}, [isVSCode, sessions]);
const liveActiveSessions = React.useMemo(() => {
if (!showRecentSection) {
return [];
}
return deriveLiveActiveNowSessions(sessions, liveSessionStatuses);
}, [liveSessionStatuses, sessions, showRecentSection]);
React.useEffect(() => {
if (!showRecentSection || liveActiveSessions.length === 0) {
return;
}
liveActiveSessions.forEach((session) => addActiveNowSessionToStore(session.id));
}, [addActiveNowSessionToStore, liveActiveSessions, showRecentSection]);
React.useEffect(() => {
if (!showRecentSection) {
return;
}
const allKnownSessionsById = new Map<string, Session>();
[...sessions, ...archivedSessions].forEach((session) => {
allKnownSessionsById.set(session.id, session);
});
pruneActiveNowEntriesInStore(allKnownSessionsById);
}, [archivedSessions, pruneActiveNowEntriesInStore, sessions, showRecentSection]);
// Prefetch is wired below, after recentSessionIds is computed.
const activitySections = React.useMemo(() => {
@@ -42,29 +42,6 @@ export function SidebarActivitySections({
const [visibleCountBySection, setVisibleCountBySection] = React.useState<Map<string, number>>(new Map());
const flatVariant = variant === 'flat';
const toggleSection = React.useCallback((key: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}, []);
const showMoreSessions = React.useCallback((key: string, currentVisibleCount: number, totalCount: number) => {
setVisibleCountBySection((prev) => {
const nextVisibleCount = flatVariant
? Math.min(totalCount, currentVisibleCount + batchSize)
: totalCount;
const next = new Map(prev);
next.set(key, nextVisibleCount);
return next;
});
}, [batchSize, flatVariant]);
const resetSectionLimit = React.useCallback((key: string) => {
setVisibleCountBySection((prev) => {
if (!prev.has(key)) {
@@ -76,6 +53,30 @@ export function SidebarActivitySections({
});
}, []);
const toggleSection = React.useCallback((key: string) => {
// Collapsing/expanding resets any "show more" batches, matching the
// worktree/project group behavior.
resetSectionLimit(key);
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}, [resetSectionLimit]);
const showMoreSessions = React.useCallback((key: string, currentVisibleCount: number, totalCount: number) => {
setVisibleCountBySection((prev) => {
const nextVisibleCount = Math.min(totalCount, currentVisibleCount + batchSize);
const next = new Map(prev);
next.set(key, nextVisibleCount);
return next;
});
}, [batchSize]);
const visibleSections = sections.filter((section) => section.items.length > 0);
if (visibleSections.length === 0) {
return null;
@@ -132,9 +133,7 @@ export function SidebarActivitySections({
onClick={() => showMoreSessions(section.key, visibleItems.length, section.items.length)}
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
{remainingCount === 1
? t('sessions.sidebar.group.showMoreSingle', { count: remainingCount })
: t('sessions.sidebar.group.showMorePlural', { count: remainingCount })}
{t('sessions.sidebar.group.showMore')}
</button>
) : null}
{canShowFewer ? (
@@ -1,12 +1,6 @@
import type { Session } from '@opencode-ai/sdk/v2';
import type { SessionStatus } from '@opencode-ai/sdk/v2/client';
export const ACTIVE_NOW_STORAGE_KEY = 'oc.sessions.activeNow';
export const ACTIVE_NOW_MAX_AGE_MS = 36 * 60 * 60 * 1000;
export type ActiveNowEntry = {
sessionId: string;
};
export const RECENT_SESSION_MAX_AGE_MS = 48 * 60 * 60 * 1000;
const isSubtaskSession = (session: Session): boolean => {
return Boolean((session as Session & { parentID?: string | null }).parentID);
@@ -28,97 +22,25 @@ const getSessionUpdatedAt = (session: Session): number => {
return 0;
};
export const readActiveNowEntries = (storage: Storage): ActiveNowEntry[] => {
try {
const raw = storage.getItem(ACTIVE_NOW_STORAGE_KEY);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
const seen = new Set<string>();
const next: ActiveNowEntry[] = [];
parsed.forEach((item) => {
const sessionId = typeof item === 'string'
? item
: (item && typeof item === 'object' && 'sessionId' in item && typeof item.sessionId === 'string' ? item.sessionId : null);
if (!sessionId || seen.has(sessionId)) {
return;
}
seen.add(sessionId);
next.push({ sessionId });
});
return next;
} catch {
return [];
}
};
export const persistActiveNowEntries = (storage: Storage, entries: ActiveNowEntry[]): void => {
try {
storage.setItem(ACTIVE_NOW_STORAGE_KEY, JSON.stringify(entries));
} catch {
// ignored
}
};
export const pruneActiveNowEntries = (
entries: ActiveNowEntry[],
sessionsById: Map<string, Session>,
now = Date.now(),
): ActiveNowEntry[] => {
const minUpdatedAt = now - ACTIVE_NOW_MAX_AGE_MS;
return entries.filter((entry) => {
const session = sessionsById.get(entry.sessionId);
if (!session) {
return true;
}
if (isArchivedSession(session)) {
return false;
}
return getSessionUpdatedAt(session) >= minUpdatedAt;
});
};
export const addActiveNowSession = (entries: ActiveNowEntry[], sessionId: string): ActiveNowEntry[] => {
if (!sessionId || entries.some((entry) => entry.sessionId === sessionId)) {
return entries;
}
return [{ sessionId }, ...entries];
};
export const sortSessionsByUpdated = (sessions: Session[]): Session[] => {
return [...sessions].sort((a, b) => getSessionUpdatedAt(b) - getSessionUpdatedAt(a));
};
export const deriveActiveNowSessions = (
entries: ActiveNowEntry[],
sessionsById: Map<string, Session>,
): Session[] => {
const sessions = entries
.map((entry) => sessionsById.get(entry.sessionId) ?? null)
.filter((session): session is Session => Boolean(session))
.filter((session) => !isArchivedSession(session))
.filter((session) => !isSubtaskSession(session));
return sortSessionsByUpdated(sessions);
};
export const deriveLiveActiveNowSessions = (
// Recent sessions are simply every non-archived, top-level session updated
// within the last RECENT_SESSION_MAX_AGE_MS. No persisted history or live-busy
// tracking — membership is derived directly from session timestamps.
export const deriveRecentSessions = (
sessions: Session[],
statuses: Record<string, SessionStatus>,
now = Date.now(),
): Session[] => {
const activeSessions = sessions.filter((session) => {
const minUpdatedAt = now - RECENT_SESSION_MAX_AGE_MS;
const recent = sessions.filter((session) => {
if (isArchivedSession(session) || isSubtaskSession(session)) {
return false;
}
const status = statuses[session.id];
return status?.type === 'busy' || status?.type === 'retry';
return getSessionUpdatedAt(session) >= minUpdatedAt;
});
return sortSessionsByUpdated(activeSessions);
return sortSessionsByUpdated(recent);
};
export const getSessionUpdatedAtMs = getSessionUpdatedAt;