## 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:
Pedram Amini
2025-12-21 17:35:44 -06:00
parent 9eef7b7728
commit 706fae7482
8 changed files with 140 additions and 41 deletions

View File

@@ -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', () => {

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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 {

View File

@@ -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>

View File

@@ -372,6 +372,9 @@ interface MaestroAPI {
cacheReadTokens: number;
cacheCreationTokens: number;
durationSeconds: number;
origin?: 'user' | 'auto';
sessionName?: string;
starred?: boolean;
}>;
hasMore: boolean;
totalCount: number;

View File

@@ -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);

View File

@@ -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 ![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 `<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'}">