mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
## CHANGES
- Right-click now selects the clicked file before opening menu 🖱️ - LogViewer shows agent name pill for toast project entries ✏️ - Autorun logs get an agent/session pill pulled from context 🏷️ - Context badges no longer show for toast and autorun logs 🚫 - Added `NOTHING_TO_REPORT` sentinel for “no meaningful work” responses 🧩 - Synopsis parsing now flags `nothingToReport` and skips history creation ⏭️ - New helper `isNothingToReport()` handles ANSI/box-drawing-wrapped tokens 🧼 - Autorun synopsis prompt now requires strict `NOTHING_TO_REPORT` output 📜 - File tree loading avoids redundant reloads after an empty initial load 🌲 - Delete confirmation button uses softer semi-transparent error styling 🎨
This commit is contained in:
@@ -1391,6 +1391,19 @@ describe('FileExplorerPanel', () => {
|
||||
expect(screen.getByText('Reveal in Finder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates selection to right-clicked item when opening context menu', () => {
|
||||
const { container } = render(<FileExplorerPanel {...defaultProps} />);
|
||||
// package.json is at index 1 (after src at index 0)
|
||||
const fileItem = Array.from(container.querySelectorAll('[data-file-index]'))
|
||||
.find(el => el.textContent?.includes('package.json'));
|
||||
const fileIndex = parseInt(fileItem!.getAttribute('data-file-index')!, 10);
|
||||
|
||||
fireEvent.contextMenu(fileItem!, { clientX: 100, clientY: 200 });
|
||||
|
||||
// Should update selection to the right-clicked item
|
||||
expect(defaultProps.setSelectedFileIndex).toHaveBeenCalledWith(fileIndex);
|
||||
});
|
||||
|
||||
it('shows Document Graph option only for markdown files', () => {
|
||||
const onFocusFileInGraph = vi.fn();
|
||||
const { container } = render(
|
||||
|
||||
@@ -41,7 +41,7 @@ const mockTheme: Theme = {
|
||||
// Mock log entries
|
||||
const createMockLog = (overrides: Partial<{
|
||||
timestamp: number;
|
||||
level: 'debug' | 'info' | 'warn' | 'error' | 'toast';
|
||||
level: 'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun';
|
||||
message: string;
|
||||
context?: string;
|
||||
data?: unknown;
|
||||
@@ -1014,6 +1014,83 @@ describe('LogViewer', () => {
|
||||
expect(screen.queryByText('TestModule')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display agent pill for toast entries with project in data', async () => {
|
||||
getMockGetLogs().mockResolvedValue([
|
||||
createMockLog({
|
||||
level: 'toast',
|
||||
message: 'Toast message',
|
||||
data: { project: 'Test Agent', type: 'success' }
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<LogViewer theme={mockTheme} onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Agent')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display agent pill for autorun entries with context', async () => {
|
||||
getMockGetLogs().mockResolvedValue([
|
||||
createMockLog({
|
||||
level: 'autorun',
|
||||
message: 'Auto run started',
|
||||
context: 'My Session'
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<LogViewer theme={mockTheme} onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('My Session')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not show agent pill for toast entries without project', async () => {
|
||||
getMockGetLogs().mockResolvedValue([
|
||||
createMockLog({
|
||||
level: 'toast',
|
||||
message: 'Toast message',
|
||||
data: { type: 'info' } // No project field
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<LogViewer theme={mockTheme} onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Toast message')).toBeInTheDocument();
|
||||
// Should not have any agent pill text
|
||||
expect(screen.queryByText('Test Agent')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not show context badge for toast entries', async () => {
|
||||
getMockGetLogs().mockResolvedValue([
|
||||
createMockLog({
|
||||
level: 'toast',
|
||||
message: 'Test notification',
|
||||
context: 'Toast',
|
||||
data: { project: 'Agent Name' }
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<LogViewer theme={mockTheme} onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show the agent name from data.project
|
||||
expect(screen.getByText('Agent Name')).toBeInTheDocument();
|
||||
// The context "Toast" should not be shown as a separate badge for toast entries
|
||||
// The level pill shows "toast" (lowercase), and context "Toast" (capitalized)
|
||||
// should not appear as a separate context badge
|
||||
const toastLevelPill = screen.getByText('toast');
|
||||
expect(toastLevelPill).toBeInTheDocument();
|
||||
// Make sure there's no separate "Toast" context badge (distinct from the level pill)
|
||||
const allTextElements = screen.queryAllByText('Toast');
|
||||
// Should be 0 - the context "Toast" is not displayed for toast level entries
|
||||
expect(allTextElements.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Footer', () => {
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
*
|
||||
* Coverage:
|
||||
* - parseSynopsis: Parse synopsis response into summary and full text
|
||||
* - isNothingToReport: Check if response indicates nothing to report
|
||||
* - NOTHING_TO_REPORT: Sentinel token constant
|
||||
* - ParsedSynopsis: Interface for parsed result
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseSynopsis, ParsedSynopsis } from '../../shared/synopsis';
|
||||
import { parseSynopsis, ParsedSynopsis, isNothingToReport, NOTHING_TO_REPORT } from '../../shared/synopsis';
|
||||
|
||||
describe('synopsis', () => {
|
||||
describe('parseSynopsis', () => {
|
||||
@@ -18,6 +20,7 @@ describe('synopsis', () => {
|
||||
|
||||
expect(result.shortSummary).toBe('Fixed the authentication bug');
|
||||
expect(result.fullSynopsis).toBe('Fixed the authentication bug\n\nUpdated the login handler to properly validate tokens and handle edge cases.');
|
||||
expect(result.nothingToReport).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse response with Summary only', () => {
|
||||
@@ -26,6 +29,7 @@ describe('synopsis', () => {
|
||||
|
||||
expect(result.shortSummary).toBe('No changes made.');
|
||||
expect(result.fullSynopsis).toBe('No changes made.');
|
||||
expect(result.nothingToReport).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive section headers', () => {
|
||||
@@ -213,13 +217,15 @@ detail line two`;
|
||||
});
|
||||
|
||||
describe('return type validation', () => {
|
||||
it('should always return object with shortSummary and fullSynopsis', () => {
|
||||
it('should always return object with shortSummary, fullSynopsis, and nothingToReport', () => {
|
||||
const result = parseSynopsis('test');
|
||||
|
||||
expect(result).toHaveProperty('shortSummary');
|
||||
expect(result).toHaveProperty('fullSynopsis');
|
||||
expect(result).toHaveProperty('nothingToReport');
|
||||
expect(typeof result.shortSummary).toBe('string');
|
||||
expect(typeof result.fullSynopsis).toBe('string');
|
||||
expect(typeof result.nothingToReport).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should satisfy ParsedSynopsis interface', () => {
|
||||
@@ -229,7 +235,86 @@ detail line two`;
|
||||
// Runtime check that properties exist
|
||||
expect(result.shortSummary).toBeDefined();
|
||||
expect(result.fullSynopsis).toBeDefined();
|
||||
expect(result.nothingToReport).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NOTHING_TO_REPORT detection', () => {
|
||||
it('should detect NOTHING_TO_REPORT token and return nothingToReport: true', () => {
|
||||
const result = parseSynopsis('NOTHING_TO_REPORT');
|
||||
|
||||
expect(result.nothingToReport).toBe(true);
|
||||
expect(result.shortSummary).toBe('');
|
||||
expect(result.fullSynopsis).toBe('');
|
||||
});
|
||||
|
||||
it('should detect NOTHING_TO_REPORT with surrounding whitespace', () => {
|
||||
const result = parseSynopsis(' \n NOTHING_TO_REPORT \n ');
|
||||
|
||||
expect(result.nothingToReport).toBe(true);
|
||||
expect(result.shortSummary).toBe('');
|
||||
expect(result.fullSynopsis).toBe('');
|
||||
});
|
||||
|
||||
it('should detect NOTHING_TO_REPORT with ANSI codes', () => {
|
||||
const result = parseSynopsis('\x1b[32mNOTHING_TO_REPORT\x1b[0m');
|
||||
|
||||
expect(result.nothingToReport).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect NOTHING_TO_REPORT with box drawing characters', () => {
|
||||
const result = parseSynopsis('───────\n│NOTHING_TO_REPORT│\n───────');
|
||||
|
||||
expect(result.nothingToReport).toBe(true);
|
||||
});
|
||||
|
||||
it('should return nothingToReport: false for normal synopsis', () => {
|
||||
const result = parseSynopsis('**Summary:** Fixed the bug\n\n**Details:** Updated code.');
|
||||
|
||||
expect(result.nothingToReport).toBe(false);
|
||||
expect(result.shortSummary).toBe('Fixed the bug');
|
||||
});
|
||||
|
||||
it('should return nothingToReport: false for empty responses', () => {
|
||||
// Empty responses should fall back to "Task completed", not NOTHING_TO_REPORT
|
||||
const result = parseSynopsis('');
|
||||
|
||||
expect(result.nothingToReport).toBe(false);
|
||||
expect(result.shortSummary).toBe('Task completed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNothingToReport', () => {
|
||||
it('should return true for exact NOTHING_TO_REPORT', () => {
|
||||
expect(isNothingToReport('NOTHING_TO_REPORT')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when NOTHING_TO_REPORT is embedded in response', () => {
|
||||
expect(isNothingToReport('Some preamble\nNOTHING_TO_REPORT\nSome postamble')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true with ANSI codes around token', () => {
|
||||
expect(isNothingToReport('\x1b[32mNOTHING_TO_REPORT\x1b[0m')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for normal responses', () => {
|
||||
expect(isNothingToReport('**Summary:** Fixed the bug')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(isNothingToReport('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for partial matches', () => {
|
||||
expect(isNothingToReport('NOTHING_TO')).toBe(false);
|
||||
expect(isNothingToReport('TO_REPORT')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NOTHING_TO_REPORT constant', () => {
|
||||
it('should be the expected string value', () => {
|
||||
expect(NOTHING_TO_REPORT).toBe('NOTHING_TO_REPORT');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,4 +9,5 @@ Rules:
|
||||
- Focus only on meaningful work that was done. Omit filler phrases like "the task is complete", "no further action needed", "everything is working", etc.
|
||||
- NEVER include preamble about session context, interaction history, or caveats like "This is our first interaction", "there's no prior work to summarize", "you asked me to", etc. Jump straight to the accomplishment.
|
||||
- Start directly with the action taken (e.g., "Fixed button visibility..." not "You asked me to fix...").
|
||||
- If nothing meaningful was accomplished, respond with only: **Summary:** No changes made.
|
||||
- If nothing meaningful was accomplished (no code changes, no files modified, no research completed, just greetings or introductions), respond with ONLY the text: NOTHING_TO_REPORT
|
||||
- Use NOTHING_TO_REPORT when the conversation was just a greeting, introduction, or there genuinely was no work to summarize.
|
||||
|
||||
@@ -4888,6 +4888,29 @@ You are taking over this conversation. Based on the context above, provide a bri
|
||||
// Parse the synopsis response
|
||||
const parsed = parseSynopsis(result.response);
|
||||
|
||||
// Check if AI indicated nothing meaningful to report
|
||||
if (parsed.nothingToReport) {
|
||||
// Update the pending log to indicate nothing to report
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== activeSession.id) return s;
|
||||
return {
|
||||
...s,
|
||||
aiTabs: s.aiTabs.map(tab => {
|
||||
if (tab.id !== activeTab.id) return tab;
|
||||
return {
|
||||
...tab,
|
||||
logs: tab.logs.map(log =>
|
||||
log.id === pendingLog.id
|
||||
? { ...log, text: 'Nothing to report - no history entry created.' }
|
||||
: log
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get group info for the history entry
|
||||
const group = groups.find(g => g.id === activeSession.groupId);
|
||||
const groupName = group?.name || 'Ungrouped';
|
||||
|
||||
@@ -72,7 +72,7 @@ export function DeleteAgentConfirmModal({
|
||||
onKeyDown={(e) => handleKeyDown(e, handleConfirm)}
|
||||
className="px-4 py-2 rounded transition-colors outline-none focus:ring-2 focus:ring-offset-1"
|
||||
style={{
|
||||
backgroundColor: theme.colors.error,
|
||||
backgroundColor: `${theme.colors.error}99`,
|
||||
color: '#ffffff',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -462,16 +462,18 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) {
|
||||
}, [onAutoRefreshChange]);
|
||||
|
||||
// Context menu handlers
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, node: FileNode, path: string) => {
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, node: FileNode, path: string, globalIndex: number) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Update selection to the right-clicked item so user sees which item the menu affects
|
||||
setSelectedFileIndex(globalIndex);
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
node,
|
||||
path
|
||||
});
|
||||
}, []);
|
||||
}, [setSelectedFileIndex]);
|
||||
|
||||
const handleFocusInGraph = useCallback(() => {
|
||||
if (contextMenu && onFocusFileInGraph) {
|
||||
@@ -819,7 +821,7 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) {
|
||||
handleFileClick(node, fullPath, session);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => handleContextMenu(e, node, fullPath)}
|
||||
onContextMenu={(e) => handleContextMenu(e, node, fullPath, globalIndex)}
|
||||
>
|
||||
{indentGuides}
|
||||
{isFolder && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { Search, X, Trash2, Download, ChevronRight, ChevronDown, ChevronsDownUp, ChevronsUpDown } from 'lucide-react';
|
||||
import { Search, X, Trash2, Download, ChevronRight, ChevronDown, ChevronsDownUp, ChevronsUpDown, Pencil } from 'lucide-react';
|
||||
import type { Theme } from '../types';
|
||||
import { useLayerStack } from '../contexts/LayerStackContext';
|
||||
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
||||
@@ -567,7 +567,8 @@ export function LogViewer({ theme, onClose, logLevel = 'info', savedSelectedLeve
|
||||
<span className="text-xs opacity-50 font-mono flex-shrink-0" style={{ color: theme.colors.textDim }}>
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
{log.context && (
|
||||
{/* Context pill - show for non-toast/autorun entries */}
|
||||
{log.level !== 'toast' && log.level !== 'autorun' && log.context && (
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded font-mono"
|
||||
style={{ backgroundColor: theme.colors.bgMain, color: theme.colors.accent }}
|
||||
@@ -575,6 +576,32 @@ export function LogViewer({ theme, onClose, logLevel = 'info', savedSelectedLeve
|
||||
{log.context}
|
||||
</span>
|
||||
)}
|
||||
{/* Agent name pill for toast entries (from data.project) */}
|
||||
{(() => {
|
||||
if (log.level !== 'toast') return null;
|
||||
const data = log.data as { project?: string } | undefined;
|
||||
const project = data?.project;
|
||||
if (!project) return null;
|
||||
return (
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded flex items-center gap-1"
|
||||
style={{ backgroundColor: 'rgba(34, 197, 94, 0.2)', color: '#22c55e' }}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
{project}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{/* Agent name pill for autorun entries (from context) */}
|
||||
{log.level === 'autorun' && log.context && (
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded flex items-center gap-1"
|
||||
style={{ backgroundColor: 'rgba(34, 197, 94, 0.2)', color: '#22c55e' }}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
{log.context}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm break-words" style={{ color: theme.colors.textMain }}>
|
||||
{log.message}
|
||||
|
||||
@@ -233,8 +233,10 @@ export function useFileTreeManagement(
|
||||
const session = sessions.find(s => s.id === activeSessionId);
|
||||
if (!session) return;
|
||||
|
||||
// Only load if file tree is empty and not already loading
|
||||
if ((!session.fileTree || session.fileTree.length === 0) && !session.fileTreeLoading) {
|
||||
// Only load if file tree is empty, not already loading, and hasn't been loaded yet
|
||||
// fileTreeStats is set after successful load, so we use it to detect "loaded but empty"
|
||||
const hasLoadedOnce = session.fileTreeStats !== undefined || session.fileTreeError !== undefined;
|
||||
if ((!session.fileTree || session.fileTree.length === 0) && !session.fileTreeLoading && !hasLoadedOnce) {
|
||||
// Check if we're in a retry backoff period
|
||||
if (session.fileTreeRetryAt && Date.now() < session.fileTreeRetryAt) {
|
||||
// Schedule retry when backoff expires (if not already scheduled)
|
||||
|
||||
@@ -4,13 +4,22 @@
|
||||
*
|
||||
* Functions:
|
||||
* - parseSynopsis: Parse AI-generated synopsis responses into structured format
|
||||
* - isNothingToReport: Check if response indicates no meaningful work was done
|
||||
*/
|
||||
|
||||
import { stripAnsiCodes } from './stringUtils';
|
||||
|
||||
/**
|
||||
* Sentinel token that AI agents should return when there's nothing meaningful to report.
|
||||
* When detected, callers should skip creating a history entry.
|
||||
*/
|
||||
export const NOTHING_TO_REPORT = 'NOTHING_TO_REPORT';
|
||||
|
||||
export interface ParsedSynopsis {
|
||||
shortSummary: string;
|
||||
fullSynopsis: string;
|
||||
/** True if the AI indicated there was nothing meaningful to report */
|
||||
nothingToReport: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,6 +38,22 @@ function isTemplatePlaceholder(text: string): boolean {
|
||||
return placeholderPatterns.some(pattern => pattern.test(text.trim()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a response indicates nothing meaningful to report.
|
||||
* Looks for the NOTHING_TO_REPORT sentinel token anywhere in the response.
|
||||
*
|
||||
* @param response - Raw AI response string
|
||||
* @returns True if the response contains NOTHING_TO_REPORT
|
||||
*/
|
||||
export function isNothingToReport(response: string): boolean {
|
||||
const clean = stripAnsiCodes(response)
|
||||
.replace(/─+/g, '')
|
||||
.replace(/[│┌┐└┘├┤┬┴┼]/g, '')
|
||||
.trim();
|
||||
|
||||
return clean.includes(NOTHING_TO_REPORT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a synopsis response into short summary and full synopsis.
|
||||
*
|
||||
@@ -40,8 +65,11 @@ function isTemplatePlaceholder(text: string): boolean {
|
||||
* Filters out template placeholders that models sometimes output literally
|
||||
* (especially common with thinking/reasoning models).
|
||||
*
|
||||
* If the response contains NOTHING_TO_REPORT, returns nothingToReport: true
|
||||
* and callers should skip creating a history entry.
|
||||
*
|
||||
* @param response - Raw AI response string (may contain ANSI codes, box drawing chars)
|
||||
* @returns Parsed synopsis with shortSummary and fullSynopsis
|
||||
* @returns Parsed synopsis with shortSummary, fullSynopsis, and nothingToReport flag
|
||||
*/
|
||||
export function parseSynopsis(response: string): ParsedSynopsis {
|
||||
// Clean up ANSI codes and box drawing characters
|
||||
@@ -50,6 +78,15 @@ export function parseSynopsis(response: string): ParsedSynopsis {
|
||||
.replace(/[│┌┐└┘├┤┬┴┼]/g, '')
|
||||
.trim();
|
||||
|
||||
// Check for the sentinel token first
|
||||
if (clean.includes(NOTHING_TO_REPORT)) {
|
||||
return {
|
||||
shortSummary: '',
|
||||
fullSynopsis: '',
|
||||
nothingToReport: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Try to extract Summary and Details sections
|
||||
const summaryMatch = clean.match(/\*\*Summary:\*\*\s*(.+?)(?=\*\*Details:\*\*|$)/is);
|
||||
const detailsMatch = clean.match(/\*\*Details:\*\*\s*(.+?)$/is);
|
||||
@@ -82,5 +119,5 @@ export function parseSynopsis(response: string): ParsedSynopsis {
|
||||
// Full synopsis includes both parts
|
||||
const fullSynopsis = details ? `${shortSummary}\n\n${details}` : shortSummary;
|
||||
|
||||
return { shortSummary, fullSynopsis };
|
||||
return { shortSummary, fullSynopsis, nothingToReport: false };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user