mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
## CHANGES
- Windows shell detection now prioritizes PowerShell first, improving reliability. 🪟 - Windows `sh` and `bash` commands now map to `bash.exe`. 🧩 - About modal tests now match Hands-on Time text more robustly. ✅ - Execution Queue Browser now advertises drag-and-drop reordering in helper text. 🧷 - Codex session cache bumped to v3 for improved previews. 🗃️ - Session previews now skip markdown-style system context headers automatically. 🧹 - Agent sessions stats now compute locally for non-Claude agents. 📊 - Claude stats subscription now keys off `agentId`, not toolType. 🔌 - Sessions now merge origin, name, and starred metadata during pagination. 🔄 - Group chat exports now embed markdown images cleanly using images map. 🖼️
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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('<environment_context>') ||
|
||||
trimmed.startsWith('<cwd>') ||
|
||||
trimmed.startsWith('<system>') ||
|
||||
trimmed.startsWith('<approval_policy>');
|
||||
if (trimmed.startsWith('<environment_context>') ||
|
||||
trimmed.startsWith('<cwd>') ||
|
||||
trimmed.startsWith('<system>') ||
|
||||
trimmed.startsWith('<approval_policy>')) {
|
||||
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 {
|
||||
|
||||
@@ -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)}
|
||||
</span>
|
||||
<span className="text-[10px]" style={{ color: theme.colors.textDim }}>
|
||||
of 200k context <span className="font-mono font-medium" style={{ color: theme.colors.accent }}>{Math.min(100, ((viewingSession.inputTokens + viewingSession.outputTokens) / 200000) * 100).toFixed(1)}%</span>
|
||||
of 200k context <span className="font-mono font-medium" style={{ color: (() => {
|
||||
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)}%</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
3
src/renderer/global.d.ts
vendored
3
src/renderer/global.d.ts
vendored
@@ -372,6 +372,9 @@ interface MaestroAPI {
|
||||
cacheReadTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
durationSeconds: number;
|
||||
origin?: 'user' | 'auto';
|
||||
sessionName?: string;
|
||||
starred?: boolean;
|
||||
}>;
|
||||
hasMore: boolean;
|
||||
totalCount: number;
|
||||
|
||||
@@ -84,6 +84,9 @@ export function useSessionPagination({
|
||||
// Container ref for scroll handling
|
||||
const sessionsContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Store origins map for merging into paginated results
|
||||
const originsMapRef = useRef<Map<string, { origin?: string; sessionName?: string; starred?: boolean }>>(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<string, { origin?: string; sessionName?: string; starred?: boolean }>();
|
||||
if (agentId === 'claude-code') {
|
||||
const origins = await window.maestro.claude.getSessionOrigins(projectPath);
|
||||
const starredFromOrigins = new Set<string>();
|
||||
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);
|
||||
|
||||
@@ -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, string> = {}): string {
|
||||
let html = escapeHtml(content);
|
||||
|
||||
// Code blocks (```)
|
||||
@@ -99,6 +100,26 @@ function formatContent(content: string): string {
|
||||
// Numbered lists
|
||||
html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
|
||||
|
||||
// Markdown images  - 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 `<img src="${dataUrl}" alt="${alt}" class="embedded-image" />`;
|
||||
}
|
||||
return `<img src="${url}" alt="${alt}" class="embedded-image" />`;
|
||||
});
|
||||
|
||||
// [Image: filename] pattern
|
||||
html = html.replace(/\[Image: ([^\]]+)\]/gi, (_match, filename) => {
|
||||
const dataUrl = images[filename.trim()];
|
||||
if (dataUrl) {
|
||||
return `<img src="${dataUrl}" alt="${filename.trim()}" class="embedded-image" />`;
|
||||
}
|
||||
return _match; // Leave as-is if no image data
|
||||
});
|
||||
|
||||
// Links [text](url)
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
|
||||
|
||||
@@ -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'),
|
||||
`<img src="${dataUrl}" alt="$1" class="embedded-image" />`
|
||||
);
|
||||
content = content.replace(
|
||||
new RegExp(`\\[Image: [^\\]]*${escapeRegExp(filename)}[^\\]]*\\]`, 'gi'),
|
||||
`<img src="${dataUrl}" alt="${filename}" class="embedded-image" />`
|
||||
);
|
||||
}
|
||||
|
||||
const formattedContent = formatContent(content);
|
||||
// Format content with images map for embedding
|
||||
const formattedContent = formatContent(msg.content, images);
|
||||
|
||||
return `
|
||||
<div class="message ${isUser ? 'message-user' : 'message-agent'}">
|
||||
|
||||
Reference in New Issue
Block a user