fix(ui): queue chat input while reconnecting

This commit is contained in:
Peter Steinberger 2026-07-09 04:36:30 -07:00
parent 6621ead871
commit b137df3cf2
No known key found for this signature in database
14 changed files with 404 additions and 63 deletions

View file

@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- **Control UI offline chat:** keep ordinary text and attachment input available during recoverable Gateway disconnects, persist queued sends in the existing session-scoped store, and resume delivery after reconnect.
- **Browser actions on Node 24:** keep browser request cancellation bound to the client and response lifetime instead of Node 24.16+'s prematurely aborted body-stream signal, preventing valid POST actions from failing after JSON parsing. Thanks @obviyus and @vincentkoc.
- **SecretRef model credentials:** keep resolved provider secrets behind process-local sentinels through auth storage, stream setup, SDK configuration, and managed local-provider probing, then inject plaintext only at the final network or provider-plugin boundary while retaining exact-value log redaction. (#102008, #102009)
- **Lean local model shell access:** keep `exec` directly visible beside the default structured Tool Search controls so coding-tuned local models can use their shell fallback instead of searching for missing domain tools. (#87587) Thanks @vincentkoc.

View file

@ -286,7 +286,11 @@ The terminal is also available as a full-screen, terminal-only document at `/?vi
Once a session is established, a dropped Gateway connection does not log you out. The dashboard
stays visible with a floating amber "Gateway connection lost — Reconnecting…" pill under the top
bar while the client retries automatically with backoff (800 ms up to 15 s). Live updates and
actions pause until the connection returns; **Retry now** in the pill forces an immediate attempt.
realtime/session actions pause until the connection returns; **Retry now** in the pill forces an
immediate attempt. Chat remains editable: ordinary text and attachment sends are kept in the
current tab's gateway/session-scoped browser storage, shown as waiting for reconnect, and sent
automatically when the Gateway returns. Live controls and slash commands remain unavailable while
offline.
When this browser already holds credentials (a configured token/password or an approved device
token), first opens and reloads show a small animated OpenClaw mark while the connection is

View file

@ -1894,6 +1894,94 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
}
});
it("stores new input while offline and sends it after reconnect", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const context = await newBrowserContext({
locale: "en-US",
...(artifactDir
? { recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } } }
: {}),
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page);
try {
await page.goto(`${server.baseUrl}chat`);
const composer = page.locator(".agent-chat__composer-combobox textarea");
await composer.waitFor({ state: "visible", timeout: 10_000 });
await gateway.setOnline(false);
await page.locator("openclaw-connection-banner").waitFor({ timeout: 10_000 });
const prompt = "send this when the Gateway returns";
const composerEnabled = await composer.isEnabled();
expect(composerEnabled).toBe(true);
await composer.fill(prompt);
const send = page.getByRole("button", { name: "Send message" });
const sendEnabled = await send.isEnabled();
expect(sendEnabled).toBe(true);
await send.click();
const queue = page.locator(".chat-queue");
await queue.getByText("Waiting for reconnect").waitFor({ timeout: 10_000 });
await queue.getByText(prompt).waitFor({ timeout: 10_000 });
const requestsBeforeReconnect = await gateway.getRequests("chat.send");
expect(requestsBeforeReconnect).toHaveLength(0);
const readStoredProof = () =>
page.evaluate((expectedPrompt) => {
const stored = Object.entries(sessionStorage)
.filter(([key]) => key.startsWith("openclaw.control.chatComposer.v1:"))
.map(([, value]) => value)
.join("\n");
return {
prompt: stored.includes(expectedPrompt),
waitingReconnect: stored.includes('"sendState":"waiting-reconnect"'),
};
}, prompt);
await expect.poll(readStoredProof).toEqual({ prompt: true, waitingReconnect: true });
const storedProof = await readStoredProof();
if (artifactDir) {
await page.screenshot({ path: `${artifactDir}/01-offline-queued.png`, fullPage: true });
}
await expect.poll(() => gateway.getSocketCount()).toBeGreaterThan(1);
await gateway.setOnline(true);
const request = await gateway.waitForRequest("chat.send");
const params = requireRecord(request.params);
const runId = requireString(params.idempotencyKey, "offline send idempotency key");
expect(params.message).toBe(prompt);
await expect.poll(async () => (await gateway.getRequests("chat.send")).length).toBe(1);
const requestsAfterReconnect = await gateway.getRequests("chat.send");
await gateway.emitChatFinal({ runId, text: "Delivered after reconnect." });
await queue.waitFor({ state: "detached", timeout: 10_000 });
await page.locator("openclaw-connection-banner").waitFor({ state: "detached" });
if (artifactDir) {
await page.screenshot({ path: `${artifactDir}/02-online-delivered.png`, fullPage: true });
}
if (process.env.OPENCLAW_BEHAVIOR_PROOF === "1") {
process.stdout.write(
`${JSON.stringify({
proof: "offline-chat-reconnect",
composerEnabled,
sendEnabled,
waitingStateVisible: true,
storedPrompt: storedProof.prompt,
storedWaitingState: storedProof.waitingReconnect,
requestsBeforeReconnect: requestsBeforeReconnect.length,
requestsAfterReconnect: requestsAfterReconnect.length,
idempotencyKeyPresent: runId.length > 0,
queueClearedAfterDelivery: true,
})}\n`,
);
}
} finally {
await closeBrowserContext(context);
}
});
it("keeps a session model override selected after switching away and back", async () => {
const context = await newBrowserContext({
locale: "en-US",

View file

@ -28,6 +28,7 @@ vi.mock("../../components/markdown.ts", () => ({
function createProps(overrides: Partial<ChatRunControlsProps> = {}): ChatRunControlsProps {
return {
canAbort: false,
canSend: true,
connected: true,
draft: "",
hasMessages: false,
@ -123,6 +124,35 @@ describe("chat run controls", () => {
expect(container.querySelector('button[aria-label="Start voice input"]')).toBeNull();
});
it("keeps queued sends available offline while disabling live voice input", () => {
const container = document.createElement("div");
const onSend = vi.fn();
const onToggleVoice = vi.fn();
render(
renderChatRunControls(
createProps({
connected: false,
draft: "queue this offline",
onSend,
onToggleVoice,
}),
),
container,
);
const sendButton = getButton(
container,
`button[aria-label="${t("chat.runControls.sendMessage")}"]`,
);
expect(sendButton.disabled).toBe(false);
sendButton.click();
expect(onSend).toHaveBeenCalledTimes(1);
render(renderChatRunControls(createProps({ connected: false, onToggleVoice })), container);
expect(getButton(container, 'button[aria-label="Start voice input"]').disabled).toBe(true);
});
it("keeps voice and generation stop actions available when both are active", () => {
const container = document.createElement("div");
const onAbort = vi.fn();

View file

@ -859,11 +859,7 @@ class ChatPane extends LitElement {
state.sessionsResult?.sessions.some(
(row) => row.archived === true && areUiSessionKeysEquivalent(row.key, state.sessionKey),
) === true;
const disabledReason = !state.connected
? t("chat.disconnected")
: selectedSessionArchived
? t("chat.archivedSessionDisabled")
: null;
const disabledReason = selectedSessionArchived ? t("chat.archivedSessionDisabled") : null;
const canOpenRealtimeTalkSettings = hasOperatorAdminAccess(
this.context.gateway.snapshot.hello?.auth ?? null,
);
@ -899,7 +895,7 @@ class ChatPane extends LitElement {
realtimeTalkInputLevel: state.realtimeTalkInputLevel,
realtimeTalkConversation: state.realtimeTalkConversation,
connected: state.connected,
canSend: state.connected && !selectedSessionArchived,
canSend: !selectedSessionArchived,
disabledReason,
error: state.lastError,
sessions: state.sessionsResult,

View file

@ -125,8 +125,17 @@ export function updateQueuedMessageForSession(
return nextItem;
}
export function persistQueuedMessagesForSession(host: ChatQueueSessionHost, sessionKey: string) {
persistStoredChatComposerQueue(host, sessionKey, readChatQueueForSession(host, sessionKey));
export function persistQueuedMessagesForSession(
host: ChatQueueSessionHost,
sessionKey: string,
requiredQueueItemId?: string,
) {
return persistStoredChatComposerQueue(
host,
sessionKey,
readChatQueueForSession(host, sessionKey),
requiredQueueItemId,
);
}
export function removeQueuedMessageWithoutReleasing(

View file

@ -4,6 +4,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts";
import type { UiSettings } from "../../app/settings.ts";
import { createSessionCapability } from "../../lib/sessions/index.ts";
import { createStorageMock } from "../../test-helpers/storage.ts";
import {
getChatAttachmentDataUrl,
getChatAttachmentPreviewUrl,
@ -1100,6 +1101,7 @@ describe("handleSendChat", () => {
beforeEach(() => {
executeSlashCommandMock.mockReset();
setLastActiveSessionKeyMock.mockReset();
vi.stubGlobal("sessionStorage", createStorageMock());
});
afterEach(() => {
@ -2616,6 +2618,60 @@ describe("handleSendChat", () => {
);
});
it("retains offline attachments when browser storage rejects the queue", async () => {
const storage = createStorageMock();
vi.spyOn(storage, "setItem").mockImplementation(() => {
throw new DOMException("quota exceeded", "QuotaExceededError");
});
vi.stubGlobal("sessionStorage", storage);
const attachment = {
id: "offline-attachment",
mimeType: "image/png",
fileName: "offline.png",
sizeBytes: 3,
dataUrl: "data:image/png;base64,AAA",
};
const host = makeHost({
client: null,
connected: false,
chatAttachments: [attachment],
});
await handleSendChat(host);
expect(host.chatAttachments).toEqual([attachment]);
expect(host.chatQueue).toStrictEqual([]);
expect(host.lastError).toBe(
"Could not store this message for reconnect. Free browser storage or reconnect before sending.",
);
});
it("consumes a reply target after queueing its turn offline", async () => {
const host = makeHost({
client: null,
connected: false,
chatMessage: "continue offline",
chatReplyTarget: {
messageId: "reply-source-offline",
text: "quoted body",
senderLabel: "User",
},
});
await handleSendChat(host);
expect(host.chatQueue[0]).toMatchObject({
text: "> **User:** quoted body\n\ncontinue offline",
sendState: "waiting-reconnect",
});
expect(host.chatReplyTarget).toBeNull();
host.chatMessage = "next offline turn";
await handleSendChat(host);
expect(host.chatQueue[1]?.text).toBe("next offline turn");
});
it("replays queued global sends under the originally selected agent", async () => {
const request = vi.fn((method: string) => {
if (method === "chat.send") {

View file

@ -530,6 +530,9 @@ function cancelPendingSendBeforeRequest(
type QueuedChatSendResult = "sent" | "pending" | "failed";
const OFFLINE_QUEUE_STORAGE_ERROR =
"Could not store this message for reconnect. Free browser storage or reconnect before sending.";
function ensureQueuedSendState(
host: ChatHost,
item: ChatQueueItem,
@ -583,11 +586,21 @@ async function sendQueuedChatMessage(
}
const sessionKey = prepared.sessionKey ?? host.sessionKey;
if (!host.connected || !host.client) {
updateQueuedMessageForSession(host, sessionKey, id, (item) => ({
const waiting = updateQueuedMessageForSession(host, sessionKey, id, (item) => ({
...item,
sendState: "waiting-reconnect",
sendError: undefined,
}));
if (!waiting || !persistQueuedMessagesForSession(host, sessionKey, waiting.id)) {
cancelPendingSendBeforeRequest(host, waiting ?? prepared, {
previousDraft: opts?.previousDraft,
previousAttachments: opts?.previousAttachments,
});
if (visibleSessionMatches(host, sessionKey, prepared.agentId)) {
setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
}
return "failed";
}
return "pending";
}
@ -744,13 +757,25 @@ async function sendQueuedChatMessage(
} catch (err) {
const error = formatConnectError(err);
if (isRecoverableChatSendError(err, error)) {
updateQueuedMessageForSession(host, sessionKey, id, (item) => ({
const waiting = updateQueuedMessageForSession(host, sessionKey, id, (item) => ({
...item,
sendError: error,
sendState: "waiting-reconnect",
}));
const stored = Boolean(
waiting && persistQueuedMessagesForSession(host, sessionKey, waiting.id),
);
if (!stored && waiting) {
updateQueuedMessageForSession(host, sessionKey, id, (item) => ({
...item,
sendError: OFFLINE_QUEUE_STORAGE_ERROR,
}));
}
if (isVisibleSession()) {
setChatError(host, "Message will send when the Gateway reconnects.");
setChatError(
host,
stored ? "Message will send when the Gateway reconnects." : OFFLINE_QUEUE_STORAGE_ERROR,
);
}
recordChatSendTiming(host, prepared, "waiting-reconnect", prepared.sendSubmittedAtMs, {
error,
@ -786,7 +811,7 @@ async function sendChatMessageNow(
refreshSessions?: boolean;
submittedAtMs?: number;
},
) {
): Promise<QueuedChatSendResult> {
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
// Reset scroll state before sending to ensure auto-scroll works for the response
resetChatScroll(host as unknown as Parameters<typeof resetChatScroll>[0]);
@ -801,15 +826,15 @@ async function sendChatMessageNow(
opts?.submittedAtMs,
);
if (!queued) {
return false;
return "failed";
}
const queuedSessionKey = queued.sessionKey ?? host.sessionKey;
const result = await sendQueuedChatMessage(host, queued.id, {
previousDraft: opts?.previousDraft,
previousAttachments: opts?.previousAttachments,
});
const ok = result === "sent";
if (ok && host.sessionKey === queuedSessionKey) {
const sent = result === "sent";
if (sent && host.sessionKey === queuedSessionKey) {
setLastActiveSessionKey(
host as unknown as Parameters<typeof setLastActiveSessionKey>[0],
queuedSessionKey,
@ -817,7 +842,7 @@ async function sendChatMessageNow(
resetChatInputHistoryNavigation(host);
}
if (
ok &&
sent &&
host.sessionKey === queuedSessionKey &&
opts?.restoreDraft &&
opts.previousDraft?.trim()
@ -825,7 +850,7 @@ async function sendChatMessageNow(
host.chatMessage = opts.previousDraft;
}
if (
ok &&
sent &&
host.sessionKey === queuedSessionKey &&
opts?.restoreAttachments &&
opts.previousAttachments?.length
@ -836,10 +861,10 @@ async function sendChatMessageNow(
if (host.sessionKey === queuedSessionKey) {
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0], true);
}
if (ok && host.sessionKey === queuedSessionKey && !host.chatRunId) {
if (sent && host.sessionKey === queuedSessionKey && !host.chatRunId) {
void flushChatQueue(host);
}
return ok;
return result;
}
function attachmentSubmitSignature(attachment: ChatAttachment): string {
@ -1051,11 +1076,12 @@ async function flushChatQueue(host: ChatHost) {
});
ok = true;
} else {
ok = await sendChatMessageNow(host, next.text, {
const result = await sendChatMessageNow(host, next.text, {
queueItemId: next.id,
attachments: next.attachments,
refreshSessions: next.refreshSessions,
});
ok = result === "sent";
}
} catch (err) {
setChatError(host, String(err));
@ -1280,6 +1306,7 @@ export async function handleSendChat(
return;
}
let sendResult: QueuedChatSendResult;
if (isChatBusy(host)) {
updateQueuedMessage(host, queued.id, (item) => ({
...item,
@ -1287,25 +1314,27 @@ export async function handleSendChat(
sendState: undefined,
}));
recordChatSendTiming(host, queued, "queued-busy", submittedAtMs);
return;
sendResult = "pending";
} else {
sendResult = await sendChatMessageNow(host, effectiveMessage, {
queueItemId: queued.id,
previousDraft: cleared.previousDraft,
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
attachments: hasAttachments ? attachmentsToSend : undefined,
previousAttachments: cleared.previousAttachments,
restoreAttachments: Boolean(messageOverride && opts?.restoreDraft),
refreshSessions,
submittedAtMs,
});
}
const accepted = await sendChatMessageNow(host, effectiveMessage, {
queueItemId: queued.id,
previousDraft: cleared.previousDraft,
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
attachments: hasAttachments ? attachmentsToSend : undefined,
previousAttachments: cleared.previousAttachments,
restoreAttachments: Boolean(messageOverride && opts?.restoreDraft),
refreshSessions,
submittedAtMs,
});
if (
accepted &&
sendResult !== "failed" &&
replyTarget &&
host.chatReplyTarget?.messageId === replyTarget.messageId &&
host.sessionKey === submittedSessionKey
) {
// A reconnect queue owns the quoted turn before the Gateway ACK. Consume
// its reply target so later offline turns cannot reuse stale context.
host.chatReplyTarget = null;
}
});

View file

@ -1092,6 +1092,31 @@ describe("chat goal status", () => {
});
describe("chat composer workbench", () => {
it("queues ordinary input offline while keeping live commands disabled", () => {
const onSend = vi.fn();
const container = renderChatView({
connected: false,
draft: "queue this offline",
getDraft: () => "queue this offline",
onSend,
});
expect(container.querySelector<HTMLTextAreaElement>("textarea")?.disabled).toBe(false);
expect(container.querySelector<HTMLInputElement>(".agent-chat__file-input")?.disabled).toBe(
false,
);
const send = container.querySelector<HTMLButtonElement>('button[aria-label="Send message"]');
expect(send?.disabled).toBe(false);
send?.click();
expect(onSend).toHaveBeenCalledTimes(1);
const commandContainer = renderChatView({ connected: false, draft: "/status" });
expect(
commandContainer.querySelector<HTMLButtonElement>('button[aria-label="Send message"]')
?.disabled,
).toBe(true);
});
it("renders session controls in the composer and workspace files in the expanded rail", () => {
const onToggleCollapsed = vi.fn();
const onRefresh = vi.fn();
@ -2399,6 +2424,41 @@ describe("chat slash menu accessibility", () => {
expect(container.querySelector(".slash-menu")).toBeNull();
});
it("does not submit a stale slash argument menu after disconnect", () => {
let draft = "";
const onDraftChange = vi.fn((next: string) => {
draft = next;
});
const onSend = vi.fn();
const container = document.createElement("div");
const renderCurrent = (connected: boolean) => {
render(
renderChat(
createChatProps({
connected,
draft,
getDraft: () => draft,
onDraftChange,
onSend,
}),
),
container,
);
};
renderCurrent(true);
inputDraft(container, "/tools ");
renderCurrent(true);
expect(container.querySelector(".slash-menu")).not.toBeNull();
renderCurrent(false);
expect(container.querySelector(".slash-menu")).toBeNull();
keydownComposer(container, "Enter");
expect(onSend).not.toHaveBeenCalled();
expect(draft).toBe("/tools ");
});
it("clears the visible local draft immediately when send clears the host draft", () => {
let draft = "";
const container = document.createElement("div");

View file

@ -164,7 +164,7 @@ export function renderChat(props: ChatProps) {
const requestUpdate = props.onRequestUpdate ?? (() => {});
const splitRatio = props.splitRatio ?? 0.6;
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
const canCompose = props.connected && props.canSend;
const canCompose = props.canSend;
let chatSection: HTMLElement | null = null;
const thread = renderChatThread({

View file

@ -1731,6 +1731,7 @@ export function renderContextNotice(
export type ChatRunControlsProps = {
canAbort: boolean;
canSend: boolean;
connected: boolean;
draft: string;
hasAttachments?: boolean;
@ -1794,7 +1795,7 @@ function renderChatPrimaryActions(props: ChatRunControlsProps) {
<button
class="chat-send-btn"
@click=${storeDraftAndSend}
?disabled=${!props.connected || props.sending}
?disabled=${!props.canSend || props.sending}
aria-label=${t("chat.runControls.queueMessage")}
>
${icons.send}
@ -1822,7 +1823,7 @@ function renderChatPrimaryActions(props: ChatRunControlsProps) {
<button
class="chat-send-btn"
@click=${storeDraftAndSend}
?disabled=${!props.connected || props.sending}
?disabled=${!props.canSend || props.sending}
aria-label=${props.isBusy
? t("chat.runControls.queueMessage")
: t("chat.runControls.sendMessage")}
@ -1896,7 +1897,7 @@ export function renderChatRunControls(props: ChatRunControlsProps) {
export function renderChatComposer(props: ChatComposerProps) {
const state = getChatComposerState(props.paneId);
const canCompose = props.connected && props.canSend;
const canCompose = props.canSend;
const isBusy = props.sending || props.stream !== null;
const canAbort = Boolean(props.canAbort && props.onAbort);
const hasTerminalStatus = hasTerminalRunStatus(props.runStatus);
@ -1924,7 +1925,7 @@ export function renderChatComposer(props: ChatComposerProps) {
props.sessions?.defaults?.contextTokens ?? null,
{
compactBusy,
compactDisabled: !canCompose || isBusy || showAbortableUi,
compactDisabled: !props.connected || !canCompose || isBusy || showAbortableUi,
messages: props.messages,
onCompact: props.onCompact,
providerQuota: props.providerQuota,
@ -1944,14 +1945,18 @@ export function renderChatComposer(props: ChatComposerProps) {
const requestUpdate = props.onRequestUpdate ?? (() => {});
const sendShortcut = normalizeChatSendShortcut(props.sendShortcut);
const placeholder = !props.connected
? t("chat.composer.placeholderDisconnected")
: !canCompose && props.disabledReason
const placeholder =
!canCompose && props.disabledReason
? props.disabledReason
: hasAttachments
? t("chat.composer.placeholderWithAttachments")
: t("chat.composer.placeholder", { name: props.assistantName || "agent" });
// Offline text and attachments may enter the persisted reconnect queue, but
// slash commands are live controls and must not execute against stale state.
const canSubmitDraft = (draft: string) =>
canCompose && (props.connected || !draft.trimStart().startsWith("/"));
const syncComposerDraftAfterSend = (target: HTMLTextAreaElement | null) => {
const submittedDraft = target?.value ?? props.getDraft?.() ?? props.draft;
const hostDraft = props.getDraft?.() ?? props.draft;
@ -1977,6 +1982,7 @@ export function renderChatComposer(props: ChatComposerProps) {
}
if (
props.connected &&
state.slashMenuOpen &&
state.slashMenuMode === "args" &&
state.slashMenuArgItems.length > 0
@ -2017,7 +2023,7 @@ export function renderChatComposer(props: ChatComposerProps) {
}
}
if (state.slashMenuOpen && state.slashMenuItems.length > 0) {
if (props.connected && state.slashMenuOpen && state.slashMenuItems.length > 0) {
const len = state.slashMenuItems.length;
switch (event.key) {
case "ArrowDown":
@ -2077,7 +2083,7 @@ export function renderChatComposer(props: ChatComposerProps) {
const sendShortcutMatches = sendShortcut === "enter" || event.metaKey || event.ctrlKey;
if (event.key === "Enter" && !event.shiftKey && sendShortcutMatches) {
if (!canCompose) {
if (!canSubmitDraft((event.target as HTMLTextAreaElement).value)) {
return;
}
event.preventDefault();
@ -2138,10 +2144,11 @@ export function renderChatComposer(props: ChatComposerProps) {
commitComposerDraft(props, target.value);
};
const handleSend = () => {
if (!canCompose) {
const draft = composerTextarea?.value ?? props.draft;
if (!canSubmitDraft(draft)) {
return;
}
commitComposerDraft(props, composerTextarea?.value ?? props.draft);
commitComposerDraft(props, draft);
props.onSend();
syncComposerDraftAfterSend(composerTextarea);
};
@ -2159,7 +2166,8 @@ export function renderChatComposer(props: ChatComposerProps) {
};
const runControlsProps: ChatRunControlsProps = {
canAbort: showAbortableUi,
connected: canCompose,
canSend: canSubmitDraft(actionDraft),
connected: props.connected,
draft: actionDraft,
hasAttachments: Boolean(props.attachments?.length),
hasMessages: props.messages.length > 0,
@ -2173,7 +2181,7 @@ export function renderChatComposer(props: ChatComposerProps) {
onStoreDraft: () => {},
onToggleVoice: props.onToggleRealtimeTalk ? handleVoicePrimaryAction : undefined,
};
const slashMenuVisible = canCompose && isSlashMenuVisible(state);
const slashMenuVisible = props.connected && canCompose && isSlashMenuVisible(state);
const activeSlashMenuOptionId = getActiveSlashMenuOptionId(state, props.paneId);
const activeSlashMenuOptionLabel = getActiveSlashMenuOptionLabel(state);
const slashMenuListboxId = paneDomId(props.paneId, "slash-menu-listbox");
@ -2183,8 +2191,8 @@ export function renderChatComposer(props: ChatComposerProps) {
${renderChatQueue({
queue: props.queue,
canAbort: showAbortableUi,
onQueueRetry: canCompose ? props.onQueueRetry : undefined,
onQueueSteer: canCompose ? props.onQueueSteer : undefined,
onQueueRetry: props.connected && canCompose ? props.onQueueRetry : undefined,
onQueueSteer: props.connected && canCompose ? props.onQueueSteer : undefined,
onQueueRemove: props.onQueueRemove,
})}
${renderSideResult(props.sideResult, props.onDismissSideResult)}
@ -2241,7 +2249,7 @@ export function renderChatComposer(props: ChatComposerProps) {
${renderFallbackIndicator(props.fallbackStatus)}
${renderCompactionIndicator(props.compactionStatus)}
${renderChatGoal(state, activeSession?.goal, {
canAct: canCompose,
canAct: props.connected && canCompose,
onGoalCommand: props.onGoalCommand,
onGoalEdit: (goal) => {
commitComposerDraft(props, `/goal edit ${goal.objective}`);

View file

@ -5,6 +5,7 @@ import { createStorageMock } from "../../test-helpers/storage.ts";
import {
loadChatComposerSnapshot,
persistChatComposerState,
persistStoredChatComposerQueue,
removeStoredChatComposerQueueItem,
restoreChatComposerState,
} from "./composer-persistence.ts";
@ -284,6 +285,23 @@ describe("chat composer persistence", () => {
});
});
it("rejects a required reconnect item beyond the stored queue limit", () => {
const queue = Array.from(
{ length: 51 },
(_, index): ChatQueueItem => ({
id: `queued-${index}`,
text: `message ${index}`,
createdAt: index,
sendState: "waiting-reconnect",
}),
);
expect(
persistStoredChatComposerQueue(createState(), "agent:lily:main", queue, "queued-50"),
).toBe(false);
expect(loadChatComposerSnapshot(createState(), "agent:lily:main")).toBeNull();
});
it("does not restore steered messages tied to a previous active run", () => {
persistChatComposerState(
createState({

View file

@ -442,10 +442,11 @@ export function persistStoredChatComposerQueue(
state: ChatComposerScope,
sessionKey: string,
queue: ChatQueueItem[],
): void {
requiredQueueItemId?: string,
): boolean {
const storage = getSafeSessionStorage();
if (!storage || !sessionKey.trim()) {
return;
return false;
}
try {
const key = storageKeyForGateway(state.settings?.gatewayUrl);
@ -456,6 +457,9 @@ export function persistStoredChatComposerQueue(
.slice(0, MAX_STORED_QUEUE_ITEMS)
.map(serializeQueueItem)
.filter((item): item is ChatQueueItem => item !== null);
if (requiredQueueItemId && !serializedQueue.some((item) => item.id === requiredQueueItemId)) {
return false;
}
if (!session?.draft && serializedQueue.length === 0) {
delete store.sessions[storeSessionKey];
} else {
@ -466,8 +470,16 @@ export function persistStoredChatComposerQueue(
};
}
writeStore(storage, key, store);
if (!requiredQueueItemId) {
return true;
}
const persistedSession = normalizeStoredSession(
readStore(storage, key).sessions[storeSessionKey],
);
return Boolean(persistedSession?.queue?.some((item) => item.id === requiredQueueItemId));
} catch {
// Best-effort only: queue persistence must not make send recovery fail.
return false;
}
}

View file

@ -90,6 +90,7 @@ export type MockGatewayControls = {
error?: { code?: string; message?: string; details?: unknown; retryable?: boolean },
) => Promise<void>;
resolveDeferred: (method: string, payload?: unknown) => Promise<void>;
setOnline: (online: boolean) => Promise<void>;
setHistoryMessages: (messages: unknown[]) => Promise<void>;
waitForRequest: (method: string) => Promise<MockGatewayRequest>;
};
@ -292,6 +293,7 @@ function installControlUiMockGateway(input: {
) => void;
requests: BrowserRequest[];
resolveDeferred: (method: string, payload?: unknown) => void;
setOnline: (online: boolean) => void;
setHistoryMessages: (messages: unknown[]) => void;
socketCount: () => number;
socketUrls: () => string[];
@ -307,6 +309,7 @@ function installControlUiMockGateway(input: {
const requests: BrowserRequest[] = [];
const sessionPatches = new Map<string, Record<string, unknown>>();
const sockets: Array<{ readonly url: string }> = [];
let online = true;
let seq = 0;
function isRecord(value: unknown): value is Record<string, unknown> {
@ -625,19 +628,23 @@ function installControlUiMockGateway(input: {
MockWebSocket.latest = this;
sockets.push(this);
window.setTimeout(() => {
if (this.readyState !== MockWebSocket.CONNECTING) {
return;
}
this.readyState = MockWebSocket.OPEN;
this.dispatchEvent(new Event("open"));
this.deliver({
event: "connect.challenge",
payload: { nonce: "control-ui-e2e-nonce" },
type: "event",
});
this.openConnection();
}, 0);
}
openConnection(): void {
if (!online || this.readyState !== MockWebSocket.CONNECTING) {
return;
}
this.readyState = MockWebSocket.OPEN;
this.dispatchEvent(new Event("open"));
this.deliver({
event: "connect.challenge",
payload: { nonce: "control-ui-e2e-nonce" },
type: "event",
});
}
override dispatchEvent(event: Event): boolean {
const dispatched = super.dispatchEvent(event);
if (event.type === "open") {
@ -763,6 +770,14 @@ function installControlUiMockGateway(input: {
type: "res",
});
},
setOnline(nextOnline) {
online = nextOnline;
if (!online) {
MockWebSocket.latest?.close(1006, "mock offline");
return;
}
MockWebSocket.latest?.openConnection();
},
setHistoryMessages(messages) {
scenario.historyMessages = Array.isArray(messages) ? messages : [];
},
@ -959,6 +974,21 @@ function createMockGatewayControls(page: Page, defaultSessionKey: string): MockG
{ targetMethod: method, responsePayload: payload },
);
},
async setOnline(online) {
await page.evaluate((nextOnline) => {
const gateway = (
window as Window & {
openclawControlUiE2eGateway?: {
setOnline: (online: boolean) => void;
};
}
).openclawControlUiE2eGateway;
if (!gateway) {
throw new Error("Mock Gateway is not installed");
}
gateway.setOnline(nextOnline);
}, online);
},
async setHistoryMessages(messages) {
await page.evaluate((nextMessages) => {
const gateway = (