mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
fix(ui): preserve input when offline storage fails
This commit is contained in:
parent
b137df3cf2
commit
d481ccb2e7
2 changed files with 160 additions and 20 deletions
|
|
@ -2589,6 +2589,74 @@ describe("handleSendChat", () => {
|
|||
expect(host.lastError).toBe("Message will send when the Gateway reconnects.");
|
||||
});
|
||||
|
||||
it("restores a pre-ack send when browser storage rejects the reconnect queue", async () => {
|
||||
const storage = createStorageMock();
|
||||
vi.spyOn(storage, "setItem").mockImplementation(() => {
|
||||
throw new DOMException("quota exceeded", "QuotaExceededError");
|
||||
});
|
||||
vi.stubGlobal("sessionStorage", storage);
|
||||
const request = vi.fn((method: string) => {
|
||||
if (method === "chat.send") {
|
||||
throw new Error("gateway closed (1006): network lost");
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const replyTarget = {
|
||||
messageId: "reply-before-ack",
|
||||
text: "quoted body",
|
||||
senderLabel: "User",
|
||||
};
|
||||
const host = makeHost({
|
||||
client: { request } as unknown as ChatHost["client"],
|
||||
chatMessage: "retry after reconnect",
|
||||
chatReplyTarget: replyTarget,
|
||||
});
|
||||
|
||||
await handleSendChat(host);
|
||||
|
||||
expect(host.chatMessage).toBe("retry after reconnect");
|
||||
expect(host.chatQueue).toStrictEqual([]);
|
||||
expect(host.chatReplyTarget).toEqual(replyTarget);
|
||||
expect(host.lastError).toBe(
|
||||
"Could not store this message for reconnect. Free browser storage or reconnect before sending.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a pre-ack send queued when newer composer input blocks restoration", async () => {
|
||||
const storage = createStorageMock();
|
||||
vi.spyOn(storage, "setItem").mockImplementation(() => {
|
||||
throw new DOMException("quota exceeded", "QuotaExceededError");
|
||||
});
|
||||
vi.stubGlobal("sessionStorage", storage);
|
||||
const sent = createDeferred<unknown>();
|
||||
const request = vi.fn((method: string) => {
|
||||
if (method === "chat.send") {
|
||||
return sent.promise;
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const host = makeHost({
|
||||
client: { request } as unknown as ChatHost["client"],
|
||||
chatMessage: "original send",
|
||||
});
|
||||
|
||||
const send = handleSendChat(host);
|
||||
await Promise.resolve();
|
||||
host.chatMessage = "new draft";
|
||||
sent.reject(new Error("gateway closed (1006): network lost"));
|
||||
await send;
|
||||
|
||||
expect(host.chatMessage).toBe("new draft");
|
||||
expect(host.chatQueue).toEqual([
|
||||
expect.objectContaining({
|
||||
text: "original send",
|
||||
sendError:
|
||||
"Could not store this message for reconnect. Free browser storage or reconnect before sending.",
|
||||
sendState: "failed",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("queues normal sends made while disconnected", async () => {
|
||||
const host = makeHost({
|
||||
client: null,
|
||||
|
|
@ -2714,6 +2782,48 @@ describe("handleSendChat", () => {
|
|||
expect(host.chatStream).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps an existing queued send visible when retry persistence fails", async () => {
|
||||
const storage = createStorageMock();
|
||||
vi.spyOn(storage, "setItem").mockImplementation(() => {
|
||||
throw new DOMException("quota exceeded", "QuotaExceededError");
|
||||
});
|
||||
vi.stubGlobal("sessionStorage", storage);
|
||||
const request = vi.fn((method: string) => {
|
||||
if (method === "chat.send") {
|
||||
throw new Error("gateway closed (1006): network lost");
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const host = makeHost({
|
||||
client: { request } as unknown as ChatHost["client"],
|
||||
connected: true,
|
||||
chatQueue: [
|
||||
{
|
||||
id: "queued-retry-storage-failure",
|
||||
text: "keep this queued message",
|
||||
createdAt: 1,
|
||||
sendRunId: "run-retry-storage-failure",
|
||||
sendState: "waiting-reconnect",
|
||||
sessionKey: "agent:main",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await retryReconnectableQueuedChatSends(host);
|
||||
|
||||
expect(host.chatQueue).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "queued-retry-storage-failure",
|
||||
sendError:
|
||||
"Could not store this message for reconnect. Free browser storage or reconnect before sending.",
|
||||
sendState: "failed",
|
||||
}),
|
||||
]);
|
||||
expect(host.lastError).toBe(
|
||||
"Could not store this message for reconnect. Free browser storage or reconnect before sending.",
|
||||
);
|
||||
});
|
||||
|
||||
it("defers queued global send agent selection until defaults are known", async () => {
|
||||
const request = vi.fn((method: string) => {
|
||||
if (method === "chat.send") {
|
||||
|
|
|
|||
|
|
@ -489,12 +489,31 @@ function restoreComposerAfterFailedSend(
|
|||
}
|
||||
}
|
||||
|
||||
type PendingComposerSnapshot = {
|
||||
previousAttachments?: ChatAttachment[];
|
||||
previousDraft?: string;
|
||||
};
|
||||
|
||||
function pendingComposerRestorePlan(host: ChatHost, snapshot: PendingComposerSnapshot) {
|
||||
const willRestoreDraft = snapshot.previousDraft != null && !host.chatMessage.trim();
|
||||
const willRestoreAttachments = Boolean(
|
||||
snapshot.previousAttachments?.length &&
|
||||
host.chatAttachments.length === 0 &&
|
||||
(willRestoreDraft || !host.chatMessage.trim()),
|
||||
);
|
||||
return {
|
||||
complete:
|
||||
(!snapshot.previousDraft?.trim() || willRestoreDraft) &&
|
||||
(!snapshot.previousAttachments?.length || willRestoreAttachments),
|
||||
willRestoreAttachments,
|
||||
willRestoreDraft,
|
||||
};
|
||||
}
|
||||
|
||||
function cancelPendingSendBeforeRequest(
|
||||
host: ChatHost,
|
||||
queued: ChatQueueItem,
|
||||
opts: {
|
||||
previousAttachments?: ChatAttachment[];
|
||||
previousDraft?: string;
|
||||
opts: PendingComposerSnapshot & {
|
||||
restoreComposer?: boolean;
|
||||
},
|
||||
) {
|
||||
|
|
@ -504,14 +523,9 @@ function cancelPendingSendBeforeRequest(
|
|||
queued.sessionKey,
|
||||
);
|
||||
const restoreComposer = opts.restoreComposer !== false && removed != null;
|
||||
const willRestoreDraft =
|
||||
restoreComposer && opts.previousDraft != null && !host.chatMessage.trim();
|
||||
const willRestoreAttachments = Boolean(
|
||||
restoreComposer &&
|
||||
opts.previousAttachments?.length &&
|
||||
host.chatAttachments.length === 0 &&
|
||||
(willRestoreDraft || !host.chatMessage.trim()),
|
||||
);
|
||||
const restorePlan = pendingComposerRestorePlan(host, opts);
|
||||
const willRestoreDraft = restoreComposer && restorePlan.willRestoreDraft;
|
||||
const willRestoreAttachments = restoreComposer && restorePlan.willRestoreAttachments;
|
||||
if (restoreComposer) {
|
||||
if (willRestoreDraft) {
|
||||
host.chatMessage = opts.previousDraft ?? "";
|
||||
|
|
@ -765,17 +779,33 @@ async function sendQueuedChatMessage(
|
|||
const stored = Boolean(
|
||||
waiting && persistQueuedMessagesForSession(host, sessionKey, waiting.id),
|
||||
);
|
||||
if (!stored && waiting) {
|
||||
updateQueuedMessageForSession(host, sessionKey, id, (item) => ({
|
||||
...item,
|
||||
sendError: OFFLINE_QUEUE_STORAGE_ERROR,
|
||||
}));
|
||||
if (!stored) {
|
||||
const hasComposerSnapshot =
|
||||
opts?.previousDraft !== undefined || opts?.previousAttachments !== undefined;
|
||||
// Remove the queue item only when its full payload can be restored
|
||||
// without overwriting newer composer input.
|
||||
if (hasComposerSnapshot && pendingComposerRestorePlan(host, opts ?? {}).complete) {
|
||||
cancelPendingSendBeforeRequest(host, waiting ?? prepared, {
|
||||
previousDraft: opts?.previousDraft,
|
||||
previousAttachments: opts?.previousAttachments,
|
||||
});
|
||||
} else {
|
||||
updateQueuedMessageForSession(host, sessionKey, id, (item) => ({
|
||||
...item,
|
||||
sendError: OFFLINE_QUEUE_STORAGE_ERROR,
|
||||
sendState: "failed",
|
||||
}));
|
||||
}
|
||||
if (isVisibleSession()) {
|
||||
setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
|
||||
}
|
||||
recordChatSendTiming(host, prepared, "failed", prepared.sendSubmittedAtMs, {
|
||||
error: OFFLINE_QUEUE_STORAGE_ERROR,
|
||||
});
|
||||
return "failed";
|
||||
}
|
||||
if (isVisibleSession()) {
|
||||
setChatError(
|
||||
host,
|
||||
stored ? "Message will send when the Gateway reconnects." : OFFLINE_QUEUE_STORAGE_ERROR,
|
||||
);
|
||||
setChatError(host, "Message will send when the Gateway reconnects.");
|
||||
}
|
||||
recordChatSendTiming(host, prepared, "waiting-reconnect", prepared.sendSubmittedAtMs, {
|
||||
error,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue