diff --git a/ui/src/e2e/chat-flow.e2e.test.ts b/ui/src/e2e/chat-flow.e2e.test.ts index b5382dffbc71..49f21d2753a6 100644 --- a/ui/src/e2e/chat-flow.e2e.test.ts +++ b/ui/src/e2e/chat-flow.e2e.test.ts @@ -2299,6 +2299,11 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { type: "file", }, ]); + await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 }); + await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 }); + if (artifactDir) { + await page.screenshot({ path: `${artifactDir}/02-reconnected-active.png`, fullPage: true }); + } await expectRequestCountStable(gateway, "chat.send", 1); const requestsAfterReconnect = await gateway.getRequests("chat.send"); await gateway.setHistoryMessages([ @@ -2318,7 +2323,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { await page.locator("openclaw-connection-banner").waitFor({ state: "detached" }); await expectRequestCountStable(gateway, "chat.send", 1); if (artifactDir) { - await page.screenshot({ path: `${artifactDir}/02-online-delivered.png`, fullPage: true }); + await page.screenshot({ path: `${artifactDir}/03-online-delivered.png`, fullPage: true }); } if (process.env.OPENCLAW_BEHAVIOR_PROOF === "1") { process.stdout.write( diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index 01c04141fe22..d089bc51e276 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -1646,6 +1646,28 @@ describe("buildChatItems", () => { ]); }); + it("keeps restored in-flight sends visible without process-local timing", () => { + const restored = { + id: "restored-send-1", + text: "stay visible across reconnect", + createdAt: 2, + sendAttempts: 1, + }; + + expect( + messageGroups({ + queue: [{ ...restored, sendAttempts: 0, sendState: "waiting-reconnect" }], + }), + ).toStrictEqual([]); + for (const sendState of ["waiting-reconnect", "sending"] as const) { + const groups = messageGroups({ queue: [{ ...restored, sendState }] }); + expect(groups).toHaveLength(1); + expect(messageRecord(groupAt(groups, 0)).content).toStrictEqual([ + { type: "text", text: "stay visible across reconnect" }, + ]); + } + }); + it("keeps steerable queued sends out of the thread until sending starts", () => { const queued = { id: "pending-send-1", diff --git a/ui/src/pages/chat/chat-thread.ts b/ui/src/pages/chat/chat-thread.ts index ecade8741b06..e383bb8b57cc 100644 --- a/ui/src/pages/chat/chat-thread.ts +++ b/ui/src/pages/chat/chat-thread.ts @@ -984,13 +984,13 @@ function sanitizeStreamText(text: string): string { } function shouldRenderQueuedSendInThread(item: ChatQueueItem): boolean { - if (typeof item.sendSubmittedAtMs !== "number" || item.sendState === "failed") { - return false; - } + // Page-local submit timing is not persisted; durable attempts keep restored prompts visible. + const sendStarted = typeof item.sendSubmittedAtMs === "number" || (item.sendAttempts ?? 0) > 0; return ( - item.sendState === "waiting-model" || - item.sendState === "sending" || - item.sendState === "waiting-reconnect" + sendStarted && + (item.sendState === "waiting-model" || + item.sendState === "sending" || + item.sendState === "waiting-reconnect") ); }