diff --git a/src/__tests__/main/ipc/handlers/history.test.ts b/src/__tests__/main/ipc/handlers/history.test.ts index e612489d7..ff1216e06 100644 --- a/src/__tests__/main/ipc/handlers/history.test.ts +++ b/src/__tests__/main/ipc/handlers/history.test.ts @@ -38,6 +38,7 @@ vi.mock('../../../../main/utils/logger', () => ({ describe('history IPC handlers', () => { let handlers: Map; let mockHistoryManager: Partial; + let mockSafeSend: ReturnType; // Sample history entries for testing const createMockEntry = (overrides: Partial = {}): HistoryEntry => ({ @@ -54,6 +55,8 @@ describe('history IPC handlers', () => { // Clear mocks vi.clearAllMocks(); + mockSafeSend = vi.fn(); + // Create mock history manager mockHistoryManager = { getEntries: vi.fn().mockReturnValue([]), @@ -101,8 +104,8 @@ describe('history IPC handlers', () => { handlers.set(channel, handler); }); - // Register handlers - registerHistoryHandlers(); + // Register handlers with mock safeSend + registerHistoryHandlers({ safeSend: mockSafeSend }); }); afterEach(() => { @@ -282,6 +285,15 @@ describe('history IPC handlers', () => { expect(result).toBe(true); }); + it('should broadcast entry via safeSend after adding', async () => { + const entry = createMockEntry({ sessionId: 'session-1', projectPath: '/test' }); + + const handler = handlers.get('history:add'); + await handler!({} as any, entry); + + expect(mockSafeSend).toHaveBeenCalledWith('history:entryAdded', entry, 'session-1'); + }); + it('should use orphaned session ID when sessionId is missing', async () => { const entry = createMockEntry({ sessionId: undefined, projectPath: '/test' }); diff --git a/src/main/ipc/handlers/history.ts b/src/main/ipc/handlers/history.ts index 1bc5875ef..ad582fe63 100644 --- a/src/main/ipc/handlers/history.ts +++ b/src/main/ipc/handlers/history.ts @@ -18,9 +18,14 @@ import { HistoryEntry } from '../../../shared/types'; import { PaginationOptions, ORPHANED_SESSION_ID } from '../../../shared/history'; import { getHistoryManager } from '../../history-manager'; import { withIpcErrorLogging, CreateHandlerOptions } from '../../utils/ipcHandler'; +import type { SafeSendFn } from '../../utils/safe-send'; const LOG_CONTEXT = '[History]'; +export interface HistoryHandlerDependencies { + safeSend: SafeSendFn; +} + // Helper to create handler options with consistent context const handlerOpts = (operation: string): Pick => ({ context: LOG_CONTEXT, @@ -39,7 +44,7 @@ const handlerOpts = (operation: string): Pick => ipcRenderer.invoke('director-notes:generateSynopsis', options), + + /** + * Subscribe to new history entries as they are added in real-time. + * Returns a cleanup function to unsubscribe. + */ + onHistoryEntryAdded: ( + callback: (entry: HistoryEntry, sourceSessionId: string) => void + ): (() => void) => { + const handler = (_event: unknown, entry: HistoryEntry, sessionId: string) => { + callback(entry, sessionId); + }; + ipcRenderer.on('history:entryAdded', handler); + return () => { + ipcRenderer.removeListener('history:entryAdded', handler); + }; + }, }; } diff --git a/src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx b/src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx index eaeda0ba3..501c64bac 100644 --- a/src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx +++ b/src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx @@ -23,6 +23,7 @@ import { import type { HistoryStats } from '../History'; import { HistoryDetailModal } from '../HistoryDetailModal'; import { useListNavigation, useSettings } from '../../hooks'; +import { useSessionStore } from '../../stores/sessionStore'; import type { TabFocusHandle } from './OverviewTab'; /** Page size for progressive loading */ @@ -86,6 +87,126 @@ export const UnifiedHistoryTab = forwardRef(null); + // --- Live agent activity from Zustand (primitive selectors for efficient re-renders) --- + const activeAgentCount = useSessionStore( + (s) => s.sessions.filter((sess) => sess.state === 'busy').length + ); + const totalQueuedItems = useSessionStore((s) => + s.sessions.reduce((sum, sess) => sum + (sess.executionQueue?.length || 0), 0) + ); + + // Merge live counts into history stats for the stats bar + const enrichedStats = useMemo(() => { + if (!historyStats) return null; + return { + ...historyStats, + activeAgentCount, + totalQueuedItems, + }; + }, [historyStats, activeAgentCount, totalQueuedItems]); + + // --- Real-time streaming of new history entries --- + const pendingEntriesRef = useRef([]); + const rafIdRef = useRef(null); + + // Stable ref for session names — avoids making the streaming effect depend on session state + const sessionsRef = useRef(useSessionStore.getState().sessions); + useEffect(() => { + return useSessionStore.subscribe((s) => { + sessionsRef.current = s.sessions; + }); + }, []); + + useEffect(() => { + const flushPending = () => { + rafIdRef.current = null; + const batch = pendingEntriesRef.current; + if (batch.length === 0) return; + pendingEntriesRef.current = []; + + // Dedupe within the batch itself + const seen = new Set(); + const uniqueBatch: UnifiedHistoryEntry[] = []; + for (const entry of batch) { + if (!seen.has(entry.id)) { + seen.add(entry.id); + uniqueBatch.push(entry); + } + } + + setEntries((prev) => { + const existingIds = new Set(prev.map((e) => e.id)); + const newEntries = uniqueBatch.filter((e) => !existingIds.has(e.id)); + if (newEntries.length === 0) return prev; + + // Update total count to match actual additions + setTotalEntries((t) => t + newEntries.length); + + // Incrementally update stats counters from deduplicated entries + setHistoryStats((prevStats) => { + if (!prevStats) return prevStats; + let newAuto = 0; + let newUser = 0; + for (const entry of newEntries) { + if (entry.type === 'AUTO') newAuto++; + else if (entry.type === 'USER') newUser++; + } + return { + ...prevStats, + autoCount: prevStats.autoCount + newAuto, + userCount: prevStats.userCount + newUser, + totalCount: prevStats.totalCount + newAuto + newUser, + }; + }); + + const merged = [...newEntries, ...prev]; + merged.sort((a, b) => b.timestamp - a.timestamp); + return merged; + }); + + // Update graph entries for ActivityGraph + setGraphEntries((prev) => { + const existingIds = new Set(prev.map((e) => e.id)); + const newEntries = uniqueBatch.filter((e) => !existingIds.has(e.id)); + if (newEntries.length === 0) return prev; + const merged = [...newEntries, ...prev]; + merged.sort((a, b) => b.timestamp - a.timestamp); + return merged; + }); + }; + + const cleanup = window.maestro.directorNotes.onHistoryEntryAdded( + (rawEntry, sourceSessionId) => { + // Check if entry is within lookback window + if (lookbackHours !== null) { + const cutoff = Date.now() - lookbackHours * 60 * 60 * 1000; + if (rawEntry.timestamp < cutoff) return; + } + + const enriched = { + ...rawEntry, + sourceSessionId, + agentName: sessionsRef.current.find((s) => s.id === sourceSessionId)?.name, + } as UnifiedHistoryEntry; + + pendingEntriesRef.current.push(enriched); + + // Coalesce into a single frame update + if (rafIdRef.current === null) { + rafIdRef.current = requestAnimationFrame(flushPending); + } + } + ); + + return () => { + cleanup(); + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + } + pendingEntriesRef.current = []; + }; + }, [lookbackHours]); + useImperativeHandle( ref, () => ({ @@ -439,8 +560,8 @@ export const UnifiedHistoryTab = forwardRef {/* Stats bar — scrolls with entries */} - {!isLoading && historyStats && historyStats.totalCount > 0 && ( - + {!isLoading && enrichedStats && enrichedStats.totalCount > 0 && ( + )} {isLoading ? ( diff --git a/src/renderer/components/History/HistoryStatsBar.tsx b/src/renderer/components/History/HistoryStatsBar.tsx index 5e7da6588..eeaccbf92 100644 --- a/src/renderer/components/History/HistoryStatsBar.tsx +++ b/src/renderer/components/History/HistoryStatsBar.tsx @@ -1,5 +1,5 @@ import React, { memo } from 'react'; -import { Layers, Hash, Bot, User, BarChart3 } from 'lucide-react'; +import { Layers, Hash, Bot, User, BarChart3, Loader2, ListOrdered } from 'lucide-react'; import type { Theme } from '../../types'; export interface HistoryStats { @@ -8,6 +8,10 @@ export interface HistoryStats { autoCount: number; userCount: number; totalCount: number; + /** Number of agents currently in 'busy' state (live indicator) */ + activeAgentCount?: number; + /** Total queued messages across all agents (live indicator) */ + totalQueuedItems?: number; } interface HistoryStatsBarProps { @@ -45,6 +49,10 @@ function StatItem({ icon, label, value, color, theme }: StatItemProps) { ); } +const showLiveIndicators = (stats: HistoryStats) => + (stats.activeAgentCount !== undefined && stats.activeAgentCount > 0) || + (stats.totalQueuedItems !== undefined && stats.totalQueuedItems > 0); + export const HistoryStatsBar = memo(function HistoryStatsBar({ stats, theme, @@ -88,6 +96,50 @@ export const HistoryStatsBar = memo(function HistoryStatsBar({ color={theme.colors.textMain} theme={theme} /> + + {/* Live activity indicators — only shown when provided and > 0 */} + {showLiveIndicators(stats) && ( + <> +
+ {stats.activeAgentCount !== undefined && stats.activeAgentCount > 0 && ( +
+ + + + + Active + + + {stats.activeAgentCount} + +
+ )} + {stats.totalQueuedItems !== undefined && stats.totalQueuedItems > 0 && ( + } + label="Queued" + value={stats.totalQueuedItems} + color={theme.colors.accent} + theme={theme} + /> + )} + + )}
); }); diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index b0b64c7c4..170e3c763 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -2658,13 +2658,7 @@ interface MaestroAPI { validated?: boolean; agentName?: string; sourceSessionId: string; - usageStats?: { - totalCostUsd: number; - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheWriteTokens: number; - }; + usageStats?: UsageStats; }>; total: number; limit: number; @@ -2695,6 +2689,28 @@ interface MaestroAPI { }; error?: string; }>; + /** Subscribe to new history entries as they are added in real-time. Returns cleanup function. */ + onHistoryEntryAdded: ( + callback: ( + entry: { + id: string; + type: HistoryEntryType; + timestamp: number; + summary: string; + fullResponse?: string; + agentSessionId?: string; + sessionName?: string; + projectPath: string; + sessionId?: string; + contextUsage?: number; + success?: boolean; + elapsedTimeMs?: number; + validated?: boolean; + usageStats?: UsageStats; + }, + sourceSessionId: string + ) => void + ) => () => void; }; // WakaTime API (CLI check, API key validation)