feat(queue): deliver queued messages from the server

Messages queued while a session is busy used to live in the browser tab and
were sent by that tab once the session went idle, so closing the tab (or
losing the connection) stranded them. The web server now owns the queue:
it persists to <data-dir>/message-queue.json, watches session.status on the
global event hub, re-verifies idleness against OpenCode before sending, and
delivers the head of the queue via prompt_async (or /command for slash
commands) with the model, agent, variant, attachments, and agent mention
captured at queue time. Failed sends stay queued and retry with backoff; a
user abort holds delivery briefly; every change is broadcast so all clients
see one queue.

The shared UI store becomes a projection of the server queue outside VS
Code (hydrate on connect, apply broadcasts, optimistic mutations settled on
the server's copy, one-time upload of locally queued messages from older
builds). Edit / send-now take the full message back from the server. A
UI-driven auto-review run asks the server to hold that session's queue.
VS Code keeps its local queue and foreground auto-send.

Claude-Session: https://claude.ai/code/session_01HB9wdLQoZX2vfyDjwv6Rso
This commit is contained in:
Bohdan Triapitsyn
2026-09-04 14:08:08 +03:00
parent 8afec51480
commit 07fa83cc72
33 changed files with 2256 additions and 197 deletions
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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",
+2
View File
@@ -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',
+2
View File
@@ -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': '親セッションに戻る',
+2
View File
@@ -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': '상위 세션으로 돌아가기',
+2
View File
@@ -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",
+2
View File
@@ -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',
+2
View File
@@ -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': '返回父會話',