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:
@@ -7,7 +7,7 @@ import { isDesktopShell } from '@/lib/desktop';
|
|||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
import { formatDirectoryName, cn } from '@/lib/utils';
|
import { formatDirectoryName, cn } from '@/lib/utils';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
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 { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useSync } from '@/sync/use-sync';
|
import { useSync } from '@/sync/use-sync';
|
||||||
import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch';
|
import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch';
|
||||||
@@ -59,11 +59,9 @@ import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore'
|
|||||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||||
import { type SessionGroup, type SessionNode } from './sidebar/types';
|
import { type SessionGroup, type SessionNode } from './sidebar/types';
|
||||||
import {
|
import {
|
||||||
deriveActiveNowSessions,
|
deriveRecentSessions,
|
||||||
deriveLiveActiveNowSessions,
|
|
||||||
getSessionUpdatedAtMs,
|
getSessionUpdatedAtMs,
|
||||||
} from './sidebar/activitySections';
|
} from './sidebar/activitySections';
|
||||||
import { useActiveNowStore } from '@/stores/useActiveNowStore';
|
|
||||||
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
|
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
|
||||||
import {
|
import {
|
||||||
compareSessionsByPinnedAndTime,
|
compareSessionsByPinnedAndTime,
|
||||||
@@ -187,9 +185,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
() => new Map(),
|
() => new Map(),
|
||||||
);
|
);
|
||||||
const safeStorage = React.useMemo(() => getSafeStorage(), []);
|
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 [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());
|
||||||
@@ -315,7 +310,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
|
|
||||||
const sync = useSync();
|
const sync = useSync();
|
||||||
const liveSessions = useAllLiveSessions();
|
const liveSessions = useAllLiveSessions();
|
||||||
const liveSessionStatuses = useAllSessionStatuses();
|
|
||||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
|
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
|
||||||
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||||
@@ -1025,9 +1019,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return deriveActiveNowSessions(activeNowEntries, new Map(sessions.map((session) => [session.id, session])))
|
return deriveRecentSessions(sessions)
|
||||||
.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
|
.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
|
||||||
}, [activeNowEntries, isVSCode, pinnedSessionIds, sessions, showRecentSection]);
|
}, [isVSCode, pinnedSessionIds, sessions, showRecentSection]);
|
||||||
|
|
||||||
const vscodeSharedSessions = React.useMemo(() => {
|
const vscodeSharedSessions = React.useMemo(() => {
|
||||||
if (!isVSCode) {
|
if (!isVSCode) {
|
||||||
@@ -1044,35 +1038,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
});
|
});
|
||||||
}, [isVSCode, sessions]);
|
}, [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.
|
// Prefetch is wired below, after recentSessionIds is computed.
|
||||||
|
|
||||||
const activitySections = React.useMemo(() => {
|
const activitySections = React.useMemo(() => {
|
||||||
|
|||||||
@@ -42,29 +42,6 @@ export function SidebarActivitySections({
|
|||||||
const [visibleCountBySection, setVisibleCountBySection] = React.useState<Map<string, number>>(new Map());
|
const [visibleCountBySection, setVisibleCountBySection] = React.useState<Map<string, number>>(new Map());
|
||||||
const flatVariant = variant === 'flat';
|
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) => {
|
const resetSectionLimit = React.useCallback((key: string) => {
|
||||||
setVisibleCountBySection((prev) => {
|
setVisibleCountBySection((prev) => {
|
||||||
if (!prev.has(key)) {
|
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);
|
const visibleSections = sections.filter((section) => section.items.length > 0);
|
||||||
if (visibleSections.length === 0) {
|
if (visibleSections.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -132,9 +133,7 @@ export function SidebarActivitySections({
|
|||||||
onClick={() => showMoreSessions(section.key, visibleItems.length, section.items.length)}
|
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"
|
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.showMore')}
|
||||||
? t('sessions.sidebar.group.showMoreSingle', { count: remainingCount })
|
|
||||||
: t('sessions.sidebar.group.showMorePlural', { count: remainingCount })}
|
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{canShowFewer ? (
|
{canShowFewer ? (
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
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 RECENT_SESSION_MAX_AGE_MS = 48 * 60 * 60 * 1000;
|
||||||
export const ACTIVE_NOW_MAX_AGE_MS = 36 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
export type ActiveNowEntry = {
|
|
||||||
sessionId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSubtaskSession = (session: Session): boolean => {
|
const isSubtaskSession = (session: Session): boolean => {
|
||||||
return Boolean((session as Session & { parentID?: string | null }).parentID);
|
return Boolean((session as Session & { parentID?: string | null }).parentID);
|
||||||
@@ -28,97 +22,25 @@ const getSessionUpdatedAt = (session: Session): number => {
|
|||||||
return 0;
|
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[] => {
|
export const sortSessionsByUpdated = (sessions: Session[]): Session[] => {
|
||||||
return [...sessions].sort((a, b) => getSessionUpdatedAt(b) - getSessionUpdatedAt(a));
|
return [...sessions].sort((a, b) => getSessionUpdatedAt(b) - getSessionUpdatedAt(a));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deriveActiveNowSessions = (
|
// Recent sessions are simply every non-archived, top-level session updated
|
||||||
entries: ActiveNowEntry[],
|
// within the last RECENT_SESSION_MAX_AGE_MS. No persisted history or live-busy
|
||||||
sessionsById: Map<string, Session>,
|
// tracking — membership is derived directly from session timestamps.
|
||||||
): Session[] => {
|
export const deriveRecentSessions = (
|
||||||
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 = (
|
|
||||||
sessions: Session[],
|
sessions: Session[],
|
||||||
statuses: Record<string, SessionStatus>,
|
now = Date.now(),
|
||||||
): Session[] => {
|
): Session[] => {
|
||||||
const activeSessions = sessions.filter((session) => {
|
const minUpdatedAt = now - RECENT_SESSION_MAX_AGE_MS;
|
||||||
|
const recent = sessions.filter((session) => {
|
||||||
if (isArchivedSession(session) || isSubtaskSession(session)) {
|
if (isArchivedSession(session) || isSubtaskSession(session)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
return getSessionUpdatedAt(session) >= minUpdatedAt;
|
||||||
const status = statuses[session.id];
|
|
||||||
return status?.type === 'busy' || status?.type === 'retry';
|
|
||||||
});
|
});
|
||||||
|
return sortSessionsByUpdated(recent);
|
||||||
return sortSessionsByUpdated(activeSessions);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSessionUpdatedAtMs = getSessionUpdatedAt;
|
export const getSessionUpdatedAtMs = getSessionUpdatedAt;
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
|
||||||
import {
|
|
||||||
type ActiveNowEntry,
|
|
||||||
addActiveNowSession,
|
|
||||||
persistActiveNowEntries,
|
|
||||||
pruneActiveNowEntries,
|
|
||||||
readActiveNowEntries,
|
|
||||||
} from '@/components/session/sidebar/activitySections';
|
|
||||||
import { getSafeStorage } from './utils/safeStorage';
|
|
||||||
|
|
||||||
type ActiveNowStore = {
|
|
||||||
entries: ActiveNowEntry[];
|
|
||||||
setEntries: (entries: ActiveNowEntry[]) => void;
|
|
||||||
addSession: (sessionId: string) => void;
|
|
||||||
prune: (sessionsById: Map<string, Session>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const safeStorage = getSafeStorage();
|
|
||||||
|
|
||||||
export const useActiveNowStore = create<ActiveNowStore>((set, get) => ({
|
|
||||||
entries: readActiveNowEntries(safeStorage),
|
|
||||||
setEntries: (entries) => {
|
|
||||||
if (entries === get().entries) return;
|
|
||||||
set({ entries });
|
|
||||||
persistActiveNowEntries(safeStorage, entries);
|
|
||||||
},
|
|
||||||
addSession: (sessionId) => {
|
|
||||||
const next = addActiveNowSession(get().entries, sessionId);
|
|
||||||
if (next === get().entries) return;
|
|
||||||
set({ entries: next });
|
|
||||||
persistActiveNowEntries(safeStorage, next);
|
|
||||||
},
|
|
||||||
prune: (sessionsById) => {
|
|
||||||
const current = get().entries;
|
|
||||||
const pruned = pruneActiveNowEntries(current, sessionsById);
|
|
||||||
if (
|
|
||||||
pruned.length === current.length
|
|
||||||
&& pruned.every((entry, index) => entry.sessionId === current[index]?.sessionId)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
set({ entries: pruned });
|
|
||||||
persistActiveNowEntries(safeStorage, pruned);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
findLiveSession,
|
findLiveSession,
|
||||||
findLiveSessionStatus,
|
findLiveSessionStatus,
|
||||||
} from '../live-aggregate.ts'
|
} from '../live-aggregate.ts'
|
||||||
import { deriveLiveActiveNowSessions } from '../../components/session/sidebar/activitySections.ts'
|
import { deriveRecentSessions, RECENT_SESSION_MAX_AGE_MS } from '../../components/session/sidebar/activitySections.ts'
|
||||||
|
|
||||||
const session = (id, directory, updated, extra = {}) => ({
|
const session = (id, directory, updated, extra = {}) => ({
|
||||||
id,
|
id,
|
||||||
@@ -94,21 +94,19 @@ describe('live aggregate', () => {
|
|||||||
)).toBe(false)
|
)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('derives active-now sessions from live statuses instead of persisted history', () => {
|
it('derives recent sessions from the 48h window, excluding archived/subtasks', () => {
|
||||||
|
const now = 1_000_000_000
|
||||||
const sessions = [
|
const sessions = [
|
||||||
session('ses-1', '/a', 20),
|
session('ses-1', '/a', now - 1_000),
|
||||||
session('ses-2', '/b', 30),
|
session('ses-2', '/b', now - 500),
|
||||||
session('ses-3', '/c', 10, { time: { created: 9, updated: 10, archived: 50 } }),
|
session('ses-3', '/c', now - 10, { time: { created: now - 11, updated: now - 10, archived: now - 5 } }),
|
||||||
session('ses-4', '/d', 40, { parentID: 'ses-parent' }),
|
session('ses-4', '/d', now - 200, { parentID: 'ses-parent' }),
|
||||||
|
session('ses-5', '/e', now - RECENT_SESSION_MAX_AGE_MS - 1),
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeNow = deriveLiveActiveNowSessions(sessions, {
|
const recent = deriveRecentSessions(sessions, now)
|
||||||
'ses-1': { type: 'busy' },
|
|
||||||
'ses-2': { type: 'retry', message: 'retrying' },
|
|
||||||
'ses-3': { type: 'busy' },
|
|
||||||
'ses-4': { type: 'busy' },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(activeNow.map((item) => item.id)).toEqual(['ses-2', 'ses-1'])
|
// ses-3 archived, ses-4 subtask, ses-5 older than 48h -> excluded; rest newest-first
|
||||||
|
expect(recent.map((item) => item.id)).toEqual(['ses-2', 'ses-1'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user