mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
fix(ui): queue chat input while reconnecting
This commit is contained in:
parent
6621ead871
commit
b137df3cf2
14 changed files with 404 additions and 63 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue