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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user