diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 3cffe89006..2e9b0655fb 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -48,7 +48,6 @@ 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'; @@ -383,10 +382,6 @@ 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 40893f29e7..adcbc63735 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -302,10 +302,7 @@ export const startGoosed = async (options: StartGoosedOptions): Promise { const text = data.toString(); - // Only log small stdout chunks to avoid memory pressure from large payloads - if (data.length < 1024) { - logger.info(`goosed stdout: ${text}`); - } + logger.info(`goosed stdout for port ${port} and dir ${workingDir}: ${text}`); if (!resolved && text.includes(FINGERPRINT_PREFIX)) { for (const line of text.split('\n')) { @@ -328,36 +325,15 @@ export const startGoosed = async (options: StartGoosedOptions): Promise { - 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}`); - } + 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}`); } } - 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 c1af7f4969..c4732fbec0 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -27,41 +27,8 @@ 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; @@ -378,10 +345,10 @@ export function useChatStream({ }, [sessionId]); useEffect(() => { - if (state.session && state.chatState === ChatState.Idle) { - resultsCacheSet(sessionId, { session: state.session, messages: state.messages }); + if (state.session) { + resultsCache.set(sessionId, { session: state.session, messages: state.messages }); } - }, [sessionId, state.session, state.messages, state.chatState]); + }, [sessionId, state.session, state.messages]); const onFinish = useCallback( async (error?: string): Promise => { diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 026471020b..bfba32cb8d 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -1536,30 +1536,31 @@ ipcMain.handle('check-ollama', async () => { const ps = spawn('ps', ['aux']); const grep = spawn('grep', ['-iw', '[o]llama']); - const outputChunks: string[] = []; + let output = ''; let errorOutput = ''; // Pipe ps output to grep ps.stdout.pipe(grep.stdin); grep.stdout.on('data', (data) => { - outputChunks.push(String(data)); + output += data.toString(); }); grep.stderr.on('data', (data) => { - errorOutput += String(data); + errorOutput += data.toString(); }); 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); }); @@ -1588,8 +1589,37 @@ ipcMain.handle('check-ollama', async () => { ipcMain.handle('read-file', async (_event, filePath) => { try { const expandedPath = expandTilde(filePath); - const buffer = await fs.readFile(expandedPath); - return { file: buffer.toString('utf8'), filePath: expandedPath, error: null, found: true }; + 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 }); + }); + }); } 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 new file mode 100644 index 0000000000..36065779a4 --- /dev/null +++ b/ui/desktop/src/utils/sessionCache.ts @@ -0,0 +1,130 @@ +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; +}