Files
openchamber/packages/ui/src/hooks/useSessionAutoCleanup.ts
T
Bohdan Triapitsyn 85400459e9 perf: overhaul session loading, caching, and runtime isolation (#2360)
Improve OpenChamber responsiveness under large session workloads while fixing
cache, synchronization, and persistence correctness across runtimes, projects,
directories, and worktrees.

- prioritize selected and visible sessions during bootstrap and defer
  non-critical enrichment work
- reduce redundant message loading, event processing, store publication, and
  hidden sidebar work
- prevent stale session and message requests from overwriting newer
  authoritative state
- preserve existing data when authoritative fetches fail instead of treating
  failures as successful empty responses
- scope session materialization, messages, drafts, queues, todos, pins,
  permissions, folders, tabs, Git state, and pull request data by runtime and
  directory identity
- harden runtime switching, reconnect, cleanup, mutation reconciliation, and
  persisted-state ordering
- preserve live subagent Task linkage when metadata arrives after an older
  message request or while streaming parts are suspended
- coalesce overlapping tail refreshes without losing newer refresh demand
- improve cold-session loading by moving deferrable work out of the critical
  bootstrap path
- isolate URL authentication, mobile credentials, native secrets, and other
  runtime-owned state across endpoint changes
- bound long-lived caches and remove avoidable allocations from event and
  rendering hot paths
- limit virtualization to archive collections where it improves rendering
  without disrupting active sidebar layout
- stabilize session folders, pin ordering, expanded state, and persisted
  sidebar behavior
- open skill files through the same secure editor and outside-workspace grant
  flow used by file navigation, including worktree sessions
- expand regression coverage for stale completions, runtime collisions,
  reconnect behavior, persistence races, authoritative empty results, and
  subagent refresh ordering
- document the updated synchronization, cache ownership, performance, and
  runtime-isolation invariants
2026-07-21 20:52:20 +03:00

233 lines
7.5 KiB
TypeScript

import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { opencodeClient } from '@/lib/opencode/client';
import { ensureGlobalSessionsLoaded, useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { getAllSyncSessions } from '@/sync/sync-refs';
import { useUIStore } from '@/stores/useUIStore';
const DAY_MS = 24 * 60 * 60 * 1000;
const AUTO_DELETE_KEEP_RECENT = 5;
const AUTO_DELETE_INTERVAL_MS = 24 * 60 * 60 * 1000;
const EMPTY_SESSIONS: Session[] = [];
const getSessionLastActivity = (session: Session): number => {
return session.time?.updated ?? session.time?.created ?? 0;
};
type BuildAutoDeleteCandidatesOptions = {
sessions: Session[];
currentSessionId: string | null;
cutoffDays: number;
keepRecent?: number;
now?: number;
};
const buildAutoDeleteCandidates = ({
sessions,
currentSessionId,
cutoffDays,
keepRecent = AUTO_DELETE_KEEP_RECENT,
now = Date.now(),
}: BuildAutoDeleteCandidatesOptions): string[] => {
if (!Array.isArray(sessions) || cutoffDays <= 0) {
return [];
}
const cutoffTime = now - cutoffDays * DAY_MS;
const sorted = [...sessions].sort(
(a, b) => getSessionLastActivity(b) - getSessionLastActivity(a)
);
const protectedIds = new Set(sorted.slice(0, keepRecent).map((session) => session.id));
return sorted
.filter((session) => {
if (!session?.id) return false;
if (protectedIds.has(session.id)) return false;
if (session.id === currentSessionId) return false;
if (session.share) return false;
const lastActivity = getSessionLastActivity(session);
if (!lastActivity) return false;
return lastActivity < cutoffTime;
})
.map((session) => session.id);
};
type CleanupResult = {
completedIds: string[];
failedIds: string[];
action: 'archive' | 'delete';
skippedReason?: 'disabled' | 'loading' | 'cooldown' | 'no-candidates' | 'running';
};
type CleanupOptions = {
autoRun?: boolean;
enabled?: boolean;
};
export const useSessionAutoCleanup = (enabledOrOptions?: boolean | CleanupOptions) => {
const options = typeof enabledOrOptions === 'object' ? enabledOrOptions : undefined;
const autoRun = options?.autoRun !== false;
const enabled = typeof enabledOrOptions === 'boolean' ? enabledOrOptions : (options?.enabled ?? true);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const isLoading = useSessionUIStore((state) => state.isLoading);
const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled);
const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays);
const sessionRetentionAction = useUIStore((state) => state.sessionRetentionAction);
const autoDeleteLastRunAt = useUIStore((state) => state.autoDeleteLastRunAt);
const setAutoDeleteLastRunAt = useUIStore((state) => state.setAutoDeleteLastRunAt);
const needsGlobalSessions = enabled && (!autoRun || autoDeleteEnabled);
const globalSessions = useGlobalSessionsStore(React.useCallback(
(state) => needsGlobalSessions ? state.activeSessions : EMPTY_SESSIONS,
[needsGlobalSessions],
));
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
const [isRunning, setIsRunning] = React.useState(false);
const runningRef = React.useRef(false);
React.useEffect(() => {
void ensureGlobalSessionsLoaded(getAllSyncSessions());
}, []);
const candidates = React.useMemo(() => {
if (autoDeleteAfterDays <= 0) {
return [];
}
return buildAutoDeleteCandidates({
sessions: globalSessions,
currentSessionId,
cutoffDays: autoDeleteAfterDays,
});
}, [autoDeleteAfterDays, currentSessionId, globalSessions]);
const runCleanup = React.useCallback(
async ({ force = false }: { force?: boolean } = {}): Promise<CleanupResult> => {
if (runningRef.current) {
return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'running' };
}
if (!autoDeleteEnabled || autoDeleteAfterDays <= 0) {
if (!force) {
return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'disabled' };
}
}
if (isLoading) {
return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'loading' };
}
const now = Date.now();
if (!force && autoDeleteLastRunAt && now - autoDeleteLastRunAt < AUTO_DELETE_INTERVAL_MS) {
return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'cooldown' };
}
const { activeSessions: sessions } = await ensureGlobalSessionsLoaded(getAllSyncSessions());
if (sessions.length === 0) {
return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'no-candidates' };
}
const candidateIds = buildAutoDeleteCandidates({
sessions,
currentSessionId,
cutoffDays: autoDeleteAfterDays,
now,
});
if (candidateIds.length === 0) {
setAutoDeleteLastRunAt(now);
return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'no-candidates' };
}
runningRef.current = true;
setIsRunning(true);
try {
const sessionMap = new Map(sessions.map((session) => [session.id, session]));
const completedIds: string[] = [];
const failedIds: string[] = [];
for (const id of candidateIds) {
const session = sessionMap.get(id);
const directory = session ? resolveGlobalSessionDirectory(session) : null;
if (!directory) {
failedIds.push(id);
continue;
}
try {
if (sessionRetentionAction === 'archive') {
await opencodeClient.updateSession(id, { time: { archived: Date.now() } }, directory);
} else {
await opencodeClient.deleteSession(id, directory);
}
completedIds.push(id);
} catch {
failedIds.push(id);
}
}
if (sessionRetentionAction === 'archive') {
useGlobalSessionsStore.getState().archiveSessions(completedIds);
} else {
useGlobalSessionsStore.getState().removeSessions(completedIds);
}
return { completedIds, failedIds, action: sessionRetentionAction };
} finally {
runningRef.current = false;
setIsRunning(false);
setAutoDeleteLastRunAt(Date.now());
}
},
[
autoDeleteAfterDays,
autoDeleteEnabled,
autoDeleteLastRunAt,
currentSessionId,
isLoading,
sessionRetentionAction,
setAutoDeleteLastRunAt,
]
);
React.useEffect(() => {
if (!enabled) {
return;
}
if (!autoRun) {
return;
}
if (!autoDeleteEnabled || autoDeleteAfterDays <= 0) {
return;
}
if (isLoading || !hasLoadedGlobalSessions || globalSessions.length === 0) {
return;
}
const now = Date.now();
if (autoDeleteLastRunAt && now - autoDeleteLastRunAt < AUTO_DELETE_INTERVAL_MS) {
return;
}
void runCleanup();
}, [
autoDeleteAfterDays,
autoDeleteEnabled,
autoDeleteLastRunAt,
autoRun,
enabled,
hasLoadedGlobalSessions,
globalSessions.length,
isLoading,
runCleanup,
]);
return {
candidates,
isRunning,
runCleanup,
keepRecentCount: AUTO_DELETE_KEEP_RECENT,
action: sessionRetentionAction,
};
};