Improve reconnect status feedback (#2931)

* fix(app): debounce reconnect status toast

Keep reconnect transport immediate while delaying the visible status toast so brief outages stay quiet. Scope the toast to the active retained panel, keep its copy and status dot consistent across locales, and add a manual real relay deployment journey that verifies streaming recovery without user action.

* fix(app): isolate manual relay deployment check

Keep the deployment journey out of the provider-wide real test project so it runs only when its local relay prerequisites are explicitly requested. Also keep the relay teardown active when packaged daemon startup or later cleanup fails.

* fix(app): preserve reconnect toast across tabs

Keep reconnect presentation timing at the host level so switching between retained panels does not restart the debounce or flash the toast. Add a browser regression covering a visible outage across retained tab switches.

* fix(app): anchor reconnect debounce to host status

Record host connection status transitions independently of mounted agent panels and preserve the outage start across reconnecting states. Use that host-level timestamp when a visible agent panel appears, with runtime coverage for the transition lifecycle.

* fix(app): reset reconnect toast state per outage

* test(app): build relay journey prerequisites
This commit is contained in:
Mohamed Boudra
2026-08-06 13:45:34 +02:00
committed by GitHub
parent e99eb7de3d
commit c1f7ed3198
24 changed files with 725 additions and 25 deletions
@@ -0,0 +1,71 @@
import { test } from "@playwright/test";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { expectRunningAgentChrome } from "../support/helpers/agent-stream";
import { startLocalElixirRelay } from "../support/helpers/local-elixir-relay";
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
startPackagedWebDaemon,
type PackagedWebDaemon,
} from "../support/helpers/packaged-web-daemon";
import {
connectDaemonWebAppOnlyThroughRelay,
measureRelayRestartDuringStream,
} from "../support/helpers/relay-deployment";
test("a streaming chat recovers from a relay deployment without user action", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const relay = await startLocalElixirRelay();
let daemon: PackagedWebDaemon | null = null;
let cleanupChat = async () => {};
try {
daemon = await startPackagedWebDaemon({ relayEndpoint: relay.endpoint });
const runningDaemon = daemon;
await test.step("Connect the daemon-served app only through the relay", async () => {
await connectDaemonWebAppOnlyThroughRelay(page, runningDaemon);
});
await test.step("Open a running mock-provider chat", async () => {
const chat = await seedMockAgentWorkspace({
repoPrefix: "relay-deployment-reconnect-",
title: "Relay deployment stream",
port: runningDaemon.port,
model: "five-minute-stream",
initialPrompt: "Stream while the relay is deployed.",
});
cleanupChat = chat.cleanup;
const route = buildHostAgentDetailRoute(
runningDaemon.serverId,
chat.agentId,
chat.workspaceId,
);
await page.goto(new URL(route, runningDaemon.origin).toString());
await expectRunningAgentChrome(page, "Relay deployment stream");
});
await test.step("Restart the relay and measure the visible interruption", async () => {
const measurements = await measureRelayRestartDuringStream({
page,
relay,
agentTitle: "Relay deployment stream",
});
await testInfo.attach("relay-deployment-measurements", {
body: JSON.stringify(measurements, null, 2),
contentType: "application/json",
});
console.log(`Relay deployment measurements: ${JSON.stringify(measurements)}`);
});
} finally {
try {
await cleanupChat();
} finally {
try {
await daemon?.close();
} finally {
await relay.close();
}
}
}
});
@@ -12,6 +12,7 @@ import {
expectTurnCopyButton,
} from "../support/helpers/agent-stream";
import {
expectReconnectingToastDebounced,
expectReconnectingToastGone,
expectReconnectingToastVisible,
} from "../support/helpers/workspace-ui";
@@ -179,6 +180,7 @@ test.describe("Viewed agent timelines", () => {
"true",
);
await gate.drop();
await expectReconnectingToastDebounced(page);
await expectReconnectingToastVisible(page);
await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects.");
await expect(
@@ -196,4 +198,23 @@ test.describe("Viewed agent timelines", () => {
await scenario.cleanup();
}
});
test("preserves reconnecting toast through retained tab switches", async ({ page }) => {
const gate = await installDaemonWebSocketGate(page);
const scenario = await seedViewedTimelineScenario();
try {
await openAgent(page, scenario, scenario.firstAgentId);
await selectAgent(page, "Second viewed chat");
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await selectAgent(page, "First viewed chat");
await gate.drop();
await expectReconnectingToastVisible(page);
await selectAgent(page, "Second viewed chat");
await expectReconnectingToastVisible(page, { timeout: 500 });
} finally {
gate.restore();
await scenario.cleanup();
}
});
});
@@ -30,6 +30,7 @@ import {
expectReconnectingToastGone,
switchWorkspaceViaSidebar,
waitForSidebarHydration,
waitForWorkspaceInSidebar,
workspaceDeckEntryLocator,
expectWorkspaceDeckEntryCount,
} from "../support/helpers/workspace-ui";
@@ -37,7 +38,8 @@ import { clickSettingsBackToWorkspace } from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { addOfflineHostAndReload } from "../support/helpers/hosts";
import { addConnectedHostAndReload, addOfflineHostAndReload } from "../support/helpers/hosts";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
async function expectNoLoadingWorkspacePane(
@@ -199,6 +201,62 @@ test.describe("Workspace navigation regression", () => {
}
});
test("does not show reconnecting for an inactive host workspace", async ({ page }) => {
const secondaryHost = await startIsolatedHostDaemon("inactive-reconnecting-host");
const primaryWorkspace = await seedWorkspace({ repoPrefix: "active-reconnecting-host-" });
const secondaryWorkspace = await seedWorkspace({
repoPrefix: "inactive-reconnecting-host-",
port: secondaryHost.port,
});
try {
await Promise.all([
createIdleAgent(primaryWorkspace.client, {
cwd: primaryWorkspace.repoPath,
workspaceId: primaryWorkspace.workspaceId,
title: "Active host agent",
}),
createIdleAgent(secondaryWorkspace.client, {
cwd: secondaryWorkspace.repoPath,
workspaceId: secondaryWorkspace.workspaceId,
title: "Inactive host agent",
}),
]);
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: "Inactive host",
port: secondaryHost.port,
});
await waitForWorkspaceInSidebar(page, {
serverId: secondaryHost.serverId,
workspaceId: secondaryWorkspace.workspaceId,
});
await switchWorkspaceViaSidebar({
page,
serverId: secondaryHost.serverId,
workspaceId: secondaryWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: primaryWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await secondaryHost.close();
await page.waitForTimeout(1_500);
await expectReconnectingToastGone(page, { timeout: 100 });
} finally {
await secondaryHost.close();
await secondaryWorkspace.cleanup();
await primaryWorkspace.cleanup();
}
});
test("cold offline workspace route gates the screen interior but keeps settings reachable", async ({
page,
}) => {