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:
+6
-2
@@ -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 auto‑send (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") {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -332,6 +332,7 @@ export interface SettingsPayload {
|
||||
showReasoningTraces?: boolean;
|
||||
autoDeleteEnabled?: boolean;
|
||||
autoDeleteAfterDays?: number;
|
||||
queueModeEnabled?: boolean;
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export type DesktopSettings = {
|
||||
autoDeleteAfterDays?: number;
|
||||
defaultModel?: string; // format: "provider/model"
|
||||
defaultAgent?: string;
|
||||
queueModeEnabled?: boolean;
|
||||
};
|
||||
|
||||
export type DesktopSettingsApi = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user