fix(ui): hide the empty outbox helper while composing (#156914)

Remove the empty offline outbox helper while keeping the existing queued-message, Gateway-accepted input, and canonical-history display paths. Connection, delivery, error, and permission states remain intact.

Validated with source-bound unit and shipped-Control-UI browser regressions, inspected before/after screenshots, and exact-head CI run 35943941552.

## Worked on by

- @Patrick-Erichsen

## Work sessions

- [Original discussion with @Patrick-Erichsen](https://team.openclaw.ai/chat/roboclaw/dashboard/60716df4-a56c-4eec-8285-5872f4e80748)
- [Implementation and verification](https://team.openclaw.ai/chat/roboclaw/dashboard/df24ba90-81d7-4698-be4e-7fdbf7e688e3)

[View the OpenClaw team session](https://team.openclaw.ai/chat/roboclaw/dashboard/df24ba90-81d7-4698-be4e-7fdbf7e688e3)

OpenClaw-Publication: a729cf52-32e2-40ff-8dd8-cebadc1c5e5d
Co-authored-by: Patrick-Erichsen <20157849+Patrick-Erichsen@users.noreply.github.com>
This commit is contained in:
RoboClaw 2026-09-23 22:52:36 -07:00 • committed by GitHub
parent 3bc343a113
commit 80db3393e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 141 additions and 70 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<HTMLTextAreaElement>("textarea")?.disabled).toBe(false);
const online = renderComposer({ queuedOutboxCount: 3 });
expect(online.container.querySelector(".agent-chat__composer-status-band")).toBeNull();

View file

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