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:
Pedram Amini
2025-11-26 05:50:36 -06:00
parent 656ef9c5ed
commit 5235b67bf4
7 changed files with 328 additions and 33 deletions

View File

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

View File

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

View File

@@ -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 --- */}

View File

@@ -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 */}

View File

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

View File

@@ -244,7 +244,8 @@ export function useSessionManager(): UseSessionManagerReturn {
fileExplorerScrollPos: 0,
shellCwd: workingDir,
aiCommandHistory: [],
shellCommandHistory: []
shellCommandHistory: [],
messageQueue: []
};
setSessions(prev => [...prev, newSession]);
setActiveSessionId(newId);

View File

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