fix(web): clear stale banner when hidden-tab reconnect is deferred

scheduleReconnect parks the PTY as "closed" while the tab is hidden or the
chat route is inactive, but left any earlier non-rejection banner (e.g. a
failed image upload) in place. maybeReconnectOnPageResume refuses to
reconnect while a banner sits on a closed PTY and the reconnect overlay
hides behind a banner too, so the tab came back disconnected with no way
to recover. Clear the banner in the deferral branch like the normal
reconnect path does.

Test: hidden-tab 1001 close after a failed upload now opens a second
socket on visibilitychange (1 -> 2), red before the fix.
This commit is contained in:
teknium1
2026-09-15 14:20:41 -07:00
committed by Teknium
parent a8a36c461b
commit 0dba105b9d
2 changed files with 63 additions and 0 deletions

View File

@@ -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<typeof import("@/lib/chatImagePaste")>()),
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(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive />
</MemoryRouter>,
);
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");

View File

@@ -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;
}