Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions src/__tests__/main/ipc/handlers/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ vi.mock('../../../../main/utils/logger', () => ({
describe('history IPC handlers', () => {
let handlers: Map<string, Function>;
let mockHistoryManager: Partial<HistoryManager>;
let mockSafeSend: ReturnType<typeof vi.fn>;

// Sample history entries for testing
const createMockEntry = (overrides: Partial<HistoryEntry> = {}): HistoryEntry => ({
Expand All @@ -54,6 +55,8 @@ describe('history IPC handlers', () => {
// Clear mocks
vi.clearAllMocks();

mockSafeSend = vi.fn();

// Create mock history manager
mockHistoryManager = {
getEntries: vi.fn().mockReturnValue([]),
Expand Down Expand Up @@ -101,8 +104,8 @@ describe('history IPC handlers', () => {
handlers.set(channel, handler);
});

// Register handlers
registerHistoryHandlers();
// Register handlers with mock safeSend
registerHistoryHandlers({ safeSend: mockSafeSend });
});

afterEach(() => {
Expand Down Expand Up @@ -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' });

Expand Down
11 changes: 10 additions & 1 deletion src/main/ipc/handlers/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CreateHandlerOptions, 'context' | 'operation'> => ({
context: LOG_CONTEXT,
Expand All @@ -39,7 +44,7 @@ const handlerOpts = (operation: string): Pick<CreateHandlerOptions, 'context' |
* - Get history file path (for AI context integration)
* - List sessions with history
*/
export function registerHistoryHandlers(): void {
export function registerHistoryHandlers(deps: HistoryHandlerDependencies): void {
const historyManager = getHistoryManager();

// Get all history entries, optionally filtered by project and/or session
Expand Down Expand Up @@ -111,6 +116,10 @@ export function registerHistoryHandlers(): void {
const sessionId = entry.sessionId || ORPHANED_SESSION_ID;
historyManager.addEntry(sessionId, entry.projectPath, entry);
logger.info(`Added history entry: ${entry.type}`, LOG_CONTEXT, { summary: entry.summary });

// Broadcast to renderer for real-time Director's Notes streaming
deps.safeSend('history:entryAdded', entry, sessionId);

return true;
})
);
Expand Down
8 changes: 6 additions & 2 deletions src/main/ipc/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import Store from 'electron-store';
import { registerGitHandlers, GitHandlerDependencies } from './git';
import { registerAutorunHandlers } from './autorun';
import { registerPlaybooksHandlers } from './playbooks';
import { registerHistoryHandlers } from './history';
import { registerHistoryHandlers, HistoryHandlerDependencies } from './history';
import { registerAgentsHandlers, AgentsHandlerDependencies } from './agents';
import { registerProcessHandlers, ProcessHandlerDependencies } from './process';
import {
Expand Down Expand Up @@ -57,6 +57,7 @@ import { AgentDetector } from '../../agents';
import { ProcessManager } from '../../process-manager';
import { WebServer } from '../../web-server';
import { tunnelManager as tunnelManagerInstance } from '../../tunnel-manager';
import { createSafeSend } from '../../utils/safe-send';

// Type for tunnel manager instance
type TunnelManagerType = typeof tunnelManagerInstance;
Expand All @@ -66,6 +67,7 @@ export { registerGitHandlers };
export { registerAutorunHandlers };
export { registerPlaybooksHandlers };
export { registerHistoryHandlers };
export type { HistoryHandlerDependencies };
export { registerAgentsHandlers };
export { registerProcessHandlers };
export { registerPersistenceHandlers };
Expand Down Expand Up @@ -172,7 +174,9 @@ export function registerAllHandlers(deps: HandlerDependencies): void {
});
registerAutorunHandlers(deps);
registerPlaybooksHandlers(deps);
registerHistoryHandlers();
registerHistoryHandlers({
safeSend: createSafeSend(deps.getMainWindow),
});
registerAgentsHandlers({
getAgentDetector: deps.getAgentDetector,
agentConfigsStore: deps.agentConfigsStore,
Expand Down
16 changes: 16 additions & 0 deletions src/main/preload/directorNotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,22 @@ export function createDirectorNotesApi() {
// Generate AI synopsis
generateSynopsis: (options: SynopsisOptions): Promise<SynopsisResult> =>
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: UnifiedHistoryEntry, sourceSessionId: string) => void
): (() => void) => {
const handler = (_event: unknown, entry: UnifiedHistoryEntry, sessionId: string) => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The IPC handler types the entry argument as UnifiedHistoryEntry, which declares sourceSessionId as a required field. However, the main process broadcasts a plain HistoryEntry (which lacks sourceSessionId); the sourceSessionId is passed separately as the sessionId parameter. This type mismatch means TypeScript will not catch downstream code that accidentally reads entry.sourceSessionId directly, believing it to be populated from the wire when it's actually absent.

Use the correct source type:

Suggested change
const handler = (_event: unknown, entry: UnifiedHistoryEntry, sessionId: string) => {
const handler = (_event: unknown, entry: HistoryEntry, sessionId: string) => {
callback(entry, sessionId);
};

(You may need to import HistoryEntry from ../../shared/types if not already imported.)

callback(entry, sessionId);
Comment on lines +114 to +118
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix onHistoryEntryAdded callback payload typing.

On Line 115, entry is typed as UnifiedHistoryEntry (which requires sourceSessionId), but sourceSessionId is delivered as the second callback arg. This contract is misleading and can cause incorrect assumptions in callers.

Suggested fix
-import type { ToolType } from '../../shared/types';
+import type { ToolType, HistoryEntry } from '../../shared/types';
@@
 		onHistoryEntryAdded: (
-			callback: (entry: UnifiedHistoryEntry, sourceSessionId: string) => void
+			callback: (entry: HistoryEntry, sourceSessionId: string) => void
 		): (() => void) => {
-			const handler = (_event: unknown, entry: UnifiedHistoryEntry, sessionId: string) => {
+			const handler = (_event: unknown, entry: HistoryEntry, sessionId: string) => {
 				callback(entry, sessionId);
 			};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/preload/directorNotes.ts` around lines 114 - 118, The callback
currently types its first arg as UnifiedHistoryEntry but the actual payload
lacks sourceSessionId (it's passed as the second arg), so update the callback
and handler signatures to reflect that (e.g. change the first parameter type to
Omit<UnifiedHistoryEntry, 'sourceSessionId'>) in onHistoryEntryAdded and the
inner handler variable so callers can't assume sourceSessionId is already
present; keep passing sessionId as the second argument when invoking callback.

};
ipcRenderer.on('history:entryAdded', handler);
return () => {
ipcRenderer.removeListener('history:entryAdded', handler);
};
},
};
}

Expand Down
113 changes: 111 additions & 2 deletions src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
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 */
Expand Down Expand Up @@ -86,6 +87,114 @@
const loadingMoreRef = useRef(false); // Guard against concurrent loads
const searchInputRef = useRef<HTMLInputElement>(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<HistoryStats | null>(() => {
if (!historyStats) return null;
return {
...historyStats,
activeAgentCount,
totalQueuedItems,
};
}, [historyStats, activeAgentCount, totalQueuedItems]);

// --- Real-time streaming of new history entries ---
const pendingEntriesRef = useRef<UnifiedHistoryEntry[]>([]);
const rafIdRef = useRef<number | null>(null);

// Build session name map for enriching streamed entries with agentName
const sessionNameMap = useSessionStore((s) => {
const map = new Map<string, string>();
for (const sess of s.sessions) {
map.set(sess.id, sess.name);
}
return map;
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sessionNameMap Zustand selector constructs a new Map on every invocation. Since Zustand uses strict reference equality checks for selector memoization, this fresh Map reference is never equal to the previous one, causing the selector result to change on every render.

This makes sessionNameMap an unstable dependency for the streaming useEffect below (line 121), which tears down and re-subscribes to the IPC listener on every render cycle. Under active agent load, this creates constant re-subscription churn, making the streaming connection effectively unstable and wasting CPU cycles.

Use a stable ref pattern instead to read session data without making it a reactive dependency:

Suggested change
const sessionNameMap = useSessionStore((s) => {
const map = new Map<string, string>();
for (const sess of s.sessions) {
map.set(sess.id, sess.name);
}
return map;
});
const sessionsRef = useRef(useSessionStore.getState().sessions);
useEffect(() => {
// Subscribe to future changes but keep current value in a ref
return useSessionStore.subscribe((s) => {
sessionsRef.current = s.sessions;
});
}, []);
// Then in the onHistoryEntryAdded callback below, read from sessionsRef:
const enriched = {
...rawEntry,
sourceSessionId,
agentName: sessionsRef.current.find((s) => s.id === sourceSessionId)?.name,
} as UnifiedHistoryEntry;

This way, the streaming effect has no reactive dependencies on session data and remains stable.


useEffect(() => {
const flushPending = () => {
rafIdRef.current = null;
const batch = pendingEntriesRef.current;
if (batch.length === 0) return;
pendingEntriesRef.current = [];

setEntries((prev) => {
const existingIds = new Set(prev.map((e) => e.id));
const newEntries = batch.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;
});

setTotalEntries((prev) => prev + batch.length);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setEntries correctly deduplicates the batch—only adding entries whose IDs aren't already in the list—but setTotalEntries increments by the full batch.length, counting duplicate IDs that were silently dropped. Over time, especially if IPC redelivers the same entry due to the re-subscription churn described above, the entry count will drift upward while the actual entry list does not grow, breaking the badge display.

The count should mirror the number of entries actually added:

Suggested change
setTotalEntries((prev) => prev + batch.length);
setEntries((prev) => {
const existingIds = new Set(prev.map((e) => e.id));
const newEntries = batch.filter((e) => !existingIds.has(e.id));
if (newEntries.length === 0) return prev;
// Track exactly how many new entries were added
setTotalEntries((t) => t + newEntries.length);
const merged = [...newEntries, ...prev];
merged.sort((a, b) => b.timestamp - a.timestamp);
return merged;
});

(Nesting setTotalEntries inside the setEntries updater is safe in React 18's automatic batching and ensures the delta always matches the deduplicated count.)


// Update graph entries for ActivityGraph
setGraphEntries((prev) => {
const existingIds = new Set(prev.map((e) => e.id));
const newEntries = batch.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;
});

// Incrementally update stats counters
setHistoryStats((prev) => {
if (!prev) return prev;
let newAuto = 0;
let newUser = 0;
for (const entry of batch) {
if (entry.type === 'AUTO') newAuto++;
else if (entry.type === 'USER') newUser++;
}
return {
...prev,
autoCount: prev.autoCount + newAuto,
userCount: prev.userCount + newUser,
totalCount: prev.totalCount + newAuto + newUser,
};
});
};

const cleanup = window.maestro.directorNotes.onHistoryEntryAdded(

Check failure on line 167 in src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx

View workflow job for this annotation

GitHub Actions / test

src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx > UnifiedHistoryTab > Stats Bar > does not render stats bar while loading

TypeError: window.maestro.directorNotes.onHistoryEntryAdded is not a function ❯ src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx:167:49 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom.development.js:23189:26 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom.development.js:24970:11 ❯ commitPassiveMountEffects_complete node_modules/react-dom/cjs/react-dom.development.js:24930:9 ❯ commitPassiveMountEffects_begin node_modules/react-dom/cjs/react-dom.development.js:24917:7 ❯ commitPassiveMountEffects node_modules/react-dom/cjs/react-dom.development.js:24905:3 ❯ flushPassiveEffectsImpl node_modules/react-dom/cjs/react-dom.development.js:27078:3 ❯ flushPassiveEffects node_modules/react-dom/cjs/react-dom.development.js:27023:14 ❯ node_modules/react-dom/cjs/react-dom.development.js:26808:9 ❯ flushActQueue node_modules/react/cjs/react.development.js:2667:24

Check failure on line 167 in src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx

View workflow job for this annotation

GitHub Actions / test

src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx > UnifiedHistoryTab > Stats Bar > does not render stats bar when no entries exist

TypeError: window.maestro.directorNotes.onHistoryEntryAdded is not a function ❯ src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx:167:49 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom.development.js:23189:26 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom.development.js:24970:11 ❯ commitPassiveMountEffects_complete node_modules/react-dom/cjs/react-dom.development.js:24930:9 ❯ commitPassiveMountEffects_begin node_modules/react-dom/cjs/react-dom.development.js:24917:7 ❯ commitPassiveMountEffects node_modules/react-dom/cjs/react-dom.development.js:24905:3 ❯ flushPassiveEffectsImpl node_modules/react-dom/cjs/react-dom.development.js:27078:3 ❯ flushPassiveEffects node_modules/react-dom/cjs/react-dom.development.js:27023:14 ❯ node_modules/react-dom/cjs/react-dom.development.js:26808:9 ❯ flushActQueue node_modules/react/cjs/react.development.js:2667:24

Check failure on line 167 in src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx

View workflow job for this annotation

GitHub Actions / test

src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx > UnifiedHistoryTab > Stats Bar > renders stats bar with aggregate counts after loading

TypeError: window.maestro.directorNotes.onHistoryEntryAdded is not a function ❯ src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx:167:49 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom.development.js:23189:26 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom.development.js:24970:11 ❯ commitPassiveMountEffects_complete node_modules/react-dom/cjs/react-dom.development.js:24930:9 ❯ commitPassiveMountEffects_begin node_modules/react-dom/cjs/react-dom.development.js:24917:7 ❯ commitPassiveMountEffects node_modules/react-dom/cjs/react-dom.development.js:24905:3 ❯ flushPassiveEffectsImpl node_modules/react-dom/cjs/react-dom.development.js:27078:3 ❯ flushPassiveEffects node_modules/react-dom/cjs/react-dom.development.js:27023:14 ❯ node_modules/react-dom/cjs/react-dom.development.js:26808:9 ❯ flushActQueue node_modules/react/cjs/react.development.js:2667:24

Check failure on line 167 in src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx

View workflow job for this annotation

GitHub Actions / test

src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx > UnifiedHistoryTab > Loading and Data Fetching > displays loaded/total when more entries exist

TypeError: window.maestro.directorNotes.onHistoryEntryAdded is not a function ❯ src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx:167:49 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom.development.js:23189:26 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom.development.js:24970:11 ❯ commitPassiveMountEffects_complete node_modules/react-dom/cjs/react-dom.development.js:24930:9 ❯ commitPassiveMountEffects_begin node_modules/react-dom/cjs/react-dom.development.js:24917:7 ❯ commitPassiveMountEffects node_modules/react-dom/cjs/react-dom.development.js:24905:3 ❯ flushPassiveEffectsImpl node_modules/react-dom/cjs/react-dom.development.js:27078:3 ❯ flushPassiveEffects node_modules/react-dom/cjs/react-dom.development.js:27023:14 ❯ node_modules/react-dom/cjs/react-dom.development.js:26808:9 ❯ flushActQueue node_modules/react/cjs/react.development.js:2667:24

Check failure on line 167 in src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx

View workflow job for this annotation

GitHub Actions / test

src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx > UnifiedHistoryTab > Loading and Data Fetching > displays total entry count

TypeError: window.maestro.directorNotes.onHistoryEntryAdded is not a function ❯ src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx:167:49 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom.development.js:23189:26 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom.development.js:24970:11 ❯ commitPassiveMountEffects_complete node_modules/react-dom/cjs/react-dom.development.js:24930:9 ❯ commitPassiveMountEffects_begin node_modules/react-dom/cjs/react-dom.development.js:24917:7 ❯ commitPassiveMountEffects node_modules/react-dom/cjs/react-dom.development.js:24905:3 ❯ flushPassiveEffectsImpl node_modules/react-dom/cjs/react-dom.development.js:27078:3 ❯ flushPassiveEffects node_modules/react-dom/cjs/react-dom.development.js:27023:14 ❯ node_modules/react-dom/cjs/react-dom.development.js:26808:9 ❯ flushActQueue node_modules/react/cjs/react.development.js:2667:24

Check failure on line 167 in src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx

View workflow job for this annotation

GitHub Actions / test

src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx > UnifiedHistoryTab > Loading and Data Fetching > renders entries from all sessions (aggregated)

TypeError: window.maestro.directorNotes.onHistoryEntryAdded is not a function ❯ src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx:167:49 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom.development.js:23189:26 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom.development.js:24970:11 ❯ commitPassiveMountEffects_complete node_modules/react-dom/cjs/react-dom.development.js:24930:9 ❯ commitPassiveMountEffects_begin node_modules/react-dom/cjs/react-dom.development.js:24917:7 ❯ commitPassiveMountEffects node_modules/react-dom/cjs/react-dom.development.js:24905:3 ❯ flushPassiveEffectsImpl node_modules/react-dom/cjs/react-dom.development.js:27078:3 ❯ flushPassiveEffects node_modules/react-dom/cjs/react-dom.development.js:27023:14 ❯ node_modules/react-dom/cjs/react-dom.development.js:26808:9 ❯ flushActQueue node_modules/react/cjs/react.development.js:2667:24

Check failure on line 167 in src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx

View workflow job for this annotation

GitHub Actions / test

src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx > UnifiedHistoryTab > Loading and Data Fetching > shows empty state when no entries found

TypeError: window.maestro.directorNotes.onHistoryEntryAdded is not a function ❯ src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx:167:49 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom.development.js:23189:26 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom.development.js:24970:11 ❯ commitPassiveMountEffects_complete node_modules/react-dom/cjs/react-dom.development.js:24930:9 ❯ commitPassiveMountEffects_begin node_modules/react-dom/cjs/react-dom.development.js:24917:7 ❯ commitPassiveMountEffects node_modules/react-dom/cjs/react-dom.development.js:24905:3 ❯ flushPassiveEffectsImpl node_modules/react-dom/cjs/react-dom.development.js:27078:3 ❯ flushPassiveEffects node_modules/react-dom/cjs/react-dom.development.js:27023:14 ❯ node_modules/react-dom/cjs/react-dom.development.js:26808:9 ❯ flushActQueue node_modules/react/cjs/react.development.js:2667:24

Check failure on line 167 in src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx

View workflow job for this annotation

GitHub Actions / test

src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx > UnifiedHistoryTab > Loading and Data Fetching > fetches all-time history when defaultLookbackDays is 0

TypeError: window.maestro.directorNotes.onHistoryEntryAdded is not a function ❯ src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx:167:49 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom.development.js:23189:26 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom.development.js:24970:11 ❯ commitPassiveMountEffects_complete node_modules/react-dom/cjs/react-dom.development.js:24930:9 ❯ commitPassiveMountEffects_begin node_modules/react-dom/cjs/react-dom.development.js:24917:7 ❯ commitPassiveMountEffects node_modules/react-dom/cjs/react-dom.development.js:24905:3 ❯ flushPassiveEffectsImpl node_modules/react-dom/cjs/react-dom.development.js:27078:3 ❯ flushPassiveEffects node_modules/react-dom/cjs/react-dom.development.js:27023:14 ❯ node_modules/react-dom/cjs/react-dom.development.js:26808:9 ❯ flushActQueue node_modules/react/cjs/react.development.js:2667:24

Check failure on line 167 in src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx

View workflow job for this annotation

GitHub Actions / test

src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx > UnifiedHistoryTab > Loading and Data Fetching > fetches unified history on mount using default lookback from settings

TypeError: window.maestro.directorNotes.onHistoryEntryAdded is not a function ❯ src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx:167:49 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom.development.js:23189:26 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom.development.js:24970:11 ❯ commitPassiveMountEffects_complete node_modules/react-dom/cjs/react-dom.development.js:24930:9 ❯ commitPassiveMountEffects_begin node_modules/react-dom/cjs/react-dom.development.js:24917:7 ❯ commitPassiveMountEffects node_modules/react-dom/cjs/react-dom.development.js:24905:3 ❯ flushPassiveEffectsImpl node_modules/react-dom/cjs/react-dom.development.js:27078:3 ❯ flushPassiveEffects node_modules/react-dom/cjs/react-dom.development.js:27023:14 ❯ node_modules/react-dom/cjs/react-dom.development.js:26808:9 ❯ flushActQueue node_modules/react/cjs/react.development.js:2667:24

Check failure on line 167 in src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx

View workflow job for this annotation

GitHub Actions / test

src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx > UnifiedHistoryTab > Loading and Data Fetching > shows loading state initially

TypeError: window.maestro.directorNotes.onHistoryEntryAdded is not a function ❯ src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx:167:49 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom.development.js:23189:26 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom.development.js:24970:11 ❯ commitPassiveMountEffects_complete node_modules/react-dom/cjs/react-dom.development.js:24930:9 ❯ commitPassiveMountEffects_begin node_modules/react-dom/cjs/react-dom.development.js:24917:7 ❯ commitPassiveMountEffects node_modules/react-dom/cjs/react-dom.development.js:24905:3 ❯ flushPassiveEffectsImpl node_modules/react-dom/cjs/react-dom.development.js:27078:3 ❯ flushPassiveEffects node_modules/react-dom/cjs/react-dom.development.js:27023:14 ❯ node_modules/react-dom/cjs/react-dom.development.js:26808:9 ❯ flushActQueue node_modules/react/cjs/react.development.js:2667:24
(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: sessionNameMap.get(sourceSessionId),
} 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);
}
};
}, [lookbackHours, sessionNameMap]);

useImperativeHandle(
ref,
() => ({
Expand Down Expand Up @@ -439,8 +548,8 @@
onScroll={handleScroll}
>
{/* Stats bar — scrolls with entries */}
{!isLoading && historyStats && historyStats.totalCount > 0 && (
<HistoryStatsBar stats={historyStats} theme={theme} />
{!isLoading && enrichedStats && enrichedStats.totalCount > 0 && (
<HistoryStatsBar stats={enrichedStats} theme={theme} />
)}

{isLoading ? (
Expand Down
54 changes: 53 additions & 1 deletion src/renderer/components/History/HistoryStatsBar.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) && (
<>
<div
className="w-px h-4 flex-shrink-0"
style={{ backgroundColor: theme.colors.border }}
/>
{stats.activeAgentCount !== undefined && stats.activeAgentCount > 0 && (
<div className="flex items-center gap-1.5">
<span
className="flex items-center justify-center w-5 h-5 rounded"
style={{
backgroundColor: theme.colors.warning + '15',
color: theme.colors.warning,
}}
>
<Loader2 className="w-3 h-3 animate-spin" />
</span>
<span
className="text-[10px] uppercase tracking-wider"
style={{ color: theme.colors.textDim }}
>
Active
</span>
<span
className="text-xs font-bold tabular-nums"
style={{ color: theme.colors.warning }}
>
{stats.activeAgentCount}
</span>
</div>
)}
{stats.totalQueuedItems !== undefined && stats.totalQueuedItems > 0 && (
<StatItem
icon={<ListOrdered className="w-3 h-3" />}
label="Queued"
value={stats.totalQueuedItems}
color={theme.colors.accent}
theme={theme}
/>
)}
</>
)}
</div>
);
});
28 changes: 28 additions & 0 deletions src/renderer/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2695,6 +2695,34 @@ 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?: {
totalCostUsd: number;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
};
},
sourceSessionId: string
) => void
) => () => void;
};

// WakaTime API (CLI check, API key validation)
Expand Down
Loading