diff --git a/web/src/pages/ChatPage.test.tsx b/web/src/pages/ChatPage.test.tsx index 9c080dae69..10e8e04f32 100644 --- a/web/src/pages/ChatPage.test.tsx +++ b/web/src/pages/ChatPage.test.tsx @@ -86,6 +86,14 @@ const maybeReloadForLoopbackWsAuthFailure = vi.fn(() => false); const apiMocks = vi.hoisted(() => ({ buildWsUrl: vi.fn(async () => "ws://localhost/api/pty?channel=chat-1"), })); +const uploadChatImage = vi.hoisted(() => + vi.fn(async () => ({ path: "/tmp/pasted.png" })), +); + +vi.mock("@/lib/chatImagePaste", async (importOriginal) => ({ + ...(await importOriginal()), + uploadChatImage, +})); vi.mock("@xterm/addon-fit", () => ({ FitAddon: FakeFitAddon })); vi.mock("@xterm/addon-unicode11", () => ({ Unicode11Addon: class {} })); @@ -317,6 +325,57 @@ describe("ChatPage", () => { } }); + it("reconnects on tab return after a hidden-tab close even when a stale upload banner is showing", async () => { + const { default: ChatPage } = await import("./ChatPage"); + await render( + + + , + ); + await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); + const socket = FakeWebSocket.instances[0]; + await act(async () => socket.onopen?.()); + + // A failed image paste leaves a non-rejection banner behind. + uploadChatImage.mockRejectedValueOnce(new Error("disk full")); + const host = container.querySelector(".hermes-chat-xterm-host"); + expect(host).not.toBeNull(); + const paste = new Event("paste", { bubbles: true, cancelable: true }); + const file = new File([new Uint8Array([1, 2, 3])], "shot.png", { type: "image/png" }); + Object.defineProperty(paste, "clipboardData", { + value: { + files: [file], + items: [{ getAsFile: () => file, kind: "file", type: "image/png" }], + }, + }); + await act(async () => { + host!.dispatchEvent(paste); + }); + await vi.waitFor(() => + expect(container.textContent).toContain("Image upload failed"), + ); + + // The socket dies while the tab is hidden: the reconnect is deferred. + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => "hidden", + }); + await act(async () => { + socket.onclose?.({ code: 1001, reason: "", wasClean: true }); + }); + expect(FakeWebSocket.instances).toHaveLength(1); + + // Coming back must start the deferred reconnect despite the old banner. + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => "visible", + }); + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + }); + await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2)); + }); + it("treats loopback 4401 closes as stale-token reload candidates", async () => { const { default: ChatPage } = await import("./ChatPage"); diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 039621169a..fd3d6b7ea1 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -1207,6 +1207,10 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { !isActiveRef.current || (typeof document !== "undefined" && document.visibilityState === "hidden") ) { + // Clear any stale banner (e.g. a failed image upload): the resume + // listener refuses to reconnect while a banner sits on a closed PTY. + setBanner(null); + setBannerAction(null); setPtyState("closed"); return; }