diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d97c800750f7..a10fc669ae04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,7 +33,9 @@ overrides: packageExtensionsChecksum: sha256-zZ8fyodhMTumshonC7kktCqTPsiHL3UAyS9vltFAlMo= patchedDependencies: - '@agentclientprotocol/claude-agent-acp@0.37.0': 3c1bd768608166e6b2799e51a56ede1fdda010fd60ab52a64f7d309dc6192b35 + '@agentclientprotocol/claude-agent-acp@0.37.0': + hash: 3c1bd768608166e6b2799e51a56ede1fdda010fd60ab52a64f7d309dc6192b35 + path: patches/@agentclientprotocol__claude-agent-acp@0.37.0.patch importers: diff --git a/src/secrets/path-utils.ts b/src/secrets/path-utils.ts index c23f20684a64..ac3af04f2cb6 100644 --- a/src/secrets/path-utils.ts +++ b/src/secrets/path-utils.ts @@ -2,10 +2,6 @@ import { isDeepStrictEqual } from "node:util"; import { parseConfigPathArrayIndex } from "../shared/path-array-index.js"; import { isRecord } from "./shared.js"; -function isArrayIndexSegment(segment: string): boolean { - return parseArrayIndexSegment(segment) !== undefined; -} - function looksLikeArrayIndexSegment(segment: string): boolean { return /^\d+$/.test(segment); } diff --git a/ui/src/i18n/.i18n/ar.meta.json b/ui/src/i18n/.i18n/ar.meta.json index 6b296b720870..0ac42e67703e 100644 --- a/ui/src/i18n/.i18n/ar.meta.json +++ b/ui/src/i18n/.i18n/ar.meta.json @@ -1,11 +1,15 @@ { - "fallbackKeys": [], - "generatedAt": "2026-05-28T05:53:50.534Z", + "fallbackKeys": [ + "chat.queue.retry", + "chat.queue.retryQueuedMessage", + "chat.queue.retrySend" + ], + "generatedAt": "2026-05-28T16:05:45.085Z", "locale": "ar", - "model": "claude-opus-4-7", - "provider": "anthropic", - "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", - "totalKeys": 1155, + "model": "gpt-5.5", + "provider": "openai", + "sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9", + "totalKeys": 1158, "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/de.meta.json b/ui/src/i18n/.i18n/de.meta.json index 7c9fd8785c31..486e8a0a18d0 100644 --- a/ui/src/i18n/.i18n/de.meta.json +++ b/ui/src/i18n/.i18n/de.meta.json @@ -1,11 +1,15 @@ { - "fallbackKeys": [], - "generatedAt": "2026-05-28T05:52:45.886Z", + "fallbackKeys": [ + "chat.queue.retry", + "chat.queue.retryQueuedMessage", + "chat.queue.retrySend" + ], + "generatedAt": "2026-05-28T16:05:40.369Z", "locale": "de", - "model": "claude-opus-4-7", - "provider": "anthropic", - "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", - "totalKeys": 1155, + "model": "gpt-5.5", + "provider": "openai", + "sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9", + "totalKeys": 1158, "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/es.meta.json b/ui/src/i18n/.i18n/es.meta.json index d5ef0696ee61..0b47185c55f6 100644 --- a/ui/src/i18n/.i18n/es.meta.json +++ b/ui/src/i18n/.i18n/es.meta.json @@ -1,11 +1,15 @@ { - "fallbackKeys": [], - "generatedAt": "2026-05-28T05:53:14.311Z", + "fallbackKeys": [ + "chat.queue.retry", + "chat.queue.retryQueuedMessage", + "chat.queue.retrySend" + ], + "generatedAt": "2026-05-28T16:05:41.157Z", "locale": "es", - "model": "claude-opus-4-7", - "provider": "anthropic", - "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", - "totalKeys": 1155, + "model": "gpt-5.5", + "provider": "openai", + "sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9", + "totalKeys": 1158, "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/fa.meta.json b/ui/src/i18n/.i18n/fa.meta.json index 95c589c7a75d..1d126ea3418d 100644 --- a/ui/src/i18n/.i18n/fa.meta.json +++ b/ui/src/i18n/.i18n/fa.meta.json @@ -1,11 +1,15 @@ { - "fallbackKeys": [], - "generatedAt": "2026-05-28T05:54:58.861Z", + "fallbackKeys": [ + "chat.queue.retry", + "chat.queue.retryQueuedMessage", + "chat.queue.retrySend" + ], + "generatedAt": "2026-05-28T16:05:52.569Z", "locale": "fa", - "model": "claude-opus-4-7", - "provider": "anthropic", - "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", - "totalKeys": 1155, + "model": "gpt-5.5", + "provider": "openai", + "sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9", + "totalKeys": 1158, "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/fr.meta.json b/ui/src/i18n/.i18n/fr.meta.json index 280bf990886c..6e1c64e8d620 100644 --- a/ui/src/i18n/.i18n/fr.meta.json +++ b/ui/src/i18n/.i18n/fr.meta.json @@ -1,11 +1,15 @@ { - "fallbackKeys": [], - "generatedAt": "2026-05-28T05:53:26.331Z", + "fallbackKeys": [ + "chat.queue.retry", + "chat.queue.retryQueuedMessage", + "chat.queue.retrySend" + ], + "generatedAt": "2026-05-28T16:05:44.275Z", "locale": "fr", - "model": "claude-opus-4-7", - "provider": "anthropic", - "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", - "totalKeys": 1155, + "model": "gpt-5.5", + "provider": "openai", + "sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9", + "totalKeys": 1158, "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/id.meta.json b/ui/src/i18n/.i18n/id.meta.json index e32177358c04..da3ba04daa59 100644 --- a/ui/src/i18n/.i18n/id.meta.json +++ b/ui/src/i18n/.i18n/id.meta.json @@ -1,11 +1,15 @@ { - "fallbackKeys": [], - "generatedAt": "2026-05-28T05:54:22.983Z", + "fallbackKeys": [ + "chat.queue.retry", + "chat.queue.retryQueuedMessage", + "chat.queue.retrySend" + ], + "generatedAt": "2026-05-28T16:05:48.486Z", "locale": "id", - "model": "claude-opus-4-7", - "provider": "anthropic", - "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", - "totalKeys": 1155, + "model": "gpt-5.5", + "provider": "openai", + "sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9", + "totalKeys": 1158, "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/it.meta.json b/ui/src/i18n/.i18n/it.meta.json index 7002d19298a4..80544c821bb1 100644 --- a/ui/src/i18n/.i18n/it.meta.json +++ b/ui/src/i18n/.i18n/it.meta.json @@ -1,11 +1,15 @@ { - "fallbackKeys": [], - "generatedAt": "2026-05-28T05:53:54.298Z", + "fallbackKeys": [ + "chat.queue.retry", + "chat.queue.retryQueuedMessage", + "chat.queue.retrySend" + ], + "generatedAt": "2026-05-28T16:05:45.949Z", "locale": "it", - "model": "claude-opus-4-7", - "provider": "anthropic", - "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", - "totalKeys": 1155, + "model": "gpt-5.5", + "provider": "openai", + "sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9", + "totalKeys": 1158, "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ja-JP.meta.json b/ui/src/i18n/.i18n/ja-JP.meta.json index 11d6ef9819c5..3d1981b740b0 100644 --- a/ui/src/i18n/.i18n/ja-JP.meta.json +++ b/ui/src/i18n/.i18n/ja-JP.meta.json @@ -1,11 +1,15 @@ { - "fallbackKeys": [], - "generatedAt": "2026-05-28T05:53:24.087Z", + "fallbackKeys": [ + "chat.queue.retry", + "chat.queue.retryQueuedMessage", + "chat.queue.retrySend" + ], + "generatedAt": "2026-05-28T16:05:41.993Z", "locale": "ja-JP", - "model": "claude-opus-4-7", - "provider": "anthropic", - "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", - "totalKeys": 1155, + "model": "gpt-5.5", + "provider": "openai", + "sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9", + "totalKeys": 1158, "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ko.meta.json b/ui/src/i18n/.i18n/ko.meta.json index 5d3a1a79a0a1..1cb801912aee 100644 --- a/ui/src/i18n/.i18n/ko.meta.json +++ b/ui/src/i18n/.i18n/ko.meta.json @@ -1,11 +1,15 @@ { - "fallbackKeys": [], - "generatedAt": "2026-05-28T05:53:22.295Z", + "fallbackKeys": [ + "chat.queue.retry", + "chat.queue.retryQueuedMessage", + "chat.queue.retrySend" + ], + "generatedAt": "2026-05-28T16:05:43.457Z", "locale": "ko", - "model": "claude-opus-4-7", - "provider": "anthropic", - "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", - "totalKeys": 1155, + "model": "gpt-5.5", + "provider": "openai", + "sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9", + "totalKeys": 1158, "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/nl.meta.json b/ui/src/i18n/.i18n/nl.meta.json index d82e26823975..aee4916825c4 100644 --- a/ui/src/i18n/.i18n/nl.meta.json +++ b/ui/src/i18n/.i18n/nl.meta.json @@ -1,11 +1,15 @@ { - "fallbackKeys": [], - "generatedAt": "2026-05-28T05:54:59.941Z", + "fallbackKeys": [ + "chat.queue.retry", + "chat.queue.retryQueuedMessage", + "chat.queue.retrySend" + ], + "generatedAt": "2026-05-28T16:05:51.760Z", "locale": "nl", - "model": "claude-opus-4-7", - "provider": "anthropic", - "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", - "totalKeys": 1155, + "model": "gpt-5.5", + "provider": "openai", + "sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9", + "totalKeys": 1158, "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/pl.meta.json b/ui/src/i18n/.i18n/pl.meta.json index c3296b1ef769..7ea558461023 100644 --- a/ui/src/i18n/.i18n/pl.meta.json +++ b/ui/src/i18n/.i18n/pl.meta.json @@ -1,11 +1,15 @@ { - "fallbackKeys": [], - "generatedAt": "2026-05-28T05:54:27.579Z", + "fallbackKeys": [ + "chat.queue.retry", + "chat.queue.retryQueuedMessage", + "chat.queue.retrySend" + ], + "generatedAt": "2026-05-28T16:05:49.306Z", "locale": "pl", - "model": "claude-opus-4-7", - "provider": "anthropic", - "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", - "totalKeys": 1155, + "model": "gpt-5.5", + "provider": "openai", + "sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9", + "totalKeys": 1158, "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/pt-BR.meta.json b/ui/src/i18n/.i18n/pt-BR.meta.json index f2ae11347570..a2db29b2395d 100644 --- a/ui/src/i18n/.i18n/pt-BR.meta.json +++ b/ui/src/i18n/.i18n/pt-BR.meta.json @@ -1,11 +1,15 @@ { - "fallbackKeys": [], - "generatedAt": "2026-05-28T05:52:48.434Z", + "fallbackKeys": [ + "chat.queue.retry", + "chat.queue.retryQueuedMessage", + "chat.queue.retrySend" + ], + "generatedAt": "2026-05-28T16:05:39.541Z", "locale": "pt-BR", - "model": "claude-opus-4-7", - "provider": "anthropic", - "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", - "totalKeys": 1155, + "model": "gpt-5.5", + "provider": "openai", + "sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9", + "totalKeys": 1158, "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/th.meta.json b/ui/src/i18n/.i18n/th.meta.json index fb39b3b687b1..74e2ed41410d 100644 --- a/ui/src/i18n/.i18n/th.meta.json +++ b/ui/src/i18n/.i18n/th.meta.json @@ -1,11 +1,15 @@ { - "fallbackKeys": [], - "generatedAt": "2026-05-28T05:54:34.944Z", + "fallbackKeys": [ + "chat.queue.retry", + "chat.queue.retryQueuedMessage", + "chat.queue.retrySend" + ], + "generatedAt": "2026-05-28T16:05:50.121Z", "locale": "th", - "model": "claude-opus-4-7", - "provider": "anthropic", - "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", - "totalKeys": 1155, + "model": "gpt-5.5", + "provider": "openai", + "sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9", + "totalKeys": 1158, "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/tr.meta.json b/ui/src/i18n/.i18n/tr.meta.json index 7bb5470455e8..e95cab728c0e 100644 --- a/ui/src/i18n/.i18n/tr.meta.json +++ b/ui/src/i18n/.i18n/tr.meta.json @@ -1,11 +1,15 @@ { - "fallbackKeys": [], - "generatedAt": "2026-05-28T05:54:02.199Z", + "fallbackKeys": [ + "chat.queue.retry", + "chat.queue.retryQueuedMessage", + "chat.queue.retrySend" + ], + "generatedAt": "2026-05-28T16:05:46.798Z", "locale": "tr", - "model": "claude-opus-4-7", - "provider": "anthropic", - "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", - "totalKeys": 1155, + "model": "gpt-5.5", + "provider": "openai", + "sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9", + "totalKeys": 1158, "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/uk.meta.json b/ui/src/i18n/.i18n/uk.meta.json index d3b1a7248f2c..8a68761b91bd 100644 --- a/ui/src/i18n/.i18n/uk.meta.json +++ b/ui/src/i18n/.i18n/uk.meta.json @@ -1,11 +1,15 @@ { - "fallbackKeys": [], - "generatedAt": "2026-05-28T05:54:01.227Z", + "fallbackKeys": [ + "chat.queue.retry", + "chat.queue.retryQueuedMessage", + "chat.queue.retrySend" + ], + "generatedAt": "2026-05-28T16:05:47.638Z", "locale": "uk", - "model": "claude-opus-4-7", - "provider": "anthropic", - "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", - "totalKeys": 1155, + "model": "gpt-5.5", + "provider": "openai", + "sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9", + "totalKeys": 1158, "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/vi.meta.json b/ui/src/i18n/.i18n/vi.meta.json index 03f88a355a1b..f4cfe1089563 100644 --- a/ui/src/i18n/.i18n/vi.meta.json +++ b/ui/src/i18n/.i18n/vi.meta.json @@ -1,11 +1,15 @@ { - "fallbackKeys": [], - "generatedAt": "2026-05-28T05:54:47.002Z", + "fallbackKeys": [ + "chat.queue.retry", + "chat.queue.retryQueuedMessage", + "chat.queue.retrySend" + ], + "generatedAt": "2026-05-28T16:05:50.934Z", "locale": "vi", - "model": "claude-opus-4-7", - "provider": "anthropic", - "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", - "totalKeys": 1155, + "model": "gpt-5.5", + "provider": "openai", + "sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9", + "totalKeys": 1158, "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/zh-CN.meta.json b/ui/src/i18n/.i18n/zh-CN.meta.json index 2c3a54c6ae32..7123907add68 100644 --- a/ui/src/i18n/.i18n/zh-CN.meta.json +++ b/ui/src/i18n/.i18n/zh-CN.meta.json @@ -1,11 +1,15 @@ { - "fallbackKeys": [], - "generatedAt": "2026-05-28T05:52:44.622Z", + "fallbackKeys": [ + "chat.queue.retry", + "chat.queue.retryQueuedMessage", + "chat.queue.retrySend" + ], + "generatedAt": "2026-05-28T16:05:37.925Z", "locale": "zh-CN", - "model": "claude-opus-4-7", - "provider": "anthropic", - "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", - "totalKeys": 1155, + "model": "gpt-5.5", + "provider": "openai", + "sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9", + "totalKeys": 1158, "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/zh-TW.meta.json b/ui/src/i18n/.i18n/zh-TW.meta.json index 244197205b05..be8135eb63ae 100644 --- a/ui/src/i18n/.i18n/zh-TW.meta.json +++ b/ui/src/i18n/.i18n/zh-TW.meta.json @@ -1,11 +1,15 @@ { - "fallbackKeys": [], - "generatedAt": "2026-05-28T05:52:42.451Z", + "fallbackKeys": [ + "chat.queue.retry", + "chat.queue.retryQueuedMessage", + "chat.queue.retrySend" + ], + "generatedAt": "2026-05-28T16:05:38.739Z", "locale": "zh-TW", - "model": "claude-opus-4-7", - "provider": "anthropic", - "sourceHash": "24e6aff2cfb534faf3b6a9c96ccafee1470519c2f4498c07841072435a0565ea", - "totalKeys": 1155, + "model": "gpt-5.5", + "provider": "openai", + "sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9", + "totalKeys": 1158, "translatedKeys": 1155, "workflow": 1 } diff --git a/ui/src/i18n/locales/ar.ts b/ui/src/i18n/locales/ar.ts index 06a1a558b25c..3ca7e5b12dca 100644 --- a/ui/src/i18n/locales/ar.ts +++ b/ui/src/i18n/locales/ar.ts @@ -1110,6 +1110,11 @@ export const ar: TranslationMap = { send: "Send", sendMessage: "Send message", }, + queue: { + retry: "Retry", + retrySend: "Retry send", + retryQueuedMessage: "Retry queued message", + }, composer: { placeholder: "Message {name} (Enter to send)", placeholderWithAttachments: "Add a message or paste more images...", diff --git a/ui/src/i18n/locales/de.ts b/ui/src/i18n/locales/de.ts index f6f66de54ab6..20f54abfdf1b 100644 --- a/ui/src/i18n/locales/de.ts +++ b/ui/src/i18n/locales/de.ts @@ -1134,6 +1134,11 @@ export const de: TranslationMap = { send: "Send", sendMessage: "Send message", }, + queue: { + retry: "Retry", + retrySend: "Retry send", + retryQueuedMessage: "Retry queued message", + }, composer: { placeholder: "Message {name} (Enter to send)", placeholderWithAttachments: "Add a message or paste more images...", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 867f8575674d..99a5f696aca9 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -1116,6 +1116,11 @@ export const en: TranslationMap = { send: "Send", sendMessage: "Send message", }, + queue: { + retry: "Retry", + retrySend: "Retry send", + retryQueuedMessage: "Retry queued message", + }, composer: { placeholder: "Message {name} (Enter to send)", placeholderWithAttachments: "Add a message or paste more images...", diff --git a/ui/src/i18n/locales/es.ts b/ui/src/i18n/locales/es.ts index 905d8ee52e6a..6b2719e3d70a 100644 --- a/ui/src/i18n/locales/es.ts +++ b/ui/src/i18n/locales/es.ts @@ -1131,6 +1131,11 @@ export const es: TranslationMap = { send: "Send", sendMessage: "Send message", }, + queue: { + retry: "Retry", + retrySend: "Retry send", + retryQueuedMessage: "Retry queued message", + }, composer: { placeholder: "Message {name} (Enter to send)", placeholderWithAttachments: "Add a message or paste more images...", diff --git a/ui/src/i18n/locales/fa.ts b/ui/src/i18n/locales/fa.ts index 99082c1b89e3..29ab855a1ab6 100644 --- a/ui/src/i18n/locales/fa.ts +++ b/ui/src/i18n/locales/fa.ts @@ -1127,6 +1127,11 @@ export const fa: TranslationMap = { send: "Send", sendMessage: "Send message", }, + queue: { + retry: "Retry", + retrySend: "Retry send", + retryQueuedMessage: "Retry queued message", + }, composer: { placeholder: "Message {name} (Enter to send)", placeholderWithAttachments: "Add a message or paste more images...", diff --git a/ui/src/i18n/locales/fr.ts b/ui/src/i18n/locales/fr.ts index 9115c8e49f52..eaee471e6019 100644 --- a/ui/src/i18n/locales/fr.ts +++ b/ui/src/i18n/locales/fr.ts @@ -1138,6 +1138,11 @@ export const fr: TranslationMap = { send: "Send", sendMessage: "Send message", }, + queue: { + retry: "Retry", + retrySend: "Retry send", + retryQueuedMessage: "Retry queued message", + }, composer: { placeholder: "Message {name} (Enter to send)", placeholderWithAttachments: "Add a message or paste more images...", diff --git a/ui/src/i18n/locales/id.ts b/ui/src/i18n/locales/id.ts index a86c93017ff3..7e86a91c33c6 100644 --- a/ui/src/i18n/locales/id.ts +++ b/ui/src/i18n/locales/id.ts @@ -1125,6 +1125,11 @@ export const id: TranslationMap = { send: "Send", sendMessage: "Send message", }, + queue: { + retry: "Retry", + retrySend: "Retry send", + retryQueuedMessage: "Retry queued message", + }, composer: { placeholder: "Message {name} (Enter to send)", placeholderWithAttachments: "Add a message or paste more images...", diff --git a/ui/src/i18n/locales/it.ts b/ui/src/i18n/locales/it.ts index f970abeed5dc..1a684c09c87d 100644 --- a/ui/src/i18n/locales/it.ts +++ b/ui/src/i18n/locales/it.ts @@ -1132,6 +1132,11 @@ export const it: TranslationMap = { send: "Send", sendMessage: "Send message", }, + queue: { + retry: "Retry", + retrySend: "Retry send", + retryQueuedMessage: "Retry queued message", + }, composer: { placeholder: "Message {name} (Enter to send)", placeholderWithAttachments: "Add a message or paste more images...", diff --git a/ui/src/i18n/locales/ja-JP.ts b/ui/src/i18n/locales/ja-JP.ts index 267b4e7b9477..1eb96ba56f48 100644 --- a/ui/src/i18n/locales/ja-JP.ts +++ b/ui/src/i18n/locales/ja-JP.ts @@ -1129,6 +1129,11 @@ export const ja_JP: TranslationMap = { send: "Send", sendMessage: "Send message", }, + queue: { + retry: "Retry", + retrySend: "Retry send", + retryQueuedMessage: "Retry queued message", + }, composer: { placeholder: "Message {name} (Enter to send)", placeholderWithAttachments: "Add a message or paste more images...", diff --git a/ui/src/i18n/locales/ko.ts b/ui/src/i18n/locales/ko.ts index 2460d7e31a95..0099e3f444ed 100644 --- a/ui/src/i18n/locales/ko.ts +++ b/ui/src/i18n/locales/ko.ts @@ -1118,6 +1118,11 @@ export const ko: TranslationMap = { send: "Send", sendMessage: "Send message", }, + queue: { + retry: "Retry", + retrySend: "Retry send", + retryQueuedMessage: "Retry queued message", + }, composer: { placeholder: "Message {name} (Enter to send)", placeholderWithAttachments: "Add a message or paste more images...", diff --git a/ui/src/i18n/locales/nl.ts b/ui/src/i18n/locales/nl.ts index 24a44e59fce1..99896677e0b1 100644 --- a/ui/src/i18n/locales/nl.ts +++ b/ui/src/i18n/locales/nl.ts @@ -1130,6 +1130,11 @@ export const nl: TranslationMap = { send: "Send", sendMessage: "Send message", }, + queue: { + retry: "Retry", + retrySend: "Retry send", + retryQueuedMessage: "Retry queued message", + }, composer: { placeholder: "Message {name} (Enter to send)", placeholderWithAttachments: "Add a message or paste more images...", diff --git a/ui/src/i18n/locales/pl.ts b/ui/src/i18n/locales/pl.ts index 115709c20354..01e2a54fbf49 100644 --- a/ui/src/i18n/locales/pl.ts +++ b/ui/src/i18n/locales/pl.ts @@ -1130,6 +1130,11 @@ export const pl: TranslationMap = { send: "Send", sendMessage: "Send message", }, + queue: { + retry: "Retry", + retrySend: "Retry send", + retryQueuedMessage: "Retry queued message", + }, composer: { placeholder: "Message {name} (Enter to send)", placeholderWithAttachments: "Add a message or paste more images...", diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index 813450e62a68..72407ac4a4ed 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -1126,6 +1126,11 @@ export const pt_BR: TranslationMap = { send: "Send", sendMessage: "Send message", }, + queue: { + retry: "Retry", + retrySend: "Retry send", + retryQueuedMessage: "Retry queued message", + }, composer: { placeholder: "Message {name} (Enter to send)", placeholderWithAttachments: "Add a message or paste more images...", diff --git a/ui/src/i18n/locales/th.ts b/ui/src/i18n/locales/th.ts index d70fb7df3d29..c6fe0c0d4e1e 100644 --- a/ui/src/i18n/locales/th.ts +++ b/ui/src/i18n/locales/th.ts @@ -1095,6 +1095,11 @@ export const th: TranslationMap = { send: "Send", sendMessage: "Send message", }, + queue: { + retry: "Retry", + retrySend: "Retry send", + retryQueuedMessage: "Retry queued message", + }, composer: { placeholder: "Message {name} (Enter to send)", placeholderWithAttachments: "Add a message or paste more images...", diff --git a/ui/src/i18n/locales/tr.ts b/ui/src/i18n/locales/tr.ts index 6cb56f93ff29..d0025435cfa1 100644 --- a/ui/src/i18n/locales/tr.ts +++ b/ui/src/i18n/locales/tr.ts @@ -1131,6 +1131,11 @@ export const tr: TranslationMap = { send: "Send", sendMessage: "Send message", }, + queue: { + retry: "Retry", + retrySend: "Retry send", + retryQueuedMessage: "Retry queued message", + }, composer: { placeholder: "Message {name} (Enter to send)", placeholderWithAttachments: "Add a message or paste more images...", diff --git a/ui/src/i18n/locales/uk.ts b/ui/src/i18n/locales/uk.ts index a0636e2a1ec5..8d4fd5209638 100644 --- a/ui/src/i18n/locales/uk.ts +++ b/ui/src/i18n/locales/uk.ts @@ -1128,6 +1128,11 @@ export const uk: TranslationMap = { send: "Send", sendMessage: "Send message", }, + queue: { + retry: "Retry", + retrySend: "Retry send", + retryQueuedMessage: "Retry queued message", + }, composer: { placeholder: "Message {name} (Enter to send)", placeholderWithAttachments: "Add a message or paste more images...", diff --git a/ui/src/i18n/locales/vi.ts b/ui/src/i18n/locales/vi.ts index 847ec265ac02..3fcf2502780b 100644 --- a/ui/src/i18n/locales/vi.ts +++ b/ui/src/i18n/locales/vi.ts @@ -1117,6 +1117,11 @@ export const vi: TranslationMap = { send: "Send", sendMessage: "Send message", }, + queue: { + retry: "Retry", + retrySend: "Retry send", + retryQueuedMessage: "Retry queued message", + }, composer: { placeholder: "Message {name} (Enter to send)", placeholderWithAttachments: "Add a message or paste more images...", diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index 2838e56c58f3..c966c038aad7 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -1090,6 +1090,11 @@ export const zh_CN: TranslationMap = { send: "发送", sendMessage: "发送消息", }, + queue: { + retry: "Retry", + retrySend: "Retry send", + retryQueuedMessage: "Retry queued message", + }, composer: { placeholder: "给 {name} 发消息(Enter 发送)", placeholderWithAttachments: "添加消息或继续粘贴图片...", diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index cf6fc8ff138e..3fd669150845 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -1092,6 +1092,11 @@ export const zh_TW: TranslationMap = { send: "Send", sendMessage: "Send message", }, + queue: { + retry: "Retry", + retrySend: "Retry send", + retryQueuedMessage: "Retry queued message", + }, composer: { placeholder: "Message {name} (Enter to send)", placeholderWithAttachments: "Add a message or paste more images...", diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 89553a10ff82..c074bc38df19 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -1025,7 +1025,8 @@ gap: 6px; } -.chat-queue__steer { +.chat-queue__steer, +.chat-queue__retry { align-self: start; display: inline-flex; align-items: center; @@ -1036,7 +1037,8 @@ line-height: 1; } -.chat-queue__steer svg { +.chat-queue__steer svg, +.chat-queue__retry svg { width: 13px; height: 13px; fill: none; @@ -1046,7 +1048,8 @@ stroke-linejoin: round; } -.chat-queue__steer:hover:not(:disabled) { +.chat-queue__steer:hover:not(:disabled), +.chat-queue__retry:hover:not(:disabled) { background: color-mix(in srgb, var(--accent) 12%, transparent); } diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index bf18b984f80e..2b35e5bdf03f 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -3834,6 +3834,13 @@ td.data-table-key-col { -webkit-box-orient: vertical; } +.chat-queue__error { + margin-top: 4px; + color: var(--danger); + font-size: 12px; + line-height: 1.35; +} + .chat-queue__remove { align-self: start; padding: 4px 10px; diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 548a5b4b87dc..089fd6e6da22 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -44,9 +44,18 @@ export type ControlUiE2eServer = { }; export type MockGatewayControls = { + closeLatest: (code?: number, reason?: string) => Promise; + deferNext: (method: string) => Promise; emitChatFinal: (params: { runId: string; sessionKey?: string; text: string }) => Promise; emitGatewayEvent: (event: string, payload?: unknown) => Promise; getRequests: (method?: string) => Promise; + getSocketCount: () => Promise; + rejectDeferred: ( + method: string, + error?: { code?: string; message?: string; details?: unknown; retryable?: boolean }, + ) => Promise; + resolveDeferred: (method: string, payload?: unknown) => Promise; + setHistoryMessages: (messages: unknown[]) => Promise; waitForRequest: (method: string) => Promise; }; @@ -183,10 +192,25 @@ function installControlUiMockGateway(input: { type BrowserMethodResponseCases = { cases?: BrowserMethodResponseCase[]; }; + type DeferredResponse = { + id: string; + method: string; + params?: unknown; + socket: { deliver: (frame: unknown) => void }; + }; type ExposedGateway = { + closeLatest: (code?: number, reason?: string) => void; + deferNext: (method: string) => void; emit: (event: string, payload?: unknown) => void; findRequests: (method?: string) => BrowserRequest[]; + rejectDeferred: ( + method: string, + error?: { code?: string; message?: string; details?: unknown; retryable?: boolean }, + ) => void; requests: BrowserRequest[]; + resolveDeferred: (method: string, payload?: unknown) => void; + setHistoryMessages: (messages: unknown[]) => void; + socketCount: () => number; }; type WindowWithGateway = Window & { openclawControlUiE2eGateway?: ExposedGateway; @@ -194,7 +218,10 @@ function installControlUiMockGateway(input: { const scenario: BrowserScenario = input.scenario; const protocolVersion = input.protocolVersion; + const deferredMethods: string[] = []; + const deferredResponses: DeferredResponse[] = []; const requests: BrowserRequest[] = []; + const sockets: unknown[] = []; let seq = 0; function isRecord(value: unknown): value is Record { @@ -337,7 +364,13 @@ function installControlUiMockGateway(input: { thinkingLevel: null, }; case "chat.send": - return { ok: true, queued: false, params }; + return { + runId: + isRecord(params) && typeof params.idempotencyKey === "string" + ? params.idempotencyKey + : "control-ui-e2e-run", + status: "started", + }; case "commands.list": return { commands: [] }; case "health": @@ -371,6 +404,15 @@ function installControlUiMockGateway(input: { } } + function shouldDefer(method: string): boolean { + const index = deferredMethods.indexOf(method); + if (index < 0) { + return false; + } + deferredMethods.splice(index, 1); + return true; + } + function parseFrame(raw: string | ArrayBufferLike | Blob | ArrayBufferView): BrowserFrame | null { if (typeof raw !== "string") { return null; @@ -405,6 +447,7 @@ function installControlUiMockGateway(input: { super(); this.url = String(url); MockWebSocket.latest = this; + sockets.push(this); window.setTimeout(() => { if (this.readyState !== MockWebSocket.CONNECTING) { return; @@ -452,6 +495,10 @@ function installControlUiMockGateway(input: { return; } requests.push({ id, method, params: frame.params }); + if (shouldDefer(method)) { + deferredResponses.push({ id, method, params: frame.params, socket: this }); + return; + } window.setTimeout(() => { this.deliver({ id, @@ -471,6 +518,12 @@ function installControlUiMockGateway(input: { } const exposed: ExposedGateway = { + closeLatest(code, reason) { + MockWebSocket.latest?.close(code ?? 1006, reason ?? "mock close"); + }, + deferNext(method) { + deferredMethods.push(method); + }, emit(event, payload) { MockWebSocket.latest?.deliver({ event, @@ -482,7 +535,44 @@ function installControlUiMockGateway(input: { findRequests(method) { return method ? requests.filter((request) => request.method === method) : [...requests]; }, + rejectDeferred(method, error) { + const index = deferredResponses.findIndex((response) => response.method === method); + if (index < 0) { + throw new Error(`No deferred mock Gateway response for ${method}`); + } + const [response] = deferredResponses.splice(index, 1); + response.socket.deliver({ + error: { + code: error?.code ?? "INVALID_REQUEST", + message: error?.message ?? "mock Gateway rejected request", + ...(error?.details ? { details: error.details } : {}), + ...(error?.retryable ? { retryable: true } : {}), + }, + id: response.id, + ok: false, + type: "res", + }); + }, requests, + resolveDeferred(method, payload) { + const index = deferredResponses.findIndex((response) => response.method === method); + if (index < 0) { + throw new Error(`No deferred mock Gateway response for ${method}`); + } + const [response] = deferredResponses.splice(index, 1); + response.socket.deliver({ + id: response.id, + ok: true, + payload: payload ?? buildResponse(response.method, response.params), + type: "res", + }); + }, + setHistoryMessages(messages) { + scenario.historyMessages = Array.isArray(messages) ? messages : []; + }, + socketCount() { + return sockets.length; + }, }; (window as WindowWithGateway).openclawControlUiE2eGateway = exposed; @@ -538,6 +628,39 @@ function createMockGatewayControls(page: Page, defaultSessionKey: string): MockG }, method); return { + async closeLatest(code, reason) { + await page.evaluate( + ({ closeCode, closeReason }) => { + const gateway = ( + window as Window & { + openclawControlUiE2eGateway?: { + closeLatest: (code?: number, reason?: string) => void; + }; + } + ).openclawControlUiE2eGateway; + if (!gateway) { + throw new Error("Mock Gateway is not installed"); + } + gateway.closeLatest(closeCode, closeReason); + }, + { closeCode: code, closeReason: reason }, + ); + }, + async deferNext(method) { + await page.evaluate((targetMethod) => { + const gateway = ( + window as Window & { + openclawControlUiE2eGateway?: { + deferNext: (method: string) => void; + }; + } + ).openclawControlUiE2eGateway; + if (!gateway) { + throw new Error("Mock Gateway is not installed"); + } + gateway.deferNext(targetMethod); + }, method); + }, async emitChatFinal(params) { await emitGatewayEvent("chat", { message: { @@ -552,6 +675,77 @@ function createMockGatewayControls(page: Page, defaultSessionKey: string): MockG }, emitGatewayEvent, getRequests, + async getSocketCount() { + return await page.evaluate(() => { + const gateway = ( + window as Window & { + openclawControlUiE2eGateway?: { + socketCount: () => number; + }; + } + ).openclawControlUiE2eGateway; + return gateway?.socketCount() ?? 0; + }); + }, + async rejectDeferred(method, error) { + await page.evaluate( + ({ targetMethod, responseError }) => { + const gateway = ( + window as Window & { + openclawControlUiE2eGateway?: { + rejectDeferred: ( + method: string, + error?: { + code?: string; + message?: string; + details?: unknown; + retryable?: boolean; + }, + ) => void; + }; + } + ).openclawControlUiE2eGateway; + if (!gateway) { + throw new Error("Mock Gateway is not installed"); + } + gateway.rejectDeferred(targetMethod, responseError); + }, + { targetMethod: method, responseError: error }, + ); + }, + async resolveDeferred(method, payload) { + await page.evaluate( + ({ targetMethod, responsePayload }) => { + const gateway = ( + window as Window & { + openclawControlUiE2eGateway?: { + resolveDeferred: (method: string, payload?: unknown) => void; + }; + } + ).openclawControlUiE2eGateway; + if (!gateway) { + throw new Error("Mock Gateway is not installed"); + } + gateway.resolveDeferred(targetMethod, responsePayload); + }, + { targetMethod: method, responsePayload: payload }, + ); + }, + async setHistoryMessages(messages) { + await page.evaluate((nextMessages) => { + const gateway = ( + window as Window & { + openclawControlUiE2eGateway?: { + setHistoryMessages: (messages: unknown[]) => void; + }; + } + ).openclawControlUiE2eGateway; + if (!gateway) { + throw new Error("Mock Gateway is not installed"); + } + gateway.setHistoryMessages(nextMessages); + }, messages); + }, async waitForRequest(method) { await page.waitForFunction( (targetMethod) => { diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 9f517664838f..561cae1678cd 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -49,6 +49,7 @@ let refreshChat: typeof import("./app-chat.ts").refreshChat; let refreshChatAvatar: typeof import("./app-chat.ts").refreshChatAvatar; let clearPendingQueueItemsForRun: typeof import("./app-chat.ts").clearPendingQueueItemsForRun; let removeQueuedMessage: typeof import("./app-chat.ts").removeQueuedMessage; +let markQueuedChatSendsWaitingForReconnect: typeof import("./app-chat.ts").markQueuedChatSendsWaitingForReconnect; async function loadChatHelpers(): Promise { ({ @@ -61,6 +62,7 @@ async function loadChatHelpers(): Promise { refreshChatAvatar, clearPendingQueueItemsForRun, removeQueuedMessage, + markQueuedChatSendsWaitingForReconnect, } = await import("./app-chat.ts")); } @@ -132,6 +134,7 @@ function makeHost(overrides?: Partial): ChatHost { chatDraftBeforeHistory: null, chatAttachments: [], chatQueue: [], + chatQueueBySession: {}, chatRunId: null, chatSending: false, lastError: null, @@ -1241,16 +1244,187 @@ describe("handleSendChat", () => { const second = handleSendChat(host, "same prompt"); expect(request).toHaveBeenCalledTimes(1); - expect(host.chatQueue).toStrictEqual([]); - expect(host.chatMessages).toHaveLength(1); + expect(host.chatQueue).toHaveLength(1); + expect(host.chatQueue[0]?.text).toBe("same prompt"); + expect(host.chatQueue[0]?.sendState).toBe("sending"); + expect(host.chatMessages).toStrictEqual([]); - sent.resolve({ runId: host.chatRunId, status: "started" }); + const queuedRunId = host.chatQueue[0]?.sendRunId; + sent.resolve({ runId: queuedRunId, status: "started" }); await Promise.all([first, second]); expect(request).toHaveBeenCalledTimes(1); + expect(host.chatQueue).toStrictEqual([]); expect(host.chatMessages).toHaveLength(1); }); + it("keeps normal prompt text visible as pending until chat.send is acknowledged", async () => { + const sent = createDeferred(); + const request = vi.fn((method: string) => { + if (method === "chat.send") { + return sent.promise; + } + throw new Error(`Unexpected request: ${method}`); + }); + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + chatMessage: "do not lose this", + }); + + const send = handleSendChat(host); + await Promise.resolve(); + + expect(host.chatMessage).toBe(""); + expect(host.chatMessages).toStrictEqual([]); + expect(host.chatQueue).toHaveLength(1); + expect(host.chatQueue[0]).toMatchObject({ + text: "do not lose this", + sendState: "sending", + sessionKey: "agent:main", + }); + const runId = host.chatQueue[0]?.sendRunId; + expect(typeof runId).toBe("string"); + + sent.resolve({ runId, status: "started" }); + await send; + + expect(host.chatQueue).toStrictEqual([]); + expect(host.chatRunId).toBe(runId); + expect(host.chatMessages).toHaveLength(1); + const userMessage = requireRecord(host.chatMessages[0], "user message"); + expect(userMessage.role).toBe("user"); + }); + + it("keeps delayed chat.send ACK effects scoped to the submitted session", async () => { + const sent = createDeferred(); + const request = vi.fn((method: string) => { + if (method === "chat.send") { + return sent.promise; + } + throw new Error(`Unexpected request: ${method}`); + }); + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + chatMessage: "stay with session A", + sessionKey: "agent:a", + }); + + const send = handleSendChat(host); + await Promise.resolve(); + + const queuedRunId = host.chatQueue[0]?.sendRunId; + expect(queuedRunId).toEqual(expect.any(String)); + + host.chatQueueBySession = { "agent:a": [...host.chatQueue] }; + host.chatQueue = []; + host.sessionKey = "agent:b"; + host.chatMessages = []; + host.chatRunId = null; + host.chatStream = null; + + sent.resolve({ runId: queuedRunId, status: "started" }); + await send; + + expect(host.sessionKey).toBe("agent:b"); + expect(host.chatMessages).toStrictEqual([]); + expect(host.chatRunId).toBeNull(); + expect(host.chatStream).toBeNull(); + expect(host.chatQueue).toStrictEqual([]); + expect(host.chatQueueBySession?.["agent:a"]).toBeUndefined(); + }); + + it("keeps a pre-ack socket close recoverable with the same run id", async () => { + const request = vi.fn((method: string) => { + if (method === "chat.send") { + throw new Error("gateway closed (1006): network lost"); + } + throw new Error(`Unexpected request: ${method}`); + }); + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + chatMessage: "retry after reconnect", + }); + + await handleSendChat(host); + + expect(host.chatMessages).toStrictEqual([]); + expect(host.chatQueue).toHaveLength(1); + const queued = host.chatQueue[0]; + expect(queued?.text).toBe("retry after reconnect"); + expect(queued?.sendState).toBe("waiting-reconnect"); + expect(queued?.sendRunId).toEqual(expect.any(String)); + expect(host.lastError).toBe("Message will send when the Gateway reconnects."); + }); + + it("queues normal sends made while disconnected", async () => { + const host = makeHost({ + client: null, + connected: false, + chatMessage: "send after reconnect", + }); + + await handleSendChat(host); + + expect(host.chatMessage).toBe(""); + expect(host.chatMessages).toStrictEqual([]); + expect(host.chatQueue).toHaveLength(1); + expect(host.chatQueue[0]).toMatchObject({ + text: "send after reconnect", + sendState: "waiting-reconnect", + sessionKey: "agent:main", + }); + expect(host.chatQueue[0]?.sendRunId).toEqual(expect.any(String)); + }); + + it("marks saved session queued sends waiting after a disconnect", () => { + const host = makeHost({ + chatQueue: [], + chatQueueBySession: { + "agent:a": [ + { + id: "pending-send-a", + text: "pending", + createdAt: 1, + sendRunId: "run-a", + sendState: "sending", + sessionKey: "agent:a", + }, + ], + }, + }); + + markQueuedChatSendsWaitingForReconnect(host); + + expect(host.chatQueueBySession?.["agent:a"]?.[0]).toMatchObject({ + sendRunId: "run-a", + sendState: "waiting-reconnect", + }); + }); + + it("marks validation failures visible and restores the composer", async () => { + const request = vi.fn((method: string) => { + if (method === "chat.send") { + throw new Error("send blocked by session policy"); + } + throw new Error(`Unexpected request: ${method}`); + }); + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + chatMessage: "blocked prompt", + }); + + await handleSendChat(host); + + expect(host.chatMessage).toBe("blocked prompt"); + expect(host.chatMessages).toStrictEqual([]); + expect(host.chatQueue).toHaveLength(1); + expect(host.chatQueue[0]).toMatchObject({ + text: "blocked prompt", + sendState: "failed", + sendError: "send blocked by session policy", + }); + }); + it("restores the BTW draft when detached send fails", async () => { const host = makeHost({ client: { diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index d2a481323ee7..bc8d76c65f42 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -21,11 +21,13 @@ import { reconcileChatRunLifecycle } from "./chat/run-lifecycle.ts"; import type { ChatSideResult } from "./chat/side-result.ts"; import { executeSlashCommand } from "./chat/slash-command-executor.ts"; import { parseSlashCommand, refreshSlashCommands } from "./chat/slash-commands.ts"; +import { formatConnectError } from "./connect-error.ts"; import { resolveControlUiAuthHeader } from "./control-ui-auth.ts"; import { abortChatRun, + appendUserChatMessage, loadChatHistory, - sendChatMessage, + requestChatSend, sendDetachedChatMessage, sendSteerChatMessage, type ChatState, @@ -36,7 +38,7 @@ import { type LoadSessionsOverrides, type SessionsState, } from "./controllers/sessions.ts"; -import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; +import { GatewayRequestError, type GatewayBrowserClient, type GatewayHelloOk } from "./gateway.ts"; import { normalizeBasePath } from "./navigation.ts"; import { parseAgentSessionKey } from "./session-key.ts"; import { isSessionRunActive } from "./session-run-state.ts"; @@ -53,6 +55,7 @@ export type ChatHost = ChatInputHistoryState & { connected: boolean; chatAttachments: ChatAttachment[]; chatQueue: ChatQueueItem[]; + chatQueueBySession?: Record; chatRunId: string | null; chatSending: boolean; lastError?: string | null; @@ -223,24 +226,24 @@ function enqueueChatMessage( attachments?: ChatAttachment[], refreshSessions?: boolean, localCommand?: { args: string; name: string }, -) { +): ChatQueueItem | null { const trimmed = text.trim(); const hasAttachments = Boolean(attachments && attachments.length > 0); if (!trimmed && !hasAttachments) { - return; + return null; } - host.chatQueue = [ - ...host.chatQueue, - { - id: generateUUID(), - text: trimmed, - createdAt: Date.now(), - attachments: hasAttachments ? cloneChatAttachmentsMetadata(attachments ?? []) : undefined, - refreshSessions, - localCommandArgs: localCommand?.args, - localCommandName: localCommand?.name, - }, - ]; + const item: ChatQueueItem = { + id: generateUUID(), + text: trimmed, + createdAt: Date.now(), + attachments: hasAttachments ? cloneChatAttachmentsMetadata(attachments ?? []) : undefined, + refreshSessions, + localCommandArgs: localCommand?.args, + localCommandName: localCommand?.name, + sessionKey: host.sessionKey, + }; + host.chatQueue = [...host.chatQueue, item]; + return item; } function enqueuePendingRunMessage( @@ -267,10 +270,268 @@ function enqueuePendingRunMessage( ]; } +function enqueuePendingSendMessage( + host: ChatHost, + text: string, + attachments?: ChatAttachment[], + refreshSessions?: boolean, +): ChatQueueItem | null { + const trimmed = text.trim(); + const hasAttachments = Boolean(attachments && attachments.length > 0); + if (!trimmed && !hasAttachments) { + return null; + } + const pending: ChatQueueItem = { + id: generateUUID(), + text: trimmed, + createdAt: Date.now(), + attachments: hasAttachments ? attachments : undefined, + refreshSessions, + sendAttempts: 0, + sendRunId: generateUUID(), + sendState: host.connected && host.client ? "sending" : "waiting-reconnect", + sessionKey: host.sessionKey, + }; + host.chatQueue = [...host.chatQueue, pending]; + return pending; +} + +function updateQueuedMessage( + host: ChatHost, + id: string, + update: (item: ChatQueueItem) => ChatQueueItem, +): ChatQueueItem | null { + return updateQueuedMessageForSession(host, host.sessionKey, id, update); +} + +function readChatQueueForSession(host: ChatHost, sessionKey: string): ChatQueueItem[] { + return sessionKey === host.sessionKey + ? host.chatQueue + : (host.chatQueueBySession?.[sessionKey] ?? []); +} + +function writeChatQueueForSession(host: ChatHost, sessionKey: string, queue: ChatQueueItem[]) { + if (sessionKey === host.sessionKey) { + host.chatQueue = queue; + return; + } + const queueBySession = { ...host.chatQueueBySession }; + if (queue.length > 0) { + queueBySession[sessionKey] = queue; + } else { + delete queueBySession[sessionKey]; + } + host.chatQueueBySession = queueBySession; + host.requestUpdate?.(); +} + +function updateQueuedMessageForSession( + host: ChatHost, + sessionKey: string, + id: string, + update: (item: ChatQueueItem) => ChatQueueItem, +): ChatQueueItem | null { + let nextItem: ChatQueueItem | null = null; + const nextQueue = readChatQueueForSession(host, sessionKey).map((item) => { + if (item.id !== id) { + return item; + } + nextItem = update(item); + return nextItem; + }); + writeChatQueueForSession(host, sessionKey, nextQueue); + return nextItem; +} + +function removeQueuedMessageWithoutReleasing( + host: ChatHost, + id: string, + sessionKey = host.sessionKey, +): ChatQueueItem | null { + const queue = readChatQueueForSession(host, sessionKey); + const item = queue.find((entry) => entry.id === id) ?? null; + writeChatQueueForSession( + host, + sessionKey, + queue.filter((entry) => entry.id !== id), + ); + return item; +} + +function isRecoverableChatSendError(err: unknown, formattedError: string): boolean { + if (err instanceof GatewayRequestError) { + return err.retryable; + } + return /gateway (?:not connected|closed)|websocket|disconnected/i.test(formattedError); +} + +function restoreComposerAfterFailedSend( + host: ChatHost, + opts: { + previousAttachments?: ChatAttachment[]; + previousDraft?: string; + }, +) { + if (opts.previousDraft != null && !host.chatMessage.trim()) { + host.chatMessage = opts.previousDraft; + } + if (opts.previousAttachments?.length && host.chatAttachments.length === 0) { + host.chatAttachments = opts.previousAttachments; + } +} + +type QueuedChatSendResult = "sent" | "pending" | "failed"; + +function ensureQueuedSendState( + host: ChatHost, + item: ChatQueueItem, + fallbackSessionKey = host.sessionKey, +): ChatQueueItem { + if (item.sendRunId && item.sendState) { + return item; + } + const sessionKey = item.sessionKey ?? fallbackSessionKey; + const prepared: ChatQueueItem = { + ...item, + sendAttempts: item.sendAttempts ?? 0, + sendRunId: item.sendRunId ?? generateUUID(), + sendState: host.connected && host.client ? "sending" : "waiting-reconnect", + sessionKey, + }; + updateQueuedMessageForSession(host, sessionKey, item.id, () => prepared); + return prepared; +} + +async function sendQueuedChatMessage( + host: ChatHost, + id: string, + opts?: { + previousAttachments?: ChatAttachment[]; + previousDraft?: string; + }, + queuedSessionKey = host.sessionKey, +): Promise { + const queued = readChatQueueForSession(host, queuedSessionKey).find((item) => item.id === id); + if (!queued || queued.pendingRunId || queued.localCommandName) { + return "failed"; + } + const prepared = ensureQueuedSendState(host, queued, queuedSessionKey); + const message = prepared.text.trim(); + const attachments = prepared.attachments ?? []; + const hasAttachments = attachments.length > 0; + if (!message && !hasAttachments) { + removeQueuedMessageWithoutReleasing(host, id, prepared.sessionKey ?? host.sessionKey); + return "sent"; + } + const sessionKey = prepared.sessionKey ?? host.sessionKey; + if (!host.connected || !host.client) { + updateQueuedMessageForSession(host, sessionKey, id, (item) => ({ + ...item, + sendState: "waiting-reconnect", + sendError: undefined, + })); + return "pending"; + } + + const runId = prepared.sendRunId ?? generateUUID(); + const startedAt = Date.now(); + updateQueuedMessageForSession(host, sessionKey, id, (item) => ({ + ...item, + sendAttempts: (item.sendAttempts ?? 0) + 1, + sendError: undefined, + sendRunId: runId, + sendState: "sending", + sessionKey, + })); + host.chatSending = true; + if (host.sessionKey === sessionKey) { + host.lastError = null; + reconcileChatRunLifecycle(host as unknown as Parameters[0], { + clearRunStatus: true, + }); + } + + try { + const ack = await requestChatSend(host as unknown as ChatState, { + message, + attachments: hasAttachments ? attachments : undefined, + runId, + sessionKey, + }); + removeQueuedMessageWithoutReleasing(host, id, sessionKey); + if (host.sessionKey === sessionKey) { + appendUserChatMessage( + host as unknown as ChatState, + message, + hasAttachments ? attachments : undefined, + startedAt, + ); + if (ack.status === "ok") { + reconcileChatRunLifecycle( + host as unknown as Parameters[0], + { + sessionStatus: "done", + runId: ack.runId, + sessionKey, + clearLocalRun: true, + clearChatStream: true, + clearToolStream: true, + clearSideResultTerminalRuns: true, + clearRunStatus: true, + }, + ); + void loadChatHistory(host as unknown as ChatState); + } else { + host.chatRunId = ack.runId; + host.chatStream = ""; + (host as ChatHost & { chatStreamStartedAt?: number | null }).chatStreamStartedAt = + startedAt; + } + } + if (prepared.refreshSessions) { + if (ack.status === "ok") { + void loadSessions(host as unknown as SessionsState, { + ...createChatSessionsLoadOverrides(host), + }); + } else { + host.refreshSessionsAfterChat.add(ack.runId); + } + } + discardChatAttachmentDataUrls(excludeComposerAttachments(host, attachments)); + return "sent"; + } catch (err) { + const error = formatConnectError(err); + if (isRecoverableChatSendError(err, error)) { + updateQueuedMessageForSession(host, sessionKey, id, (item) => ({ + ...item, + sendError: error, + sendState: "waiting-reconnect", + })); + if (host.sessionKey === sessionKey) { + host.lastError = "Message will send when the Gateway reconnects."; + } + return "pending"; + } + updateQueuedMessageForSession(host, sessionKey, id, (item) => ({ + ...item, + sendError: error, + sendState: "failed", + })); + if (host.sessionKey === sessionKey) { + host.lastError = error; + restoreComposerAfterFailedSend(host, opts ?? {}); + } + return "failed"; + } finally { + host.chatSending = false; + } +} + async function sendChatMessageNow( host: ChatHost, message: string, opts?: { + queueItemId?: string; previousDraft?: string; restoreDraft?: boolean; attachments?: ChatAttachment[]; @@ -282,38 +543,49 @@ async function sendChatMessageNow( resetToolStream(host as unknown as Parameters[0]); // Reset scroll state before sending to ensure auto-scroll works for the response resetChatScroll(host as unknown as Parameters[0]); - const runId = await sendChatMessage(host as unknown as ChatState, message, opts?.attachments); - const ok = Boolean(runId); - if (!ok && opts?.previousDraft != null) { - host.chatMessage = opts.previousDraft; + const queued = + opts?.queueItemId != null + ? (host.chatQueue.find((item) => item.id === opts.queueItemId) ?? null) + : enqueuePendingSendMessage(host, message, opts?.attachments, opts?.refreshSessions); + if (!queued) { + return false; } - if (!ok && opts?.previousAttachments) { - host.chatAttachments = opts.previousAttachments; - } - if (ok) { + const queuedSessionKey = queued.sessionKey ?? host.sessionKey; + const result = await sendQueuedChatMessage(host, queued.id, { + previousDraft: opts?.previousDraft, + previousAttachments: opts?.previousAttachments, + }); + const ok = result === "sent"; + if (ok && host.sessionKey === queuedSessionKey) { setLastActiveSessionKey( host as unknown as Parameters[0], - host.sessionKey, + queuedSessionKey, ); resetChatInputHistoryNavigation(host); } - if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) { + if ( + ok && + host.sessionKey === queuedSessionKey && + opts?.restoreDraft && + opts.previousDraft?.trim() + ) { host.chatMessage = opts.previousDraft; } - if (ok && opts?.restoreAttachments && opts.previousAttachments?.length) { + if ( + ok && + host.sessionKey === queuedSessionKey && + opts?.restoreAttachments && + opts.previousAttachments?.length + ) { host.chatAttachments = opts.previousAttachments; } // Force scroll after sending to ensure viewport is at bottom for incoming stream - scheduleChatScroll(host as unknown as Parameters[0], true); - if (ok && !host.chatRunId) { + if (host.sessionKey === queuedSessionKey) { + scheduleChatScroll(host as unknown as Parameters[0], true); + } + if (ok && host.sessionKey === queuedSessionKey && !host.chatRunId) { void flushChatQueue(host); } - if (ok && opts?.refreshSessions && runId) { - host.refreshSessionsAfterChat.add(runId); - } - if (ok) { - discardChatAttachmentDataUrls(excludeComposerAttachments(host, opts?.attachments)); - } return ok; } @@ -504,19 +776,26 @@ async function flushChatQueue(host: ChatHost) { if (!host.connected || isChatBusy(host)) { return; } - const nextIndex = host.chatQueue.findIndex((item) => !item.pendingRunId); + const nextIndex = host.chatQueue.findIndex( + (item) => + !item.pendingRunId && + item.sendState !== "sending" && + item.sendState !== "failed" && + (item.sessionKey == null || item.sessionKey === host.sessionKey), + ); if (nextIndex < 0) { return; } const next = host.chatQueue[nextIndex]; - host.chatQueue = host.chatQueue.filter((_, index) => index !== nextIndex); let ok = false; try { if (next.localCommandName) { + host.chatQueue = host.chatQueue.filter((_, index) => index !== nextIndex); await dispatchSlashCommand(host, next.localCommandName, next.localCommandArgs ?? ""); ok = true; } else { ok = await sendChatMessageNow(host, next.text, { + queueItemId: next.id, attachments: next.attachments, refreshSessions: next.refreshSessions, }); @@ -524,9 +803,9 @@ async function flushChatQueue(host: ChatHost) { } catch (err) { host.lastError = String(err); } - if (!ok) { + if (!ok && next.localCommandName) { host.chatQueue = [next, ...host.chatQueue]; - } else if (host.chatQueue.length > 0) { + } else if (ok && host.chatQueue.length > 0) { // Continue draining — local commands don't block on server response void flushChatQueue(host); } @@ -536,7 +815,7 @@ export function removeQueuedMessage(host: ChatHost, id: string) { const removed = host.chatQueue.filter((item) => item.id === id); host.chatQueue = host.chatQueue.filter((item) => item.id !== id); for (const item of removed) { - releaseChatAttachmentPayloads(item.attachments); + releaseChatAttachmentPayloads(excludeComposerAttachments(host, item.attachments)); } } @@ -547,7 +826,104 @@ export function clearPendingQueueItemsForRun(host: ChatHost, runId: string | und const removed = host.chatQueue.filter((item) => item.pendingRunId === runId); host.chatQueue = host.chatQueue.filter((item) => item.pendingRunId !== runId); for (const item of removed) { - releaseChatAttachmentPayloads(item.attachments); + releaseChatAttachmentPayloads(excludeComposerAttachments(host, item.attachments)); + } +} + +type ChatQueueStoreHost = { + chatQueue: ChatQueueItem[]; + chatQueueBySession?: Record; +}; + +function chatQueueCollections(host: ChatQueueStoreHost): ChatQueueItem[][] { + return [host.chatQueue, ...Object.values(host.chatQueueBySession ?? {})]; +} + +export function hasReconnectableQueuedChatSends(host: ChatQueueStoreHost): boolean { + return chatQueueCollections(host).some((queue) => + queue.some((item) => item.sendRunId && item.sendState === "waiting-reconnect"), + ); +} + +export function markQueuedChatSendsWaitingForReconnect(host: ChatQueueStoreHost) { + const markQueue = (queue: ChatQueueItem[]): { changed: boolean; queue: ChatQueueItem[] } => { + let changed = false; + const nextQueue = queue.map((item) => { + if (!item.sendRunId || item.sendState !== "sending") { + return item; + } + changed = true; + return { + ...item, + sendState: "waiting-reconnect" as const, + }; + }); + return { changed, queue: nextQueue }; + }; + + const active = markQueue(host.chatQueue); + if (active.changed) { + host.chatQueue = active.queue; + } + + let changed = false; + const queueBySession = { ...host.chatQueueBySession }; + for (const [sessionKey, queue] of Object.entries(queueBySession)) { + const next = markQueue(queue); + if (next.changed) { + changed = true; + queueBySession[sessionKey] = next.queue; + } + } + if (changed) { + host.chatQueueBySession = queueBySession; + } +} + +export async function retryReconnectableQueuedChatSends(host: ChatHost) { + if (!host.connected || !host.client || host.chatSending) { + return; + } + const sessionKeys = [ + host.sessionKey, + ...Object.keys(host.chatQueueBySession ?? {}).filter( + (sessionKey) => sessionKey !== host.sessionKey, + ), + ]; + for (const sessionKey of sessionKeys) { + const item = readChatQueueForSession(host, sessionKey).find( + (entry) => + entry.sendRunId && + entry.sendState === "waiting-reconnect" && + !entry.pendingRunId && + !entry.localCommandName, + ); + if (!item) { + continue; + } + await sendQueuedChatMessage(host, item.id, undefined, sessionKey); + if (host.chatRunId) { + return; + } + } + if (!host.chatRunId) { + void flushChatQueue(host); + } +} + +export async function retryQueuedChatMessage(host: ChatHost, id: string) { + const item = host.chatQueue.find((entry) => entry.id === id); + if (!item || item.localCommandName || item.pendingRunId || item.sendState === "sending") { + return; + } + updateQueuedMessage(host, id, (entry) => ({ + ...entry, + sendError: undefined, + sendState: host.connected && host.client ? "sending" : "waiting-reconnect", + })); + await sendQueuedChatMessage(host, id); + if (!host.chatRunId) { + void flushChatQueue(host); } } @@ -556,9 +932,6 @@ export async function handleSendChat( messageOverride?: string, opts?: ChatSendOptions, ) { - if (!host.connected) { - return; - } const previousDraft = host.chatMessage; const message = (messageOverride ?? host.chatMessage).trim(); const submittedSessionKey = host.sessionKey; diff --git a/ui/src/ui/app-gateway-chat-load.node.test.ts b/ui/src/ui/app-gateway-chat-load.node.test.ts index 3140bf67dfea..046509bdb27d 100644 --- a/ui/src/ui/app-gateway-chat-load.node.test.ts +++ b/ui/src/ui/app-gateway-chat-load.node.test.ts @@ -62,6 +62,9 @@ vi.mock("./app-chat.ts", () => ({ createChatSessionsLoadOverrides: () => ({ activeMinutes: 60, limit: 50 }), clearPendingQueueItemsForRun: vi.fn(), flushChatQueueForEvent: vi.fn(), + hasReconnectableQueuedChatSends: vi.fn(() => false), + markQueuedChatSendsWaitingForReconnect: vi.fn(), + retryReconnectableQueuedChatSends: vi.fn(async () => undefined), refreshChatAvatar: refreshChatAvatarMock, })); diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index a4cd3043720f..f62a8b95675b 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -118,6 +118,8 @@ vi.mock("./controllers/control-ui-bootstrap.ts", () => ({ type TestGatewayHost = Parameters[0] & { chatMessages: unknown[]; + chatQueue: import("./ui-types.ts").ChatQueueItem[]; + chatQueueBySession: Record; chatSideResult: unknown; chatSideResultTerminalRuns: Set; chatStream: string | null; @@ -169,6 +171,7 @@ function createHost(): TestGatewayHost { sessionKey: "main", chatMessages: [], chatQueue: [], + chatQueueBySession: {}, chatToolMessages: [], activityEntries: [], chatStreamSegments: [], @@ -946,6 +949,76 @@ describe("connectGateway", () => { expect(host.pendingAbort).toBeNull(); }); + it("retries reconnectable queued chat sends after reconnect hello", async () => { + const host = createHost(); + host.chatQueue = [ + { + id: "pending-send", + text: "retry this prompt", + createdAt: 1, + sendRunId: "run-retry-1", + sendState: "waiting-reconnect", + sessionKey: "main", + }, + ]; + + connectGateway(host); + const client = requireGatewayClient(); + + client.emitHello(); + + await vi.waitFor(() => { + expect(client.request).toHaveBeenCalledWith("chat.send", { + sessionKey: "main", + message: "retry this prompt", + deliver: false, + idempotencyKey: "run-retry-1", + attachments: undefined, + }); + }); + await vi.waitFor(() => { + expect(host.chatQueue).toStrictEqual([]); + expect(host.chatRunId).toBe("run-retry-1"); + }); + }); + + it("retries saved-session queued chat sends after reconnect hello", async () => { + const host = createHost(); + host.sessionKey = "other"; + host.chatQueueBySession = { + main: [ + { + id: "pending-send-main", + text: "retry main prompt", + createdAt: 1, + sendRunId: "run-retry-main", + sendState: "waiting-reconnect", + sessionKey: "main", + }, + ], + }; + + connectGateway(host); + const client = requireGatewayClient(); + + client.emitHello(); + + await vi.waitFor(() => { + expect(client.request).toHaveBeenCalledWith("chat.send", { + sessionKey: "main", + message: "retry main prompt", + deliver: false, + idempotencyKey: "run-retry-main", + attachments: undefined, + }); + }); + await vi.waitFor(() => { + expect(host.chatQueueBySession.main).toBeUndefined(); + expect(host.chatMessages).toStrictEqual([]); + expect(host.chatRunId).toBeNull(); + }); + }); + it("logs and drops stale queued chat abort failures after reconnect", async () => { const host = createHost(); host.pendingAbort = { runId: "run-stale", sessionKey: "main" }; diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index de336a43d882..06ed6c365762 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -7,7 +7,10 @@ import { clearPendingQueueItemsForRun, createChatSessionsLoadOverrides, flushChatQueueForEvent, + hasReconnectableQueuedChatSends, + markQueuedChatSendsWaitingForReconnect, refreshChatAvatar, + retryReconnectableQueuedChatSends, } from "./app-chat.ts"; import type { EventLogEntry } from "./app-events.ts"; import { @@ -581,10 +584,18 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption clearRunStatus: !hadOrphanedRun, }, ); - if (shutdownHost.resumeChatQueueAfterReconnect) { + const hasReconnectableChatSends = hasReconnectableQueuedChatSends( + host as unknown as Parameters[0], + ); + if (shutdownHost.resumeChatQueueAfterReconnect || hasReconnectableChatSends) { // The interrupted run will never emit its terminal event now that the // old client is gone, so resume any deferred commands after hello. shutdownHost.resumeChatQueueAfterReconnect = false; + if (hasReconnectableChatSends) { + void retryReconnectableQueuedChatSends( + host as unknown as Parameters[0], + ); + } void flushChatQueueForEvent( host as unknown as Parameters[0], ); @@ -609,6 +620,9 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption return; } host.connected = false; + markQueuedChatSendsWaitingForReconnect( + host as unknown as Parameters[0], + ); clearSessionsChangedReloadTimer(host); // Code 1012 = Service Restart (expected during config saves, don't show as error) host.lastErrorCode = diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 36737c34249d..2925660daca7 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -2837,6 +2837,7 @@ export function renderApp(state: AppViewState) { canAbort: hasAbortableSessionRun(state), onAbort: () => void state.handleAbortChat({ preserveDraft: true }), onQueueRemove: (id) => state.removeQueuedMessage(id), + onQueueRetry: (id) => void state.retryQueuedChatMessage(id), onQueueSteer: (id) => void state.steerQueuedChatMessage(id), onDismissSideResult: () => { state.chatSideResult = null; diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 96837676521d..6bfd48920aa4 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -527,6 +527,7 @@ export type AppViewState = { steerQueuedChatMessage: (id: string) => Promise; handleAbortChat: (opts?: ChatAbortOptions) => Promise; removeQueuedMessage: (id: string) => void; + retryQueuedChatMessage: (id: string) => Promise; handleChatScroll: (event: Event) => void; resetToolStream: () => void; resetChatScroll: () => void; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 1e3323291004..64eb98946fc2 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -22,6 +22,7 @@ import { handleSendChat as handleSendChatInternal, removeQueuedMessage as removeQueuedMessageInternal, resetChatInputHistoryNavigation as resetChatInputHistoryNavigationInternal, + retryQueuedChatMessage as retryQueuedChatMessageInternal, steerQueuedChatMessage as steerQueuedChatMessageInternal, type ChatInputHistoryKeyInput, type ChatInputHistoryKeyResult, @@ -1061,6 +1062,13 @@ export class OpenClawApp extends LitElement { ); } + async retryQueuedChatMessage(id: string) { + await retryQueuedChatMessageInternal( + this as unknown as Parameters[0], + id, + ); + } + async handleSendChat( messageOverride?: string, opts?: Parameters[2], diff --git a/ui/src/ui/chat/chat-queue.ts b/ui/src/ui/chat/chat-queue.ts index ed4fb7c8dc2d..63acc8c58a73 100644 --- a/ui/src/ui/chat/chat-queue.ts +++ b/ui/src/ui/chat/chat-queue.ts @@ -1,14 +1,29 @@ import { html, nothing } from "lit"; +import { t } from "../../i18n/index.ts"; import { icons } from "../icons.ts"; import type { ChatQueueItem } from "../ui-types.ts"; export type ChatQueueProps = { queue: ChatQueueItem[]; canAbort?: boolean; + onQueueRetry?: (id: string) => void; onQueueSteer?: (id: string) => void; onQueueRemove: (id: string) => void; }; +function sendStateLabel(item: ChatQueueItem): string | null { + switch (item.sendState) { + case "sending": + return "Sending"; + case "waiting-reconnect": + return "Waiting for reconnect"; + case "failed": + return "Failed"; + default: + return null; + } +} + export function renderChatQueue(props: ChatQueueProps) { if (!props.queue.length) { return nothing; @@ -17,8 +32,9 @@ export function renderChatQueue(props: ChatQueueProps) {
Queued (${props.queue.length})
- ${props.queue.map( - (item) => html` + ${props.queue.map((item) => { + const stateLabel = sendStateLabel(item); + return html`
@@ -26,15 +42,34 @@ export function renderChatQueue(props: ChatQueueProps) { ${item.kind === "steered" ? html`Steered` : nothing} + ${stateLabel ? html`${stateLabel}` : nothing}
${item.text || (item.attachments?.length ? `Image (${item.attachments.length})` : "")}
+ ${item.sendError + ? html`
${item.sendError}
` + : nothing}
+ ${item.sendState === "failed" && props.onQueueRetry + ? html` + + ` + : nothing} ${props.canAbort && props.onQueueSteer && item.kind !== "steered" && + !item.sendState && !item.localCommandName ? html`
- `, - )} + `; + })}
`; diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index 14ce7a3898f1..958e95b39b79 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -1045,6 +1045,21 @@ describe("sendChatMessage", () => { expect(sendParams.message).toBe("continue"); }); + it("adopts the run id and terminal status from the chat.send ack", async () => { + const request = vi.fn().mockResolvedValue({ runId: "gateway-complete-run", status: "ok" }); + const state = createState({ + connected: true, + client: { request } as unknown as ChatState["client"], + }); + + const result = await sendChatMessage(state, "already handled"); + + expect(result).toBe("gateway-complete-run"); + expect(state.chatRunId).toBeNull(); + expect(state.chatStream).toBeNull(); + expect(state.chatStreamStartedAt).toBeNull(); + }); + it("serializes non-image chat attachments as files", async () => { const request = vi.fn().mockResolvedValue({ runId: "run-1", status: "started" }); const state = createState({ diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 16a0b17fe79c..26b5f502de24 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -414,22 +414,53 @@ function buildApiAttachments(attachments?: ChatAttachment[]) { : undefined; } -async function requestChatSend( +export type ChatSendAckStatus = "started" | "in_flight" | "ok"; + +export type ChatSendAck = { + runId: string; + status: ChatSendAckStatus; +}; + +function normalizeChatSendAck(payload: unknown, fallbackRunId: string): ChatSendAck { + if (!payload || typeof payload !== "object") { + return { runId: fallbackRunId, status: "started" }; + } + const record = payload as Record; + const runId = + typeof record.runId === "string" && record.runId.trim() ? record.runId.trim() : fallbackRunId; + const status = record.status; + return { + runId, + status: status === "in_flight" || status === "ok" ? status : "started", + }; +} + +export async function requestChatSend( state: ChatState, - params: { message: string; attachments?: ChatAttachment[]; runId: string }, -) { + params: { + message: string; + attachments?: ChatAttachment[]; + runId: string; + sessionKey?: string; + }, +): Promise { + const sessionKey = params.sessionKey ?? state.sessionKey; + const currentSessionId = state.currentSessionId; const sessionId = - typeof state.currentSessionId === "string" && state.currentSessionId.trim() - ? state.currentSessionId.trim() + sessionKey === state.sessionKey && + typeof currentSessionId === "string" && + currentSessionId.trim() + ? currentSessionId.trim() : undefined; - await state.client!.request("chat.send", { - sessionKey: state.sessionKey, + const payload = await state.client!.request("chat.send", { + sessionKey, ...(sessionId ? { sessionId } : {}), message: params.message, deliver: false, idempotencyKey: params.runId, attachments: buildApiAttachments(params.attachments), }); + return normalizeChatSendAck(payload, params.runId); } type AssistantMessageNormalizationOptions = { @@ -499,8 +530,61 @@ export async function sendChatMessage( } const now = Date.now(); + appendUserChatMessage(state, msg, attachments, now); - // Build user message content blocks + state.chatSending = true; + state.lastError = null; + reconcileChatRunLifecycle(state as unknown as Parameters[0], { + clearRunStatus: true, + }); + const runId = generateUUID(); + state.chatRunId = runId; + state.chatStream = ""; + state.chatStreamStartedAt = now; + + try { + const ack = await requestChatSend(state, { message: msg, attachments, runId }); + if (ack.status === "ok") { + state.chatRunId = null; + state.chatStream = null; + state.chatStreamStartedAt = null; + } else { + state.chatRunId = ack.runId; + } + return ack.status === "ok" ? ack.runId : runId; + } catch (err) { + const error = formatConnectError(err); + reconcileChatRunLifecycle(state as unknown as Parameters[0], { + outcome: "interrupted", + sessionStatus: "failed", + runId, + sessionKey: state.sessionKey, + clearLocalRun: true, + clearChatStream: true, + }); + state.lastError = error; + state.chatMessages = [ + ...state.chatMessages, + { + role: "assistant", + content: [{ type: "text", text: "Error: " + error }], + timestamp: Date.now(), + }, + ]; + return null; + } finally { + state.chatSending = false; + } +} + +export function appendUserChatMessage( + state: ChatState, + message: string, + attachments?: ChatAttachment[], + timestamp = Date.now(), +) { + const msg = message.trim(); + const hasAttachments = attachments && attachments.length > 0; const contentBlocks: Array<{ type: string; text?: string; @@ -516,7 +600,6 @@ export async function sendChatMessage( if (msg) { contentBlocks.push({ type: "text", text: msg }); } - // Add image previews to the message for display if (hasAttachments) { for (const att of attachments) { const previewUrl = getChatAttachmentPreviewUrl(att); @@ -546,52 +629,14 @@ export async function sendChatMessage( }); } } - state.chatMessages = [ ...state.chatMessages, { role: "user", content: contentBlocks, - timestamp: now, + timestamp, }, ]; - - state.chatSending = true; - state.lastError = null; - reconcileChatRunLifecycle(state as unknown as Parameters[0], { - clearRunStatus: true, - }); - const runId = generateUUID(); - state.chatRunId = runId; - state.chatStream = ""; - state.chatStreamStartedAt = now; - - try { - await requestChatSend(state, { message: msg, attachments, runId }); - return runId; - } catch (err) { - const error = formatConnectError(err); - reconcileChatRunLifecycle(state as unknown as Parameters[0], { - outcome: "interrupted", - sessionStatus: "failed", - runId, - sessionKey: state.sessionKey, - clearLocalRun: true, - clearChatStream: true, - }); - state.lastError = error; - state.chatMessages = [ - ...state.chatMessages, - { - role: "assistant", - content: [{ type: "text", text: "Error: " + error }], - timestamp: Date.now(), - }, - ]; - return null; - } finally { - state.chatSending = false; - } } export async function sendDetachedChatMessage( @@ -610,8 +655,8 @@ export async function sendDetachedChatMessage( state.lastError = null; const runId = generateUUID(); try { - await requestChatSend(state, { message: msg, attachments, runId }); - return runId; + const ack = await requestChatSend(state, { message: msg, attachments, runId }); + return ack.runId; } catch (err) { state.lastError = formatConnectError(err); return null; @@ -634,8 +679,8 @@ export async function sendSteerChatMessage( state.lastError = null; const runId = generateUUID(); try { - await requestChatSend(state, { message: msg, attachments, runId }); - return runId; + const ack = await requestChatSend(state, { message: msg, attachments, runId }); + return ack.runId; } catch (err) { state.lastError = formatConnectError(err); return null; diff --git a/ui/src/ui/e2e/chat-flow.e2e.test.ts b/ui/src/ui/e2e/chat-flow.e2e.test.ts index 1c09acd81feb..ba133502d650 100644 --- a/ui/src/ui/e2e/chat-flow.e2e.test.ts +++ b/ui/src/ui/e2e/chat-flow.e2e.test.ts @@ -5,6 +5,7 @@ import { installMockGateway, startControlUiE2eServer, type ControlUiE2eServer, + type MockGatewayRequest, } from "../../test-helpers/control-ui-e2e.ts"; const chromiumExecutablePath = chromium.executablePath(); @@ -29,6 +30,22 @@ function requireString(value: unknown, label: string): string { return value; } +async function waitForRequests( + gateway: Awaited>, + method: string, + count: number, +): Promise { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const requests = await gateway.getRequests(method); + if (requests.length >= count) { + return requests; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`Timed out waiting for ${count} ${method} requests`); +} + describeControlUiE2e("Control UI mocked Gateway E2E", () => { beforeAll(async () => { if (!chromiumAvailable) { @@ -84,4 +101,159 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { await context.close(); } }); + + it("keeps a delayed chat.send ACK visible as pending until the ACK resolves", async () => { + const context = await browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page); + + try { + await page.goto(`${server.baseUrl}chat`); + await gateway.deferNext("chat.send"); + + const prompt = "hold this until the ack arrives"; + await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + + const sendRequest = await gateway.waitForRequest("chat.send"); + const params = requireRecord(sendRequest.params); + const runId = requireString(params.idempotencyKey, "chat send idempotency key"); + + await page.locator(".chat-queue").getByText("Sending").waitFor({ timeout: 10_000 }); + await page.locator(".chat-queue").getByText(prompt).waitFor({ timeout: 10_000 }); + expect(await page.locator(".chat-thread").getByText(prompt).count()).toBe(0); + + await gateway.resolveDeferred("chat.send", { runId, status: "started" }); + + await page.locator(".chat-queue").waitFor({ state: "detached", timeout: 10_000 }); + await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 }); + } finally { + await context.close(); + } + }); + + it("keeps rejected pre-ACK sends visible and restores the draft", async () => { + const context = await browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page); + + try { + await page.goto(`${server.baseUrl}chat`); + await gateway.deferNext("chat.send"); + + const prompt = "policy should not eat this"; + const composer = page.locator(".agent-chat__composer-combobox textarea"); + await composer.fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + await gateway.waitForRequest("chat.send"); + + await gateway.rejectDeferred("chat.send", { + code: "INVALID_REQUEST", + message: "send blocked by session policy", + }); + + await page.locator(".chat-queue").getByText("Failed").waitFor({ timeout: 10_000 }); + await page.locator(".chat-queue").getByText(prompt).waitFor({ timeout: 10_000 }); + expect(await composer.inputValue()).toBe(prompt); + } finally { + await context.close(); + } + }); + + it("retries an ACK-lost send after reconnect with the same idempotency key", async () => { + const context = await browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page); + + try { + await page.goto(`${server.baseUrl}chat`); + await gateway.deferNext("chat.send"); + + const prompt = "retry with the same key"; + await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + + const firstRequest = await gateway.waitForRequest("chat.send"); + const firstParams = requireRecord(firstRequest.params); + const runId = requireString(firstParams.idempotencyKey, "first idempotency key"); + + await gateway.closeLatest(1006, "lost ack"); + + const sends = await waitForRequests(gateway, "chat.send", 2); + const secondParams = requireRecord(sends[1]?.params); + expect(secondParams.idempotencyKey).toBe(runId); + expect(secondParams.message).toBe(prompt); + await page.locator(".chat-queue").waitFor({ state: "detached", timeout: 10_000 }); + } finally { + await context.close(); + } + }); + + it("refreshes history after a tool-call window disconnects and reconnects", async () => { + const context = await browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page); + + try { + await page.goto(`${server.baseUrl}chat`); + + const prompt = "use a tool then reconnect"; + await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + + const sendRequest = await gateway.waitForRequest("chat.send"); + const params = requireRecord(sendRequest.params); + const runId = requireString(params.idempotencyKey, "chat send idempotency key"); + await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 }); + + await gateway.emitGatewayEvent("agent", { + data: { + args: { query: "status" }, + name: "status", + phase: "start", + toolCallId: "tool-1", + }, + runId, + seq: 1, + sessionKey: "main", + stream: "tool", + ts: Date.now(), + }); + await gateway.setHistoryMessages([ + { + content: [{ text: prompt, type: "text" }], + role: "user", + timestamp: Date.now(), + }, + { + content: [{ text: "Recovered from refreshed history.", type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + ]); + + await gateway.closeLatest(1006, "lost during tool call"); + + await page.getByText("Recovered from refreshed history.").waitFor({ timeout: 15_000 }); + expect(await page.locator(".chat-queue").count()).toBe(0); + } finally { + await context.close(); + } + }); }); diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 51206f0f89bd..74a43a4ee542 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -17,6 +17,11 @@ export type ChatQueueItem = { localCommandArgs?: string; localCommandName?: string; pendingRunId?: string; + sendAttempts?: number; + sendError?: string; + sendRunId?: string; + sendState?: "sending" | "waiting-reconnect" | "failed"; + sessionKey?: string; }; export const CRON_CHANNEL_LAST = "last"; diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index eed76aa429df..35015af12dc0 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -204,6 +204,7 @@ vi.mock("./agents-utils.ts", () => ({ function renderQueue(params: { queue: ChatQueueItem[]; canAbort?: boolean; + onQueueRetry?: (id: string) => void; onQueueSteer?: (id: string) => void; }) { const container = document.createElement("div"); @@ -211,6 +212,7 @@ function renderQueue(params: { renderChatQueue({ queue: params.queue, canAbort: params.canAbort ?? true, + onQueueRetry: params.onQueueRetry, onQueueSteer: params.onQueueSteer, onQueueRemove: () => undefined, }), @@ -1167,6 +1169,34 @@ describe("chat queue", () => { expect(inactiveContainer.querySelector(".chat-queue__steer")).toBeNull(); }); + + it("renders failed send state with retry and remove affordances", () => { + const onQueueRetry = vi.fn(); + const container = renderQueue({ + onQueueRetry, + queue: [ + { + id: "failed-1", + text: "still recoverable", + createdAt: 1, + sendError: "send blocked by session policy", + sendRunId: "run-failed-1", + sendState: "failed", + }, + ], + }); + + expect(container.querySelector(".chat-queue__badge")?.textContent?.trim()).toBe("Failed"); + expect(container.querySelector(".chat-queue__error")?.textContent?.trim()).toBe( + "send blocked by session policy", + ); + const retry = container.querySelector(".chat-queue__retry"); + expect(retry?.textContent?.trim()).toBe("Retry"); + + retry?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onQueueRetry).toHaveBeenCalledWith("failed-1"); + }); }); describe("chat sidebar raw content", () => { diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 36f1daf1ed3a..e9ab007b3424 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -154,6 +154,7 @@ export type ChatProps = { onDismissError?: () => void; onAbort?: () => void; onQueueRemove: (id: string) => void; + onQueueRetry?: (id: string) => void; onQueueSteer?: (id: string) => void; onDismissSideResult?: () => void; onNewSession: () => void; @@ -1493,6 +1494,7 @@ export function renderChat(props: ChatProps) { ${renderChatQueue({ queue: props.queue, canAbort: showAbortableUi, + onQueueRetry: props.onQueueRetry, onQueueSteer: props.onQueueSteer, onQueueRemove: props.onQueueRemove, })}