mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
feat: Add message queue for AI mode
Messages sent while the AI is busy are now queued and automatically processed when the current task completes. Features include: - Queue display in terminal output with "QUEUED" separator - Ability to remove individual queued messages via UI - Blocks slash commands and session clearing while queue has items - Sequential processing of queued messages on agent exit Updated README.md and CLAUDE.md to document the new feature.
This commit is contained in:
@@ -181,6 +181,7 @@ interface Session {
|
||||
isGitRepo: boolean; // Git features enabled
|
||||
fileTree: any[]; // File explorer tree
|
||||
fileExplorerExpanded: string[]; // Expanded folder paths
|
||||
messageQueue: LogEntry[]; // Messages queued while AI is busy
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ Download the latest release for your platform from the [Releases](https://github
|
||||
- 📋 **Session Management** - Group, rename, and organize your sessions
|
||||
- 📝 **Scratchpad** - Built-in markdown editor with live preview for task management
|
||||
- ⚡ **Slash Commands** - Extensible command system with autocomplete
|
||||
- 📬 **Message Queueing** - Queue messages while AI is busy; they're sent automatically when ready
|
||||
- 🌐 **Remote Access** - Built-in web server with optional ngrok/Cloudflare tunneling
|
||||
- 💰 **Cost Tracking** - Real-time token usage and cost tracking per session
|
||||
|
||||
|
||||
@@ -480,9 +480,28 @@ export default function MaestroConsole() {
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== actualSessionId) return s;
|
||||
|
||||
// For AI agent exits, just update state without adding log entry
|
||||
// For AI agent exits, check if there are queued messages to process
|
||||
// For terminal exits, show the exit code
|
||||
if (isFromAi) {
|
||||
// Check if there are queued messages
|
||||
if (s.messageQueue.length > 0) {
|
||||
// Dequeue first message and add to logs
|
||||
const [nextMessage, ...remainingQueue] = s.messageQueue;
|
||||
|
||||
// Schedule the next message to be sent (async, after state update)
|
||||
setTimeout(() => {
|
||||
processQueuedMessage(actualSessionId, nextMessage);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
...s,
|
||||
aiLogs: [...s.aiLogs, nextMessage],
|
||||
messageQueue: remainingQueue,
|
||||
thinkingStartTime: Date.now()
|
||||
// Keep state as 'busy' since we're processing next message
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...s,
|
||||
state: 'idle' as SessionState,
|
||||
@@ -824,6 +843,23 @@ export default function MaestroConsole() {
|
||||
const startNewClaudeSession = useCallback(() => {
|
||||
if (!activeSession) return;
|
||||
|
||||
// Block clearing when there are queued messages
|
||||
if (activeSession.messageQueue.length > 0) {
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== activeSession.id) return s;
|
||||
return {
|
||||
...s,
|
||||
aiLogs: [...s.aiLogs, {
|
||||
id: generateId(),
|
||||
timestamp: Date.now(),
|
||||
source: 'system',
|
||||
text: 'Cannot clear session while messages are queued. Remove queued messages first.'
|
||||
}]
|
||||
};
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setSessions(prev => prev.map(s =>
|
||||
s.id === activeSession.id ? { ...s, claudeSessionId: undefined, aiLogs: [], state: 'idle' as SessionState } : s
|
||||
));
|
||||
@@ -1639,6 +1675,26 @@ export default function MaestroConsole() {
|
||||
const processInput = () => {
|
||||
if (!activeSession || (!inputValue.trim() && stagedImages.length === 0)) return;
|
||||
|
||||
// Block slash commands when there are queued messages
|
||||
if (inputValue.trim().startsWith('/') && activeSession.messageQueue.length > 0) {
|
||||
const targetLogKey = activeSession.inputMode === 'ai' ? 'aiLogs' : 'shellLogs';
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== activeSessionId) return s;
|
||||
return {
|
||||
...s,
|
||||
[targetLogKey]: [...s[targetLogKey], {
|
||||
id: generateId(),
|
||||
timestamp: Date.now(),
|
||||
source: 'system',
|
||||
text: 'Cannot execute commands while messages are queued. Clear the queue first.'
|
||||
}]
|
||||
};
|
||||
}));
|
||||
setInputValue('');
|
||||
if (inputRef.current) inputRef.current.style.height = 'auto';
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle slash commands
|
||||
if (inputValue.trim().startsWith('/')) {
|
||||
const commandText = inputValue.trim();
|
||||
@@ -1702,6 +1758,31 @@ export default function MaestroConsole() {
|
||||
const currentMode = activeSession.inputMode;
|
||||
const targetLogKey = currentMode === 'ai' ? 'aiLogs' : 'shellLogs';
|
||||
|
||||
// Queue messages when AI is busy (only in AI mode)
|
||||
if (activeSession.state === 'busy' && currentMode === 'ai') {
|
||||
const queuedEntry: LogEntry = {
|
||||
id: generateId(),
|
||||
timestamp: Date.now(),
|
||||
source: 'user',
|
||||
text: inputValue,
|
||||
images: [...stagedImages]
|
||||
};
|
||||
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== activeSessionId) return s;
|
||||
return {
|
||||
...s,
|
||||
messageQueue: [...s.messageQueue, queuedEntry]
|
||||
};
|
||||
}));
|
||||
|
||||
// Clear input
|
||||
setInputValue('');
|
||||
setStagedImages([]);
|
||||
if (inputRef.current) inputRef.current.style.height = 'auto';
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[processInput] Processing input', {
|
||||
currentMode,
|
||||
inputValue: inputValue.substring(0, 50),
|
||||
@@ -1910,6 +1991,59 @@ export default function MaestroConsole() {
|
||||
}
|
||||
};
|
||||
|
||||
// Process a queued message (called from onExit when queue has items)
|
||||
const processQueuedMessage = async (sessionId: string, entry: LogEntry) => {
|
||||
const session = sessions.find(s => s.id === sessionId);
|
||||
if (!session) {
|
||||
console.error('[processQueuedMessage] Session not found:', sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetSessionId = `${sessionId}-ai`;
|
||||
|
||||
try {
|
||||
// Get agent configuration
|
||||
const agent = await window.maestro.agents.get('claude-code');
|
||||
if (!agent) throw new Error('Claude Code agent not found');
|
||||
|
||||
// Build spawn args with resume if we have a session ID
|
||||
const spawnArgs = [...agent.args];
|
||||
|
||||
if (session.claudeSessionId) {
|
||||
spawnArgs.push('--resume', session.claudeSessionId);
|
||||
}
|
||||
|
||||
// Spawn Claude with prompt from queued entry
|
||||
const commandToUse = agent.path || agent.command;
|
||||
console.log('[processQueuedMessage] Spawning Claude for queued message:', { sessionId, text: entry.text.substring(0, 50) });
|
||||
|
||||
await window.maestro.process.spawn({
|
||||
sessionId: targetSessionId,
|
||||
toolType: 'claude-code',
|
||||
cwd: session.cwd,
|
||||
command: commandToUse,
|
||||
args: spawnArgs,
|
||||
prompt: entry.text,
|
||||
images: entry.images && entry.images.length > 0 ? entry.images : undefined
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[processQueuedMessage] Failed to spawn Claude:', error);
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== sessionId) return s;
|
||||
return {
|
||||
...s,
|
||||
state: 'idle',
|
||||
aiLogs: [...s.aiLogs, {
|
||||
id: generateId(),
|
||||
timestamp: Date.now(),
|
||||
source: 'system',
|
||||
text: `Error: Failed to process queued message - ${error.message}`
|
||||
}]
|
||||
};
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleInterrupt = async () => {
|
||||
if (!activeSession) return;
|
||||
|
||||
@@ -2427,6 +2561,22 @@ export default function MaestroConsole() {
|
||||
startFreshSession={() => {
|
||||
// Create a fresh AI terminal session by clearing the Claude session ID and AI logs
|
||||
if (activeSession) {
|
||||
// Block clearing when there are queued messages
|
||||
if (activeSession.messageQueue.length > 0) {
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== activeSession.id) return s;
|
||||
return {
|
||||
...s,
|
||||
aiLogs: [...s.aiLogs, {
|
||||
id: generateId(),
|
||||
timestamp: Date.now(),
|
||||
source: 'system',
|
||||
text: 'Cannot clear session while messages are queued. Remove queued messages first.'
|
||||
}]
|
||||
};
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setSessions(prev => prev.map(s =>
|
||||
s.id === activeSession.id ? { ...s, claudeSessionId: undefined, aiLogs: [], state: 'idle' } : s
|
||||
));
|
||||
@@ -2626,6 +2776,22 @@ export default function MaestroConsole() {
|
||||
onNewClaudeSession={() => {
|
||||
// Create a fresh AI terminal session by clearing the Claude session ID and AI logs
|
||||
if (activeSession) {
|
||||
// Block clearing when there are queued messages
|
||||
if (activeSession.messageQueue.length > 0) {
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== activeSession.id) return s;
|
||||
return {
|
||||
...s,
|
||||
aiLogs: [...s.aiLogs, {
|
||||
id: generateId(),
|
||||
timestamp: Date.now(),
|
||||
source: 'system',
|
||||
text: 'Cannot clear session while messages are queued. Remove queued messages first.'
|
||||
}]
|
||||
};
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setSessions(prev => prev.map(s =>
|
||||
s.id === activeSession.id ? { ...s, claudeSessionId: undefined, aiLogs: [], state: 'idle' } : s
|
||||
));
|
||||
@@ -2725,6 +2891,16 @@ export default function MaestroConsole() {
|
||||
|
||||
return nextUserCommandIndex;
|
||||
}}
|
||||
onRemoveQueuedMessage={(messageId: string) => {
|
||||
if (!activeSession) return;
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== activeSession.id) return s;
|
||||
return {
|
||||
...s,
|
||||
messageQueue: s.messageQueue.filter(msg => msg.id !== messageId)
|
||||
};
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* --- RIGHT PANEL --- */}
|
||||
|
||||
@@ -87,6 +87,7 @@ interface MainPanelProps {
|
||||
getContextColor: (usage: number, theme: Theme) => string;
|
||||
setActiveSessionId: (id: string) => void;
|
||||
onDeleteLog?: (logId: string) => void;
|
||||
onRemoveQueuedMessage?: (messageId: string) => void;
|
||||
|
||||
// Auto mode props
|
||||
batchRunState?: BatchRunState;
|
||||
@@ -108,7 +109,7 @@ export function MainPanel(props: MainPanelProps) {
|
||||
setAboutModalOpen, setRightPanelOpen, inputRef, logsEndRef, terminalOutputRef,
|
||||
fileTreeContainerRef, fileTreeFilterInputRef, toggleTunnel, toggleInputMode, processInput, handleInterrupt,
|
||||
handleInputKeyDown, handlePaste, handleDrop, getContextColor, setActiveSessionId,
|
||||
batchRunState, onStopBatchRun, showConfirmation
|
||||
batchRunState, onStopBatchRun, showConfirmation, onRemoveQueuedMessage
|
||||
} = props;
|
||||
|
||||
const isAutoModeActive = batchRunState?.isRunning || false;
|
||||
@@ -484,6 +485,7 @@ export function MainPanel(props: MainPanelProps) {
|
||||
logsEndRef={logsEndRef}
|
||||
maxOutputLines={maxOutputLines}
|
||||
onDeleteLog={props.onDeleteLog}
|
||||
onRemoveQueuedMessage={onRemoveQueuedMessage}
|
||||
/>
|
||||
|
||||
{/* Input Area */}
|
||||
|
||||
@@ -22,13 +22,14 @@ interface TerminalOutputProps {
|
||||
logsEndRef: React.RefObject<HTMLDivElement>;
|
||||
maxOutputLines: number;
|
||||
onDeleteLog?: (logId: string) => number | null; // Returns the index to scroll to after deletion
|
||||
onRemoveQueuedMessage?: (messageId: string) => void; // Callback to remove a queued message
|
||||
}
|
||||
|
||||
export const TerminalOutput = forwardRef<HTMLDivElement, TerminalOutputProps>((props, ref) => {
|
||||
const {
|
||||
session, theme, fontFamily, activeFocus, outputSearchOpen, outputSearchQuery,
|
||||
setOutputSearchOpen, setOutputSearchQuery, setActiveFocus, setLightboxImage,
|
||||
inputRef, logsEndRef, maxOutputLines, onDeleteLog
|
||||
inputRef, logsEndRef, maxOutputLines, onDeleteLog, onRemoveQueuedMessage
|
||||
} = props;
|
||||
|
||||
// Use the forwarded ref if provided, otherwise create a local one
|
||||
@@ -50,6 +51,9 @@ export const TerminalOutput = forwardRef<HTMLDivElement, TerminalOutputProps>((p
|
||||
// Delete confirmation state
|
||||
const [deleteConfirmLogId, setDeleteConfirmLogId] = useState<string | null>(null);
|
||||
|
||||
// Queue removal confirmation state
|
||||
const [queueRemoveConfirmId, setQueueRemoveConfirmId] = useState<string | null>(null);
|
||||
|
||||
// Elapsed time for thinking indicator (in seconds)
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
|
||||
@@ -837,40 +841,148 @@ export const TerminalOutput = forwardRef<HTMLDivElement, TerminalOutputProps>((p
|
||||
className="flex-1"
|
||||
itemContent={(index, log) => <LogItem index={index} log={log} />}
|
||||
components={{
|
||||
Footer: () => session.state === 'busy' ? (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-1 py-6 mx-6 my-4 rounded-xl border"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgActivity,
|
||||
borderColor: theme.colors.border
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
Footer: () => (
|
||||
<>
|
||||
{/* Busy indicator */}
|
||||
{session.state === 'busy' && (
|
||||
<div
|
||||
className="w-2 h-2 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: theme.colors.warning }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: theme.colors.textMain }}>
|
||||
{session.statusMessage || (session.inputMode === 'ai' ? 'Claude is thinking...' : 'Executing command...')}
|
||||
</span>
|
||||
<span
|
||||
className="text-sm font-mono"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
className="flex flex-col items-center justify-center gap-1 py-6 mx-6 my-4 rounded-xl border"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgActivity,
|
||||
borderColor: theme.colors.border
|
||||
}}
|
||||
>
|
||||
{formatElapsedTime(elapsedSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
{session.usageStats && (
|
||||
<div className="flex items-center gap-4 mt-1 text-xs" style={{ color: theme.colors.textDim }}>
|
||||
<span>In: {session.usageStats.inputTokens.toLocaleString()}</span>
|
||||
<span>Out: {session.usageStats.outputTokens.toLocaleString()}</span>
|
||||
{session.usageStats.cacheReadInputTokens > 0 && (
|
||||
<span>Cache: {session.usageStats.cacheReadInputTokens.toLocaleString()}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: theme.colors.warning }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: theme.colors.textMain }}>
|
||||
{session.statusMessage || (session.inputMode === 'ai' ? 'Claude is thinking...' : 'Executing command...')}
|
||||
</span>
|
||||
<span
|
||||
className="text-sm font-mono"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
>
|
||||
{formatElapsedTime(elapsedSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
{session.usageStats && (
|
||||
<div className="flex items-center gap-4 mt-1 text-xs" style={{ color: theme.colors.textDim }}>
|
||||
<span>In: {session.usageStats.inputTokens.toLocaleString()}</span>
|
||||
<span>Out: {session.usageStats.outputTokens.toLocaleString()}</span>
|
||||
{session.usageStats.cacheReadInputTokens > 0 && (
|
||||
<span>Cache: {session.usageStats.cacheReadInputTokens.toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : <div ref={logsEndRef} />
|
||||
|
||||
{/* Queued messages section */}
|
||||
{session.messageQueue && session.messageQueue.length > 0 && (
|
||||
<>
|
||||
{/* QUEUED separator */}
|
||||
<div className="mx-6 my-3 flex items-center gap-3">
|
||||
<div className="flex-1 h-px" style={{ backgroundColor: theme.colors.border }} />
|
||||
<span
|
||||
className="text-xs font-bold tracking-wider"
|
||||
style={{ color: theme.colors.warning }}
|
||||
>
|
||||
QUEUED
|
||||
</span>
|
||||
<div className="flex-1 h-px" style={{ backgroundColor: theme.colors.border }} />
|
||||
</div>
|
||||
|
||||
{/* Queued messages */}
|
||||
{session.messageQueue.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="mx-6 mb-2 p-3 rounded-lg opacity-60 relative group"
|
||||
style={{
|
||||
backgroundColor: theme.colors.accent + '20',
|
||||
borderLeft: `3px solid ${theme.colors.accent}`
|
||||
}}
|
||||
>
|
||||
{/* Remove button */}
|
||||
<button
|
||||
onClick={() => setQueueRemoveConfirmId(msg.id)}
|
||||
className="absolute top-2 right-2 p-1 rounded hover:bg-black/20 transition-colors"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
title="Remove from queue"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Message content */}
|
||||
<div
|
||||
className="text-sm pr-8 whitespace-pre-wrap break-words"
|
||||
style={{ color: theme.colors.textMain }}
|
||||
>
|
||||
{msg.text.length > 200 ? msg.text.substring(0, 200) + '...' : msg.text}
|
||||
</div>
|
||||
|
||||
{/* Images indicator */}
|
||||
{msg.images && msg.images.length > 0 && (
|
||||
<div
|
||||
className="mt-1 text-xs"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
>
|
||||
{msg.images.length} image{msg.images.length > 1 ? 's' : ''} attached
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Queue removal confirmation modal */}
|
||||
{queueRemoveConfirmId && (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center z-50"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={() => setQueueRemoveConfirmId(null)}
|
||||
>
|
||||
<div
|
||||
className="p-4 rounded-lg shadow-xl max-w-md mx-4"
|
||||
style={{ backgroundColor: theme.colors.bgMain }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-lg font-semibold mb-2" style={{ color: theme.colors.textMain }}>
|
||||
Remove Queued Message?
|
||||
</h3>
|
||||
<p className="text-sm mb-4" style={{ color: theme.colors.textDim }}>
|
||||
This message will be removed from the queue and will not be sent.
|
||||
</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => setQueueRemoveConfirmId(null)}
|
||||
className="px-3 py-1.5 rounded text-sm"
|
||||
style={{ backgroundColor: theme.colors.bgActivity, color: theme.colors.textMain }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onRemoveQueuedMessage) {
|
||||
onRemoveQueuedMessage(queueRemoveConfirmId);
|
||||
}
|
||||
setQueueRemoveConfirmId(null);
|
||||
}}
|
||||
className="px-3 py-1.5 rounded text-sm"
|
||||
style={{ backgroundColor: theme.colors.error, color: 'white' }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* End ref for scrolling */}
|
||||
{session.state !== 'busy' && <div ref={logsEndRef} />}
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -244,7 +244,8 @@ export function useSessionManager(): UseSessionManagerReturn {
|
||||
fileExplorerScrollPos: 0,
|
||||
shellCwd: workingDir,
|
||||
aiCommandHistory: [],
|
||||
shellCommandHistory: []
|
||||
shellCommandHistory: [],
|
||||
messageQueue: []
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveSessionId(newId);
|
||||
|
||||
@@ -144,6 +144,8 @@ export interface Session {
|
||||
statusMessage?: string;
|
||||
// Timestamp when agent started processing (for elapsed time display)
|
||||
thinkingStartTime?: number;
|
||||
// Message queue for AI mode - messages sent while busy are queued here
|
||||
messageQueue: LogEntry[];
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
|
||||
Reference in New Issue
Block a user