2026-03-06 17:22:59 +02:00
|
|
|
import React from 'react';
|
|
|
|
|
import type { Session } from '@opencode-ai/sdk/v2';
|
2026-03-31 18:47:00 +03:00
|
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
2026-05-07 18:57:44 +03:00
|
|
|
import { getSyncSessionMaterializationStatus } from '@/sync/sync-refs';
|
2026-05-21 15:45:15 +03:00
|
|
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
2026-03-06 17:22:59 +02:00
|
|
|
|
|
|
|
|
const SESSION_PREFETCH_HOVER_DELAY_MS = 180;
|
2026-03-31 18:47:00 +03:00
|
|
|
const SESSION_PREFETCH_SETTLE_MS = 600;
|
2026-03-06 17:22:59 +02:00
|
|
|
const SESSION_PREFETCH_CONCURRENCY = 1;
|
|
|
|
|
const SESSION_PREFETCH_PENDING_LIMIT = 6;
|
|
|
|
|
|
|
|
|
|
type Args = {
|
2026-07-21 20:52:20 +03:00
|
|
|
enabled?: boolean;
|
2026-03-06 17:22:59 +02:00
|
|
|
currentSessionId: string | null;
|
|
|
|
|
sortedSessions: Session[];
|
2026-07-21 20:52:20 +03:00
|
|
|
recentSessions?: Session[];
|
|
|
|
|
prefetchSession: (sessionId: string, directory: string) => Promise<unknown>;
|
2026-03-06 17:22:59 +02:00
|
|
|
};
|
|
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
type PrefetchRequest = {
|
|
|
|
|
sessionId: string;
|
|
|
|
|
directory: string;
|
|
|
|
|
generation: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const sessionDirectory = (session: Session | null | undefined): string | null => {
|
|
|
|
|
const directory = (session as (Session & { directory?: string | null }) | null | undefined)?.directory;
|
|
|
|
|
return typeof directory === 'string' && directory.trim() ? directory : null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-13 15:30:54 +03:00
|
|
|
const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions, recentSessions = [], prefetchSession }: Args): void => {
|
2026-03-06 17:22:59 +02:00
|
|
|
const sessionPrefetchTimersRef = React.useRef<Map<string, number>>(new Map());
|
2026-07-21 20:52:20 +03:00
|
|
|
const sessionPrefetchQueueRef = React.useRef<PrefetchRequest[]>([]);
|
2026-03-06 17:22:59 +02:00
|
|
|
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
|
2026-07-21 20:52:20 +03:00
|
|
|
const generationRef = React.useRef(0);
|
2026-05-21 15:45:15 +03:00
|
|
|
const prefetchDisabled = React.useMemo(() => isVSCodeRuntime(), []);
|
2026-03-06 17:22:59 +02:00
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
const requestKey = React.useCallback((request: Pick<PrefetchRequest, 'directory' | 'sessionId'>) => (
|
|
|
|
|
`${request.directory}\n${request.sessionId}`
|
|
|
|
|
), []);
|
|
|
|
|
|
|
|
|
|
const clearPendingPrefetches = React.useCallback(() => {
|
|
|
|
|
generationRef.current += 1;
|
|
|
|
|
sessionPrefetchQueueRef.current = [];
|
|
|
|
|
sessionPrefetchTimersRef.current.forEach((timer) => window.clearTimeout(timer));
|
|
|
|
|
sessionPrefetchTimersRef.current.clear();
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-03-06 17:22:59 +02:00
|
|
|
const pumpSessionPrefetchQueue = React.useCallback(() => {
|
2026-07-21 20:52:20 +03:00
|
|
|
if (!enabled || prefetchDisabled || typeof window === 'undefined') {
|
2026-03-06 17:22:59 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
while (sessionPrefetchInFlightRef.current.size < SESSION_PREFETCH_CONCURRENCY && sessionPrefetchQueueRef.current.length > 0) {
|
2026-07-21 20:52:20 +03:00
|
|
|
const request = sessionPrefetchQueueRef.current.shift();
|
|
|
|
|
if (!request) {
|
2026-03-06 17:22:59 +02:00
|
|
|
break;
|
|
|
|
|
}
|
2026-07-21 20:52:20 +03:00
|
|
|
if (request.generation !== generationRef.current) continue;
|
2026-03-06 17:22:59 +02:00
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
const state = useSessionUIStore.getState();
|
2026-07-21 20:52:20 +03:00
|
|
|
if (state.currentSessionId === request.sessionId) {
|
2026-03-06 17:22:59 +02:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 18:57:44 +03:00
|
|
|
// Check if the session is already renderable in the sync child store.
|
2026-07-21 20:52:20 +03:00
|
|
|
if (getSyncSessionMaterializationStatus(request.sessionId, request.directory).renderable) {
|
2026-03-06 17:22:59 +02:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
const key = requestKey(request);
|
|
|
|
|
sessionPrefetchInFlightRef.current.add(key);
|
|
|
|
|
void prefetchSession(request.sessionId, request.directory)
|
2026-03-06 17:22:59 +02:00
|
|
|
.catch(() => undefined)
|
|
|
|
|
.finally(() => {
|
2026-07-21 20:52:20 +03:00
|
|
|
sessionPrefetchInFlightRef.current.delete(key);
|
2026-03-06 17:22:59 +02:00
|
|
|
pumpSessionPrefetchQueue();
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-07-21 20:52:20 +03:00
|
|
|
}, [enabled, prefetchDisabled, prefetchSession, requestKey]);
|
2026-03-06 17:22:59 +02:00
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
const scheduleSessionPrefetch = React.useCallback((session: Session | null | undefined) => {
|
|
|
|
|
const sessionId = session?.id;
|
|
|
|
|
const directory = sessionDirectory(session);
|
|
|
|
|
if (!enabled || prefetchDisabled || !sessionId || !directory || sessionId === currentSessionId || typeof window === 'undefined') {
|
2026-03-06 17:22:59 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-07-21 20:52:20 +03:00
|
|
|
const request = { sessionId, directory, generation: generationRef.current };
|
|
|
|
|
const key = requestKey(request);
|
2026-03-06 17:22:59 +02:00
|
|
|
|
2026-05-07 18:57:44 +03:00
|
|
|
// Already renderable in sync
|
2026-07-21 20:52:20 +03:00
|
|
|
if (getSyncSessionMaterializationStatus(sessionId, directory).renderable) {
|
2026-03-06 17:22:59 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
if (sessionPrefetchInFlightRef.current.has(key)) {
|
2026-03-06 17:22:59 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
if (sessionPrefetchQueueRef.current.some((candidate) => requestKey(candidate) === key)) {
|
2026-03-06 17:22:59 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
const existingTimer = sessionPrefetchTimersRef.current.get(key);
|
2026-03-06 17:22:59 +02:00
|
|
|
if (existingTimer !== undefined) {
|
|
|
|
|
window.clearTimeout(existingTimer);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const timer = window.setTimeout(() => {
|
2026-07-21 20:52:20 +03:00
|
|
|
sessionPrefetchTimersRef.current.delete(key);
|
|
|
|
|
if (request.generation !== generationRef.current) return;
|
|
|
|
|
const queue = sessionPrefetchQueueRef.current;
|
|
|
|
|
if (queue.length >= SESSION_PREFETCH_PENDING_LIMIT) {
|
|
|
|
|
queue.shift();
|
|
|
|
|
}
|
|
|
|
|
queue.push(request);
|
2026-03-06 17:22:59 +02:00
|
|
|
pumpSessionPrefetchQueue();
|
|
|
|
|
}, SESSION_PREFETCH_HOVER_DELAY_MS);
|
2026-07-21 20:52:20 +03:00
|
|
|
sessionPrefetchTimersRef.current.set(key, timer);
|
|
|
|
|
}, [currentSessionId, enabled, prefetchDisabled, pumpSessionPrefetchQueue, requestKey]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
clearPendingPrefetches();
|
|
|
|
|
}, [clearPendingPrefetches, currentSessionId, enabled, prefetchDisabled]);
|
2026-03-06 17:22:59 +02:00
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
// Wait for the active session to finish loading before prefetching neighbors.
|
|
|
|
|
// On rapid session switches the timer resets, so only the final session triggers prefetch.
|
2026-03-06 17:22:59 +02:00
|
|
|
React.useEffect(() => {
|
2026-07-21 20:52:20 +03:00
|
|
|
if (!enabled || prefetchDisabled || !currentSessionId || sortedSessions.length === 0) {
|
2026-03-06 17:22:59 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-03-31 18:47:00 +03:00
|
|
|
const timer = window.setTimeout(() => {
|
|
|
|
|
const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId);
|
|
|
|
|
if (currentIndex < 0) return;
|
2026-07-21 20:52:20 +03:00
|
|
|
scheduleSessionPrefetch(sortedSessions[currentIndex - 1]);
|
|
|
|
|
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]);
|
2026-03-31 18:47:00 +03:00
|
|
|
}, SESSION_PREFETCH_SETTLE_MS);
|
|
|
|
|
return () => window.clearTimeout(timer);
|
2026-07-21 20:52:20 +03:00
|
|
|
}, [currentSessionId, enabled, prefetchDisabled, scheduleSessionPrefetch, sortedSessions]);
|
2026-03-06 17:22:59 +02:00
|
|
|
|
2026-03-20 01:01:03 +02:00
|
|
|
React.useEffect(() => {
|
2026-07-21 20:52:20 +03:00
|
|
|
if (!enabled || prefetchDisabled || !currentSessionId || recentSessions.length === 0) {
|
2026-03-20 01:01:03 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-03-31 18:47:00 +03:00
|
|
|
const timer = window.setTimeout(() => {
|
2026-07-21 20:52:20 +03:00
|
|
|
const currentIndex = recentSessions.findIndex((session) => session.id === currentSessionId);
|
2026-03-31 18:47:00 +03:00
|
|
|
if (currentIndex < 0) return;
|
2026-07-21 20:52:20 +03:00
|
|
|
scheduleSessionPrefetch(recentSessions[currentIndex - 1]);
|
|
|
|
|
scheduleSessionPrefetch(recentSessions[currentIndex + 1]);
|
2026-03-31 18:47:00 +03:00
|
|
|
}, SESSION_PREFETCH_SETTLE_MS);
|
|
|
|
|
return () => window.clearTimeout(timer);
|
2026-07-21 20:52:20 +03:00
|
|
|
}, [currentSessionId, enabled, prefetchDisabled, recentSessions, scheduleSessionPrefetch]);
|
2026-03-20 01:01:03 +02:00
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
React.useEffect(() => clearPendingPrefetches, [clearPendingPrefetches]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const SessionPrefetchEffect: React.FC<Omit<Args, 'currentSessionId'>> = (args) => {
|
|
|
|
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
|
|
|
|
useSessionPrefetch({ ...args, currentSessionId });
|
|
|
|
|
return null;
|
2026-03-06 17:22:59 +02:00
|
|
|
};
|