diff --git a/src/__tests__/main/utils/shellDetector.test.ts b/src/__tests__/main/utils/shellDetector.test.ts index 4349d8fc..223ee633 100644 --- a/src/__tests__/main/utils/shellDetector.test.ts +++ b/src/__tests__/main/utils/shellDetector.test.ts @@ -166,14 +166,15 @@ describe('shellDetector', () => { Object.defineProperty(process, 'platform', { value: 'win32', writable: true }); mockedExecFileNoThrow.mockResolvedValue({ - stdout: 'C:\\Windows\\System32\\bash.exe\n', + stdout: 'C:\\Windows\\System32\\powershell.exe\n', stderr: '', exitCode: 0, }); await detectShells(); - expect(mockedExecFileNoThrow).toHaveBeenCalledWith('where', ['zsh']); + // On Windows, the first shell is powershell.exe + expect(mockedExecFileNoThrow).toHaveBeenCalledWith('where', ['powershell.exe']); // Restore original platform Object.defineProperty(process, 'platform', { value: originalPlatform, writable: true }); @@ -305,12 +306,12 @@ describe('shellDetector', () => { Object.defineProperty(process, 'platform', { value: originalPlatform, writable: true }); }); - it('should return bash for sh on Windows', () => { - expect(getShellCommand('sh')).toBe('bash'); + it('should return bash.exe for sh on Windows', () => { + expect(getShellCommand('sh')).toBe('bash.exe'); }); - it('should return bash for bash on Windows', () => { - expect(getShellCommand('bash')).toBe('bash'); + it('should return bash.exe for bash on Windows', () => { + expect(getShellCommand('bash')).toBe('bash.exe'); }); it('should return powershell.exe for zsh on Windows', () => { diff --git a/src/__tests__/renderer/components/AboutModal.test.tsx b/src/__tests__/renderer/components/AboutModal.test.tsx index 5d60628d..339ecb84 100644 --- a/src/__tests__/renderer/components/AboutModal.test.tsx +++ b/src/__tests__/renderer/components/AboutModal.test.tsx @@ -762,7 +762,7 @@ describe('AboutModal', () => { } }); - expect(screen.getByText('1h 5m')).toBeInTheDocument(); + expect(screen.getByText(/Hands-on Time:.*1h 5m/)).toBeInTheDocument(); }); it('should format minutes and seconds', async () => { @@ -781,7 +781,7 @@ describe('AboutModal', () => { } }); - expect(screen.getByText('2m 5s')).toBeInTheDocument(); + expect(screen.getByText(/Hands-on Time:.*2m 5s/)).toBeInTheDocument(); }); it('should format only seconds for small values', async () => { @@ -800,7 +800,7 @@ describe('AboutModal', () => { } }); - expect(screen.getByText('45s')).toBeInTheDocument(); + expect(screen.getByText(/Hands-on Time:.*45s/)).toBeInTheDocument(); }); it('should not show Active Time when totalActiveTimeMs is 0', async () => { @@ -841,7 +841,7 @@ describe('AboutModal', () => { } }); - expect(screen.getByText('2m 0s')).toBeInTheDocument(); + expect(screen.getByText(/Hands-on Time:.*2m 0s/)).toBeInTheDocument(); }); }); diff --git a/src/__tests__/renderer/components/ExecutionQueueBrowser.test.tsx b/src/__tests__/renderer/components/ExecutionQueueBrowser.test.tsx index 1d6d66da..d2a67571 100644 --- a/src/__tests__/renderer/components/ExecutionQueueBrowser.test.tsx +++ b/src/__tests__/renderer/components/ExecutionQueueBrowser.test.tsx @@ -1082,7 +1082,7 @@ describe('ExecutionQueueBrowser', () => { /> ); - expect(screen.getByText('Items are processed sequentially per project to prevent file conflicts.')).toBeInTheDocument(); + expect(screen.getByText('Drag and drop to reorder. Items are processed sequentially per project to prevent file conflicts.')).toBeInTheDocument(); }); }); diff --git a/src/main/storage/codex-session-storage.ts b/src/main/storage/codex-session-storage.ts index 34c37a2f..49c7eb93 100644 --- a/src/main/storage/codex-session-storage.ts +++ b/src/main/storage/codex-session-storage.ts @@ -52,7 +52,7 @@ function getCodexSessionsDir(): string { const CODEX_SESSIONS_DIR = getCodexSessionsDir(); -const CODEX_SESSION_CACHE_VERSION = 2; // Bumped: skip system context in firstMessage preview +const CODEX_SESSION_CACHE_VERSION = 3; // Bumped: skip markdown-style system context in firstMessage preview const CODEX_SESSION_CACHE_FILENAME = 'codex-sessions-cache.json'; /** @@ -142,10 +142,19 @@ function isSystemContextMessage(text: string): boolean { if (!text) return false; const trimmed = text.trim(); // Skip messages that start with environment/system context XML tags - return trimmed.startsWith('') || - trimmed.startsWith('') || - trimmed.startsWith('') || - trimmed.startsWith(''); + if (trimmed.startsWith('') || + trimmed.startsWith('') || + trimmed.startsWith('') || + trimmed.startsWith('')) { + return true; + } + // Skip markdown-formatted system context (e.g., "# Context Your name is **Maestro Codex**...") + if (trimmed.startsWith('# Context') || + trimmed.startsWith('# Maestro System Context') || + trimmed.startsWith('# System Context')) { + return true; + } + return false; } function extractCwdFromText(text: string): string | null { diff --git a/src/renderer/components/AgentSessionsBrowser.tsx b/src/renderer/components/AgentSessionsBrowser.tsx index b207b114..a2f63160 100644 --- a/src/renderer/components/AgentSessionsBrowser.tsx +++ b/src/renderer/components/AgentSessionsBrowser.tsx @@ -186,7 +186,7 @@ export function AgentSessionsBrowser({ // Use projectRoot for consistent session storage access (same as useSessionPagination) if (!activeSession?.projectRoot) return; // Only subscribe for Claude Code sessions - if (activeSession.toolType !== 'claude-code') return; + if (agentId !== 'claude-code') return; const unsubscribe = window.maestro.claude.onProjectStatsUpdate((stats) => { // Only update if this is for our project (use projectRoot, not cwd) @@ -204,7 +204,43 @@ export function AgentSessionsBrowser({ }); return unsubscribe; - }, [activeSession?.projectRoot, activeSession?.toolType]); + }, [activeSession?.projectRoot, agentId]); + + // Compute stats from loaded sessions for non-Claude agents + useEffect(() => { + // Only for non-Claude agents (Claude uses progressive stats from backend) + if (agentId === 'claude-code') return; + if (loading) return; + + // Compute aggregate stats from the sessions array + let totalMessages = 0; + let totalCostUsd = 0; + let totalSizeBytes = 0; + let totalTokens = 0; + let oldestTimestamp: string | null = null; + + for (const session of sessions) { + totalMessages += session.messageCount || 0; + totalCostUsd += session.costUsd || 0; + totalSizeBytes += session.sizeBytes || 0; + totalTokens += (session.inputTokens || 0) + (session.outputTokens || 0); + if (session.timestamp) { + if (!oldestTimestamp || session.timestamp < oldestTimestamp) { + oldestTimestamp = session.timestamp; + } + } + } + + setAggregateStats({ + totalSessions: sessions.length, + totalMessages, + totalCostUsd, + totalSizeBytes, + totalTokens, + oldestTimestamp, + isComplete: !hasMoreSessions, // Complete when all sessions are loaded + }); + }, [agentId, sessions, loading, hasMoreSessions]); // Toggle star status for a session const toggleStar = useCallback(async (sessionId: string, e: React.MouseEvent) => { @@ -763,7 +799,12 @@ export function AgentSessionsBrowser({ {formatNumber(viewingSession.inputTokens + viewingSession.outputTokens)} - of 200k context {Math.min(100, ((viewingSession.inputTokens + viewingSession.outputTokens) / 200000) * 100).toFixed(1)}% + of 200k context { + const usagePercent = ((viewingSession.inputTokens + viewingSession.outputTokens) / 200000) * 100; + if (usagePercent >= 90) return theme.colors.error; + if (usagePercent >= 70) return theme.colors.warning; + return theme.colors.accent; + })() }}>{Math.min(100, ((viewingSession.inputTokens + viewingSession.outputTokens) / 200000) * 100).toFixed(1)}% diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 78036393..4ed6012c 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -372,6 +372,9 @@ interface MaestroAPI { cacheReadTokens: number; cacheCreationTokens: number; durationSeconds: number; + origin?: 'user' | 'auto'; + sessionName?: string; + starred?: boolean; }>; hasMore: boolean; totalCount: number; diff --git a/src/renderer/hooks/useSessionPagination.ts b/src/renderer/hooks/useSessionPagination.ts index 5314a16a..91227e05 100644 --- a/src/renderer/hooks/useSessionPagination.ts +++ b/src/renderer/hooks/useSessionPagination.ts @@ -84,6 +84,9 @@ export function useSessionPagination({ // Container ref for scroll handling const sessionsContainerRef = useRef(null); + // Store origins map for merging into paginated results + const originsMapRef = useRef>(new Map()); + // Load sessions on mount or when projectPath/agentId changes useEffect(() => { // Reset pagination state @@ -99,22 +102,44 @@ export function useSessionPagination({ } try { - // Load session metadata (starred status) from Claude session origins - // Note: Origin/starred tracking is currently Claude-specific; other agents will get empty results + // Load session metadata (starred status, sessionName) from session origins + // Note: Origins are currently Claude-specific; other agents will need their own implementation + const originsMap = new Map(); if (agentId === 'claude-code') { const origins = await window.maestro.claude.getSessionOrigins(projectPath); const starredFromOrigins = new Set(); for (const [sessionId, originData] of Object.entries(origins)) { - if (typeof originData === 'object' && originData?.starred) { - starredFromOrigins.add(sessionId); + if (typeof originData === 'object') { + if (originData?.starred) { + starredFromOrigins.add(sessionId); + } + originsMap.set(sessionId, originData); + } else if (typeof originData === 'string') { + originsMap.set(sessionId, { origin: originData }); } } onStarredSessionsLoaded?.(starredFromOrigins); } + // Store for use in loadMoreSessions + originsMapRef.current = originsMap; + // Use generic agentSessions API with agentId parameter for paginated loading const result = await window.maestro.agentSessions.listPaginated(agentId, projectPath, { limit: 100 }); - setSessions(result.sessions); + + // Merge origins data (sessionName, starred) into sessions + // Type cast to ClaudeSession since the API returns compatible data + const sessionsWithOrigins: ClaudeSession[] = result.sessions.map(session => { + const originData = originsMapRef.current.get(session.sessionId); + return { + ...session, + sessionName: originData?.sessionName || session.sessionName, + starred: originData?.starred || session.starred, + origin: (originData?.origin || session.origin) as 'user' | 'auto' | undefined, + }; + }); + + setSessions(sessionsWithOrigins); setHasMoreSessions(result.hasMore); setTotalSessionCount(result.totalCount); nextCursorRef.current = result.nextCursor; @@ -146,10 +171,22 @@ export function useSessionPagination({ limit: 100, }); + // Merge origins data (sessionName, starred) into new sessions + // Type cast to ClaudeSession since the API returns compatible data + const sessionsWithOrigins: ClaudeSession[] = result.sessions.map(session => { + const originData = originsMapRef.current.get(session.sessionId); + return { + ...session, + sessionName: originData?.sessionName || session.sessionName, + starred: originData?.starred || session.starred, + origin: (originData?.origin || session.origin) as 'user' | 'auto' | undefined, + }; + }); + // Append new sessions, avoiding duplicates setSessions(prev => { const existingIds = new Set(prev.map(s => s.sessionId)); - const newSessions = result.sessions.filter(s => !existingIds.has(s.sessionId)); + const newSessions = sessionsWithOrigins.filter(s => !existingIds.has(s.sessionId)); return [...prev, ...newSessions]; }); setHasMoreSessions(result.hasMore); diff --git a/src/renderer/utils/groupChatExport.ts b/src/renderer/utils/groupChatExport.ts index c3d671c0..e563c0ee 100644 --- a/src/renderer/utils/groupChatExport.ts +++ b/src/renderer/utils/groupChatExport.ts @@ -69,8 +69,9 @@ function getParticipantColor( /** * Convert markdown-style formatting to HTML + * Accepts an images map to embed base64 images */ -function formatContent(content: string): string { +function formatContent(content: string, images: Record = {}): string { let html = escapeHtml(content); // Code blocks (```) @@ -99,6 +100,26 @@ function formatContent(content: string): string { // Numbered lists html = html.replace(/^\d+\. (.+)$/gm, '
  • $1
  • '); + // Markdown images ![alt](url) - must come before links + html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => { + // Check if this image filename has a base64 version + const filename = url.split('/').pop() || url; + const dataUrl = images[filename]; + if (dataUrl) { + return `${alt}`; + } + return `${alt}`; + }); + + // [Image: filename] pattern + html = html.replace(/\[Image: ([^\]]+)\]/gi, (_match, filename) => { + const dataUrl = images[filename.trim()]; + if (dataUrl) { + return `${filename.trim()}`; + } + return _match; // Leave as-is if no image data + }); + // Links [text](url) html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); @@ -138,21 +159,8 @@ export function generateGroupChatExportHtml( const color = getParticipantColor(groupChat, msg.from, theme); const isUser = msg.from === 'user'; - // Replace image references with actual base64 data - let content = msg.content; - for (const [filename, dataUrl] of Object.entries(images)) { - // Replace various image reference patterns - content = content.replace( - new RegExp(`!\\[([^\\]]*)\\]\\([^)]*${escapeRegExp(filename)}[^)]*\\)`, 'g'), - `$1` - ); - content = content.replace( - new RegExp(`\\[Image: [^\\]]*${escapeRegExp(filename)}[^\\]]*\\]`, 'gi'), - `${filename}` - ); - } - - const formattedContent = formatContent(content); + // Format content with images map for embedding + const formattedContent = formatContent(msg.content, images); return `