diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 19c61da947bf..403a21af8bb1 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -99,10 +99,6 @@ const PairRouteWrapper = ({ chat={chat} setChat={setChat} setView={setView} - agentState={agentState} - loadCurrentChat={loadCurrentChat} - setFatalError={setFatalError} - setAgentWaitingMessage={setAgentWaitingMessage} setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} resumeSessionId={resumeSessionId} initialMessage={initialMessage} @@ -346,7 +342,6 @@ export function AppInner() { setAgentWaitingMessage, setIsExtensionsLoading, }); - // Update the chat state with the loaded session to ensure sessionId is available globally setChat(loadedChat); } catch (e) { if (e instanceof NoProviderOrModelError) { diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index 12de9f437dea..cdf5292fd089 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -10,16 +10,17 @@ import { MainPanelLayout } from './Layout/MainPanelLayout'; import ChatInput from './ChatInput'; import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area'; import { useFileDrop } from '../hooks/useFileDrop'; -import { Message } from '../api'; +import { Message, Session } from '../api'; import { ChatState } from '../types/chatState'; import { ChatType } from '../types/chat'; import { useIsMobile } from '../hooks/use-mobile'; import { useSidebar } from './ui/sidebar'; import { cn } from '../utils'; import { useChatStream } from '../hooks/useChatStream'; +import { loadSession } from '../utils/sessionCache'; interface BaseChatProps { - chat: ChatType | null; + chat: ChatType; setChat: (chat: ChatType) => void; setView: (view: View, viewOptions?: ViewOptions) => void; setIsGoosehintsModalOpen?: (isOpen: boolean) => void; @@ -35,10 +36,12 @@ interface BaseChatProps { showPopularTopics?: boolean; suppressEmptyState?: boolean; autoSubmit?: boolean; + resumeSessionId?: string; // Optional session ID to resume on mount } function BaseChatContent({ chat, + setChat, setView, setIsGoosehintsModalOpen, renderHeader, @@ -47,6 +50,7 @@ function BaseChatContent({ customChatInputProps = {}, customMainLayoutProps = {}, disableSearch = false, + resumeSessionId, }: BaseChatProps) { const location = useLocation(); const scrollRef = useRef(null); @@ -72,17 +76,62 @@ function BaseChatContent({ // session: sessionMetadata, // }); - const [messages, setMessages] = useState(chat?.messages || []); + // Session loading state + const [sessionLoadError, setSessionLoadError] = useState(null); + const hasLoadedSessionRef = useRef(false); + + const [messages, setMessages] = useState(chat.messages || []); + + // Load session on mount if resumeSessionId is provided + useEffect(() => { + const needsLoad = resumeSessionId && !hasLoadedSessionRef.current; + + if (needsLoad) { + hasLoadedSessionRef.current = true; + setSessionLoadError(null); + + // Set chat to empty session to indicate loading state + // todo: set to null instead and handle that in other places + const emptyChat: ChatType = { + sessionId: resumeSessionId, + title: 'Loading...', + messageHistoryIndex: 0, + messages: [], + recipe: null, + recipeParameters: null, + }; + setChat(emptyChat); + + loadSession(resumeSessionId) + .then((session: Session) => { + const conversation = session.conversation || []; + const loadedChat: ChatType = { + sessionId: session.id, + title: session.description || 'Untitled Chat', + messageHistoryIndex: 0, + messages: conversation, + recipe: null, + recipeParameters: null, + }; + + setChat(loadedChat); + }) + .catch((error: Error) => { + const errorMessage = error.message || 'Failed to load session'; + setSessionLoadError(errorMessage); + }); + } + }, [resumeSessionId, setChat]); // Update messages when chat changes (e.g., when resuming a session) useEffect(() => { - if (chat?.messages) { + if (chat.messages) { setMessages(chat.messages); } - }, [chat?.messages, chat?.sessionId]); + }, [chat.messages, chat.sessionId]); const { chatState, handleSubmit, stopStreaming } = useChatStream({ - sessionId: chat?.sessionId || '', + sessionId: chat.sessionId || '', messages, setMessages, onStreamFinish: () => {}, @@ -236,9 +285,27 @@ function BaseChatContent({ {/* */} {/*)}*/} + {sessionLoadError && ( +
+
+

Failed to Load Session

+

{sessionLoadError}

+
+ +
+ )} + {/* Messages or Popular Topics */} { - !chat ? null : messages.length > 0 || recipe ? ( + messages.length > 0 || recipe ? ( <> {disableSearch ? ( renderProgressiveMessageList(chat) @@ -304,11 +371,11 @@ function BaseChatContent({ {/* Fixed loading indicator at bottom left of chat container */} - {(!chat || isCompacting) && ( + {(messages.length === 0 || isCompacting) && !sessionLoadError && (
void; setView: (view: View, viewOptions?: ViewOptions) => void; setIsGoosehintsModalOpen: (isOpen: boolean) => void; - setFatalError: (value: ((prevState: string | null) => string | null) | string | null) => void; - setAgentWaitingMessage: (msg: string | null) => void; - agentState: AgentState; - loadCurrentChat: (context: InitializationContext) => Promise; } export default function Pair({ + chat, + setChat, setView, setIsGoosehintsModalOpen, - setFatalError, - setAgentWaitingMessage, - agentState, - loadCurrentChat, resumeSessionId, }: PairProps & PairRouteState) { - const [_searchParams, setSearchParams] = useSearchParams(); - const [chat, setChat] = useState(null); - - useEffect(() => { - const initializeFromState = async () => { - try { - const chat = await loadCurrentChat({ - resumeSessionId, - setAgentWaitingMessage, - }); - setChat(chat); - setSearchParams((prev) => { - prev.set('resumeSessionId', chat.sessionId); - return prev; - }); - } catch (error) { - setFatalError(`Agent init failure: ${error instanceof Error ? error.message : '' + error}`); - } - }; - initializeFromState(); - }, [ - agentState, - setChat, - setFatalError, - setAgentWaitingMessage, - loadCurrentChat, - resumeSessionId, - setSearchParams, - ]); - return ( ); } diff --git a/ui/desktop/src/utils/sessionCache.ts b/ui/desktop/src/utils/sessionCache.ts new file mode 100644 index 000000000000..b4a9ef7173ea --- /dev/null +++ b/ui/desktop/src/utils/sessionCache.ts @@ -0,0 +1,130 @@ +import { Session } from '../api'; +import { getApiUrl } from '../config'; + +/** + * In-memory cache for session data + * Maps session ID to Session object + */ +const sessionCache = new Map(); + +/** + * In-flight request tracking to prevent duplicate fetches + * Maps session ID to Promise of Session + */ +const inFlightRequests = new Map>(); + +/** + * Load a session from the server using the /agent/resume endpoint + * Implements caching to avoid redundant fetches + * + * @param sessionId - The unique identifier for the session + * @param forceRefresh - If true, bypass cache and fetch fresh data + * @returns Promise resolving to the Session object + * @throws Error if the request fails or session not found + */ +export async function loadSession(sessionId: string, forceRefresh = false): Promise { + if (!forceRefresh && sessionCache.has(sessionId)) { + return sessionCache.get(sessionId)!; + } + + if (inFlightRequests.has(sessionId)) { + return inFlightRequests.get(sessionId)!; + } + + const fetchPromise = (async () => { + try { + const url = getApiUrl('/agent/resume'); + const secretKey = await window.electron.getSecretKey(); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': secretKey, + }, + body: JSON.stringify({ + session_id: sessionId, + }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`Failed to load session: HTTP ${response.status} - ${errorText}`); + } + + const session: Session = await response.json(); + sessionCache.set(sessionId, session); + + return session; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Error loading session ${sessionId}: ${error.message}`); + } + throw new Error(`Error loading session ${sessionId}: Unknown error`); + } finally { + inFlightRequests.delete(sessionId); + } + })(); + + inFlightRequests.set(sessionId, fetchPromise); + return fetchPromise; +} + +/** + * Clear a specific session from the cache + * Useful when a session has been updated and needs to be refetched + * + * @param sessionId - The unique identifier for the session to clear + */ +export function clearSessionCache(sessionId: string): void { + sessionCache.delete(sessionId); +} + +/** + * Clear all sessions from the cache + * Useful for logout or when switching contexts + */ +export function clearAllSessionCache(): void { + sessionCache.clear(); +} + +/** + * Check if a session is currently cached + * + * @param sessionId - The unique identifier for the session + * @returns true if the session is in cache, false otherwise + */ +export function isSessionCached(sessionId: string): boolean { + return sessionCache.has(sessionId); +} + +/** + * Get a session from cache without fetching + * Returns undefined if not cached + * + * @param sessionId - The unique identifier for the session + * @returns The cached Session object or undefined + */ +export function getCachedSession(sessionId: string): Session | undefined { + return sessionCache.get(sessionId); +} + +/** + * Preload a session into cache + * Useful when you already have session data from another source + * + * @param session - The Session object to cache + */ +export function preloadSession(session: Session): void { + sessionCache.set(session.id, session); +} + +/** + * Get the current cache size + * Useful for debugging and monitoring + * + * @returns The number of sessions currently cached + */ +export function getCacheSize(): number { + return sessionCache.size; +}