diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 2e9b0655fb..3cffe89006 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -48,6 +48,7 @@ import StandaloneAppView from './components/apps/StandaloneAppView'; import { View, ViewOptions } from './utils/navigationUtils'; import { useNavigation } from './hooks/useNavigation'; +import { evictSessionFromCache } from './hooks/useChatStream'; import { errorMessage } from './utils/conversionUtils'; import { getInitialWorkingDir } from './utils/workingDir'; import { usePageViewTracking } from './hooks/useAnalytics'; @@ -382,6 +383,10 @@ export function AppInner() { const newSession = { sessionId, initialMessage }; const updated = [...prev, newSession]; if (updated.length > MAX_ACTIVE_SESSIONS) { + const evicted = updated.slice(0, updated.length - MAX_ACTIVE_SESSIONS); + for (const s of evicted) { + evictSessionFromCache(s.sessionId); + } return updated.slice(updated.length - MAX_ACTIVE_SESSIONS); } return updated; diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index adcbc63735..40893f29e7 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -302,7 +302,10 @@ export const startGoosed = async (options: StartGoosedOptions): Promise { const text = data.toString(); - logger.info(`goosed stdout for port ${port} and dir ${workingDir}: ${text}`); + // Only log small stdout chunks to avoid memory pressure from large payloads + if (data.length < 1024) { + logger.info(`goosed stdout: ${text}`); + } if (!resolved && text.includes(FINGERPRINT_PREFIX)) { for (const line of text.split('\n')) { @@ -325,15 +328,36 @@ export const startGoosed = async (options: StartGoosedOptions): Promise { - const lines = data.toString().split('\n'); - for (const line of lines) { - if (line.trim()) { - errorLog.push(line); - if (isFatalError(line)) { - logger.error(`goosed stderr for port ${port} and dir ${workingDir}: ${line}`); + stderrBytesProcessed += data.length; + + // Only convert to string if the chunk might contain a fatal error, + // or during startup when we need errorLog for status checks. + const isStartup = stderrBytesProcessed < 1024 * 1024; + const hasFatal = fatalBytes.some((pattern) => data.includes(pattern)); + + if (isStartup || hasFatal) { + const text = data.toString(); + const lines = text.split('\n'); + for (const line of lines) { + if (line.trim()) { + errorLog.push(line); + if (isFatalError(line)) { + logger.error(`goosed fatal: ${line}`); + } } } + if (errorLog.length > MAX_ERROR_LOG_LINES * 2) { + errorLog.splice(0, errorLog.length - MAX_ERROR_LOG_LINES); + } } }); diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index c4732fbec0..c1af7f4969 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -27,8 +27,41 @@ import { errorMessage } from '../utils/conversionUtils'; import { showExtensionLoadResults } from '../utils/extensionErrorUtils'; import { maybeHandlePlatformEvent } from '../utils/platform_events'; +const MAX_CACHED_SESSIONS = 5; +const MAX_ENTRY_WEIGHT = 200; + const resultsCache = new Map(); +function messageWeight(messages: Message[]): number { + let weight = 0; + for (const msg of messages) { + for (const content of msg.content) { + weight += content.type === 'toolResponse' || content.type === 'toolRequest' ? 3 : 1; + } + } + return weight; +} + +function resultsCacheSet(key: string, value: { messages: Message[]; session: Session }) { + if (messageWeight(value.messages) > MAX_ENTRY_WEIGHT) { + resultsCache.delete(key); + return; + } + + resultsCache.delete(key); + resultsCache.set(key, value); + + while (resultsCache.size > MAX_CACHED_SESSIONS) { + const oldest = resultsCache.keys().next().value; + if (oldest !== undefined) resultsCache.delete(oldest); + else break; + } +} + +export function evictSessionFromCache(sessionId: string) { + resultsCache.delete(sessionId); +} + interface UseChatStreamProps { sessionId: string; onStreamFinish: () => void; @@ -345,10 +378,10 @@ export function useChatStream({ }, [sessionId]); useEffect(() => { - if (state.session) { - resultsCache.set(sessionId, { session: state.session, messages: state.messages }); + if (state.session && state.chatState === ChatState.Idle) { + resultsCacheSet(sessionId, { session: state.session, messages: state.messages }); } - }, [sessionId, state.session, state.messages]); + }, [sessionId, state.session, state.messages, state.chatState]); const onFinish = useCallback( async (error?: string): Promise => { diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index bfba32cb8d..026471020b 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -1536,31 +1536,30 @@ ipcMain.handle('check-ollama', async () => { const ps = spawn('ps', ['aux']); const grep = spawn('grep', ['-iw', '[o]llama']); - let output = ''; + const outputChunks: string[] = []; let errorOutput = ''; // Pipe ps output to grep ps.stdout.pipe(grep.stdin); grep.stdout.on('data', (data) => { - output += data.toString(); + outputChunks.push(String(data)); }); grep.stderr.on('data', (data) => { - errorOutput += data.toString(); + errorOutput += String(data); }); grep.on('close', (code) => { + const output = outputChunks.join(''); + if (code !== null && code !== 0 && code !== 1) { // grep returns 1 when no matches found console.error('Error executing grep command:', errorOutput); return resolve(false); } - console.log('Raw stdout from ps|grep command:', output); const trimmedOutput = output.trim(); - console.log('Trimmed stdout:', trimmedOutput); - const isRunning = trimmedOutput.length > 0; resolve(isRunning); }); @@ -1589,37 +1588,8 @@ ipcMain.handle('check-ollama', async () => { ipcMain.handle('read-file', async (_event, filePath) => { try { const expandedPath = expandTilde(filePath); - if (process.platform === 'win32') { - const buffer = await fs.readFile(expandedPath); - return { file: buffer.toString('utf8'), filePath: expandedPath, error: null, found: true }; - } - // Non-Windows: keep previous behavior via cat for parity - return await new Promise((resolve) => { - const cat = spawn('cat', [expandedPath]); - let output = ''; - let errorOutput = ''; - - cat.stdout.on('data', (data) => { - output += data.toString(); - }); - - cat.stderr.on('data', (data) => { - errorOutput += data.toString(); - }); - - cat.on('close', (code) => { - if (code !== 0) { - resolve({ file: '', filePath: expandedPath, error: errorOutput || null, found: false }); - return; - } - resolve({ file: output, filePath: expandedPath, error: null, found: true }); - }); - - cat.on('error', (error) => { - console.error('Error reading file:', error); - resolve({ file: '', filePath: expandedPath, error, found: false }); - }); - }); + const buffer = await fs.readFile(expandedPath); + return { file: buffer.toString('utf8'), filePath: expandedPath, error: null, found: true }; } catch (error) { console.error('Error reading file:', error); return { file: '', filePath: expandTilde(filePath), error, found: false }; diff --git a/ui/desktop/src/utils/sessionCache.ts b/ui/desktop/src/utils/sessionCache.ts deleted file mode 100644 index 36065779a4..0000000000 --- a/ui/desktop/src/utils/sessionCache.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { Session } from '../api'; -import { getApiUrl } from '../config'; -import { errorMessage } from './conversionUtils'; - -/** - * 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) { - throw new Error( - `Error loading session ${sessionId}: ${errorMessage(error, '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; -}