basecamp2 - Resume chat session from server with a cache (#5088)

This commit is contained in:
Zane
2025-10-09 12:28:34 -07:00
committed by GitHub
parent 06231fbb71
commit 67e9d7496f
4 changed files with 209 additions and 55 deletions
-5
View File
@@ -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) {
+76 -9
View File
@@ -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<ScrollAreaHandle>(null);
@@ -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
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({
{/* </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)
@@ -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...'
+3 -41
View File
@@ -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 {
@@ -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
View File
@@ -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;
}