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:
Bohdan Triapitsyn
2025-12-29 02:16:42 +02:00
parent aefbb1eac2
commit 66428cbded
15 changed files with 668 additions and 59 deletions
+6 -2
View File
@@ -4,8 +4,12 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
- Added Intel Mac (x86_64) support for the desktop application.
- Build workflow now generates separate builds for Apple Silicon (arm64) and Intel (x86_64) Macs.
- Added Intel Mac (x86_64) support for the desktop application (thanks to @rothnic).
- Build workflow now generates separate builds for Apple Silicon (arm64) and Intel (x86_64) Macs (thanks to @rothnic).
- Improved dev server HMR by reusing a healthy OpenCode process to avoid zombie instances.
- Added queued message mode with chips, batching, and idle autosend (including attachments).
- Added queue mode toggle to OpenChamber settings (chat section) with persistence across runtimes.
## [1.3.7] - 2025-12-28
@@ -152,6 +152,9 @@ fn sanitize_settings_update(payload: &Value) -> Value {
if let Some(Value::Bool(b)) = obj.get("autoDeleteEnabled") {
result_obj.insert("autoDeleteEnabled".to_string(), json!(b));
}
if let Some(Value::Bool(b)) = obj.get("queueModeEnabled") {
result_obj.insert("queueModeEnabled".to_string(), json!(b));
}
// Number fields
if let Some(Value::Number(n)) = obj.get("autoDeleteAfterDays") {
+213 -35
View File
@@ -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';
@@ -69,9 +69,9 @@ const VisualSectionContent: React.FC = () => {
return <OpenChamberVisualSettings visibleSettings={['theme', 'fontSize', 'spacing']} />;
};
// Chat section: Default Tool Output, Diff layout, Show reasoning traces
// Chat section: Default Tool Output, Diff layout, Show reasoning traces, Queue mode
const ChatSectionContent: React.FC = () => {
return <OpenChamberVisualSettings visibleSettings={['toolOutput', 'diffLayout', 'reasoning']} />;
return <OpenChamberVisualSettings visibleSettings={['toolOutput', 'diffLayout', 'reasoning', 'queueMode']} />;
};
// Sessions section: Default model & agent, Session retention
@@ -4,6 +4,7 @@ import { RiRestartLine } from '@remixicon/react';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import type { ThemeMode } from '@/types/theme';
import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore } from '@/stores/messageQueueStore';
import { cn } from '@/lib/utils';
import { ButtonSmall } from '@/components/ui/button-small';
import { NumberInput } from '@/components/ui/number-input';
@@ -55,7 +56,7 @@ const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [
},
];
export type VisibleSetting = 'theme' | 'fontSize' | 'spacing' | 'toolOutput' | 'diffLayout' | 'reasoning';
export type VisibleSetting = 'theme' | 'fontSize' | 'spacing' | 'toolOutput' | 'diffLayout' | 'reasoning' | 'queueMode';
interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */
@@ -74,6 +75,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setPadding = useUIStore(state => state.setPadding);
const diffLayoutPreference = useUIStore(state => state.diffLayoutPreference);
const setDiffLayoutPreference = useUIStore(state => state.setDiffLayoutPreference);
const queueModeEnabled = useMessageQueueStore(state => state.queueModeEnabled);
const setQueueMode = useMessageQueueStore(state => state.setQueueMode);
const {
themeMode,
setThemeMode,
@@ -292,6 +295,27 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</span>
</label>
)}
{shouldShow('queueMode') && (
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="h-3.5 w-3.5 accent-primary"
checked={queueModeEnabled}
onChange={(event) => setQueueMode(event.target.checked)}
/>
<span className="typography-ui-header font-semibold text-foreground">
Queue messages by default
</span>
</label>
<p className="typography-meta text-muted-foreground pl-5">
{queueModeEnabled
? 'Enter queues messages, Ctrl+Enter sends immediately.'
: 'Enter sends immediately, Ctrl+Enter queues messages.'}
</p>
</div>
)}
</div>
);
};
+1
View File
@@ -332,6 +332,7 @@ export interface SettingsPayload {
showReasoningTraces?: boolean;
autoDeleteEnabled?: boolean;
autoDeleteAfterDays?: number;
queueModeEnabled?: boolean;
[key: string]: unknown;
}
+1
View File
@@ -43,6 +43,7 @@ export type DesktopSettings = {
autoDeleteAfterDays?: number;
defaultModel?: string; // format: "provider/model"
defaultAgent?: string;
queueModeEnabled?: boolean;
};
export type DesktopSettingsApi = {
+32
View File
@@ -393,6 +393,16 @@ class OpencodeService {
filename?: string;
url: string;
}>;
/** Additional text/file parts to include (for batch sending queued messages) */
additionalParts?: Array<{
text: string;
files?: Array<{
type: 'file';
mime: string;
filename?: string;
url: string;
}>;
}>;
messageId?: string;
agentMentions?: Array<{ name: string; source?: { value: string; start: number; end: number } }>;
}): Promise<string> {
@@ -433,6 +443,28 @@ class OpencodeService {
});
}
// Add additional parts (for batch/queued messages)
if (params.additionalParts && params.additionalParts.length > 0) {
for (const additional of params.additionalParts) {
if (additional.text && additional.text.trim()) {
parts.push({
type: 'text',
text: additional.text
});
}
if (additional.files && additional.files.length > 0) {
for (const file of additional.files) {
parts.push({
type: 'file',
mime: file.mime,
filename: file.filename,
url: file.url
});
}
}
}
}
if (params.agentMentions && params.agentMentions.length > 0) {
const [first] = params.agentMentions;
if (first?.name) {
+8
View File
@@ -1,6 +1,7 @@
import { getDesktopSettings, updateDesktopSettings as updateDesktopSettingsApi, isDesktopRuntime } from '@/lib/desktop';
import type { DesktopSettings } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore } from '@/stores/messageQueueStore';
import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
@@ -55,6 +56,7 @@ const getRuntimeSettingsAPI = () => getRegisteredRuntimeAPIs()?.settings ?? null
const applyDesktopUiPreferences = (settings: DesktopSettings) => {
const store = useUIStore.getState();
const queueStore = useMessageQueueStore.getState();
if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) {
store.setShowReasoningTraces(settings.showReasoningTraces);
@@ -68,6 +70,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
store.setAutoDeleteAfterDays(normalized);
}
}
if (typeof settings.queueModeEnabled === 'boolean' && settings.queueModeEnabled !== queueStore.queueModeEnabled) {
queueStore.setQueueMode(settings.queueModeEnabled);
}
};
const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
@@ -131,6 +136,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.defaultAgent === 'string' && candidate.defaultAgent.length > 0) {
result.defaultAgent = candidate.defaultAgent;
}
if (typeof candidate.queueModeEnabled === 'boolean') {
result.queueModeEnabled = candidate.queueModeEnabled;
}
return result;
};
+144
View File
@@ -0,0 +1,144 @@
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import { getSafeStorage } from './utils/safeStorage';
import type { AttachedFile } from './types/sessionTypes';
import { updateDesktopSettings } from '@/lib/persistence';
export interface QueuedMessage {
id: string;
content: string;
attachments?: AttachedFile[];
createdAt: number;
}
interface MessageQueueState {
queuedMessages: Record<string, QueuedMessage[]>; // sessionId → queue
queueModeEnabled: boolean; // global toggle
}
interface MessageQueueActions {
addToQueue: (sessionId: string, message: Omit<QueuedMessage, 'id' | 'createdAt'>) => void;
removeFromQueue: (sessionId: string, messageId: string) => void;
popToInput: (sessionId: string, messageId: string) => QueuedMessage | null;
clearQueue: (sessionId: string) => void;
clearAllQueues: () => void;
setQueueMode: (enabled: boolean) => void;
getQueueForSession: (sessionId: string) => QueuedMessage[];
}
type MessageQueueStore = MessageQueueState & MessageQueueActions;
export const useMessageQueueStore = create<MessageQueueStore>()(
devtools(
persist(
(set, get) => ({
queuedMessages: {},
queueModeEnabled: false,
addToQueue: (sessionId, message) => {
const id = `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const queuedMessage: QueuedMessage = {
id,
content: message.content,
attachments: message.attachments,
createdAt: Date.now(),
};
set((state) => {
const currentQueue = state.queuedMessages[sessionId] ?? [];
return {
queuedMessages: {
...state.queuedMessages,
[sessionId]: [...currentQueue, queuedMessage],
},
};
});
},
removeFromQueue: (sessionId, messageId) => {
set((state) => {
const currentQueue = state.queuedMessages[sessionId] ?? [];
const newQueue = currentQueue.filter((m) => m.id !== messageId);
if (newQueue.length === 0) {
const { [sessionId]: _removed, ...rest } = state.queuedMessages;
void _removed;
return { queuedMessages: rest };
}
return {
queuedMessages: {
...state.queuedMessages,
[sessionId]: newQueue,
},
};
});
},
popToInput: (sessionId, messageId) => {
const state = get();
const currentQueue = state.queuedMessages[sessionId] ?? [];
const message = currentQueue.find((m) => m.id === messageId);
if (!message) {
return null;
}
// Remove from queue
set((prevState) => {
const queue = prevState.queuedMessages[sessionId] ?? [];
const newQueue = queue.filter((m) => m.id !== messageId);
if (newQueue.length === 0) {
const { [sessionId]: _removed, ...rest } = prevState.queuedMessages;
void _removed;
return { queuedMessages: rest };
}
return {
queuedMessages: {
...prevState.queuedMessages,
[sessionId]: newQueue,
},
};
});
return message;
},
clearQueue: (sessionId) => {
set((state) => {
const { [sessionId]: _removed, ...rest } = state.queuedMessages;
void _removed;
return { queuedMessages: rest };
});
},
clearAllQueues: () => {
set({ queuedMessages: {} });
},
setQueueMode: (enabled) => {
set({ queueModeEnabled: enabled });
// Persist to settings.json (async, fire-and-forget)
void updateDesktopSettings({ queueModeEnabled: enabled });
},
getQueueForSession: (sessionId) => {
return get().queuedMessages[sessionId] ?? [];
},
}),
{
name: 'message-queue-store',
storage: createJSONStorage(() => getSafeStorage()),
partialize: (state) => ({
queuedMessages: state.queuedMessages,
queueModeEnabled: state.queueModeEnabled,
}),
}
),
{
name: 'message-queue-store',
}
)
);
+14 -2
View File
@@ -351,7 +351,7 @@ interface MessageState {
interface MessageActions {
loadMessages: (sessionId: string) => Promise<void>;
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null) => Promise<void>;
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>) => Promise<void>;
abortCurrentOperation: (currentSessionId?: string) => Promise<void>;
_addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void;
addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void;
@@ -551,7 +551,7 @@ export const useMessageStore = create<MessageStore>()(
});
},
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null) => {
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>) => {
if (!currentSessionId) {
throw new Error("No session selected");
}
@@ -676,6 +676,17 @@ export const useMessageStore = create<MessageStore>()(
return { pendingAssistantHeaderSessions: next, pendingUserMessageMetaBySession: nextUserMeta };
});
// Convert additional parts to SDK format
const additionalPartsPayload = additionalParts?.map((part) => ({
text: part.text,
files: part.attachments?.map((file) => ({
type: "file" as const,
mime: file.mimeType,
filename: file.filename,
url: file.dataUrl,
})),
}));
await opencodeClient.sendMessage({
id: sessionId,
providerID,
@@ -683,6 +694,7 @@ export const useMessageStore = create<MessageStore>()(
text: effectiveContent,
agent,
files: filePayloads.length > 0 ? filePayloads : undefined,
additionalParts: additionalPartsPayload,
agentMentions: agentMentionName ? [{ name: agentMentionName }] : undefined,
});
+1 -1
View File
@@ -125,7 +125,7 @@ export interface SessionStore {
unshareSession: (id: string) => Promise<Session | null>;
setCurrentSession: (id: string | null) => void;
loadMessages: (sessionId: string) => Promise<void>;
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string) => Promise<void>;
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>) => Promise<void>;
abortCurrentOperation: () => Promise<void>;
acknowledgeSessionAbort: (sessionId: string) => void;
armAbortPrompt: (durationMs?: number) => number | null;
+3 -3
View File
@@ -278,7 +278,7 @@ export const useSessionStore = create<SessionStore>()(
get().evictLeastRecentlyUsed();
},
loadMessages: (sessionId: string) => useMessageStore.getState().loadMessages(sessionId),
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string) => {
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>) => {
const draft = get().newSessionDraft;
const trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined;
@@ -353,7 +353,7 @@ export const useSessionStore = create<SessionStore>()(
try {
return await useMessageStore
.getState()
.sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName);
.sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName, additionalParts);
} catch (error) {
setIdlePhase(created.id);
throw error;
@@ -380,7 +380,7 @@ export const useSessionStore = create<SessionStore>()(
}
try {
return await useMessageStore.getState().sendMessage(content, providerID, modelID, effectiveAgent, currentSessionId || undefined, attachments, agentMentionName);
return await useMessageStore.getState().sendMessage(content, providerID, modelID, effectiveAgent, currentSessionId || undefined, attachments, agentMentionName, additionalParts);
} catch (error) {
if (currentSessionId) {
setIdlePhase(currentSessionId);
+98 -13
View File
@@ -421,6 +421,9 @@ const sanitizeSettingsUpdate = (payload) => {
if (typeof candidate.defaultAgent === 'string' && candidate.defaultAgent.length > 0) {
result.defaultAgent = candidate.defaultAgent;
}
if (typeof candidate.queueModeEnabled === 'boolean') {
result.queueModeEnabled = candidate.queueModeEnabled;
}
return result;
};
@@ -501,12 +504,26 @@ const persistSettings = async (changes) => {
return formatSettingsResponse(next);
};
// Global state
let openCodeProcess = null;
let openCodePort = null;
// HMR-persistent state via globalThis
// These values survive Vite HMR reloads to prevent zombie OpenCode processes
const HMR_STATE_KEY = '__openchamberHmrState';
const getHmrState = () => {
if (!globalThis[HMR_STATE_KEY]) {
globalThis[HMR_STATE_KEY] = {
openCodeProcess: null,
openCodePort: null,
openCodeWorkingDirectory: process.cwd(),
isShuttingDown: false,
signalsAttached: false,
};
}
return globalThis[HMR_STATE_KEY];
};
const hmrState = getHmrState();
// Non-HMR state (safe to reset on reload)
let healthCheckInterval = null;
let server = null;
let isShuttingDown = false;
let cachedModelsMetadata = null;
let cachedModelsMetadataTimestamp = 0;
let expressApp = null;
@@ -522,10 +539,60 @@ let openCodePortWaiters = [];
let isOpenCodeReady = false;
let openCodeNotReadySince = 0;
let exitOnShutdown = true;
let signalsAttached = false;
let openCodeWorkingDirectory = process.cwd();
let uiAuthController = null;
// Sync helper - call after modifying any HMR state variable
const syncToHmrState = () => {
hmrState.openCodeProcess = openCodeProcess;
hmrState.openCodePort = openCodePort;
hmrState.isShuttingDown = isShuttingDown;
hmrState.signalsAttached = signalsAttached;
hmrState.openCodeWorkingDirectory = openCodeWorkingDirectory;
};
// Sync helper - call to restore state from HMR (e.g., on module reload)
const syncFromHmrState = () => {
openCodeProcess = hmrState.openCodeProcess;
openCodePort = hmrState.openCodePort;
isShuttingDown = hmrState.isShuttingDown;
signalsAttached = hmrState.signalsAttached;
openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory;
};
// Module-level variables that shadow HMR state
// These are synced to/from hmrState to survive HMR reloads
let openCodeProcess = hmrState.openCodeProcess;
let openCodePort = hmrState.openCodePort;
let isShuttingDown = hmrState.isShuttingDown;
let signalsAttached = hmrState.signalsAttached;
let openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory;
/**
* Check if an existing OpenCode process is still alive and responding
* Used to reuse process across HMR reloads
*/
async function isOpenCodeProcessHealthy() {
if (!openCodeProcess || !openCodePort) {
return false;
}
// Check if process is still running
if (openCodeProcess.exitCode !== null || openCodeProcess.signalCode !== null) {
return false;
}
// Health check via HTTP
try {
const response = await fetch(`http://127.0.0.1:${openCodePort}/session`, {
method: 'GET',
signal: AbortSignal.timeout(2000),
});
return response.ok;
} catch {
return false;
}
}
const OPENCODE_BINARY_ENV =
process.env.OPENCODE_BINARY ||
process.env.OPENCHAMBER_BINARY ||
@@ -657,6 +724,7 @@ function setOpenCodePort(port) {
if (portChanged || openCodePort === null) {
openCodePort = numericPort;
syncToHmrState();
console.log(`Detected OpenCode port: ${openCodePort}`);
if (portChanged) {
@@ -1165,6 +1233,7 @@ async function startOpenCode() {
}).catch((error) => {
lastOpenCodeError = error.message;
openCodePort = null;
syncToHmrState();
settleFirstSignal();
return error;
});
@@ -1285,6 +1354,7 @@ async function restartOpenCode() {
}
openCodeProcess = null;
syncToHmrState();
await new Promise((resolve) => setTimeout(resolve, 250));
}
@@ -1294,6 +1364,7 @@ async function restartOpenCode() {
setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT);
} else {
openCodePort = null;
syncToHmrState();
}
openCodeApiPrefixDetected = false;
if (openCodeApiDetectionTimer) {
@@ -1304,6 +1375,7 @@ async function restartOpenCode() {
lastOpenCodeError = null;
openCodeProcess = await startOpenCode();
syncToHmrState();
if (!ENV_CONFIGURED_OPENCODE_PORT) {
await waitForOpenCodePort();
@@ -1322,6 +1394,7 @@ async function restartOpenCode() {
lastOpenCodeError = error.message;
if (!ENV_CONFIGURED_OPENCODE_PORT) {
openCodePort = null;
syncToHmrState();
}
openCodeApiPrefixDetected = false;
throw error;
@@ -1684,6 +1757,7 @@ async function gracefulShutdown(options = {}) {
if (isShuttingDown) return;
isShuttingDown = true;
syncToHmrState();
console.log('Starting graceful shutdown...');
const exitProcess = typeof options.exitProcess === 'boolean' ? options.exitProcess : exitOnShutdown;
@@ -3026,6 +3100,7 @@ async function main(options = {}) {
}
openCodeWorkingDirectory = resolvedPath;
syncToHmrState();
await refreshOpenCodeAfterConfigChange('directory change');
@@ -3489,15 +3564,24 @@ async function main(options = {}) {
});
try {
if (ENV_CONFIGURED_OPENCODE_PORT) {
console.log(`Using OpenCode port from environment: ${ENV_CONFIGURED_OPENCODE_PORT}`);
setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT);
// Check if we can reuse an existing OpenCode process from a previous HMR cycle
syncFromHmrState();
if (await isOpenCodeProcessHealthy()) {
console.log(`[HMR] Reusing existing OpenCode process on port ${openCodePort}`);
} else {
openCodePort = null;
}
// No healthy process, start fresh
if (ENV_CONFIGURED_OPENCODE_PORT) {
console.log(`Using OpenCode port from environment: ${ENV_CONFIGURED_OPENCODE_PORT}`);
setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT);
} else {
openCodePort = null;
syncToHmrState();
}
lastOpenCodeError = null;
openCodeProcess = await startOpenCode();
lastOpenCodeError = null;
openCodeProcess = await startOpenCode();
syncToHmrState();
}
await waitForOpenCodePort();
try {
await waitForOpenCodeReady();
@@ -3555,6 +3639,7 @@ async function main(options = {}) {
process.on('SIGINT', gracefulShutdown);
process.on('SIGQUIT', gracefulShutdown);
signalsAttached = true;
syncToHmrState();
}
process.on('unhandledRejection', (reason, promise) => {