Merge remote-tracking branch 'origin/main' into fix/ui-thinking-effort-draft-project-rename
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
||||
import { usePwaManifestSync } from '@/hooks/usePwaManifestSync';
|
||||
import { useMessageQueueHoldSync } from '@/hooks/useMessageQueueHoldSync';
|
||||
import { useQueuedMessageAutoSend } from '@/hooks/useQueuedMessageAutoSend';
|
||||
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
|
||||
import { useWindowControlsOverlayLayout } from '@/hooks/useWindowControlsOverlayLayout';
|
||||
@@ -8,6 +9,7 @@ import { setOptimisticRefs } from '@/sync/session-actions';
|
||||
import { markSessionViewed } from '@/sync/notification-store';
|
||||
import { setExternallyViewedSession } from '@/sync/sync-context';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { isServerOwnedMessageQueue } from '@/stores/messageQueueStore';
|
||||
|
||||
const MINI_CHAT_PRESENCE_CHANNEL = 'openchamber:mini-chat-presence';
|
||||
|
||||
@@ -66,7 +68,10 @@ export function SyncRuntimeEffects({ embeddedBackgroundWorkEnabled }: {
|
||||
embeddedBackgroundWorkEnabled: boolean;
|
||||
}) {
|
||||
useSessionAutoCleanup(embeddedBackgroundWorkEnabled);
|
||||
useQueuedMessageAutoSend(embeddedBackgroundWorkEnabled);
|
||||
// Web, desktop, and mobile hand the queue to the OpenChamber server, which
|
||||
// delivers it with or without a UI; only VS Code still sends from the UI.
|
||||
useQueuedMessageAutoSend(embeddedBackgroundWorkEnabled && !isServerOwnedMessageQueue());
|
||||
useMessageQueueHoldSync();
|
||||
|
||||
return <SyncOptimisticBridge />;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useProjectContextStore } from '@/stores/useProjectContextStore';
|
||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
import { useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||
@@ -70,6 +71,7 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
|
||||
// restarts the resume window so the switch is treated as a fresh load.
|
||||
resetSessionActivityTiming();
|
||||
usePermissionStore.getState().reset();
|
||||
useMessageQueueStore.getState().resetForRuntimeSwitch(detail.previousRuntimeKey);
|
||||
useFileSearchStore.getState().resetForRuntimeSwitch();
|
||||
useGitStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
||||
useGitHubPrStatusStore.getState().resetForRuntimeSwitch();
|
||||
|
||||
@@ -186,7 +186,6 @@ const MAX_MOBILE_COMPOSER_LINES = 16;
|
||||
*/
|
||||
const MOBILE_COMPOSER_BOUND_GAP_PX = 4;
|
||||
const EMPTY_QUEUE: QueuedMessage[] = [];
|
||||
const EMPTY_SENDING_IDS: string[] = [];
|
||||
const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560;
|
||||
const renameFileForAttachmentCitation = (file: File, filename: string): File => {
|
||||
if (file.name === filename) {
|
||||
@@ -774,8 +773,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
)
|
||||
);
|
||||
const addToQueue = useMessageQueueStore((state) => state.addToQueue);
|
||||
const clearQueue = useMessageQueueStore((state) => state.clearQueue);
|
||||
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
|
||||
const takeForSend = useMessageQueueStore((state) => state.takeForSend);
|
||||
|
||||
// Inline comment drafts
|
||||
const inlineDraftSessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
|
||||
@@ -929,9 +927,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
// queue consumes them and attaches them as structured context parts.
|
||||
const messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
|
||||
const attachmentsToQueue = sanitizeAttachmentsForSend(attachedFiles);
|
||||
// Resolved now, not at delivery: the server that sends a queued
|
||||
// message has no agent list, and the mention must match what was
|
||||
// visible when the user typed it.
|
||||
const { sanitizedText, mention } = parseAgentMentions(messageToQueue, agents);
|
||||
|
||||
addToQueue(messageQueueTarget, {
|
||||
content: messageToQueue,
|
||||
text: sanitizedText,
|
||||
agentMention: mention?.name,
|
||||
attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined,
|
||||
sendConfig: currentProviderId && currentModelId ? {
|
||||
providerID: currentProviderId,
|
||||
@@ -939,6 +943,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
agent: currentAgentName ?? undefined,
|
||||
variant: currentVariant ?? undefined,
|
||||
} : undefined,
|
||||
}).catch((error) => {
|
||||
console.warn('[queue] failed to queue message:', error);
|
||||
toast.error(t('chat.queuedMessage.toast.queueFailed'));
|
||||
// The composer was cleared on queueing; give the text back unless
|
||||
// the user has already typed something new.
|
||||
const currentInput = composerRef.current?.getValue() ?? messageRef.current;
|
||||
if (!currentInput) {
|
||||
setMessage(messageToQueue);
|
||||
} else {
|
||||
useInputStore.getState().setPendingInputText(messageToQueue, 'append');
|
||||
}
|
||||
if (attachmentsToQueue.length > 0) {
|
||||
useInputStore.getState().setAttachedFiles([...useInputStore.getState().attachedFiles, ...attachmentsToQueue]);
|
||||
}
|
||||
});
|
||||
|
||||
// Sending while the agent works must still take the reader to the
|
||||
@@ -959,7 +977,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
if (!isMobile) {
|
||||
composerRef.current?.focus();
|
||||
}
|
||||
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant, scrollToLatest]);
|
||||
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant, scrollToLatest, agents, t]);
|
||||
|
||||
const handleQueuedMessageEdit = React.useCallback((content: string) => {
|
||||
setMessage(content);
|
||||
@@ -1023,28 +1041,23 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
hasContent: options.presetText.trim().length > 0 || attachedFiles.length > 0 || hasDrafts,
|
||||
}
|
||||
: getCurrentInputSnapshot();
|
||||
// A queued item stays in the queue until its own send resolves, so the
|
||||
// auto-send hook may already be delivering one of these. Merging it here
|
||||
// would send the same message twice (the window is seconds over a relay).
|
||||
const sendingIds = messageQueueTarget
|
||||
? useMessageQueueStore.getState().sendingIds[getMessageQueueKey(messageQueueTarget)] ?? EMPTY_SENDING_IDS
|
||||
: EMPTY_SENDING_IDS;
|
||||
const queuedMessagesToSend = (queuedMessageId
|
||||
? queuedMessages.filter((message) => message.id === queuedMessageId)
|
||||
: queuedMessages
|
||||
).filter((message) => !sendingIds.includes(message.id));
|
||||
|
||||
if (queuedOnly && autoReviewRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (queuedOnly) {
|
||||
if (queuedMessagesToSend.length === 0 || !currentSessionId) return;
|
||||
if (!queuedMessages.some((message) => !queuedMessageId || message.id === queuedMessageId) || !currentSessionId) return;
|
||||
} else if ((!inputSnapshot.hasContent && !hasQueuedMessages) || (!currentSessionId && !newSessionDraftOpen)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const capturedSendConfig = queuedOnly ? queuedMessagesToSend[0]?.sendConfig : undefined;
|
||||
// The projection knows the captured send configuration; the full
|
||||
// messages are taken from the queue only once nothing below can still
|
||||
// bail out, so an early return leaves the queue untouched.
|
||||
const queuedProjection = queuedMessageId
|
||||
? queuedMessages.filter((message) => message.id === queuedMessageId)
|
||||
: queuedMessages;
|
||||
const capturedSendConfig = queuedOnly ? queuedProjection[0]?.sendConfig : undefined;
|
||||
const providerIdToSend = capturedSendConfig?.providerID ?? currentProviderId;
|
||||
const modelIdToSend = capturedSendConfig?.modelID ?? currentModelId;
|
||||
const agentNameToSend = capturedSendConfig?.agent ?? currentAgentName;
|
||||
@@ -1115,10 +1128,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
const preparedDocumentMentions = new Map<string, AttachedFile[]>();
|
||||
const reservedFilenames = new Set([
|
||||
...attachedFiles.map((attachment) => attachment.filename),
|
||||
...queuedMessagesToSend.flatMap((queued) => queued.attachments?.map((attachment) => attachment.filename) ?? []),
|
||||
...queuedProjection.flatMap((queued) => queued.attachments?.map((attachment) => attachment.filename) ?? []),
|
||||
]);
|
||||
const mentionTexts = [
|
||||
...queuedMessagesToSend.map((queued) => queued.content),
|
||||
...queuedProjection.map((queued) => queued.content),
|
||||
...(!queuedOnly && inputSnapshot.hasContent ? [inputSnapshot.message] : []),
|
||||
];
|
||||
for (const rawText of mentionTexts) {
|
||||
@@ -1150,6 +1163,22 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// The composer delivers these itself, so they leave the queue now — the
|
||||
// queue's own delivery (server-side, or the auto-send hook in VS Code)
|
||||
// skips anything already in flight, and a message already being
|
||||
// delivered stays out of this send so it cannot go out twice.
|
||||
let queuedMessagesToSend: QueuedMessage[] = [];
|
||||
if (capturedTarget && hasQueuedMessages) {
|
||||
try {
|
||||
queuedMessagesToSend = await takeForSend(capturedTarget, queuedMessageId);
|
||||
} catch (error) {
|
||||
console.warn('[queue] failed to take queued messages for sending:', error);
|
||||
toast.error(t('chat.queuedMessage.toast.takeFailed'));
|
||||
return;
|
||||
}
|
||||
if (queuedOnly && queuedMessagesToSend.length === 0) return;
|
||||
}
|
||||
|
||||
// Inline review comments and synthetic context are consumed before
|
||||
// assembly so a failed send can restore exactly what it took. Context
|
||||
// drafts ride with whichever send goes out next, including queued
|
||||
@@ -1201,12 +1230,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
|
||||
if (outgoing.isEmpty) return;
|
||||
|
||||
// Clear queue and input
|
||||
if (capturedTarget && queuedMessageId) {
|
||||
removeFromQueue(capturedTarget, queuedMessageId);
|
||||
} else if (capturedTarget && hasQueuedMessages) {
|
||||
clearQueue(capturedTarget);
|
||||
}
|
||||
// Clear input (the queue was taken above)
|
||||
if (!queuedOnly) {
|
||||
setMessage('');
|
||||
confirmedMentionsRef.current.clear();
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useInputStore } from '@/sync/input-store';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface QueuedMessageChipProps {
|
||||
@@ -149,16 +150,21 @@ export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: Queued
|
||||
|
||||
const handleEdit = React.useCallback((message: QueuedMessage) => {
|
||||
if (!target) return;
|
||||
|
||||
const popped = popToInput(target, message.id);
|
||||
if (popped) {
|
||||
|
||||
// The full message (attachments included) comes back from the queue's
|
||||
// owner; the chip itself only knows the summary.
|
||||
void popToInput(target, message.id).then((popped) => {
|
||||
if (!popped) return;
|
||||
if (popped.attachments && popped.attachments.length > 0) {
|
||||
const currentAttachments = useInputStore.getState().attachedFiles;
|
||||
useInputStore.getState().setAttachedFiles([...currentAttachments, ...popped.attachments]);
|
||||
}
|
||||
onEditMessage(popped.content, popped.attachments);
|
||||
}
|
||||
}, [target, popToInput, onEditMessage]);
|
||||
}).catch((error) => {
|
||||
console.warn('[queue] failed to take queued message for editing:', error);
|
||||
toast.error(t('chat.queuedMessage.toast.takeFailed'));
|
||||
});
|
||||
}, [target, popToInput, onEditMessage, t]);
|
||||
|
||||
const handleSend = React.useCallback((message: QueuedMessage) => {
|
||||
onSendMessage(message.id);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||
import { isServerOwnedMessageQueue, useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
// Server holds expire on their own (the UI that asserted them may be gone);
|
||||
// re-assert well inside that window while a run is still going.
|
||||
const REASSERT_INTERVAL_MS = 2 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Auto-review is driven from the UI: it forwards the implementer's answer to
|
||||
* the reviewer and back while the original session bounces through idle. The
|
||||
* queue owner must not deliver into those gaps, so while a run is going the
|
||||
* server is told to hold that session's queue, and released when it ends.
|
||||
*/
|
||||
export function useMessageQueueHoldSync(): void {
|
||||
const runs = useAutoReviewStore((state) => state.runsByOriginalSessionID);
|
||||
const heldRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
const running = React.useMemo(() => {
|
||||
if (!isServerOwnedMessageQueue()) return new Set<string>();
|
||||
const runtimeKey = getRuntimeKey();
|
||||
return new Set(
|
||||
Object.values(runs)
|
||||
.filter((run) => run.status === 'running' && run.runtimeKey === runtimeKey)
|
||||
.map((run) => run.originalSessionID),
|
||||
);
|
||||
}, [runs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const setHold = (sessionId: string, held: boolean) => {
|
||||
useMessageQueueStore.getState().setServerHold(sessionId, held).catch((error) => {
|
||||
console.warn(`[queue] failed to ${held ? 'hold' : 'release'} the queue for ${sessionId}:`, error);
|
||||
});
|
||||
};
|
||||
|
||||
for (const sessionId of heldRef.current) {
|
||||
if (!running.has(sessionId)) {
|
||||
heldRef.current.delete(sessionId);
|
||||
setHold(sessionId, false);
|
||||
}
|
||||
}
|
||||
for (const sessionId of running) {
|
||||
if (!heldRef.current.has(sessionId)) {
|
||||
heldRef.current.add(sessionId);
|
||||
setHold(sessionId, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (running.size === 0) return;
|
||||
const interval = setInterval(() => {
|
||||
for (const sessionId of running) setHold(sessionId, true);
|
||||
}, REASSERT_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [running]);
|
||||
|
||||
React.useEffect(() => () => {
|
||||
const held = heldRef.current;
|
||||
for (const sessionId of held) {
|
||||
void useMessageQueueStore.getState().setServerHold(sessionId, false).catch(() => undefined);
|
||||
}
|
||||
held.clear();
|
||||
}, []);
|
||||
}
|
||||
@@ -2003,6 +2003,8 @@ export const dict = {
|
||||
'chat.queuedMessage.send': 'senden',
|
||||
'chat.queuedMessage.removeAria': 'Aus der Warteschlange entfernen',
|
||||
'chat.queuedMessage.reorderAria': 'Ziehen, um neu anzuordnen',
|
||||
'chat.queuedMessage.toast.queueFailed': 'Die Nachricht konnte nicht in die Warteschlange gestellt werden. Sie ist wieder im Eingabefeld.',
|
||||
'chat.queuedMessage.toast.takeFailed': 'Die Nachricht aus der Warteschlange konnte nicht geladen werden. Bitte erneut versuchen.',
|
||||
'chat.container.returnToParent.aria': 'Zurück zur übergeordneten Sitzung',
|
||||
'chat.container.returnToParent.titleNamed': 'Zurück zu: {title}',
|
||||
'chat.container.returnToParent.title': 'Zurück zur übergeordneten Sitzung',
|
||||
|
||||
@@ -2208,6 +2208,8 @@ export const dict = {
|
||||
'chat.queuedMessage.send': 'send',
|
||||
'chat.queuedMessage.removeAria': 'Remove from queue',
|
||||
'chat.queuedMessage.reorderAria': 'Drag to reorder',
|
||||
'chat.queuedMessage.toast.queueFailed': 'Couldn\'t queue the message. It\'s back in the composer.',
|
||||
'chat.queuedMessage.toast.takeFailed': 'Couldn\'t load the queued message. Please try again.',
|
||||
'chat.container.returnToParent.aria': 'Return to parent session',
|
||||
'chat.container.returnToParent.titleNamed': 'Return to: {title}',
|
||||
'chat.container.returnToParent.title': 'Return to parent session',
|
||||
|
||||
@@ -2186,6 +2186,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.queuedMessage.send": "send",
|
||||
"chat.queuedMessage.removeAria": "Eliminar de la cola",
|
||||
"chat.queuedMessage.reorderAria": "Arrastra para reordenar",
|
||||
"chat.queuedMessage.toast.queueFailed": "No se pudo poner el mensaje en cola. Vuelve a estar en el editor.",
|
||||
"chat.queuedMessage.toast.takeFailed": "No se pudo cargar el mensaje en cola. Inténtalo de nuevo.",
|
||||
"chat.container.returnToParent.aria": "Volver a la sesión principal",
|
||||
"chat.container.returnToParent.titleNamed": "Volver a: {title}",
|
||||
"chat.container.returnToParent.title": "Volver a la sesión principal",
|
||||
|
||||
@@ -1938,6 +1938,8 @@ export const dict = {
|
||||
'chat.queuedMessage.send': 'envoyer',
|
||||
'chat.queuedMessage.removeAria': 'Supprimer de la file d\'attente',
|
||||
'chat.queuedMessage.reorderAria': 'Glisser pour réorganiser',
|
||||
'chat.queuedMessage.toast.queueFailed': 'Impossible de mettre le message en file d\'attente. Il est de retour dans l\'éditeur.',
|
||||
'chat.queuedMessage.toast.takeFailed': 'Impossible de charger le message en file d\'attente. Veuillez réessayer.',
|
||||
'chat.container.returnToParent.aria': 'Retour à la session parents',
|
||||
'chat.container.returnToParent.titleNamed': 'Retourner à : {title}',
|
||||
'chat.container.returnToParent.title': 'Retour à la session parents',
|
||||
|
||||
@@ -2204,6 +2204,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.queuedMessage.send': '送信',
|
||||
'chat.queuedMessage.removeAria': 'キューから削除',
|
||||
'chat.queuedMessage.reorderAria': 'ドラッグして並び替え',
|
||||
'chat.queuedMessage.toast.queueFailed': 'メッセージをキューに追加できませんでした。入力欄に戻しました。',
|
||||
'chat.queuedMessage.toast.takeFailed': 'キューのメッセージを読み込めませんでした。もう一度お試しください。',
|
||||
'chat.container.returnToParent.aria': '親セッションに戻る',
|
||||
'chat.container.returnToParent.titleNamed': '戻る: {title}',
|
||||
'chat.container.returnToParent.title': '親セッションに戻る',
|
||||
|
||||
@@ -2210,6 +2210,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.queuedMessage.send': 'send',
|
||||
'chat.queuedMessage.removeAria': '큐에서 제거',
|
||||
'chat.queuedMessage.reorderAria': '드래그하여 순서 변경',
|
||||
'chat.queuedMessage.toast.queueFailed': '메시지를 대기열에 추가하지 못했습니다. 입력창으로 되돌렸습니다.',
|
||||
'chat.queuedMessage.toast.takeFailed': '대기열의 메시지를 불러오지 못했습니다. 다시 시도해 주세요.',
|
||||
'chat.container.returnToParent.aria': '상위 세션으로 돌아가기',
|
||||
'chat.container.returnToParent.titleNamed': '돌아가기: {title}',
|
||||
'chat.container.returnToParent.title': '상위 세션으로 돌아가기',
|
||||
|
||||
@@ -863,6 +863,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.queuedMessage.send': 'send',
|
||||
'chat.queuedMessage.removeAria': 'Usuń z kolejki',
|
||||
'chat.queuedMessage.reorderAria': 'Przeciągnij, aby zmienić kolejność',
|
||||
'chat.queuedMessage.toast.queueFailed': 'Nie udało się dodać wiadomości do kolejki. Wróciła do pola wpisywania.',
|
||||
'chat.queuedMessage.toast.takeFailed': 'Nie udało się wczytać wiadomości z kolejki. Spróbuj ponownie.',
|
||||
'chat.container.returnToParent.aria': 'Powrót do sesji nadrzędnej',
|
||||
'chat.container.returnToParent.titleNamed': 'Powrót do: {title}',
|
||||
'chat.container.returnToParent.title': 'Powrót do sesji nadrzędnej',
|
||||
|
||||
@@ -2186,6 +2186,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.queuedMessage.send": "send",
|
||||
"chat.queuedMessage.removeAria": "Excluir da fila",
|
||||
"chat.queuedMessage.reorderAria": "Arraste para reordenar",
|
||||
"chat.queuedMessage.toast.queueFailed": "Não foi possível enfileirar a mensagem. Ela voltou para o campo de texto.",
|
||||
"chat.queuedMessage.toast.takeFailed": "Não foi possível carregar a mensagem da fila. Tente novamente.",
|
||||
"chat.container.returnToParent.aria": "Voltar para a sessão principal",
|
||||
"chat.container.returnToParent.titleNamed": "Voltar para: {title}",
|
||||
"chat.container.returnToParent.title": "Voltar para a sessão principal",
|
||||
|
||||
@@ -2167,6 +2167,8 @@ export const dict = {
|
||||
'chat.queuedMessage.send': 'gönder',
|
||||
'chat.queuedMessage.removeAria': 'Kuyruktan kaldır',
|
||||
'chat.queuedMessage.reorderAria': 'Yeniden sıralamak için sürükle',
|
||||
'chat.queuedMessage.toast.queueFailed': 'Mesaj kuyruğa eklenemedi. Yazma alanına geri kondu.',
|
||||
'chat.queuedMessage.toast.takeFailed': 'Kuyruktaki mesaj yüklenemedi. Lütfen tekrar deneyin.',
|
||||
'chat.container.returnToParent.aria': 'Üst session\'a dön',
|
||||
'chat.container.returnToParent.titleNamed': 'Şuraya dön: {title}',
|
||||
'chat.container.returnToParent.title': 'Üst session\'a dön',
|
||||
|
||||
@@ -2186,6 +2186,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.queuedMessage.send": "send",
|
||||
"chat.queuedMessage.removeAria": "Видалити з черги",
|
||||
"chat.queuedMessage.reorderAria": "Перетягніть, щоб змінити порядок",
|
||||
"chat.queuedMessage.toast.queueFailed": "Не вдалося додати повідомлення в чергу. Воно повернулося в поле вводу.",
|
||||
"chat.queuedMessage.toast.takeFailed": "Не вдалося завантажити повідомлення з черги. Спробуйте ще раз.",
|
||||
"chat.container.returnToParent.aria": "Повернутися до батьківської сесії",
|
||||
"chat.container.returnToParent.titleNamed": "Повернутися до: {title}",
|
||||
"chat.container.returnToParent.title": "Повернутися до батьківської сесії",
|
||||
|
||||
@@ -2174,6 +2174,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.queuedMessage.send': 'send',
|
||||
'chat.queuedMessage.removeAria': '从队列移除',
|
||||
'chat.queuedMessage.reorderAria': '拖动以重新排序',
|
||||
'chat.queuedMessage.toast.queueFailed': '无法将消息加入队列,已放回输入框。',
|
||||
'chat.queuedMessage.toast.takeFailed': '无法加载队列中的消息,请重试。',
|
||||
'chat.container.returnToParent.aria': '返回父会话',
|
||||
'chat.container.returnToParent.titleNamed': '返回到:{title}',
|
||||
'chat.container.returnToParent.title': '返回父会话',
|
||||
|
||||
@@ -2178,6 +2178,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.queuedMessage.send': 'send',
|
||||
'chat.queuedMessage.removeAria': '從佇列移除',
|
||||
'chat.queuedMessage.reorderAria': '拖曳以重新排序',
|
||||
'chat.queuedMessage.toast.queueFailed': '無法將訊息加入佇列,已放回輸入框。',
|
||||
'chat.queuedMessage.toast.takeFailed': '無法載入佇列中的訊息,請再試一次。',
|
||||
'chat.container.returnToParent.aria': '返回父會話',
|
||||
'chat.container.returnToParent.titleNamed': '返回到:{title}',
|
||||
'chat.container.returnToParent.title': '返回父會話',
|
||||
|
||||
@@ -64,7 +64,9 @@ These stores coordinate persistent project/session metadata across multiple view
|
||||
|
||||
`useProjectContextStore.ts` caches server-owned project notes, todos, and plan links, keyed by the path-derived project id. It replaced a pair of `window` CustomEvents that made every mounted notes panel re-read the whole project config. Writes are optimistic and roll back on failure; they are serialized per project, because the server's own store does a read-modify-write and two concurrent saves would otherwise race it. A load that resolves while a write is in flight keeps the local value for that field group only, so a slow snapshot cannot undo newer typing while still delivering the plan list it fetched. A failed load sets `error` and preserves the cached snapshot — an unreachable server must never render as "this project has no notes". Note and plan creation are deliberately not optimistic, since ids and timestamps are assigned by the server. Notes, todos, and plans are written through separate routes and tracked by separate in-flight flags, so a todo toggle cannot clobber a note edit in the same window. Pinned notes and plans are assembled into a synthetic context part by `lib/projectContextPinning.ts` at send time; that module tracks per-session what it already sent so an unchanged pinned set is not re-sent every turn.
|
||||
|
||||
`messageQueueStore.ts` keeps a queued message until its own send resolves, so between dispatch and resolution the entry is still visible to every reader. Dispatchers must therefore mark the send (`markSending`/`clearSending`) and read `getSendableQueue()` — or filter `sendingIds` themselves — instead of dispatching straight from `queuedMessages`; otherwise a composer submit merges a message the auto-send hook is already delivering and it is sent twice (the window is seconds over a relay). `clearQueue()` retains in-flight entries for the same reason. `sendingIds` is deliberately not persisted: a restart has no in-flight sends, and a stale flag would strand a queued message. Desktop queues use the configured host id as runtime identity, not the current API URL, because an SSH reconnect allocates a new local forwarding port while the remote host remains the same.
|
||||
`messageQueueStore.ts` has two owners, decided by `isServerOwnedMessageQueue()`. On web, desktop, and mobile the OpenChamber server owns the queue (`packages/web/server/lib/message-queue/`): it delivers queued messages when the session goes idle whether or not any UI is open, and the store is a projection of it — `hydrate()` loads the server snapshot for the active runtime, `openchamber:message-queue.updated` broadcasts keep it current, and every mutation is optimistic locally then settled on the server's copy of that session (a failed round-trip re-reads the server instead of guessing). A per-key server revision rejects stale snapshots. Projection items carry attachment metadata only; `popToInput()`/`takeForSend()` remove the message on the server and get the full payload back, which is why both are async. Messages a previous build left in this browser are uploaded once on the first hydration of a runtime and then dropped from persistence for that runtime (`partialize` skips server-owned runtime keys). VS Code has no server and keeps the local queue with the foreground auto-send hook (`useQueuedMessageAutoSend`, enabled only there); `useMessageQueueHoldSync` tells the server to hold a session's queue while a UI-driven auto-review run is going.
|
||||
|
||||
In the local (VS Code) mode the store keeps a queued message until its own send resolves, so between dispatch and resolution the entry is still visible to every reader. Dispatchers must therefore mark the send (`markSending`/`clearSending`) and read `getSendableQueue()` — or filter `sendingIds` themselves — instead of dispatching straight from `queuedMessages`; otherwise a composer submit merges a message the auto-send hook is already delivering and it is sent twice (the window is seconds over a relay). `clearQueue()` retains in-flight entries for the same reason. `sendingIds` is deliberately not persisted: a restart has no in-flight sends, and a stale flag would strand a queued message; in the server-owned mode it mirrors the server's in-flight item. Desktop queues use the configured host id as runtime identity, not the current API URL, because an SSH reconnect allocates a new local forwarding port while the remote host remains the same.
|
||||
|
||||
`useGlobalSessionsStore.ts` owns cold/global active and archived session coverage. Its entity map and active root, parent/child, and directory indexes are maintained in the same transaction as the compatibility arrays and `sessionsByDirectory`. Full authoritative snapshots may rebuild those indexes once; direct create, update, move, archive, and delete mutations update only affected hierarchy and directory buckets. Metadata-only updates preserve the structure reference. It is complementary to directory child stores: it is not the source of live busy/retry status or session messages.
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import type { AttachedFile } from "./types/sessionTypes"
|
||||
import type { MessageQueueUpdatedEvent } from "./messageQueueStore"
|
||||
|
||||
type FetchCall = { path: string; method: string; body: ReturnType<typeof JSON.parse> }
|
||||
let calls: FetchCall[] = []
|
||||
let respond: (call: FetchCall) => Response = () => new Response("{}", { status: 200 })
|
||||
|
||||
mock.module("@/lib/runtime-fetch", () => ({
|
||||
runtimeFetch: async (path: string, init?: RequestInit) => {
|
||||
const call = {
|
||||
path,
|
||||
method: init?.method ?? "GET",
|
||||
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
|
||||
}
|
||||
calls.push(call)
|
||||
return respond(call)
|
||||
},
|
||||
}))
|
||||
const desktop = await import("@/lib/desktop")
|
||||
mock.module("@/lib/desktop", () => ({ ...desktop, isVSCodeRuntime: () => false }))
|
||||
mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => "runtime-a" }))
|
||||
mock.module("@/lib/persistence", () => ({ updateDesktopSettings: async () => undefined }))
|
||||
|
||||
const {
|
||||
applyMessageQueueUpdatedEvent,
|
||||
createMessageQueueTarget,
|
||||
getMessageQueueKey,
|
||||
useMessageQueueStore,
|
||||
} = await import("./messageQueueStore")
|
||||
|
||||
type ServerItem = MessageQueueUpdatedEvent["properties"]["session"]["items"][number]
|
||||
type ServerSession = MessageQueueUpdatedEvent["properties"]["session"]
|
||||
|
||||
type ServerReply = {
|
||||
revision: number
|
||||
session?: ServerSession
|
||||
sessions?: ServerSession[]
|
||||
item?: ServerItem
|
||||
items?: ServerItem[]
|
||||
}
|
||||
|
||||
const json = (value: ServerReply, status = 200) => new Response(JSON.stringify(value), { status })
|
||||
|
||||
const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
|
||||
const key = getMessageQueueKey(target)
|
||||
|
||||
const serverItem = (id: string, content: string, extra: Partial<ServerItem> = {}): ServerItem => ({
|
||||
id,
|
||||
createdAt: 1,
|
||||
content,
|
||||
attachments: [],
|
||||
sendConfig: { providerID: "p", modelID: "m" },
|
||||
...extra,
|
||||
})
|
||||
|
||||
const session = (items: ServerItem[], sendingId: string | null = null): ServerSession => ({
|
||||
sessionId: "session-1",
|
||||
directory: "/repo",
|
||||
items,
|
||||
sendingId,
|
||||
})
|
||||
|
||||
const updated = (revision: number, updatedSession: ServerSession): MessageQueueUpdatedEvent => ({
|
||||
type: "openchamber:message-queue.updated",
|
||||
properties: { revision, session: updatedSession },
|
||||
})
|
||||
|
||||
const attachment: AttachedFile = {
|
||||
id: "att-1",
|
||||
file: new File(["hi"], "note.txt", { type: "text/plain" }),
|
||||
dataUrl: "data:text/plain;base64,aGk=",
|
||||
mimeType: "text/plain",
|
||||
filename: "note.txt",
|
||||
size: 2,
|
||||
source: "local",
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calls = []
|
||||
respond = () => json({ revision: 1, session: session([]) })
|
||||
// Forgetting also drops the revision guard, so each test starts unordered.
|
||||
useMessageQueueStore.getState().forgetQueue(target)
|
||||
useMessageQueueStore.setState({ queuedMessages: {}, quarantinedLegacyMessages: {}, sendingIds: {} })
|
||||
})
|
||||
|
||||
describe("server-owned message queue", () => {
|
||||
// First: the one-time upload of a legacy local queue happens before this
|
||||
// runtime is known to be server-owned, which the later hydrations establish.
|
||||
test("hydrate uploads messages queued by an older build before reading the server", async () => {
|
||||
useMessageQueueStore.setState({
|
||||
queuedMessages: {
|
||||
[key]: [{ id: "local-1", content: "from before", createdAt: 1, sendConfig: { providerID: "p", modelID: "m" } }],
|
||||
},
|
||||
})
|
||||
respond = (call) => (call.method === "POST"
|
||||
? json({ revision: 2, session: session([serverItem("q1", "from before")]) })
|
||||
: json({ revision: 2, sessions: [session([serverItem("q1", "from before")])] }))
|
||||
await useMessageQueueStore.getState().hydrate()
|
||||
|
||||
expect(calls[0]).toEqual({
|
||||
method: "POST",
|
||||
path: "/api/message-queue/sessions/session-1/items",
|
||||
body: { directory: "/repo", item: { content: "from before", text: "from before", attachments: [], sendConfig: { providerID: "p", modelID: "m" } } },
|
||||
})
|
||||
expect(calls[1]?.path).toBe("/api/message-queue")
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q1"])
|
||||
})
|
||||
|
||||
test("hydrate replaces the runtime's projection with the server queue", async () => {
|
||||
respond = () => json({ revision: 3, sessions: [session([serverItem("q1", "hello")], "q1")] })
|
||||
await useMessageQueueStore.getState().hydrate()
|
||||
|
||||
expect(calls.map((call) => `${call.method} ${call.path}`)).toEqual(["GET /api/message-queue"])
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["hello"])
|
||||
expect(useMessageQueueStore.getState().sendingIds[key]).toEqual(["q1"])
|
||||
})
|
||||
|
||||
test("addToQueue shows the message at once and settles on the server's copy", async () => {
|
||||
respond = () => json({ revision: 5, session: session([serverItem("srv-1", "hi @reviewer", { agentMention: "reviewer" })]) })
|
||||
const pending = useMessageQueueStore.getState().addToQueue(target, {
|
||||
content: "hi @reviewer",
|
||||
text: "hi",
|
||||
agentMention: "reviewer",
|
||||
attachments: [attachment],
|
||||
sendConfig: { providerID: "p", modelID: "m", agent: "build" },
|
||||
})
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toHaveLength(1)
|
||||
await pending
|
||||
|
||||
expect(calls[0]).toEqual({
|
||||
method: "POST",
|
||||
path: "/api/message-queue/sessions/session-1/items",
|
||||
body: {
|
||||
directory: "/repo",
|
||||
item: {
|
||||
content: "hi @reviewer",
|
||||
text: "hi",
|
||||
agentMention: "reviewer",
|
||||
attachments: [{ id: "att-1", filename: "note.txt", mimeType: "text/plain", size: 2, source: "local", dataUrl: attachment.dataUrl }],
|
||||
sendConfig: { providerID: "p", modelID: "m", agent: "build" },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["srv-1"])
|
||||
})
|
||||
|
||||
test("addToQueue rolls the optimistic entry back when the server refuses", async () => {
|
||||
respond = () => new Response("nope", { status: 500 })
|
||||
await expect(useMessageQueueStore.getState().addToQueue(target, {
|
||||
content: "x",
|
||||
sendConfig: { providerID: "p", modelID: "m" },
|
||||
})).rejects.toThrow()
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
|
||||
})
|
||||
|
||||
test("addToQueue refuses a message with no captured model", async () => {
|
||||
await expect(useMessageQueueStore.getState().addToQueue(target, { content: "x" })).rejects.toThrow()
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("takeForSend brings the full message back, attachments included", async () => {
|
||||
respond = () => json({
|
||||
revision: 7,
|
||||
session: session([]),
|
||||
item: serverItem("q1", "with file", {
|
||||
attachments: [{ id: "att-1", filename: "note.txt", mimeType: "text/plain", size: 2, source: "local", dataUrl: "data:text/plain;base64,aGk=" }],
|
||||
}),
|
||||
})
|
||||
const [taken] = await useMessageQueueStore.getState().takeForSend(target, "q1")
|
||||
|
||||
expect(calls[0]?.path).toBe("/api/message-queue/sessions/session-1/items/q1/take")
|
||||
expect(calls[0]?.method).toBe("POST")
|
||||
expect(taken?.content).toBe("with file")
|
||||
expect(taken?.attachments?.[0]?.dataUrl).toBe("data:text/plain;base64,aGk=")
|
||||
expect(taken?.attachments?.[0]?.file.size).toBe(2)
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
|
||||
})
|
||||
|
||||
test("takeForSend without an id takes everything the server is not already sending", async () => {
|
||||
respond = () => json({ revision: 8, session: session([serverItem("q1", "in flight")], "q1"), items: [serverItem("q2", "second")] })
|
||||
const taken = await useMessageQueueStore.getState().takeForSend(target)
|
||||
|
||||
expect(calls[0]?.path).toBe("/api/message-queue/sessions/session-1/take")
|
||||
expect(calls[0]?.method).toBe("POST")
|
||||
expect(taken.map((m) => m.content)).toEqual(["second"])
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q1"])
|
||||
})
|
||||
|
||||
test("broadcasts update the projection but never move it backwards", () => {
|
||||
applyMessageQueueUpdatedEvent(updated(4, session([serverItem("q1", "newer")])), "runtime-a")
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["newer"])
|
||||
|
||||
applyMessageQueueUpdatedEvent(updated(2, session([serverItem("q0", "older")])), "runtime-a")
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["newer"])
|
||||
|
||||
applyMessageQueueUpdatedEvent(updated(9, session([serverItem("q1", "newer")])), "runtime-b")
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["newer"])
|
||||
})
|
||||
|
||||
test("removeFromQueue and clearQueue update locally and tell the server", async () => {
|
||||
useMessageQueueStore.setState({ queuedMessages: { [key]: [{ id: "q1", content: "a", createdAt: 1 }, { id: "q2", content: "b", createdAt: 2 }] } })
|
||||
respond = () => json({ revision: 10, session: session([serverItem("q2", "b")]) })
|
||||
useMessageQueueStore.getState().removeFromQueue(target, "q1")
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q2"])
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(calls[0]).toEqual({ method: "DELETE", path: "/api/message-queue/sessions/session-1/items/q1", body: undefined })
|
||||
|
||||
respond = () => json({ revision: 11, session: session([]) })
|
||||
useMessageQueueStore.getState().clearQueue(target)
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
|
||||
await Promise.resolve()
|
||||
expect(calls[1]).toEqual({ method: "DELETE", path: "/api/message-queue/sessions/session-1", body: undefined })
|
||||
})
|
||||
|
||||
test("reorderQueue sends the complete new order", async () => {
|
||||
useMessageQueueStore.setState({ queuedMessages: { [key]: [{ id: "q1", content: "a", createdAt: 1 }, { id: "q2", content: "b", createdAt: 2 }] } })
|
||||
respond = () => json({ revision: 12, session: session([serverItem("q2", "b"), serverItem("q1", "a")]) })
|
||||
useMessageQueueStore.getState().reorderQueue(target, "q2", "q1")
|
||||
await Promise.resolve()
|
||||
expect(calls[0]).toEqual({ method: "PUT", path: "/api/message-queue/sessions/session-1/order", body: { itemIds: ["q2", "q1"] } })
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,17 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
import {
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
// The local queue is the VS Code behavior; every other runtime hands the
|
||||
// queue to the server (see messageQueueStore.server.test.ts).
|
||||
const desktop = await import("@/lib/desktop")
|
||||
mock.module("@/lib/desktop", () => ({ ...desktop, isVSCodeRuntime: () => true }))
|
||||
|
||||
const {
|
||||
createMessageQueueTarget,
|
||||
getMessageQueueKey,
|
||||
migrateMessageQueueState,
|
||||
parseMessageQueueKey,
|
||||
useMessageQueueStore,
|
||||
} from "./messageQueueStore"
|
||||
} = await import("./messageQueueStore")
|
||||
|
||||
beforeEach(() => {
|
||||
useMessageQueueStore.setState({ queuedMessages: {}, quarantinedLegacyMessages: {}, sendingIds: {} })
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { z } from 'zod';
|
||||
import type { Event } from '@opencode-ai/sdk/v2';
|
||||
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
||||
import type { AttachedFile } from './types/sessionTypes';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
|
||||
export type FollowUpBehavior = 'steer' | 'queue';
|
||||
@@ -40,18 +44,38 @@ export const normalizeFollowUpBehavior = (
|
||||
return DEFAULT_FOLLOW_UP_BEHAVIOR;
|
||||
};
|
||||
|
||||
/**
|
||||
* Who delivers the queue. Web, desktop, and mobile talk to an OpenChamber
|
||||
* server that owns the queue and sends it whether or not any UI is open. VS
|
||||
* Code has no server of its own, so the extension UI keeps the local queue
|
||||
* and the foreground auto-send hook.
|
||||
*/
|
||||
export const isServerOwnedMessageQueue = (): boolean => !isVSCodeRuntime();
|
||||
|
||||
export interface QueuedMessageSendConfig {
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
agent?: string;
|
||||
variant?: string;
|
||||
}
|
||||
|
||||
export interface QueuedMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
attachments?: AttachedFile[];
|
||||
createdAt: number;
|
||||
/** Send config captured at queue time — used as-is when auto-sending */
|
||||
sendConfig?: {
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
agent?: string;
|
||||
variant?: string;
|
||||
};
|
||||
sendConfig?: QueuedMessageSendConfig;
|
||||
}
|
||||
|
||||
interface QueuedMessageInput {
|
||||
content: string;
|
||||
attachments?: AttachedFile[];
|
||||
sendConfig?: QueuedMessageSendConfig;
|
||||
/** Text to deliver once the agent mention is stripped; defaults to `content`. */
|
||||
text?: string;
|
||||
/** Agent mentioned at the start of `content`, delivered as an agent part. */
|
||||
agentMention?: string;
|
||||
}
|
||||
|
||||
export type MessageQueueTarget = {
|
||||
@@ -81,6 +105,177 @@ export const parseMessageQueueKey = (key: string): MessageQueueTarget | null =>
|
||||
return createMessageQueueTarget(sessionParts.join('\n'), directory, runtimeKey);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server contract (packages/web/server/lib/message-queue)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const serverSendConfigSchema = z.object({
|
||||
providerID: z.string().min(1),
|
||||
modelID: z.string().min(1),
|
||||
agent: z.string().optional(),
|
||||
variant: z.string().optional(),
|
||||
});
|
||||
|
||||
const serverAttachmentSchema = z.object({
|
||||
id: z.string(),
|
||||
filename: z.string(),
|
||||
mimeType: z.string(),
|
||||
size: z.number(),
|
||||
source: z.enum(['local', 'server', 'vscode']),
|
||||
serverPath: z.string().optional(),
|
||||
/** Present only on a taken item; broadcasts and snapshots omit payloads. */
|
||||
dataUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
const serverItemSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
createdAt: z.number(),
|
||||
content: z.string(),
|
||||
agentMention: z.string().optional(),
|
||||
attachments: z.array(serverAttachmentSchema),
|
||||
sendConfig: serverSendConfigSchema,
|
||||
});
|
||||
|
||||
const serverSessionSchema = z.object({
|
||||
sessionId: z.string().min(1),
|
||||
directory: z.string(),
|
||||
items: z.array(serverItemSchema),
|
||||
sendingId: z.string().nullable(),
|
||||
});
|
||||
|
||||
const serverSnapshotSchema = z.object({
|
||||
revision: z.number(),
|
||||
sessions: z.array(serverSessionSchema),
|
||||
});
|
||||
|
||||
const serverSessionResponseSchema = z.object({
|
||||
revision: z.number(),
|
||||
session: serverSessionSchema,
|
||||
});
|
||||
|
||||
const serverTakeResponseSchema = serverSessionResponseSchema.extend({ item: serverItemSchema });
|
||||
const serverTakeAllResponseSchema = serverSessionResponseSchema.extend({ items: z.array(serverItemSchema) });
|
||||
|
||||
type ServerQueueSession = z.infer<typeof serverSessionSchema>;
|
||||
type ServerQueueItem = z.infer<typeof serverItemSchema>;
|
||||
type ServerQueueAttachment = z.infer<typeof serverAttachmentSchema>;
|
||||
|
||||
const decodeDataUrl = (dataUrl: string): ArrayBuffer | null => {
|
||||
const commaIndex = dataUrl.indexOf(',');
|
||||
if (!dataUrl.startsWith('data:') || commaIndex === -1) return null;
|
||||
const meta = dataUrl.slice(5, commaIndex);
|
||||
const payload = dataUrl.slice(commaIndex + 1);
|
||||
try {
|
||||
if (meta.endsWith(';base64')) {
|
||||
const binary = atob(payload);
|
||||
const buffer = new ArrayBuffer(binary.length);
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return buffer;
|
||||
}
|
||||
const encoded = new TextEncoder().encode(decodeURIComponent(payload));
|
||||
const buffer = new ArrayBuffer(encoded.byteLength);
|
||||
new Uint8Array(buffer).set(encoded);
|
||||
return buffer;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** A taken item carries its payload; a projection item has an empty file. */
|
||||
const toAttachedFile = (attachment: ServerQueueAttachment): AttachedFile => {
|
||||
const dataUrl = attachment.dataUrl ?? '';
|
||||
const bytes = dataUrl ? decodeDataUrl(dataUrl) : null;
|
||||
const file: AttachedFile = {
|
||||
id: attachment.id,
|
||||
file: new File(bytes ? [bytes] : [], attachment.filename, { type: attachment.mimeType }),
|
||||
dataUrl,
|
||||
mimeType: attachment.mimeType,
|
||||
filename: attachment.filename,
|
||||
size: attachment.size,
|
||||
source: attachment.source,
|
||||
};
|
||||
if (attachment.serverPath) file.serverPath = attachment.serverPath;
|
||||
return file;
|
||||
};
|
||||
|
||||
const toQueuedMessage = (item: ServerQueueItem): QueuedMessage => ({
|
||||
id: item.id,
|
||||
content: item.content,
|
||||
createdAt: item.createdAt,
|
||||
attachments: item.attachments.length > 0 ? item.attachments.map(toAttachedFile) : undefined,
|
||||
sendConfig: { ...item.sendConfig },
|
||||
});
|
||||
|
||||
type ServerQueueAttachmentInput = Omit<ServerQueueAttachment, 'dataUrl'> & { dataUrl: string };
|
||||
|
||||
type ServerQueueItemInput = {
|
||||
content: string;
|
||||
text: string;
|
||||
agentMention?: string;
|
||||
attachments: ServerQueueAttachmentInput[];
|
||||
sendConfig: QueuedMessageSendConfig;
|
||||
};
|
||||
|
||||
type ServerQueueRequestBody =
|
||||
| { directory: string; item: ServerQueueItemInput }
|
||||
| { itemIds: string[] }
|
||||
| { held: boolean };
|
||||
|
||||
const toServerAttachment = (attachment: AttachedFile): ServerQueueAttachmentInput => {
|
||||
const input: ServerQueueAttachmentInput = {
|
||||
id: attachment.id,
|
||||
filename: attachment.filename,
|
||||
mimeType: attachment.mimeType,
|
||||
size: attachment.size,
|
||||
source: attachment.source,
|
||||
dataUrl: attachment.dataUrl,
|
||||
};
|
||||
if (attachment.serverPath) input.serverPath = attachment.serverPath;
|
||||
return input;
|
||||
};
|
||||
|
||||
const toServerItemInput = (message: QueuedMessageInput, sendConfig: QueuedMessageSendConfig): ServerQueueItemInput => {
|
||||
const item: ServerQueueItemInput = {
|
||||
content: message.content,
|
||||
text: message.text ?? message.content,
|
||||
attachments: (message.attachments ?? []).filter((file) => Boolean(file.dataUrl)).map(toServerAttachment),
|
||||
sendConfig,
|
||||
};
|
||||
if (message.agentMention) item.agentMention = message.agentMention;
|
||||
return item;
|
||||
};
|
||||
|
||||
const requestJson = async <T,>(schema: z.ZodType<T>, path: string, init?: RequestInit): Promise<T> => {
|
||||
const response = await runtimeFetch(path, init);
|
||||
if (!response.ok) {
|
||||
const error: Error & { status?: number } = new Error(`Message queue request failed (${response.status})`);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
const parsed = schema.safeParse(await response.json());
|
||||
if (!parsed.success) throw new Error('Invalid message queue response');
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
const jsonInit = (method: string, body?: ServerQueueRequestBody): RequestInit => {
|
||||
if (body === undefined) return { method };
|
||||
return { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
|
||||
};
|
||||
|
||||
const sessionPath = (sessionId: string) => `/api/message-queue/sessions/${encodeURIComponent(sessionId)}`;
|
||||
|
||||
/**
|
||||
* Runtime keys whose queue the server owns, established by a successful
|
||||
* hydration. Their entries are a projection and must not be persisted: a
|
||||
* stale local copy would resurrect messages the server already delivered.
|
||||
*/
|
||||
const serverOwnedRuntimeKeys = new Set<string>();
|
||||
|
||||
/** Server revision last applied per queue key; older snapshots are ignored. */
|
||||
const appliedRevisions = new Map<string, number>();
|
||||
let hydrationGeneration = 0;
|
||||
|
||||
interface MessageQueueState {
|
||||
queuedMessages: Record<string, QueuedMessage[]>; // runtime + directory + session → queue
|
||||
quarantinedLegacyMessages: Record<string, QueuedMessage[]>;
|
||||
@@ -95,23 +290,39 @@ interface MessageQueueState {
|
||||
* twice. Dispatchers must skip entries listed here.
|
||||
*
|
||||
* Never persisted: a restart has no in-flight sends, and a stale flag would
|
||||
* strand a queued message permanently.
|
||||
* strand a queued message permanently. With a server-owned queue this
|
||||
* mirrors the server's in-flight item.
|
||||
*/
|
||||
sendingIds: Record<string, string[]>;
|
||||
}
|
||||
|
||||
interface MessageQueueActions {
|
||||
addToQueue: (target: MessageQueueTarget, message: Omit<QueuedMessage, 'id' | 'createdAt'>) => void;
|
||||
addToQueue: (target: MessageQueueTarget, message: QueuedMessageInput) => Promise<void>;
|
||||
removeFromQueue: (target: MessageQueueTarget, messageId: string) => void;
|
||||
reorderQueue: (target: MessageQueueTarget, fromId: string, toId: string) => void;
|
||||
popToInput: (target: MessageQueueTarget, messageId: string) => QueuedMessage | null;
|
||||
/** Removes the message and returns it in full, attachments included. */
|
||||
popToInput: (target: MessageQueueTarget, messageId: string) => Promise<QueuedMessage | null>;
|
||||
/**
|
||||
* Removes what the composer is about to send itself — one message or every
|
||||
* message not already being delivered — and returns it in full.
|
||||
*/
|
||||
takeForSend: (target: MessageQueueTarget, messageId?: string) => Promise<QueuedMessage[]>;
|
||||
clearQueue: (target: MessageQueueTarget) => void;
|
||||
/** Drops the local projection only (the session is gone); never a server call. */
|
||||
forgetQueue: (target: MessageQueueTarget) => void;
|
||||
clearAllQueues: () => void;
|
||||
markSending: (target: MessageQueueTarget, messageId: string) => void;
|
||||
clearSending: (target: MessageQueueTarget, messageId: string) => void;
|
||||
getSendableQueue: (target: MessageQueueTarget) => QueuedMessage[];
|
||||
setFollowUpBehavior: (behavior: FollowUpBehavior) => void;
|
||||
getQueueForTarget: (target: MessageQueueTarget) => QueuedMessage[];
|
||||
/** Server-owned queue: load the authoritative queue for the active runtime. */
|
||||
hydrate: () => Promise<void>;
|
||||
/** Server-owned queue: apply one session's authoritative state (broadcast or response). */
|
||||
applyServerSession: (session: ServerQueueSession, revision: number, expectedRuntimeKey: string) => void;
|
||||
/** Server-owned queue: tell the server to hold or release a session's delivery. */
|
||||
setServerHold: (sessionId: string, held: boolean) => Promise<void>;
|
||||
resetForRuntimeSwitch: (previousRuntimeKey: string | null | undefined) => void;
|
||||
}
|
||||
|
||||
type MessageQueueStore = MessageQueueState & MessageQueueActions;
|
||||
@@ -136,190 +347,362 @@ export const migrateMessageQueueState = (persistedState: unknown, version: numbe
|
||||
};
|
||||
};
|
||||
|
||||
const withoutKey = <T,>(record: Record<string, T>, key: string): Record<string, T> => {
|
||||
const { [key]: _removed, ...rest } = record;
|
||||
void _removed;
|
||||
return rest;
|
||||
};
|
||||
|
||||
const removeMessageLocally = (
|
||||
state: Pick<MessageQueueState, 'queuedMessages'>,
|
||||
key: string,
|
||||
messageId: string,
|
||||
): Pick<MessageQueueState, 'queuedMessages'> => {
|
||||
const newQueue = (state.queuedMessages[key] ?? []).filter((m) => m.id !== messageId);
|
||||
if (newQueue.length === 0) return { queuedMessages: withoutKey(state.queuedMessages, key) };
|
||||
return { queuedMessages: { ...state.queuedMessages, [key]: newQueue } };
|
||||
};
|
||||
|
||||
export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
queuedMessages: {},
|
||||
quarantinedLegacyMessages: {},
|
||||
followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
|
||||
sendingIds: {},
|
||||
|
||||
addToQueue: (target, message) => {
|
||||
(set, get) => {
|
||||
const applyServerSession = (session: ServerQueueSession, revision: number, expectedRuntimeKey: string) => {
|
||||
if (expectedRuntimeKey !== getRuntimeKey()) return;
|
||||
const target = createMessageQueueTarget(session.sessionId, session.directory, expectedRuntimeKey);
|
||||
if (!target) return;
|
||||
const key = getMessageQueueKey(target);
|
||||
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(),
|
||||
sendConfig: message.sendConfig,
|
||||
};
|
||||
|
||||
if ((appliedRevisions.get(key) ?? -1) > revision) return;
|
||||
appliedRevisions.set(key, revision);
|
||||
set((state) => {
|
||||
const currentQueue = state.queuedMessages[key] ?? [];
|
||||
const queuedMessages = {
|
||||
...state.queuedMessages,
|
||||
[key]: [...currentQueue, queuedMessage].slice(-MAX_MESSAGES_PER_QUEUE),
|
||||
};
|
||||
const keys = Object.keys(queuedMessages);
|
||||
if (keys.length > MAX_QUEUE_TARGETS) {
|
||||
keys.sort((left, right) => (
|
||||
(queuedMessages[left]?.[0]?.createdAt ?? 0) - (queuedMessages[right]?.[0]?.createdAt ?? 0)
|
||||
));
|
||||
for (const staleKey of keys.slice(0, keys.length - MAX_QUEUE_TARGETS)) delete queuedMessages[staleKey];
|
||||
}
|
||||
return {
|
||||
queuedMessages,
|
||||
};
|
||||
const queue = session.items.map(toQueuedMessage);
|
||||
const queuedMessages = queue.length > 0
|
||||
? { ...state.queuedMessages, [key]: queue }
|
||||
: withoutKey(state.queuedMessages, key);
|
||||
const sendingIds = session.sendingId
|
||||
? { ...state.sendingIds, [key]: [session.sendingId] }
|
||||
: withoutKey(state.sendingIds, key);
|
||||
return { queuedMessages, sendingIds };
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
removeFromQueue: (target, messageId) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
const currentQueue = state.queuedMessages[key] ?? [];
|
||||
const newQueue = currentQueue.filter((m) => m.id !== messageId);
|
||||
|
||||
if (newQueue.length === 0) {
|
||||
const { [key]: _removed, ...rest } = state.queuedMessages;
|
||||
void _removed;
|
||||
return { queuedMessages: rest };
|
||||
}
|
||||
|
||||
return {
|
||||
queuedMessages: {
|
||||
/** Server state wins; a failed round-trip re-reads it instead of guessing. */
|
||||
const refreshSession = async (target: MessageQueueTarget) => {
|
||||
try {
|
||||
const snapshot = await requestJson(serverSnapshotSchema, '/api/message-queue');
|
||||
const session = snapshot.sessions.find((entry) => entry.sessionId === target.sessionId)
|
||||
?? { sessionId: target.sessionId, directory: target.directory, items: [], sendingId: null };
|
||||
applyServerSession(session, snapshot.revision, target.runtimeKey);
|
||||
} catch {
|
||||
// Offline: keep the optimistic projection; the next broadcast or hydration corrects it.
|
||||
}
|
||||
};
|
||||
|
||||
const serverMutation = async (
|
||||
target: MessageQueueTarget,
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
) => {
|
||||
try {
|
||||
const result = await requestJson(serverSessionResponseSchema, path, init);
|
||||
applyServerSession(result.session, result.revision, target.runtimeKey);
|
||||
} catch (error) {
|
||||
console.warn('[queue] server update failed:', error);
|
||||
await refreshSession(target);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
queuedMessages: {},
|
||||
quarantinedLegacyMessages: {},
|
||||
followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
|
||||
sendingIds: {},
|
||||
|
||||
addToQueue: async (target, message) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
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(),
|
||||
sendConfig: message.sendConfig,
|
||||
};
|
||||
|
||||
set((state) => {
|
||||
const currentQueue = state.queuedMessages[key] ?? [];
|
||||
const queuedMessages = {
|
||||
...state.queuedMessages,
|
||||
[key]: newQueue,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
[key]: [...currentQueue, queuedMessage].slice(-MAX_MESSAGES_PER_QUEUE),
|
||||
};
|
||||
const keys = Object.keys(queuedMessages);
|
||||
if (keys.length > MAX_QUEUE_TARGETS) {
|
||||
keys.sort((left, right) => (
|
||||
(queuedMessages[left]?.[0]?.createdAt ?? 0) - (queuedMessages[right]?.[0]?.createdAt ?? 0)
|
||||
));
|
||||
for (const staleKey of keys.slice(0, keys.length - MAX_QUEUE_TARGETS)) delete queuedMessages[staleKey];
|
||||
}
|
||||
return {
|
||||
queuedMessages,
|
||||
};
|
||||
});
|
||||
|
||||
reorderQueue: (target, fromId, toId) => {
|
||||
if (fromId === toId) return;
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
const currentQueue = state.queuedMessages[key];
|
||||
if (!currentQueue) return state;
|
||||
if (!isServerOwnedMessageQueue()) return;
|
||||
if (!message.sendConfig) {
|
||||
set((state) => removeMessageLocally(state, key, id));
|
||||
throw new Error('A queued message needs a provider and model to be delivered later.');
|
||||
}
|
||||
try {
|
||||
const result = await requestJson(serverSessionResponseSchema, `${sessionPath(target.sessionId)}/items`, jsonInit('POST', {
|
||||
directory: target.directory,
|
||||
item: toServerItemInput(message, message.sendConfig),
|
||||
}));
|
||||
// The optimistic entry is replaced by the server's copy of the queue.
|
||||
set((state) => removeMessageLocally(state, key, id));
|
||||
applyServerSession(result.session, result.revision, target.runtimeKey);
|
||||
} catch (error) {
|
||||
set((state) => removeMessageLocally(state, key, id));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
removeFromQueue: (target, messageId) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => removeMessageLocally(state, key, messageId));
|
||||
if (isServerOwnedMessageQueue()) {
|
||||
void serverMutation(target, `${sessionPath(target.sessionId)}/items/${encodeURIComponent(messageId)}`, jsonInit('DELETE'));
|
||||
}
|
||||
},
|
||||
|
||||
reorderQueue: (target, fromId, toId) => {
|
||||
if (fromId === toId) return;
|
||||
const key = getMessageQueueKey(target);
|
||||
const currentQueue = get().queuedMessages[key];
|
||||
if (!currentQueue) return;
|
||||
const fromIndex = currentQueue.findIndex((m) => m.id === fromId);
|
||||
const toIndex = currentQueue.findIndex((m) => m.id === toId);
|
||||
if (fromIndex === -1 || toIndex === -1) return state;
|
||||
if (fromIndex === -1 || toIndex === -1) return;
|
||||
|
||||
const newQueue = currentQueue.slice();
|
||||
const [moved] = newQueue.splice(fromIndex, 1);
|
||||
newQueue.splice(toIndex, 0, moved);
|
||||
|
||||
return {
|
||||
set((state) => ({
|
||||
queuedMessages: {
|
||||
...state.queuedMessages,
|
||||
[key]: newQueue,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
popToInput: (target, messageId) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
const state = get();
|
||||
const currentQueue = state.queuedMessages[key] ?? [];
|
||||
const message = currentQueue.find((m) => m.id === messageId);
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove from queue
|
||||
set((prevState) => {
|
||||
const queue = prevState.queuedMessages[key] ?? [];
|
||||
const newQueue = queue.filter((m) => m.id !== messageId);
|
||||
|
||||
if (newQueue.length === 0) {
|
||||
const { [key]: _removed, ...rest } = prevState.queuedMessages;
|
||||
void _removed;
|
||||
return { queuedMessages: rest };
|
||||
}));
|
||||
if (isServerOwnedMessageQueue()) {
|
||||
const itemIds = newQueue.map((message) => message.id);
|
||||
void serverMutation(target, `${sessionPath(target.sessionId)}/order`, jsonInit('PUT', { itemIds }));
|
||||
}
|
||||
|
||||
return {
|
||||
queuedMessages: {
|
||||
...prevState.queuedMessages,
|
||||
[key]: newQueue,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
return message;
|
||||
},
|
||||
popToInput: async (target, messageId) => {
|
||||
const [message] = await get().takeForSend(target, messageId);
|
||||
return message ?? null;
|
||||
},
|
||||
|
||||
clearQueue: (target) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
// Clearing drops what is still queued, never a message
|
||||
// already handed to the server: that send will resolve
|
||||
// and must find its entry to remove or restore.
|
||||
takeForSend: async (target, messageId) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
if (isServerOwnedMessageQueue()) {
|
||||
if (messageId) {
|
||||
const result = await requestJson(
|
||||
serverTakeResponseSchema,
|
||||
`${sessionPath(target.sessionId)}/items/${encodeURIComponent(messageId)}/take`,
|
||||
jsonInit('POST'),
|
||||
);
|
||||
applyServerSession(result.session, result.revision, target.runtimeKey);
|
||||
return [toQueuedMessage(result.item)];
|
||||
}
|
||||
const result = await requestJson(serverTakeAllResponseSchema, `${sessionPath(target.sessionId)}/take`, jsonInit('POST'));
|
||||
applyServerSession(result.session, result.revision, target.runtimeKey);
|
||||
return result.items.map(toQueuedMessage);
|
||||
}
|
||||
|
||||
const state = get();
|
||||
const sending = state.sendingIds[key] ?? [];
|
||||
const retained = (state.queuedMessages[key] ?? []).filter((m) => sending.includes(m.id));
|
||||
if (retained.length > 0) {
|
||||
return { queuedMessages: { ...state.queuedMessages, [key]: retained } };
|
||||
const taken = (state.queuedMessages[key] ?? []).filter((message) => (
|
||||
(messageId ? message.id === messageId : true) && !sending.includes(message.id)
|
||||
));
|
||||
if (taken.length === 0) return [];
|
||||
const takenIds = new Set(taken.map((message) => message.id));
|
||||
set((prevState) => {
|
||||
const remaining = (prevState.queuedMessages[key] ?? []).filter((message) => !takenIds.has(message.id));
|
||||
if (remaining.length === 0) return { queuedMessages: withoutKey(prevState.queuedMessages, key) };
|
||||
return { queuedMessages: { ...prevState.queuedMessages, [key]: remaining } };
|
||||
});
|
||||
return taken;
|
||||
},
|
||||
|
||||
clearQueue: (target) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
// Clearing drops what is still queued, never a message
|
||||
// already handed to the server: that send will resolve
|
||||
// and must find its entry to remove or restore.
|
||||
const sending = state.sendingIds[key] ?? [];
|
||||
const retained = (state.queuedMessages[key] ?? []).filter((m) => sending.includes(m.id));
|
||||
if (retained.length > 0) {
|
||||
return { queuedMessages: { ...state.queuedMessages, [key]: retained } };
|
||||
}
|
||||
return { queuedMessages: withoutKey(state.queuedMessages, key) };
|
||||
});
|
||||
if (isServerOwnedMessageQueue()) {
|
||||
void serverMutation(target, sessionPath(target.sessionId), jsonInit('DELETE'));
|
||||
}
|
||||
const { [key]: _removed, ...rest } = state.queuedMessages;
|
||||
void _removed;
|
||||
return { queuedMessages: rest };
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
clearAllQueues: () => {
|
||||
set({ queuedMessages: {}, sendingIds: {} });
|
||||
},
|
||||
forgetQueue: (target) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
appliedRevisions.delete(key);
|
||||
set((state) => ({
|
||||
queuedMessages: withoutKey(state.queuedMessages, key),
|
||||
sendingIds: withoutKey(state.sendingIds, key),
|
||||
}));
|
||||
},
|
||||
|
||||
markSending: (target, messageId) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
const current = state.sendingIds[key] ?? [];
|
||||
if (current.includes(messageId)) return state;
|
||||
return { sendingIds: { ...state.sendingIds, [key]: [...current, messageId] } };
|
||||
});
|
||||
},
|
||||
clearAllQueues: () => {
|
||||
set({ queuedMessages: {}, sendingIds: {} });
|
||||
},
|
||||
|
||||
clearSending: (target, messageId) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
const current = state.sendingIds[key];
|
||||
if (!current || !current.includes(messageId)) return state;
|
||||
const next = current.filter((id) => id !== messageId);
|
||||
if (next.length === 0) {
|
||||
const { [key]: _removed, ...rest } = state.sendingIds;
|
||||
void _removed;
|
||||
return { sendingIds: rest };
|
||||
markSending: (target, messageId) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
const current = state.sendingIds[key] ?? [];
|
||||
if (current.includes(messageId)) return state;
|
||||
return { sendingIds: { ...state.sendingIds, [key]: [...current, messageId] } };
|
||||
});
|
||||
},
|
||||
|
||||
clearSending: (target, messageId) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
const current = state.sendingIds[key];
|
||||
if (!current || !current.includes(messageId)) return state;
|
||||
const next = current.filter((id) => id !== messageId);
|
||||
if (next.length === 0) return { sendingIds: withoutKey(state.sendingIds, key) };
|
||||
return { sendingIds: { ...state.sendingIds, [key]: next } };
|
||||
});
|
||||
},
|
||||
|
||||
getSendableQueue: (target) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
const state = get();
|
||||
const queue = state.queuedMessages[key] ?? [];
|
||||
const sending = state.sendingIds[key];
|
||||
if (!sending || sending.length === 0) return queue;
|
||||
return queue.filter((message) => !sending.includes(message.id));
|
||||
},
|
||||
|
||||
setFollowUpBehavior: (behavior) => {
|
||||
set({ followUpBehavior: behavior });
|
||||
void updateDesktopSettings({ followUpBehavior: behavior });
|
||||
},
|
||||
|
||||
getQueueForTarget: (target) => {
|
||||
return get().queuedMessages[getMessageQueueKey(target)] ?? [];
|
||||
},
|
||||
|
||||
hydrate: async () => {
|
||||
if (!isServerOwnedMessageQueue()) return;
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const generation = ++hydrationGeneration;
|
||||
const isCurrent = () => generation === hydrationGeneration && runtimeKey === getRuntimeKey();
|
||||
|
||||
// Messages queued by an older build live in this browser only.
|
||||
// Hand them to the server once so they are still delivered;
|
||||
// whatever cannot be uploaded is superseded by the server's queue.
|
||||
const legacyEntries = Object.entries(get().queuedMessages)
|
||||
.map(([key, queue]) => ({ target: parseMessageQueueKey(key), queue }))
|
||||
.filter((entry): entry is { target: MessageQueueTarget; queue: QueuedMessage[] } => (
|
||||
entry.target !== null && entry.target.runtimeKey === runtimeKey && !serverOwnedRuntimeKeys.has(runtimeKey)
|
||||
));
|
||||
for (const { target, queue } of legacyEntries) {
|
||||
for (const message of queue) {
|
||||
if (!message.sendConfig) continue;
|
||||
try {
|
||||
await requestJson(serverSessionResponseSchema, `${sessionPath(target.sessionId)}/items`, jsonInit('POST', {
|
||||
directory: target.directory,
|
||||
item: toServerItemInput(message, message.sendConfig),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn('[queue] failed to migrate a locally queued message to the server:', error);
|
||||
}
|
||||
if (!isCurrent()) return;
|
||||
}
|
||||
}
|
||||
return { sendingIds: { ...state.sendingIds, [key]: next } };
|
||||
});
|
||||
},
|
||||
|
||||
getSendableQueue: (target) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
const state = get();
|
||||
const queue = state.queuedMessages[key] ?? [];
|
||||
const sending = state.sendingIds[key];
|
||||
if (!sending || sending.length === 0) return queue;
|
||||
return queue.filter((message) => !sending.includes(message.id));
|
||||
},
|
||||
const snapshot = await requestJson(serverSnapshotSchema, '/api/message-queue');
|
||||
if (!isCurrent()) return;
|
||||
serverOwnedRuntimeKeys.add(runtimeKey);
|
||||
set((state) => {
|
||||
const queuedMessages: Record<string, QueuedMessage[]> = {};
|
||||
const sendingIds: Record<string, string[]> = {};
|
||||
for (const [key, queue] of Object.entries(state.queuedMessages)) {
|
||||
if (parseMessageQueueKey(key)?.runtimeKey !== runtimeKey) queuedMessages[key] = queue;
|
||||
}
|
||||
for (const [key, ids] of Object.entries(state.sendingIds)) {
|
||||
if (parseMessageQueueKey(key)?.runtimeKey !== runtimeKey) sendingIds[key] = ids;
|
||||
}
|
||||
for (const session of snapshot.sessions) {
|
||||
const target = createMessageQueueTarget(session.sessionId, session.directory, runtimeKey);
|
||||
if (!target) continue;
|
||||
const key = getMessageQueueKey(target);
|
||||
if ((appliedRevisions.get(key) ?? -1) > snapshot.revision) {
|
||||
// A broadcast newer than this snapshot already landed; keep it.
|
||||
if (state.queuedMessages[key]) queuedMessages[key] = state.queuedMessages[key];
|
||||
if (state.sendingIds[key]) sendingIds[key] = state.sendingIds[key];
|
||||
continue;
|
||||
}
|
||||
appliedRevisions.set(key, snapshot.revision);
|
||||
if (session.items.length > 0) queuedMessages[key] = session.items.map(toQueuedMessage);
|
||||
if (session.sendingId) sendingIds[key] = [session.sendingId];
|
||||
}
|
||||
return { queuedMessages, sendingIds };
|
||||
});
|
||||
},
|
||||
|
||||
setFollowUpBehavior: (behavior) => {
|
||||
set({ followUpBehavior: behavior });
|
||||
void updateDesktopSettings({ followUpBehavior: behavior });
|
||||
},
|
||||
applyServerSession,
|
||||
|
||||
getQueueForTarget: (target) => {
|
||||
return get().queuedMessages[getMessageQueueKey(target)] ?? [];
|
||||
},
|
||||
}),
|
||||
setServerHold: async (sessionId, held) => {
|
||||
if (!isServerOwnedMessageQueue()) return;
|
||||
const response = await runtimeFetch(`${sessionPath(sessionId)}/hold`, jsonInit('PUT', { held }));
|
||||
if (!response.ok) throw new Error(`Message queue hold request failed (${response.status})`);
|
||||
},
|
||||
|
||||
resetForRuntimeSwitch: (previousRuntimeKey) => {
|
||||
hydrationGeneration += 1;
|
||||
if (!previousRuntimeKey || !serverOwnedRuntimeKeys.has(previousRuntimeKey)) return;
|
||||
// The previous runtime's projection belongs to its server;
|
||||
// switching back re-hydrates it from there.
|
||||
set((state) => {
|
||||
const queuedMessages: Record<string, QueuedMessage[]> = {};
|
||||
const sendingIds: Record<string, string[]> = {};
|
||||
for (const [key, queue] of Object.entries(state.queuedMessages)) {
|
||||
if (parseMessageQueueKey(key)?.runtimeKey === previousRuntimeKey) appliedRevisions.delete(key);
|
||||
else queuedMessages[key] = queue;
|
||||
}
|
||||
for (const [key, ids] of Object.entries(state.sendingIds)) {
|
||||
if (parseMessageQueueKey(key)?.runtimeKey !== previousRuntimeKey) sendingIds[key] = ids;
|
||||
}
|
||||
return { queuedMessages, sendingIds };
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
name: 'message-queue-store',
|
||||
version: 2,
|
||||
storage: createDeferredSafeJSONStorage(),
|
||||
partialize: (state) => ({
|
||||
queuedMessages: state.queuedMessages,
|
||||
queuedMessages: Object.fromEntries(
|
||||
Object.entries(state.queuedMessages).filter(([key]) => {
|
||||
const runtimeKey = parseMessageQueueKey(key)?.runtimeKey;
|
||||
return !runtimeKey || !serverOwnedRuntimeKeys.has(runtimeKey);
|
||||
}),
|
||||
),
|
||||
quarantinedLegacyMessages: state.quarantinedLegacyMessages,
|
||||
followUpBehavior: state.followUpBehavior,
|
||||
}),
|
||||
@@ -331,3 +714,21 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const serverUpdatedEventSchema = z.object({
|
||||
properties: z.object({ revision: z.number(), session: serverSessionSchema }),
|
||||
});
|
||||
|
||||
export type MessageQueueUpdatedEvent = {
|
||||
type: 'openchamber:message-queue.updated';
|
||||
properties: z.infer<typeof serverUpdatedEventSchema>['properties'];
|
||||
};
|
||||
|
||||
/** `openchamber:message-queue.updated` broadcast → projection. */
|
||||
export const applyMessageQueueUpdatedEvent = (payload: Event | MessageQueueUpdatedEvent, expectedRuntimeKey: string): void => {
|
||||
if (!isServerOwnedMessageQueue()) return;
|
||||
const parsed = serverUpdatedEventSchema.safeParse(payload);
|
||||
if (!parsed.success) return;
|
||||
const { session, revision } = parsed.data.properties;
|
||||
useMessageQueueStore.getState().applyServerSession(session, revision, expectedRuntimeKey);
|
||||
};
|
||||
|
||||
@@ -284,7 +284,7 @@ Rules:
|
||||
2. If an action targets a session by ID, resolve the **session's own directory**. Do not assume the current directory is correct.
|
||||
3. `session-ui-store.ts` should delegate to `session-actions.ts` for these mutations instead of duplicating SDK calls.
|
||||
4. Sending after a revert commits the new branch optimistically: remove the reverted tail and marker before inserting the new message, and restore both if the send is rejected.
|
||||
5. Composer and queued sends carry their captured runtime, directory, and session through asynchronous preparation. A runtime change cancels the send instead of re-resolving it against the new runtime.
|
||||
5. Composer and queued sends carry their captured runtime, directory, and session through asynchronous preparation. A runtime change cancels the send instead of re-resolving it against the new runtime. Outside VS Code the queue itself is server-owned (`packages/web/server/lib/message-queue/`): the UI hands the server the captured send configuration at queue time and the server delivers on idle; the composer only sends a queued message itself after taking it back from the server (`takeForSend`). See the `messageQueueStore.ts` section in `stores/DOCUMENTATION.md`.
|
||||
6. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session.
|
||||
7. Regular new-chat drafts that inherit the persisted current/last directory must not create a session against a confirmed-missing path. Fall back to the active project only when OpenCode reports the directory missing; keep explicit worktree targets, in-flight worktree creation, and unknown/offline probes unchanged, and do not persist the fallback until session creation succeeds. A concurrent draft rewrite to that same active-project fallback must not abort session creation.
|
||||
8. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message.
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Todo } from '@opencode-ai/sdk/v2/client';
|
||||
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { createChatDraftIdentity, readChatDraft, writeChatDraft } from '@/lib/chatDraftPersistence';
|
||||
import { createMessageQueueTarget, useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||
import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useTodosPersistStore } from '@/stores/useTodosPersistStore';
|
||||
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||
@@ -25,8 +25,14 @@ describe('cleanupPersistedSessionState', () => {
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const deleted = createMessageQueueTarget('session-1', '/repo-a', runtimeKey)!;
|
||||
const retained = createMessageQueueTarget('session-1', '/repo-b', runtimeKey)!;
|
||||
useMessageQueueStore.getState().addToQueue(deleted, { content: 'delete' });
|
||||
useMessageQueueStore.getState().addToQueue(retained, { content: 'retain' });
|
||||
// Outside VS Code the queue is a projection of the server's; seed it the
|
||||
// way a server snapshot would, and expect only the projection to go.
|
||||
useMessageQueueStore.setState({
|
||||
queuedMessages: {
|
||||
[getMessageQueueKey(deleted)]: [{ id: 'q-delete', content: 'delete', createdAt: 1 }],
|
||||
[getMessageQueueKey(retained)]: [{ id: 'q-retain', content: 'retain', createdAt: 1 }],
|
||||
},
|
||||
});
|
||||
useTodosPersistStore.getState().setSessionTodos('/repo-a', 'session-1', [todo]);
|
||||
useTodosPersistStore.getState().setSessionTodos('/repo-b', 'session-1', [todo]);
|
||||
const deletedDraft = createChatDraftIdentity(runtimeKey, '/repo-a', 'session-1')!;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { clearChatDraft, createChatDraftIdentity } from '@/lib/chatDraftPersistence';
|
||||
import { createMessageQueueTarget, useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||
import { createMessageQueueTarget, isServerOwnedMessageQueue, useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useTodosPersistStore } from '@/stores/useTodosPersistStore';
|
||||
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||
@@ -14,7 +14,12 @@ export const cleanupPersistedSessionState = (identity: {
|
||||
if (identity.runtimeKey !== getRuntimeKey() || !identity.directory || identity.directory === 'global' || !identity.sessionId) return;
|
||||
|
||||
const queueTarget = createMessageQueueTarget(identity.sessionId, identity.directory, identity.runtimeKey);
|
||||
if (queueTarget) useMessageQueueStore.getState().clearQueue(queueTarget);
|
||||
if (queueTarget) {
|
||||
// A server-owned queue drops the deleted session itself; only the local
|
||||
// projection needs to go. VS Code owns its queue and clears it here.
|
||||
if (isServerOwnedMessageQueue()) useMessageQueueStore.getState().forgetQueue(queueTarget);
|
||||
else useMessageQueueStore.getState().clearQueue(queueTarget);
|
||||
}
|
||||
useTodosPersistStore.getState().clearSessionTodos(identity.runtimeKey, identity.directory, identity.sessionId);
|
||||
useSessionFoldersStore.getState().removeSessionEverywhere(identity.runtimeKey, identity.sessionId);
|
||||
useInlineCommentDraftStore.getState().clearSessionDrafts(identity.runtimeKey, identity.directory, identity.sessionId);
|
||||
|
||||
@@ -48,6 +48,7 @@ import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./recon
|
||||
import { messagesBefore } from "./message-ordering"
|
||||
import { opencodeClient } from "@/lib/opencode/client"
|
||||
import { usePermissionStore } from "@/stores/permissionStore"
|
||||
import { applyMessageQueueUpdatedEvent, useMessageQueueStore } from "@/stores/messageQueueStore"
|
||||
import {
|
||||
processVSCodePermissionAutoAccept,
|
||||
processVSCodeReconciledPermissionAutoAccept,
|
||||
@@ -1577,6 +1578,11 @@ export function handleEvent(
|
||||
batch?: DirectoryEventBatch,
|
||||
globalEffectsAlreadyApplied = false,
|
||||
) {
|
||||
if ((payload as { type?: unknown }).type === "openchamber:message-queue.updated") {
|
||||
applyMessageQueueUpdatedEvent(payload, expectedRuntimeKey)
|
||||
return
|
||||
}
|
||||
|
||||
if ((payload as { type?: unknown }).type === "openchamber:permission-auto-accept.updated") {
|
||||
const properties = (payload as unknown as { properties?: unknown }).properties
|
||||
if (properties && typeof properties === "object") {
|
||||
@@ -2241,6 +2247,7 @@ export function SyncProvider(props: {
|
||||
// Configure child store manager
|
||||
useEffect(() => {
|
||||
void usePermissionStore.getState().hydrate().catch(() => undefined)
|
||||
void useMessageQueueStore.getState().hydrate().catch(() => undefined)
|
||||
}, [props.sdk])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user