diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index 42545b90a6e..0fe51f42757 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -2589,6 +2589,74 @@ describe("handleSendChat", () => { expect(host.lastError).toBe("Message will send when the Gateway reconnects."); }); + it("restores a pre-ack send when browser storage rejects the reconnect queue", async () => { + const storage = createStorageMock(); + vi.spyOn(storage, "setItem").mockImplementation(() => { + throw new DOMException("quota exceeded", "QuotaExceededError"); + }); + vi.stubGlobal("sessionStorage", storage); + 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 replyTarget = { + messageId: "reply-before-ack", + text: "quoted body", + senderLabel: "User", + }; + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + chatMessage: "retry after reconnect", + chatReplyTarget: replyTarget, + }); + + await handleSendChat(host); + + expect(host.chatMessage).toBe("retry after reconnect"); + expect(host.chatQueue).toStrictEqual([]); + expect(host.chatReplyTarget).toEqual(replyTarget); + expect(host.lastError).toBe( + "Could not store this message for reconnect. Free browser storage or reconnect before sending.", + ); + }); + + it("keeps a pre-ack send queued when newer composer input blocks restoration", async () => { + const storage = createStorageMock(); + vi.spyOn(storage, "setItem").mockImplementation(() => { + throw new DOMException("quota exceeded", "QuotaExceededError"); + }); + vi.stubGlobal("sessionStorage", storage); + 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: "original send", + }); + + const send = handleSendChat(host); + await Promise.resolve(); + host.chatMessage = "new draft"; + sent.reject(new Error("gateway closed (1006): network lost")); + await send; + + expect(host.chatMessage).toBe("new draft"); + expect(host.chatQueue).toEqual([ + expect.objectContaining({ + text: "original send", + sendError: + "Could not store this message for reconnect. Free browser storage or reconnect before sending.", + sendState: "failed", + }), + ]); + }); + it("queues normal sends made while disconnected", async () => { const host = makeHost({ client: null, @@ -2714,6 +2782,48 @@ describe("handleSendChat", () => { expect(host.chatStream).toBeNull(); }); + it("keeps an existing queued send visible when retry persistence fails", async () => { + const storage = createStorageMock(); + vi.spyOn(storage, "setItem").mockImplementation(() => { + throw new DOMException("quota exceeded", "QuotaExceededError"); + }); + vi.stubGlobal("sessionStorage", storage); + 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"], + connected: true, + chatQueue: [ + { + id: "queued-retry-storage-failure", + text: "keep this queued message", + createdAt: 1, + sendRunId: "run-retry-storage-failure", + sendState: "waiting-reconnect", + sessionKey: "agent:main", + }, + ], + }); + + await retryReconnectableQueuedChatSends(host); + + expect(host.chatQueue).toEqual([ + expect.objectContaining({ + id: "queued-retry-storage-failure", + sendError: + "Could not store this message for reconnect. Free browser storage or reconnect before sending.", + sendState: "failed", + }), + ]); + expect(host.lastError).toBe( + "Could not store this message for reconnect. Free browser storage or reconnect before sending.", + ); + }); + it("defers queued global send agent selection until defaults are known", async () => { const request = vi.fn((method: string) => { if (method === "chat.send") { diff --git a/ui/src/pages/chat/chat-send.ts b/ui/src/pages/chat/chat-send.ts index 7780581b6e8..80e4577c606 100644 --- a/ui/src/pages/chat/chat-send.ts +++ b/ui/src/pages/chat/chat-send.ts @@ -489,12 +489,31 @@ function restoreComposerAfterFailedSend( } } +type PendingComposerSnapshot = { + previousAttachments?: ChatAttachment[]; + previousDraft?: string; +}; + +function pendingComposerRestorePlan(host: ChatHost, snapshot: PendingComposerSnapshot) { + const willRestoreDraft = snapshot.previousDraft != null && !host.chatMessage.trim(); + const willRestoreAttachments = Boolean( + snapshot.previousAttachments?.length && + host.chatAttachments.length === 0 && + (willRestoreDraft || !host.chatMessage.trim()), + ); + return { + complete: + (!snapshot.previousDraft?.trim() || willRestoreDraft) && + (!snapshot.previousAttachments?.length || willRestoreAttachments), + willRestoreAttachments, + willRestoreDraft, + }; +} + function cancelPendingSendBeforeRequest( host: ChatHost, queued: ChatQueueItem, - opts: { - previousAttachments?: ChatAttachment[]; - previousDraft?: string; + opts: PendingComposerSnapshot & { restoreComposer?: boolean; }, ) { @@ -504,14 +523,9 @@ function cancelPendingSendBeforeRequest( queued.sessionKey, ); const restoreComposer = opts.restoreComposer !== false && removed != null; - const willRestoreDraft = - restoreComposer && opts.previousDraft != null && !host.chatMessage.trim(); - const willRestoreAttachments = Boolean( - restoreComposer && - opts.previousAttachments?.length && - host.chatAttachments.length === 0 && - (willRestoreDraft || !host.chatMessage.trim()), - ); + const restorePlan = pendingComposerRestorePlan(host, opts); + const willRestoreDraft = restoreComposer && restorePlan.willRestoreDraft; + const willRestoreAttachments = restoreComposer && restorePlan.willRestoreAttachments; if (restoreComposer) { if (willRestoreDraft) { host.chatMessage = opts.previousDraft ?? ""; @@ -765,17 +779,33 @@ async function sendQueuedChatMessage( const stored = Boolean( waiting && persistQueuedMessagesForSession(host, sessionKey, waiting.id), ); - if (!stored && waiting) { - updateQueuedMessageForSession(host, sessionKey, id, (item) => ({ - ...item, - sendError: OFFLINE_QUEUE_STORAGE_ERROR, - })); + if (!stored) { + const hasComposerSnapshot = + opts?.previousDraft !== undefined || opts?.previousAttachments !== undefined; + // Remove the queue item only when its full payload can be restored + // without overwriting newer composer input. + if (hasComposerSnapshot && pendingComposerRestorePlan(host, opts ?? {}).complete) { + cancelPendingSendBeforeRequest(host, waiting ?? prepared, { + previousDraft: opts?.previousDraft, + previousAttachments: opts?.previousAttachments, + }); + } else { + updateQueuedMessageForSession(host, sessionKey, id, (item) => ({ + ...item, + sendError: OFFLINE_QUEUE_STORAGE_ERROR, + sendState: "failed", + })); + } + if (isVisibleSession()) { + setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR); + } + recordChatSendTiming(host, prepared, "failed", prepared.sendSubmittedAtMs, { + error: OFFLINE_QUEUE_STORAGE_ERROR, + }); + return "failed"; } if (isVisibleSession()) { - setChatError( - host, - stored ? "Message will send when the Gateway reconnects." : OFFLINE_QUEUE_STORAGE_ERROR, - ); + setChatError(host, "Message will send when the Gateway reconnects."); } recordChatSendTiming(host, prepared, "waiting-reconnect", prepared.sendSubmittedAtMs, { error,