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
233 lines
7.5 KiB
TypeScript
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,
|
|
};
|
|
};
|