fix(webchat): preserve sends through reconnect (#87531)

* fix(webchat): preserve sends through reconnect

* fix(webchat): scope queued sends by session

* fix(webchat): localize queue retry labels

* fix(secrets): remove unused path helper

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Val Alexander
2026-05-28 11:18:24 -05:00
committed by GitHub
parent c00ac952a8
commit 96635c7c27
57 changed files with 1541 additions and 221 deletions

4
pnpm-lock.yaml generated
View File

@@ -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:

View File

@@ -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);
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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: "添加消息或继续粘贴图片...",

View File

@@ -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...",

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -44,9 +44,18 @@ export type ControlUiE2eServer = {
};
export type MockGatewayControls = {
closeLatest: (code?: number, reason?: string) => Promise<void>;
deferNext: (method: string) => Promise<void>;
emitChatFinal: (params: { runId: string; sessionKey?: string; text: string }) => Promise<void>;
emitGatewayEvent: (event: string, payload?: unknown) => Promise<void>;
getRequests: (method?: string) => Promise<MockGatewayRequest[]>;
getSocketCount: () => Promise<number>;
rejectDeferred: (
method: string,
error?: { code?: string; message?: string; details?: unknown; retryable?: boolean },
) => Promise<void>;
resolveDeferred: (method: string, payload?: unknown) => Promise<void>;
setHistoryMessages: (messages: unknown[]) => Promise<void>;
waitForRequest: (method: string) => Promise<MockGatewayRequest>;
};
@@ -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<string, unknown> {
@@ -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) => {

View File

@@ -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<void> {
({
@@ -61,6 +62,7 @@ async function loadChatHelpers(): Promise<void> {
refreshChatAvatar,
clearPendingQueueItemsForRun,
removeQueuedMessage,
markQueuedChatSendsWaitingForReconnect,
} = await import("./app-chat.ts"));
}
@@ -132,6 +134,7 @@ function makeHost(overrides?: Partial<ChatHost>): 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<unknown>();
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<unknown>();
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: {

View File

@@ -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<string, ChatQueueItem[]>;
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<QueuedChatSendResult> {
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<typeof reconcileChatRunLifecycle>[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<typeof reconcileChatRunLifecycle>[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<typeof resetToolStream>[0]);
// Reset scroll state before sending to ensure auto-scroll works for the response
resetChatScroll(host as unknown as Parameters<typeof resetChatScroll>[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<typeof setLastActiveSessionKey>[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<typeof scheduleChatScroll>[0], true);
if (ok && !host.chatRunId) {
if (host.sessionKey === queuedSessionKey) {
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[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<string, ChatQueueItem[]>;
};
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;

View File

@@ -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,
}));

View File

@@ -118,6 +118,8 @@ vi.mock("./controllers/control-ui-bootstrap.ts", () => ({
type TestGatewayHost = Parameters<typeof connectGateway>[0] & {
chatMessages: unknown[];
chatQueue: import("./ui-types.ts").ChatQueueItem[];
chatQueueBySession: Record<string, import("./ui-types.ts").ChatQueueItem[]>;
chatSideResult: unknown;
chatSideResultTerminalRuns: Set<string>;
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" };

View File

@@ -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<typeof hasReconnectableQueuedChatSends>[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<typeof retryReconnectableQueuedChatSends>[0],
);
}
void flushChatQueueForEvent(
host as unknown as Parameters<typeof flushChatQueueForEvent>[0],
);
@@ -609,6 +620,9 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
return;
}
host.connected = false;
markQueuedChatSendsWaitingForReconnect(
host as unknown as Parameters<typeof markQueuedChatSendsWaitingForReconnect>[0],
);
clearSessionsChangedReloadTimer(host);
// Code 1012 = Service Restart (expected during config saves, don't show as error)
host.lastErrorCode =

View File

@@ -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;

View File

@@ -527,6 +527,7 @@ export type AppViewState = {
steerQueuedChatMessage: (id: string) => Promise<void>;
handleAbortChat: (opts?: ChatAbortOptions) => Promise<void>;
removeQueuedMessage: (id: string) => void;
retryQueuedChatMessage: (id: string) => Promise<void>;
handleChatScroll: (event: Event) => void;
resetToolStream: () => void;
resetChatScroll: () => void;

View File

@@ -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<typeof retryQueuedChatMessageInternal>[0],
id,
);
}
async handleSendChat(
messageOverride?: string,
opts?: Parameters<typeof handleSendChatInternal>[2],

View File

@@ -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) {
<div class="chat-queue" role="status" aria-live="polite">
<div class="chat-queue__title">Queued (${props.queue.length})</div>
<div class="chat-queue__list">
${props.queue.map(
(item) => html`
${props.queue.map((item) => {
const stateLabel = sendStateLabel(item);
return html`
<div
class="chat-queue__item ${item.kind === "steered" ? "chat-queue__item--steered" : ""}"
>
@@ -26,15 +42,34 @@ export function renderChatQueue(props: ChatQueueProps) {
${item.kind === "steered"
? html`<span class="chat-queue__badge">Steered</span>`
: nothing}
${stateLabel ? html`<span class="chat-queue__badge">${stateLabel}</span>` : nothing}
<div class="chat-queue__text">
${item.text ||
(item.attachments?.length ? `Image (${item.attachments.length})` : "")}
</div>
${item.sendError
? html`<div class="chat-queue__error">${item.sendError}</div>`
: nothing}
</div>
<div class="chat-queue__actions">
${item.sendState === "failed" && props.onQueueRetry
? html`
<button
class="btn chat-queue__retry"
type="button"
title=${t("chat.queue.retrySend")}
aria-label=${t("chat.queue.retryQueuedMessage")}
@click=${() => props.onQueueRetry?.(item.id)}
>
${icons.refresh}
<span>${t("chat.queue.retry")}</span>
</button>
`
: nothing}
${props.canAbort &&
props.onQueueSteer &&
item.kind !== "steered" &&
!item.sendState &&
!item.localCommandName
? html`
<button
@@ -59,8 +94,8 @@ export function renderChatQueue(props: ChatQueueProps) {
</button>
</div>
</div>
`,
)}
`;
})}
</div>
</div>
`;

View File

@@ -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({

View File

@@ -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<string, unknown>;
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<ChatSendAck> {
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<typeof reconcileChatRunLifecycle>[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<typeof reconcileChatRunLifecycle>[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<typeof reconcileChatRunLifecycle>[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<typeof reconcileChatRunLifecycle>[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;

View File

@@ -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<ReturnType<typeof installMockGateway>>,
method: string,
count: number,
): Promise<MockGatewayRequest[]> {
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();
}
});
});

View File

@@ -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";

View File

@@ -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<HTMLButtonElement>(".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", () => {

View File

@@ -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,
})}