Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions ui/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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) {
Expand Down
85 changes: 76 additions & 9 deletions ui/desktop/src/components/BaseChat2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -47,6 +50,7 @@ function BaseChatContent({
customChatInputProps = {},
customMainLayoutProps = {},
disableSearch = false,
resumeSessionId,
}: BaseChatProps) {
const location = useLocation();
const scrollRef = useRef<ScrollAreaHandle>(null);
Expand All @@ -72,17 +76,62 @@ function BaseChatContent({
// session: sessionMetadata,
// });

const [messages, setMessages] = useState(chat?.messages || []);
// Session loading state
const [sessionLoadError, setSessionLoadError] = useState<string | null>(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
Copy link
Collaborator Author

@zanesq zanesq Oct 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I originally set chat to null but it started having cascading effects all over the codebase setting the types to ChatType | null so opted for a less disruptive change for now and figured we can come back to it later

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: () => {},
Expand Down Expand Up @@ -236,9 +285,27 @@ function BaseChatContent({
{/* </div>*/}
{/*)}*/}

{sessionLoadError && (
<div className="flex flex-col items-center justify-center p-8">
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-4 rounded-lg mb-4 max-w-md">
<h3 className="font-semibold mb-2">Failed to Load Session</h3>
<p className="text-sm">{sessionLoadError}</p>
</div>
<button
onClick={() => {
setSessionLoadError(null);
hasLoadedSessionRef.current = false;
}}
className="px-4 py-2 text-center cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-lg transition-all duration-150"
>
Retry
</button>
</div>
)}

{/* Messages or Popular Topics */}
{
!chat ? null : messages.length > 0 || recipe ? (
messages.length > 0 || recipe ? (
<>
{disableSearch ? (
renderProgressiveMessageList(chat)
Expand Down Expand Up @@ -304,11 +371,11 @@ function BaseChatContent({
</ScrollArea>

{/* Fixed loading indicator at bottom left of chat container */}
{(!chat || isCompacting) && (
{(messages.length === 0 || isCompacting) && !sessionLoadError && (
<div className="absolute bottom-1 left-4 z-20 pointer-events-none">
<LoadingGoose
message={
!chat
messages.length === 0
? 'loading conversation...'
: isCompacting
? 'goose is compacting the conversation...'
Expand Down
44 changes: 3 additions & 41 deletions ui/desktop/src/components/Pair2.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { useEffect, useState } from 'react';
import { View, ViewOptions } from '../utils/navigationUtils';
import { AgentState, InitializationContext } from '../hooks/useAgent';
import 'react-toastify/dist/ReactToastify.css';

import { ChatType } from '../types/chat';
import { useSearchParams } from 'react-router-dom';
import BaseChat2 from './BaseChat2';

export interface PairRouteState {
Expand All @@ -17,57 +14,22 @@ interface PairProps {
setChat: (chat: ChatType) => 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<ChatType>;
}

export default function Pair({
chat,
setChat,
setView,
setIsGoosehintsModalOpen,
setFatalError,
setAgentWaitingMessage,
agentState,
loadCurrentChat,
resumeSessionId,
}: PairProps & PairRouteState) {
const [_searchParams, setSearchParams] = useSearchParams();
const [chat, setChat] = useState<ChatType | null>(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 (
<BaseChat2
chat={chat}
setChat={setChat}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
resumeSessionId={resumeSessionId}
/>
);
}
130 changes: 130 additions & 0 deletions ui/desktop/src/utils/sessionCache.ts
Original file line number Diff line number Diff line change
@@ -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<string, Session>();

/**
* In-flight request tracking to prevent duplicate fetches
* Maps session ID to Promise of Session
*/
const inFlightRequests = new Map<string, Promise<Session>>();

/**
* 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<Session> {
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;
}