mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
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:
parent
3bc343a113
commit
80db3393e6
15 changed files with 141 additions and 70 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
117
ui/src/e2e/chat-composer-outbox.e2e.test.ts
Normal file
117
ui/src/e2e/chat-composer-outbox.e2e.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue