diff --git a/docs/web/control-ui/offline-and-reconnect.md b/docs/web/control-ui/offline-and-reconnect.md index e420f1c49122..9480e9d430e7 100644 --- a/docs/web/control-ui/offline-and-reconnect.md +++ b/docs/web/control-ui/offline-and-reconnect.md @@ -67,8 +67,8 @@ the WebSocket close code for troubleshooting; specific Gateway errors keep their Open the account menu and use **Retry now** to request an immediate attempt when offered. Sign-in failures use the sign-in flow, and a required dashboard refresh uses its reload flow; retrying the connection does not replace either action. Live updates and realtime/session actions pause until the connection -returns. Chat remains editable, with a conversation-specific outbox notice instead of another -global connection warning. +returns. Chat remains editable without a pre-queue helper. The conversation-specific outbox +summary appears only after a message is queued, alongside the actual queued message. Ordinary text and attachment sends require successful admission to the current tab's Gateway/session-scoped browser outbox. Eligible messages resume automatically after connection diff --git a/ui/src/e2e/chat-attachment-read-lifecycle.e2e.test.ts b/ui/src/e2e/chat-attachment-read-lifecycle.e2e.test.ts index f83177bc6787..c1c9f7ff8df3 100644 --- a/ui/src/e2e/chat-attachment-read-lifecycle.e2e.test.ts +++ b/ui/src/e2e/chat-attachment-read-lifecycle.e2e.test.ts @@ -626,7 +626,7 @@ suite.define(() => { await composer.waitFor(); await gateway.setOnline(false); - await page.locator('.agent-chat__composer-status[data-tone="info"]').waitFor(); + await page.locator(".agent-chat__input--offline").waitFor(); await composer.fill(text); await page.locator(".agent-chat__file-input").setInputFiles({ name: "offline.txt", diff --git a/ui/src/e2e/chat-composer-outbox.e2e.test.ts b/ui/src/e2e/chat-composer-outbox.e2e.test.ts new file mode 100644 index 000000000000..40840eb15eea --- /dev/null +++ b/ui/src/e2e/chat-composer-outbox.e2e.test.ts @@ -0,0 +1,117 @@ +import { expect, it } from "vitest"; +import { installMockGateway } from "../test-helpers/control-ui-e2e.ts"; +import { requireRecord, requireString } from "./chat-flow.test-support.ts"; +import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; + +const suite = createControlUiE2eSuite({ name: "Control UI composer outbox" }); + +suite.define(() => { + it("shows the queued message once instead of pre-queue guidance", async () => { + await suite.withPage({ viewport: { width: 1280, height: 900 } }, async ({ page }) => { + const sessionKey = "agent:main:main"; + const sessionId = "session:" + sessionKey; + const prompt = "Please also review the installation notes."; + const gateway = await installMockGateway(page, { sessionKey }); + await page.goto(suite.server.baseUrl + "chat"); + await gateway.waitForRequest("chat.startup"); + await gateway.setOnline(false); + await page.locator(".agent-chat__input--offline").waitFor(); + await page.locator(".gateway-status__label").filter({ hasText: "Reconnecting…" }).waitFor(); + + const statusBand = page.locator(".agent-chat__composer-status-band"); + const composer = page.locator(".agent-chat__composer-combobox textarea"); + expect(await statusBand.count()).toBe(0); + await composer.fill(prompt); + expect(await statusBand.count()).toBe(0); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + const queue = page.locator(".chat-queue__item"); + await queue.getByText(prompt, { exact: true }).waitFor(); + expect(await queue.count()).toBe(1); + await queue.getByText("Waiting for reconnect", { exact: true }).waitFor(); + expect( + await page.locator(".chat-group.user").getByText(prompt, { exact: true }).count(), + ).toBe(0); + expect(await composer.inputValue()).toBe(""); + expect(await gateway.getRequests("chat.send")).toHaveLength(0); + expect(await statusBand.textContent()).toContain("1 in this conversation’s outbox."); + + await gateway.deferNext("chat.send"); + await gateway.setOnline(true); + const request = await gateway.waitForRequest("chat.send"); + expect(request.params).toMatchObject({ sessionKey, message: prompt }); + const runId = requireString(requireRecord(request.params).idempotencyKey, "queued send id"); + const pendingMessage = { + role: "user", + content: prompt, + timestamp: Date.now(), + __openclaw: { id: "pending:composer-queued-input" }, + }; + const pending = { + id: "composer-queued-input", + runId, + state: "queued", + acceptedAt: pendingMessage.timestamp, + message: pendingMessage, + }; + const history = { + sessionId, + messages: [], + sessionInfo: { key: sessionKey, sessionId, hasActiveRun: false, status: "done" }, + }; + await gateway.setMethodResponse("chat.history", { + ...history, + pendingInputs: { items: [pending], total: 1 }, + inputReceipts: [{ runId, state: "pending" }], + }); + await gateway.resolveDeferred("chat.send", { runId, status: "queued" }); + await gateway.emitGatewayEvent("sessions.changed", { + sessionKey, + agentId: "main", + reason: "send", + }); + // Accepted custody has no persisted entry ID until the input is consumed. + await page.waitForFunction( + ({ runId: expectedRunId, sessionId: expectedSessionId }) => + document + .querySelector< + HTMLElement & { + state?: { chatQueue: Array<{ sendRunId?: string; sessionId?: string }> }; + } + >("openclaw-chat-pane") + ?.state?.chatQueue.some( + (item) => item.sendRunId === expectedRunId && item.sessionId === expectedSessionId, + ), + { runId, sessionId }, + ); + await page.locator(".chat-group.user").getByText(prompt, { exact: true }).waitFor(); + expect(await page.getByText(prompt, { exact: true }).count()).toBe(1); + expect(await queue.count()).toBe(0); + expect(await statusBand.count()).toBe(0); + + const promoted = { + ...pendingMessage, + __openclaw: { id: "composer-queued-input", seq: 1, idempotencyKey: runId + ":user" }, + }; + await gateway.setMethodResponse("chat.history", { + ...history, + messages: [promoted], + pendingInputs: { items: [], total: 0 }, + inputReceipts: [{ runId, state: "consumed", consumedByEventId: "composer-queued-input" }], + }); + await gateway.emitGatewayEvent("session.message", { + sessionKey, + message: promoted, + messageId: "composer-queued-input", + messageSeq: 1, + clientRunId: runId, + }); + await page + .locator('.chat-bubble[data-entry-id="composer-queued-input"]') + .getByText(prompt, { exact: true }) + .waitFor(); + expect(await page.getByText(prompt, { exact: true }).count()).toBe(1); + expect(await queue.count()).toBe(0); + expect(await gateway.getRequests("chat.send")).toHaveLength(1); + }); + }); +}); diff --git a/ui/src/e2e/chat-composer-redesign.e2e.test.ts b/ui/src/e2e/chat-composer-redesign.e2e.test.ts index ddf7a2ba499d..a880229cc8c6 100644 --- a/ui/src/e2e/chat-composer-redesign.e2e.test.ts +++ b/ui/src/e2e/chat-composer-redesign.e2e.test.ts @@ -190,32 +190,6 @@ suite.define(() => { }); }); - it("keeps offline outbox guidance in one bounded composer row", async () => { - await suite.withPage({ viewport: { width: 1280, height: 900 } }, async ({ page }) => { - const gateway = await installMockGateway(page); - await page.goto(`${suite.server.baseUrl}chat`); - await gateway.waitForRequest("chat.startup"); - await gateway.setOnline(false); - - const statusBand = page.locator(".agent-chat__composer-status-band"); - await expect - .poll(() => statusBand.locator("xpath=..").getAttribute("data-tone")) - .toBe("info"); - await expect.poll(() => statusBand.textContent()).toContain("You can keep writing."); - await expect - .poll(() => - statusBand.locator("svg").evaluate((node) => { - const bounds = node.getBoundingClientRect(); - return [bounds.width, bounds.height]; - }), - ) - .toEqual([16, 16]); - await expect - .poll(() => statusBand.evaluate((node) => node.getBoundingClientRect().height)) - .toBe(44); - }); - }); - it("keeps mobile picker panels above an attachment-expanded composer", async () => { await suite.withPage({ viewport: { width: 393, height: 852 } }, async ({ page }) => { const gateway = await installMockGateway(page); diff --git a/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts b/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts index 9dc156582655..eb5a4366ad44 100644 --- a/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts +++ b/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts @@ -740,9 +740,7 @@ suite.define(() => { await composer.waitFor({ state: "visible", timeout: 10_000 }); await gateway.setOnline(false); - await page - .locator('.agent-chat__composer-status[data-tone="info"] .agent-chat__composer-status-band') - .waitFor({ timeout: 10_000 }); + await page.locator(".agent-chat__input--offline").waitFor({ timeout: 10_000 }); const prompt = "send this when the Gateway returns"; const attachmentName = "offline-proof.txt"; @@ -867,9 +865,7 @@ suite.define(() => { return proof.attachment || proof.prompt || proof.runId === runId; }) .toBe(false); - await page - .locator('.agent-chat__composer-status[data-tone="info"] .agent-chat__composer-status-band') - .waitFor({ state: "detached" }); + await page.locator(".agent-chat__input--offline").waitFor({ state: "detached" }); await expectRequestCountStable(gateway, "chat.send", 1); if (artifactDir) { await writeFile( diff --git a/ui/src/e2e/chat-flow.queue-edit.e2e.test.ts b/ui/src/e2e/chat-flow.queue-edit.e2e.test.ts index b9933635ca2f..248082813140 100644 --- a/ui/src/e2e/chat-flow.queue-edit.e2e.test.ts +++ b/ui/src/e2e/chat-flow.queue-edit.e2e.test.ts @@ -220,9 +220,7 @@ suite.define(() => { await editRow.dblclick(); await gateway.setOnline(false); await gateway.closeLatest(); - await page - .locator('.agent-chat__composer-status[data-tone="info"] .agent-chat__composer-status-band') - .waitFor({ timeout: 10_000 }); + await page.locator(".agent-chat__input--offline").waitFor({ timeout: 10_000 }); // `hasText` stops matching once the row text becomes a textarea value. const inlineEditor = page.locator(".chat-queue__edit-input"); @@ -416,7 +414,7 @@ suite.define(() => { await gateway.deferNext("chat.send"); await gateway.setOnline(true); await page - .locator('.agent-chat__composer-status[data-tone="info"] .agent-chat__composer-status-band') + .locator(".agent-chat__input--offline") .waitFor({ state: "detached", timeout: 10_000 }); await gateway.emitChatFinal({ runId: activeRunId, text: "Initial run completed." }); await gateway.emitGatewayEvent("sessions.changed", terminalSession); diff --git a/ui/src/e2e/chat-outbox-agent-scope.e2e.test.ts b/ui/src/e2e/chat-outbox-agent-scope.e2e.test.ts index 5da417154da5..5012c58a2c53 100644 --- a/ui/src/e2e/chat-outbox-agent-scope.e2e.test.ts +++ b/ui/src/e2e/chat-outbox-agent-scope.e2e.test.ts @@ -132,10 +132,8 @@ suite.define(() => { await composer.waitFor({ state: "visible", timeout: 10_000 }); timeoutStage = "go offline"; await gateway.setOnline(false); - timeoutStage = "wait for offline outbox notice"; - await page - .locator('.agent-chat__composer-status[data-tone="info"] .agent-chat__composer-status-band') - .waitFor({ timeout: 10_000 }); + timeoutStage = "wait for offline composer"; + await page.locator(".agent-chat__input--offline").waitFor({ timeout: 10_000 }); const prompt = "deliver the work outbox independently"; timeoutStage = "fill composer"; @@ -169,7 +167,7 @@ suite.define(() => { await gateway.setOnline(true); timeoutStage = "wait for online composer"; await page - .locator('.agent-chat__composer-status[data-tone="info"] .agent-chat__composer-status-band') + .locator(".agent-chat__input--offline") .waitFor({ state: "detached", timeout: 10_000 }); timeoutStage = "wait for transcript readiness"; await expect diff --git a/ui/src/e2e/chat-outbox-recovery.e2e.test.ts b/ui/src/e2e/chat-outbox-recovery.e2e.test.ts index 132de44d4197..f119433ba9f8 100644 --- a/ui/src/e2e/chat-outbox-recovery.e2e.test.ts +++ b/ui/src/e2e/chat-outbox-recovery.e2e.test.ts @@ -439,7 +439,7 @@ suite.define(() => { const composer = page.locator(".agent-chat__composer-combobox textarea"); await composer.waitFor(); await gateway.setOnline(false); - await page.locator('.agent-chat__composer-status[data-tone="info"]').waitFor(); + await page.locator(".agent-chat__input--offline").waitFor(); await composer.fill(`retain destination ${sessionKey}`); await page.getByRole("button", { name: "Send message" }).click(); await page.locator(".chat-queue").getByText("Waiting for reconnect").waitFor(); diff --git a/ui/src/e2e/chat-position-rail-layout.e2e.test.ts b/ui/src/e2e/chat-position-rail-layout.e2e.test.ts index e0c8e245f91f..1b7c6c133b0c 100644 --- a/ui/src/e2e/chat-position-rail-layout.e2e.test.ts +++ b/ui/src/e2e/chat-position-rail-layout.e2e.test.ts @@ -366,7 +366,7 @@ suite.define(() => { } await gateway.setOnline(false); await gateway.closeLatest(); - await page.locator('.agent-chat__composer-status[data-tone="info"]').waitFor(); + await page.locator(".agent-chat__input--offline").waitFor(); const queuedTexts = ["Review the next checkpoint", "Check the supporting notes"]; for (const text of queuedTexts) { const queueSamples = sampleAnchor(); diff --git a/ui/src/e2e/session-progress-disclosure.e2e.test.ts b/ui/src/e2e/session-progress-disclosure.e2e.test.ts index 06f09d793c41..8010d764f0b5 100644 --- a/ui/src/e2e/session-progress-disclosure.e2e.test.ts +++ b/ui/src/e2e/session-progress-disclosure.e2e.test.ts @@ -124,7 +124,7 @@ suite.define(() => { await expect.poll(open).toBe(false); const retainedCard = await card.elementHandle(); await gateway.setOnline(false); - const offline = page.locator('.agent-chat__composer-status[data-tone="info"]'); + const offline = page.locator(".agent-chat__input--offline"); await offline.waitFor(); expect(await retainedCard?.evaluate((element) => element.isConnected)).toBe(true); expect(await open()).toBe(false); @@ -271,7 +271,7 @@ suite.define(() => { await expect.poll(open).toBe(false); } await gateway.setOnline(false); - await pane.locator('.agent-chat__composer-status[data-tone="info"]').waitFor(); + await pane.locator(".agent-chat__input--offline").waitFor(); if (choice === "manual") { await card.locator("summary").press("Enter"); expect( @@ -283,9 +283,7 @@ suite.define(() => { await page.screenshot({ path: path.join(artifactDir, "disconnected.png") }); expect(await open()).toBe(false); await gateway.setOnline(true); - await pane - .locator('.agent-chat__composer-status[data-tone="info"]') - .waitFor({ state: "hidden" }); + await pane.locator(".agent-chat__input--offline").waitFor({ state: "hidden" }); await page.screenshot({ path: path.join(artifactDir, "reconnected.png") }); expect(await open()).toBe(false); } finally { diff --git a/ui/src/e2e/settings-prefs-reconnect.e2e.test.ts b/ui/src/e2e/settings-prefs-reconnect.e2e.test.ts index 42f350526281..6c72fc020024 100644 --- a/ui/src/e2e/settings-prefs-reconnect.e2e.test.ts +++ b/ui/src/e2e/settings-prefs-reconnect.e2e.test.ts @@ -244,10 +244,7 @@ suite.define(() => { await gateway.setOnline(false); await page.locator(".gateway-status__label").filter({ hasText: "Reconnecting…" }).waitFor(); - await page - .locator(".agent-chat__composer-status-band") - .filter({ hasText: "You can keep writing." }) - .waitFor(); + await page.locator(".agent-chat__input--offline").waitFor(); await expect.poll(() => page.locator("html").getAttribute("data-theme-mode")).toBe("light"); await expect diff --git a/ui/src/e2e/theme-boot.e2e.test.ts b/ui/src/e2e/theme-boot.e2e.test.ts index 242fbc0d77da..504f84b37f01 100644 --- a/ui/src/e2e/theme-boot.e2e.test.ts +++ b/ui/src/e2e/theme-boot.e2e.test.ts @@ -220,9 +220,7 @@ suite.define(() => { return; } await gateway.setOnline(false); - await page - .locator(".agent-chat__composer-status-band", { hasText: "You can keep writing." }) - .waitFor(); + await page.locator(".agent-chat__input--offline").waitFor(); await assertThemeFrames(page, expectedAppearance); await gateway.setOnline(true); await waitForControlUiGatewayReady(page); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 42b6684d3671..cfd9332a34f6 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -4074,8 +4074,6 @@ export const en: TranslationMap & { placeholder: "Message {name}", emptyHint: "Write a message to send.", placeholderWithAttachments: "Add a message or paste more images...", - offlineHint: - "You can keep writing. Send when you’re ready to add a message to this conversation’s outbox.", offlineQueuedHint: "{count} in this conversation’s outbox.", preparingModel: "Preparing model...", responding: "{name} is responding...", diff --git a/ui/src/pages/chat/chat-composer.test.ts b/ui/src/pages/chat/chat-composer.test.ts index a9b428dd0098..ddeccd907752 100644 --- a/ui/src/pages/chat/chat-composer.test.ts +++ b/ui/src/pages/chat/chat-composer.test.ts @@ -256,7 +256,7 @@ describe("renderChatComposer controls", () => { }, ); - it("keeps composing enabled and explains the conversation outbox while offline", () => { + it("keeps offline composing quiet until the conversation has queued messages", () => { const { container } = renderComposer({ offline: true, queuedOutboxCount: 3, @@ -277,11 +277,9 @@ describe("renderChatComposer controls", () => { expect(button(container, t("chat.runControls.sendMessage")).disabled).toBe(false); const empty = renderComposer({ offline: true, queuedOutboxCount: 0 }); - expect( - empty.container.querySelector(".agent-chat__composer-status-band")?.textContent?.trim(), - ).toBe( - "You can keep writing. Send when you’re ready to add a message to this conversation’s outbox.", - ); + expect(empty.container.querySelector(".agent-chat__composer-status-band")).toBeNull(); + expect(empty.container.querySelector(".chat-queue")).toBeNull(); + expect(empty.container.querySelector("textarea")?.disabled).toBe(false); const online = renderComposer({ queuedOutboxCount: 3 }); expect(online.container.querySelector(".agent-chat__composer-status-band")).toBeNull(); diff --git a/ui/src/pages/chat/components/chat-composer-view.ts b/ui/src/pages/chat/components/chat-composer-view.ts index 4fb92849aed3..217e5add73d4 100644 --- a/ui/src/pages/chat/components/chat-composer-view.ts +++ b/ui/src/pages/chat/components/chat-composer-view.ts @@ -237,11 +237,10 @@ export function renderChatComposerView(context: ChatComposerViewContext) { } ` : nothing; - const offlineText = props.offline - ? props.queuedOutboxCount + const offlineText = + props.offline && props.queuedOutboxCount ? t("chat.composer.offlineQueuedHint", { count: String(props.queuedOutboxCount) }) - : t("chat.composer.offlineHint") - : null; + : null; const primaryComposerStatus = props.disabledReason ? { text: props.disabledReason,