diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/components/connection-status.test.ts b/crates/ironclaw_webui/frontend/src/pages/chat/components/connection-status.test.ts index 3f13687f3d..689a4aa05d 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/components/connection-status.test.ts +++ b/crates/ironclaw_webui/frontend/src/pages/chat/components/connection-status.test.ts @@ -73,6 +73,11 @@ test("ConnectionStatus keeps an empty live region mounted for routine states", ( CONNECTION_STATUS.IDLE, CONNECTION_STATUS.CONNECTING, CONNECTION_STATUS.CONNECTED, + // RECONNECTING is an internal state only: a proxy that closes the SSE + // body between streamed frames makes the transport retry on every chunk, + // so rendering it would only blink. It stays surfaced for run-failure + // attribution via `isConnectionLostStatus` but is not rendered. + CONNECTION_STATUS.RECONNECTING, ]) { const rendered = ConnectionStatus({ status }); assert.notEqual(rendered, null, status); @@ -99,7 +104,6 @@ test("ConnectionStatus renders static desktop status and mobile disclosure", () const ConnectionStatus = loadConnectionStatusForTest(); for (const [status, style] of [ - [CONNECTION_STATUS.RECONNECTING, "--v2-warning-soft"], [CONNECTION_STATUS.DISCONNECTED, "--v2-danger-soft"], [CONNECTION_STATUS.PAUSED, "--v2-surface-soft"], ]) { @@ -143,7 +147,7 @@ test("ConnectionStatus renders static desktop status and mobile disclosure", () test("ConnectionStatus exposes the expanded mobile label state", () => { const ConnectionStatus = loadConnectionStatusForTest({ expanded: true }); - const rendered = ConnectionStatus({ status: CONNECTION_STATUS.RECONNECTING }); + const rendered = ConnectionStatus({ status: CONNECTION_STATUS.DISCONNECTED }); const toggle = nodeByTestId(rendered, "connection-status-toggle"); const floatingLabel = nodeByTestId(rendered, "connection-status-label"); @@ -161,3 +165,43 @@ test("ConnectionStatus falls back safely for an unknown interruption", () => { assert.ok(floatingLabel.props.className.includes("--v2-surface-soft")); assert.equal(floatingLabel.children[0], "blocked"); }); + +test("ConnectionStatus does not render a Reconnecting badge during streaming (#7071)", () => { + // Regression for #7071: a proxy that closes the SSE body between streamed + // frames makes the transport retry on every chunk, flipping the status to + // RECONNECTING for each retry. The badge must not surface that routine + // in-flight reconnect — only a sustained loss that escalates to + // DISCONNECTED is user-visible. + const ConnectionStatus = loadConnectionStatusForTest(); + + const reconnecting = ConnectionStatus({ status: CONNECTION_STATUS.RECONNECTING }); + assert( + nodeByTestId(reconnecting, "connection-status") === null, + "RECONNECTING must not render the desktop status badge", + ); + assert( + nodeByTestId(reconnecting, "connection-status-toggle") === null, + "RECONNECTING must not render the mobile status toggle", + ); + assert( + nodeByTestId(reconnecting, "connection-status-label") === null, + "RECONNECTING must not render the mobile status label", + ); + + const liveStatus = findNode( + reconnecting, + (node) => node.props?.role === "status", + ); + assert(liveStatus !== null, "the live region stays mounted for RECONNECTING"); + assert.equal(liveStatus.children[0], "", "RECONNECTING keeps the live region empty"); + + const disconnected = ConnectionStatus({ status: CONNECTION_STATUS.DISCONNECTED }); + assert( + nodeByTestId(disconnected, "connection-status") !== null, + "DISCONNECTED still renders the desktop status badge", + ); + assert( + nodeByTestId(disconnected, "connection-status-label").children[0] === "disconnected", + "DISCONNECTED still labels the badge", + ); +}); diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/components/connection-status.tsx b/crates/ironclaw_webui/frontend/src/pages/chat/components/connection-status.tsx index d25fd8653e..52b5d44f9e 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/components/connection-status.tsx +++ b/crates/ironclaw_webui/frontend/src/pages/chat/components/connection-status.tsx @@ -26,10 +26,17 @@ const STATUS_DOT_STYLES: Partial> = { const DEFAULT_DOT_STYLE = "text-[var(--v2-text-muted)]"; +// `RECONNECTING` is kept as an internal connection state (it still feeds +// run-failure attribution via `isConnectionLostStatus`) but is intentionally +// not rendered: a proxy that closes the SSE body between streamed frames +// makes the transport retry on every chunk, and surfacing "Reconnecting" for +// those routine in-flight reconnects only produces a misleading blinking +// badge. Sustained loss still escalates to `DISCONNECTED`, which is shown. const HIDDEN_STATUSES: ReadonlySet = new Set([ CONNECTION_STATUS.IDLE, CONNECTION_STATUS.CONNECTING, CONNECTION_STATUS.CONNECTED, + CONNECTION_STATUS.RECONNECTING, ]); export function ConnectionStatus({ status }: ConnectionStatusProps) { diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py index 0782217502..f087aec079 100644 --- a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py +++ b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py @@ -220,6 +220,14 @@ async def _install_fake_v2_event_stream(page) -> None: return activeStream; }; + // Readiness probes so tests do not race forced failures against the + // fake stream lifecycle. A hidden RECONNECTING badge can no longer + // double as a wait for reconnect readiness. + window.__v2SseHasOpenStream = () => + Boolean(activeStream && !activeStream.closed && activeStream.controller); + window.__v2SseHasHeldConnection = () => + Boolean(activeStream && !activeStream.closed && activeStream.resolve); + const closeStream = (stream, error = null) => { if (!stream || stream.closed) return; stream.closed = true; @@ -1663,44 +1671,42 @@ async def test_reborn_v2_disconnected_run_shows_status_and_stops_typing( connection_status = page.locator(SEL_V2["connection_status"]) await context.set_offline(True) - await expect(connection_status).to_have_text("Reconnecting...", timeout=5000) - await expect(connection_status).to_have_css("position", "static") - assert await connection_status.evaluate("node => Boolean(node.closest('header'))") - await expect(connection_status).to_be_in_viewport() - + # RECONNECTING is no longer rendered (internal state only): a proxy + # that closes the SSE body between streamed frames would otherwise + # blink the badge on every chunk. The badge stays absent during a + # transient/retryable reconnect and only reappears on a terminal + # DISCONNECTED state. + await expect(connection_status).to_have_count(0, timeout=5000) await page.set_viewport_size({"width": 390, "height": 844}) connection_status_toggle = page.locator(SEL_V2["connection_status_toggle"]) connection_status_label = page.locator(SEL_V2["connection_status_label"]) - disclosure_id = await connection_status_label.get_attribute("id") - assert disclosure_id - await expect(connection_status_label).to_be_hidden() - await expect(connection_status_label).to_have_attribute("aria-hidden", "true") - await expect(connection_status_toggle).to_have_attribute("aria-expanded", "false") - await expect(connection_status_toggle).to_have_attribute("aria-controls", disclosure_id) - await expect(connection_status_toggle).to_be_in_viewport() - - await connection_status_toggle.click() - await expect(connection_status_toggle).to_have_attribute("aria-expanded", "true") - await expect(connection_status_label).to_have_attribute("aria-hidden", "false") - await expect(connection_status_label).to_be_visible() - await expect(connection_status_label).to_have_text("Reconnecting...") - await expect(connection_status_label).to_have_css("position", "absolute") - await expect(connection_status_toggle).to_be_in_viewport() - await expect(connection_status_label).to_be_in_viewport() + # No visible status affordance while RECONNECTING is hidden. + await expect(connection_status_toggle).to_have_count(0, timeout=5000) + await expect(connection_status_label).to_have_count(0, timeout=5000) await expect(page.locator(SEL_V2["header_logs_link"])).to_be_visible() await expect(page.locator(SEL_V2["header_docs_link"])).to_be_visible() await page.set_viewport_size({"width": 1280, "height": 720}) await context.set_offline(False) + await page.wait_for_function("() => window.__v2SseHasOpenStream?.() === true") await expect(connection_status).to_have_count(0, timeout=5000) await composer.fill("summarize 3 X/Twitter posts") await composer.press("Enter") await expect(page.locator(SEL_V2["typing_indicator"])).to_be_visible(timeout=5000) + # A retryable stream interruption (readyState 0) stays RECONNECTING + # internally and is not rendered; the badge remains absent. Wait for + # an open stream first so the forced failure does not race the fake + # stream lifecycle, then wait for the held pending connection so the + # terminal failure below targets the held promise. + await page.wait_for_function("() => window.__v2SseHasOpenStream?.() === true") await page.evaluate("() => window.__failLatestV2Sse(0)") - await expect(connection_status).to_have_text("Reconnecting...", timeout=5000) + await page.wait_for_function("() => window.__v2SseHasHeldConnection?.() === true") + await expect(connection_status).to_have_count(0, timeout=5000) + # A terminal (non-retryable) failure escalates to DISCONNECTED, which + # is still rendered. await page.evaluate("() => window.__failLatestV2Sse(2)") await expect(connection_status).to_have_text("Disconnected", timeout=5000)