fix(ui): preserve input when offline storage fails

This commit is contained in:
Peter Steinberger 2026-07-09 04:55:54 -07:00
parent b137df3cf2
commit d481ccb2e7
2 changed files with 160 additions and 20 deletions

View file

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

View file

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