feat: add message queue functionality with auto-send and queue mode toggle
- Improved development server hot module replacement (HMR) by reusing OpenCode processes. - Introduced queued message mode with batching and idle auto-send, including support for attachments. - Added a toggle for queue mode in OpenChamber settings with persistence across sessions. - Updated various components to handle queued messages, including ChatInput and QueuedMessageChips. - Modified settings payload and desktop settings to include queue mode configuration. - Enhanced message sending logic to support additional parts for queued messages.
This commit is contained in:
@@ -11,9 +11,11 @@ import {
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
|
||||
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||
import type { AttachedFile, EditPermissionMode } from '@/stores/types/sessionTypes';
|
||||
import { getEditModeColors } from '@/lib/permissions/editModeColors';
|
||||
import { AttachedFilesList } from './FileAttachment';
|
||||
import { QueuedMessageChips } from './QueuedMessageChips';
|
||||
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
|
||||
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
|
||||
import { AgentMentionAutocomplete, type AgentMentionAutocompleteHandle } from './AgentMentionAutocomplete';
|
||||
@@ -23,6 +25,7 @@ import { ModelControls } from './ModelControls';
|
||||
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||
import { StatusRow } from './StatusRow';
|
||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
||||
import { toast } from 'sonner';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults';
|
||||
@@ -35,6 +38,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
||||
const EMPTY_QUEUE: QueuedMessage[] = [];
|
||||
|
||||
interface ChatInputProps {
|
||||
onOpenSettings?: () => void;
|
||||
@@ -67,6 +71,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const abortPromptSessionId = useSessionStore((state) => state.abortPromptSessionId);
|
||||
const abortPromptExpiresAt = useSessionStore((state) => state.abortPromptExpiresAt);
|
||||
const clearAbortPrompt = useSessionStore((state) => state.clearAbortPrompt);
|
||||
const sessionAbortFlags = useSessionStore((state) => state.sessionAbortFlags);
|
||||
const attachedFiles = useSessionStore((state) => state.attachedFiles);
|
||||
const addAttachedFile = useSessionStore((state) => state.addAttachedFile);
|
||||
const addServerFile = useSessionStore((state) => state.addServerFile);
|
||||
@@ -84,6 +89,25 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const prevWasAbortedRef = React.useRef(false);
|
||||
const sendTriggeredByPointerDownRef = React.useRef(false);
|
||||
|
||||
// Message queue
|
||||
const queueModeEnabled = useMessageQueueStore((state) => state.queueModeEnabled);
|
||||
const queuedMessages = useMessageQueueStore(
|
||||
React.useCallback(
|
||||
(state) => {
|
||||
if (!currentSessionId) return EMPTY_QUEUE;
|
||||
return state.queuedMessages[currentSessionId] ?? EMPTY_QUEUE;
|
||||
},
|
||||
[currentSessionId]
|
||||
)
|
||||
);
|
||||
const addToQueue = useMessageQueueStore((state) => state.addToQueue);
|
||||
const clearQueue = useMessageQueueStore((state) => state.clearQueue);
|
||||
|
||||
// Session activity for auto-send on idle
|
||||
const { phase: sessionPhase } = useCurrentSessionActivity();
|
||||
const prevSessionPhaseRef = React.useRef(sessionPhase);
|
||||
const autoSendTriggeredRef = React.useRef(false);
|
||||
|
||||
const handleTextareaPointerDownCapture = React.useCallback((event: React.PointerEvent<HTMLTextAreaElement>) => {
|
||||
if (!isMobile) {
|
||||
return;
|
||||
@@ -219,6 +243,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}, [chatInputAccent, softenBorderColor]);
|
||||
|
||||
const hasContent = message.trim() || attachedFiles.length > 0;
|
||||
const hasQueuedMessages = queuedMessages.length > 0;
|
||||
const canSend = hasContent || hasQueuedMessages;
|
||||
|
||||
const canAbort = working.isWorking;
|
||||
|
||||
@@ -227,16 +253,110 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
return abortPromptSessionId === currentSessionId && Boolean(abortPromptExpiresAt);
|
||||
}, [abortPromptSessionId, abortPromptExpiresAt, currentSessionId]);
|
||||
|
||||
// Add message to queue instead of sending
|
||||
const handleQueueMessage = React.useCallback(() => {
|
||||
if (!hasContent || !currentSessionId) return;
|
||||
|
||||
const messageToQueue = message.replace(/^\n+|\n+$/g, '');
|
||||
const attachmentsToQueue = attachedFiles.map((file) => ({ ...file }));
|
||||
|
||||
addToQueue(currentSessionId, {
|
||||
content: messageToQueue,
|
||||
attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined,
|
||||
});
|
||||
|
||||
// Clear input and attachments
|
||||
setMessage('');
|
||||
if (attachmentsToQueue.length > 0) {
|
||||
clearAttachedFiles();
|
||||
}
|
||||
|
||||
if (!isMobile) {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [hasContent, currentSessionId, message, attachedFiles, addToQueue, clearAttachedFiles, isMobile]);
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
|
||||
if (!hasContent || (!currentSessionId && !newSessionDraftOpen)) return;
|
||||
|
||||
const messageToSend = message.replace(/^\n+|\n+$/g, '');
|
||||
if (!canSend || (!currentSessionId && !newSessionDraftOpen)) return;
|
||||
|
||||
scrollToBottom?.({ instant: true, force: true });
|
||||
|
||||
const normalizedCommand = messageToSend.trimStart();
|
||||
if (!currentProviderId || !currentModelId) {
|
||||
console.warn('Cannot send message: provider or model not selected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the primary message (first part) and additional parts
|
||||
let primaryText = '';
|
||||
let primaryAttachments: AttachedFile[] = [];
|
||||
let agentMentionName: string | undefined;
|
||||
const additionalParts: Array<{ text: string; attachments?: AttachedFile[] }> = [];
|
||||
|
||||
// Process queued messages first
|
||||
for (let i = 0; i < queuedMessages.length; i++) {
|
||||
const queuedMsg = queuedMessages[i];
|
||||
const { sanitizedText, mention } = parseAgentMentions(queuedMsg.content, agents);
|
||||
|
||||
// Use agent mention from first message that has one
|
||||
if (!agentMentionName && mention?.name) {
|
||||
agentMentionName = mention.name;
|
||||
}
|
||||
|
||||
if (i === 0) {
|
||||
// First queued message becomes primary
|
||||
primaryText = sanitizedText;
|
||||
primaryAttachments = queuedMsg.attachments ?? [];
|
||||
} else {
|
||||
// Subsequent queued messages become additional parts
|
||||
additionalParts.push({
|
||||
text: sanitizedText,
|
||||
attachments: queuedMsg.attachments,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add current input
|
||||
if (hasContent) {
|
||||
const messageToSend = message.replace(/^\n+|\n+$/g, '');
|
||||
const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents);
|
||||
const attachmentsToSend = attachedFiles.map((file) => ({ ...file }));
|
||||
|
||||
if (!agentMentionName && mention?.name) {
|
||||
agentMentionName = mention.name;
|
||||
}
|
||||
|
||||
if (queuedMessages.length === 0) {
|
||||
// No queue - current input is primary
|
||||
primaryText = sanitizedText;
|
||||
primaryAttachments = attachmentsToSend;
|
||||
} else {
|
||||
// Has queue - current input is additional part
|
||||
additionalParts.push({
|
||||
text: sanitizedText,
|
||||
attachments: attachmentsToSend.length > 0 ? attachmentsToSend : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!primaryText && additionalParts.length === 0) return;
|
||||
|
||||
// Clear queue and input
|
||||
if (currentSessionId && hasQueuedMessages) {
|
||||
clearQueue(currentSessionId);
|
||||
}
|
||||
setMessage('');
|
||||
if (attachedFiles.length > 0) {
|
||||
clearAttachedFiles();
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
textareaRef.current?.blur();
|
||||
}
|
||||
|
||||
// Handle /summarize command scroll
|
||||
const normalizedCommand = primaryText.trimStart();
|
||||
if (normalizedCommand.startsWith('/')) {
|
||||
const commandName = normalizedCommand
|
||||
.slice(1)
|
||||
@@ -248,29 +368,21 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentProviderId || !currentModelId) {
|
||||
// Collect all attachments for error recovery
|
||||
const allAttachments = [
|
||||
...primaryAttachments,
|
||||
...additionalParts.flatMap(p => p.attachments ?? []),
|
||||
];
|
||||
|
||||
console.warn('Cannot send message: provider or model not selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents);
|
||||
const agentMentionName = mention?.name;
|
||||
|
||||
const attachmentsToSend = attachedFiles.map((file) => ({ ...file }));
|
||||
if (attachmentsToSend.length > 0) {
|
||||
clearAttachedFiles();
|
||||
}
|
||||
|
||||
setMessage('');
|
||||
|
||||
if (isMobile) {
|
||||
textareaRef.current?.blur();
|
||||
}
|
||||
|
||||
await sendMessage(sanitizedText, currentProviderId, currentModelId, currentAgentName, attachmentsToSend, agentMentionName)
|
||||
|
||||
.catch((error: unknown) => {
|
||||
await sendMessage(
|
||||
primaryText,
|
||||
currentProviderId,
|
||||
currentModelId,
|
||||
currentAgentName,
|
||||
primaryAttachments,
|
||||
agentMentionName,
|
||||
additionalParts.length > 0 ? additionalParts : undefined
|
||||
).catch((error: unknown) => {
|
||||
const rawMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
@@ -293,12 +405,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
normalized === 'failed to send message';
|
||||
|
||||
if (isSoftNetworkError) {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachmentsToSend.length > 0) {
|
||||
useFileStore.setState({ attachedFiles: attachmentsToSend });
|
||||
if (allAttachments.length > 0) {
|
||||
useFileStore.setState({ attachedFiles: allAttachments });
|
||||
}
|
||||
toast.error(rawMessage || 'Message failed to send. Attachments restored.');
|
||||
});
|
||||
@@ -306,9 +417,42 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
if (!isMobile) {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
// Keep a ref to handleSubmit for auto-send effect
|
||||
const handleSubmitRef = React.useRef(handleSubmit);
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
|
||||
// Auto-send queued messages when session becomes idle (but not after abort)
|
||||
React.useEffect(() => {
|
||||
const wasWorking = prevSessionPhaseRef.current === 'busy' || prevSessionPhaseRef.current === 'cooldown';
|
||||
const isNowIdle = sessionPhase === 'idle';
|
||||
|
||||
// Check if session was recently aborted (within last 2 seconds)
|
||||
const wasRecentlyAborted = currentSessionId && sessionAbortFlags.has(currentSessionId) && (() => {
|
||||
const abortRecord = sessionAbortFlags.get(currentSessionId);
|
||||
if (!abortRecord) return false;
|
||||
const timeSinceAbort = Date.now() - abortRecord.timestamp;
|
||||
return timeSinceAbort < 2000;
|
||||
})();
|
||||
|
||||
// Detect transition from working to idle, but skip if aborted
|
||||
if (wasWorking && isNowIdle && queuedMessages.length > 0 && !autoSendTriggeredRef.current && !wasRecentlyAborted) {
|
||||
// Prevent double-triggering
|
||||
autoSendTriggeredRef.current = true;
|
||||
|
||||
// Use setTimeout to avoid calling during render
|
||||
setTimeout(() => {
|
||||
if (currentSessionId && currentProviderId && currentModelId) {
|
||||
void handleSubmitRef.current();
|
||||
}
|
||||
autoSendTriggeredRef.current = false;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
prevSessionPhaseRef.current = sessionPhase;
|
||||
}, [sessionPhase, queuedMessages.length, currentSessionId, currentProviderId, currentModelId, sessionAbortFlags]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
|
||||
if (showCommandAutocomplete && commandRef.current) {
|
||||
@@ -341,9 +485,35 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Enter/Ctrl+Enter based on queue mode
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isMobile) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
|
||||
const isCtrlEnter = e.ctrlKey || e.metaKey;
|
||||
|
||||
// Queue mode: Enter queues, Ctrl+Enter sends
|
||||
// Normal mode: Enter sends, Ctrl+Enter queues
|
||||
// Note: Queueing only works when there's an existing session (currentSessionId)
|
||||
// For new sessions (draft), always send immediately
|
||||
const canQueue = hasContent && currentSessionId;
|
||||
|
||||
if (queueModeEnabled) {
|
||||
if (isCtrlEnter || !canQueue) {
|
||||
// Ctrl+Enter sends, or Enter when can't queue (new session)
|
||||
handleSubmit();
|
||||
} else {
|
||||
// Enter queues when we have a session
|
||||
handleQueueMessage();
|
||||
}
|
||||
} else {
|
||||
if (isCtrlEnter && canQueue) {
|
||||
// Ctrl+Enter queues when we have a session
|
||||
handleQueueMessage();
|
||||
} else {
|
||||
// Enter sends
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -839,13 +1009,13 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
) : (
|
||||
<button
|
||||
type={isMobile ? 'button' : 'submit'}
|
||||
disabled={!hasContent || (!currentSessionId && !newSessionDraftOpen)}
|
||||
disabled={!canSend || (!currentSessionId && !newSessionDraftOpen)}
|
||||
onPointerDownCapture={(event) => {
|
||||
if (!isMobile || event.pointerType !== 'touch') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasContent || (!currentSessionId && !newSessionDraftOpen)) {
|
||||
if (!canSend || (!currentSessionId && !newSessionDraftOpen)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -869,7 +1039,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}}
|
||||
className={cn(
|
||||
iconButtonBaseClass,
|
||||
hasContent && (currentSessionId || newSessionDraftOpen)
|
||||
canSend && (currentSessionId || newSessionDraftOpen)
|
||||
? 'text-primary hover:text-primary'
|
||||
: 'opacity-30'
|
||||
)}
|
||||
@@ -1040,6 +1210,14 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
</div>
|
||||
)}
|
||||
<AttachedFilesList />
|
||||
<QueuedMessageChips
|
||||
onEditMessage={(content) => {
|
||||
setMessage(content);
|
||||
setTimeout(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, 0);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-border/80 bg-input/10 dark:bg-input/30",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { memo } from 'react';
|
||||
import { RiCloseLine, RiMessage2Line } from '@remixicon/react';
|
||||
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
|
||||
interface QueuedMessageChipProps {
|
||||
message: QueuedMessage;
|
||||
sessionId: string;
|
||||
onEdit: (message: QueuedMessage) => void;
|
||||
}
|
||||
|
||||
const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChipProps) => {
|
||||
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
|
||||
|
||||
// Get first line of message, truncated
|
||||
const firstLine = React.useMemo(() => {
|
||||
const lines = message.content.split('\n');
|
||||
const first = lines[0] || '';
|
||||
const maxLength = 50;
|
||||
if (first.length > maxLength) {
|
||||
return first.substring(0, maxLength) + '...';
|
||||
}
|
||||
return first + (lines.length > 1 ? '...' : '');
|
||||
}, [message.content]);
|
||||
|
||||
const attachmentCount = message.attachments?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-muted/30 border border-border/30 rounded-xl typography-meta group">
|
||||
<RiMessage2Line className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(message)}
|
||||
className="flex items-center gap-1.5 hover:text-foreground transition-colors text-left"
|
||||
title="Click to edit"
|
||||
>
|
||||
<span className="truncate max-w-[200px]">
|
||||
{firstLine || '(empty)'}
|
||||
</span>
|
||||
{attachmentCount > 0 && (
|
||||
<span className="text-muted-foreground flex-shrink-0">
|
||||
+{attachmentCount} file{attachmentCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFromQueue(sessionId, message.id)}
|
||||
className="ml-1 hover:text-destructive p-0.5 opacity-60 group-hover:opacity-100 transition-opacity"
|
||||
title="Remove from queue"
|
||||
>
|
||||
<RiCloseLine className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
QueuedMessageChip.displayName = 'QueuedMessageChip';
|
||||
|
||||
interface QueuedMessageChipsProps {
|
||||
onEditMessage: (content: string, attachments?: QueuedMessage['attachments']) => void;
|
||||
}
|
||||
|
||||
const EMPTY_QUEUE: QueuedMessage[] = [];
|
||||
|
||||
export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsProps) => {
|
||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||
const queuedMessages = useMessageQueueStore(
|
||||
React.useCallback(
|
||||
(state) => {
|
||||
if (!currentSessionId) return EMPTY_QUEUE;
|
||||
return state.queuedMessages[currentSessionId] ?? EMPTY_QUEUE;
|
||||
},
|
||||
[currentSessionId]
|
||||
)
|
||||
);
|
||||
const popToInput = useMessageQueueStore((state) => state.popToInput);
|
||||
|
||||
const handleEdit = React.useCallback((message: QueuedMessage) => {
|
||||
if (!currentSessionId) return;
|
||||
|
||||
const popped = popToInput(currentSessionId, message.id);
|
||||
if (popped) {
|
||||
// Restore attachments to file store if any
|
||||
if (popped.attachments && popped.attachments.length > 0) {
|
||||
const currentAttachments = useFileStore.getState().attachedFiles;
|
||||
useFileStore.setState({
|
||||
attachedFiles: [...currentAttachments, ...popped.attachments]
|
||||
});
|
||||
}
|
||||
onEditMessage(popped.content, popped.attachments);
|
||||
}
|
||||
}, [currentSessionId, popToInput, onEditMessage]);
|
||||
|
||||
if (queuedMessages.length === 0 || !currentSessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-2">
|
||||
<div className="flex items-center flex-wrap gap-2 px-3 py-2 bg-muted/30 rounded-xl border border-border/30">
|
||||
<span className="typography-meta text-muted-foreground font-medium">Queued:</span>
|
||||
{queuedMessages.map((message) => (
|
||||
<QueuedMessageChip
|
||||
key={message.id}
|
||||
message={message}
|
||||
sessionId={currentSessionId}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
QueuedMessageChips.displayName = 'QueuedMessageChips';
|
||||
Reference in New Issue
Block a user