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:
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { existsSync } from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
export interface LocalElixirRelay {
|
||||
endpoint: string;
|
||||
port: number;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function availablePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Could not allocate a relay port")));
|
||||
return;
|
||||
}
|
||||
server.close(() => resolve(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill("SIGTERM");
|
||||
const force = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
}, 5_000);
|
||||
try {
|
||||
await once(child, "exit");
|
||||
} finally {
|
||||
clearTimeout(force);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilReady(
|
||||
port: number,
|
||||
child: ChildProcess,
|
||||
recentOutput: () => string,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + 60_000;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(
|
||||
`Elixir relay exited before becoming ready (exit ${child.exitCode}).\n${recentOutput()}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/ready`, {
|
||||
signal: AbortSignal.timeout(1_000),
|
||||
});
|
||||
if (response.ok) return;
|
||||
lastError = new Error(`Relay readiness returned HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(
|
||||
`Elixir relay did not become ready on port ${port}: ${lastError instanceof Error ? lastError.message : String(lastError)}\n${recentOutput()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
|
||||
const relayRoot =
|
||||
process.env.PASEO_RELAY_CHECKOUT ?? path.resolve(__dirname, "../../../../../..", "paseo-relay");
|
||||
if (!existsSync(path.join(relayRoot, "mix.exs"))) {
|
||||
throw new Error(
|
||||
`Expected the Elixir relay checkout at ${relayRoot}. Set PASEO_RELAY_CHECKOUT to override it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const port = await availablePort();
|
||||
let child: ChildProcess | null = null;
|
||||
let output: string[] = [];
|
||||
|
||||
const start = async () => {
|
||||
if (child && child.exitCode === null && child.signalCode === null) {
|
||||
throw new Error("Elixir relay is already running");
|
||||
}
|
||||
output = [];
|
||||
child = spawn("asdf", ["exec", "mix", "run", "--no-halt"], {
|
||||
cwd: relayRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
MIX_ENV: "prod",
|
||||
PASEO_RELAY_HOST: "127.0.0.1",
|
||||
PASEO_RELAY_PORT: String(port),
|
||||
PASEO_RELAY_MIN_CLUSTER_SIZE: "1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const capture = (chunk: Buffer) => {
|
||||
output.push(chunk.toString("utf8"));
|
||||
output = output.join("").split("\n").slice(-80);
|
||||
};
|
||||
child.stdout?.on("data", capture);
|
||||
child.stderr?.on("data", capture);
|
||||
try {
|
||||
await waitUntilReady(port, child, () => output.join("\n"));
|
||||
} catch (error) {
|
||||
await stopProcess(child);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
if (!child) return;
|
||||
await stopProcess(child);
|
||||
child = null;
|
||||
};
|
||||
|
||||
await start();
|
||||
return {
|
||||
endpoint: `127.0.0.1:${port}`,
|
||||
port,
|
||||
start,
|
||||
stop,
|
||||
close: stop,
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export interface MockAgentWorkspace {
|
||||
export interface MockAgentOptions {
|
||||
repoPrefix: string;
|
||||
title: string;
|
||||
port?: number;
|
||||
initialPrompt?: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
@@ -28,7 +29,7 @@ export interface MockAgentOptions {
|
||||
export async function seedMockAgentWorkspace(
|
||||
options: MockAgentOptions,
|
||||
): Promise<MockAgentWorkspace> {
|
||||
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix });
|
||||
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: options.port });
|
||||
try {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface PackagedWebDaemon {
|
||||
home: string;
|
||||
origin: string;
|
||||
port: number;
|
||||
serverId: string;
|
||||
pairingOfferUrl(): Promise<string>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function availablePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Could not allocate a daemon port")));
|
||||
return;
|
||||
}
|
||||
server.close(() => resolve(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForWebUi(origin: string, home: string): Promise<void> {
|
||||
const deadline = Date.now() + 30_000;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(origin, { signal: AbortSignal.timeout(1_000) });
|
||||
const body = await response.text();
|
||||
if (response.ok && body.includes("<html")) return;
|
||||
lastError = new Error(`Daemon web UI returned HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
const log = await readFile(path.join(home, "daemon.log"), "utf8").catch(() => "");
|
||||
throw new Error(
|
||||
`Daemon web UI did not become ready: ${lastError instanceof Error ? lastError.message : String(lastError)}\n${log.split("\n").slice(-80).join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function startPackagedWebDaemon(input: {
|
||||
relayEndpoint: string;
|
||||
}): Promise<PackagedWebDaemon> {
|
||||
const port = await availablePort();
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-relay-deployment-e2e-"));
|
||||
const serverId = `relay-deployment-${Date.now().toString(36)}`;
|
||||
const paseo = path.resolve(__dirname, "../../../../../node_modules/.bin/paseo");
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
CI: "true",
|
||||
NODE_ENV: "development",
|
||||
PASEO_NODE_ENV: "development",
|
||||
PASEO_SERVER_ID: serverId,
|
||||
PASEO_RELAY_ENDPOINT: input.relayEndpoint,
|
||||
PASEO_RELAY_PUBLIC_ENDPOINT: input.relayEndpoint,
|
||||
PASEO_RELAY_USE_TLS: "false",
|
||||
PASEO_RELAY_PUBLIC_USE_TLS: "false",
|
||||
};
|
||||
|
||||
try {
|
||||
await execFileAsync(
|
||||
paseo,
|
||||
["daemon", "start", "--home", home, "--port", String(port), "--relay", "--web-ui"],
|
||||
{ env },
|
||||
);
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
await waitForWebUi(origin, home);
|
||||
|
||||
return {
|
||||
home,
|
||||
origin,
|
||||
port,
|
||||
serverId,
|
||||
pairingOfferUrl: async () => {
|
||||
const { stdout } = await execFileAsync(
|
||||
paseo,
|
||||
["daemon", "pair", "--home", home, "--relay", "--json"],
|
||||
{ env },
|
||||
);
|
||||
const result = JSON.parse(stdout) as { url?: unknown };
|
||||
if (typeof result.url !== "string") {
|
||||
throw new Error(`Pairing command returned no URL: ${stdout}`);
|
||||
}
|
||||
return result.url;
|
||||
},
|
||||
close: async () => {
|
||||
await execFileAsync(paseo, ["daemon", "stop", "--home", home], { env }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
await rm(home, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await execFileAsync(paseo, ["daemon", "stop", "--home", home], { env }).catch(() => undefined);
|
||||
await rm(home, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
|
||||
import type { LocalElixirRelay } from "./local-elixir-relay";
|
||||
import type { PackagedWebDaemon } from "./packaged-web-daemon";
|
||||
import { expectRunningAgentChrome } from "./agent-stream";
|
||||
import { buildCreateAgentPreferences } from "./daemon-registry";
|
||||
|
||||
export interface RelayDeploymentMeasurements {
|
||||
reconnectToastDelayMs: number;
|
||||
reconnectToastVisibleMs: number;
|
||||
totalReconnectMs: number;
|
||||
relayStartupMs: number;
|
||||
streamPausedWhileDisconnected: boolean;
|
||||
runningStatePreserved: boolean;
|
||||
streamResumedWithoutUserAction: boolean;
|
||||
}
|
||||
|
||||
export async function connectDaemonWebAppOnlyThroughRelay(
|
||||
page: Page,
|
||||
daemon: PackagedWebDaemon,
|
||||
): Promise<void> {
|
||||
const offerUrl = await daemon.pairingOfferUrl();
|
||||
const offer = parseConnectionOfferFromUrl(offerUrl);
|
||||
if (!offer) throw new Error("Daemon pairing command returned an invalid offer URL");
|
||||
const relayConnection = {
|
||||
id: `relay:${offer.relay.endpoint}`,
|
||||
type: "relay" as const,
|
||||
relayEndpoint: offer.relay.endpoint,
|
||||
useTls: offer.relay.useTls ?? false,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
};
|
||||
const now = new Date().toISOString();
|
||||
const host = {
|
||||
serverId: offer.serverId,
|
||||
label: "Relay deployment host",
|
||||
connections: [relayConnection],
|
||||
preferredConnectionId: relayConnection.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await page.route(/:(6767)\b/, (route) => route.abort());
|
||||
await page.routeWebSocket(/:(6767)\b/, async (socket) => {
|
||||
await socket.close({ code: 1008, reason: "Blocked developer daemon during relay E2E" });
|
||||
});
|
||||
await page.route("**/*", async (route) => {
|
||||
if (route.request().resourceType() !== "document") {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
const response = await route.fetch();
|
||||
const html = await response.text();
|
||||
await route.fulfill({
|
||||
response,
|
||||
body: html.replace(/<script>window\.__PASEO_INITIAL_DAEMON_CONNECTION__=.*?<\/script>/, ""),
|
||||
});
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ storedHost, preferences }) => {
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([storedHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ storedHost: host, preferences: buildCreateAgentPreferences(offer.serverId) },
|
||||
);
|
||||
|
||||
const relaySocketOpened = new Promise<void>((resolve) => {
|
||||
page.on("websocket", (socket) => {
|
||||
if (socket.url().includes(offer.relay.endpoint)) resolve();
|
||||
});
|
||||
});
|
||||
await page.goto(daemon.origin);
|
||||
await relaySocketOpened;
|
||||
await expect(page.getByTestId("sidebar-settings")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function latestAssistantText(page: Page): Promise<string> {
|
||||
return (await page.getByTestId("assistant-message").last().innerText()).trim();
|
||||
}
|
||||
|
||||
async function waitForAssistantTextToGrow(page: Page, previous: string): Promise<void> {
|
||||
await expect.poll(() => latestAssistantText(page), { timeout: 30_000 }).not.toBe(previous);
|
||||
}
|
||||
|
||||
async function expectRunningStatusPreserved(page: Page, agentTitle: string): Promise<void> {
|
||||
const tab = page.getByRole("button", { name: agentTitle, exact: true });
|
||||
await expect(tab.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function measureRelayRestartDuringStream(input: {
|
||||
page: Page;
|
||||
relay: LocalElixirRelay;
|
||||
agentTitle: string;
|
||||
}): Promise<RelayDeploymentMeasurements> {
|
||||
const { page, relay, agentTitle } = input;
|
||||
const toast = page.getByTestId("agent-reconnecting-toast");
|
||||
const beforeOutage = await latestAssistantText(page);
|
||||
await waitForAssistantTextToGrow(page, beforeOutage);
|
||||
|
||||
const outageStartedAt = performance.now();
|
||||
const toastVisibleAtPromise = toast
|
||||
.waitFor({ state: "visible", timeout: 30_000 })
|
||||
.then(() => performance.now());
|
||||
await relay.stop();
|
||||
const toastVisibleAt = await toastVisibleAtPromise;
|
||||
|
||||
await expectRunningStatusPreserved(page, agentTitle);
|
||||
await page.waitForTimeout(750);
|
||||
const disconnectedText = await latestAssistantText(page);
|
||||
await page.waitForTimeout(1_000);
|
||||
const streamPausedWhileDisconnected = (await latestAssistantText(page)) === disconnectedText;
|
||||
expect(streamPausedWhileDisconnected).toBe(true);
|
||||
|
||||
const relayStartAt = performance.now();
|
||||
await relay.start();
|
||||
const relayReadyAt = performance.now();
|
||||
await toast.waitFor({ state: "hidden", timeout: 30_000 });
|
||||
const reconnectedAt = performance.now();
|
||||
|
||||
await expectRunningAgentChrome(page, agentTitle);
|
||||
await waitForAssistantTextToGrow(page, disconnectedText);
|
||||
|
||||
return {
|
||||
reconnectToastDelayMs: Math.round(toastVisibleAt - outageStartedAt),
|
||||
reconnectToastVisibleMs: Math.round(reconnectedAt - toastVisibleAt),
|
||||
totalReconnectMs: Math.round(reconnectedAt - outageStartedAt),
|
||||
relayStartupMs: Math.round(relayReadyAt - relayStartAt),
|
||||
streamPausedWhileDisconnected,
|
||||
runningStatePreserved: true,
|
||||
streamResumedWithoutUserAction: true,
|
||||
};
|
||||
}
|
||||
@@ -95,9 +95,12 @@ export async function expectReconnectingToastVisible(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
|
||||
const toast = page.getByTestId("agent-reconnecting-toast");
|
||||
await expect(toast).toBeVisible({
|
||||
timeout: options?.timeout ?? 30_000,
|
||||
});
|
||||
await expect(toast).toHaveText("Reconnecting");
|
||||
await expect(toast.getByTestId("agent-reconnecting-status-dot")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectReconnectingToastGone(
|
||||
@@ -109,6 +112,14 @@ export async function expectReconnectingToastGone(
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectReconnectingToastDebounced(
|
||||
page: Page,
|
||||
options?: { durationMs?: number },
|
||||
): Promise<void> {
|
||||
await page.waitForTimeout(options?.durationMs ?? 750);
|
||||
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, { timeout: 100 });
|
||||
}
|
||||
|
||||
export async function expectHostConnectingOrOffline(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"test:browser": "vitest run --project browser",
|
||||
"test:e2e": "playwright test --project=browser",
|
||||
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
|
||||
"test:e2e:relay-deployment": "npm --prefix ../.. run build:server && npm --prefix ../.. run build:daemon-web-ui && cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=relay-deployment e2e/browser/relay-deployment-reconnect.real.spec.ts",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"build": "npm run build:web",
|
||||
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
// This allows multiple test runs in parallel across different worktrees
|
||||
const baseURL =
|
||||
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
||||
const relayDeploymentSpec = "**/relay-deployment-reconnect.real.spec.ts";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e/browser",
|
||||
@@ -33,6 +34,12 @@ export default defineConfig({
|
||||
{
|
||||
name: "real-provider",
|
||||
testMatch: ["**/*.real.spec.ts"],
|
||||
testIgnore: [relayDeploymentSpec],
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "relay-deployment",
|
||||
testMatch: [relayDeploymentSpec],
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -205,7 +205,7 @@ export const ar: TranslationResources = {
|
||||
states: {
|
||||
notFound: "لم يتم العثور على Agent",
|
||||
failedToLoad: "فشل تحميل الوكيل",
|
||||
reconnecting: "جارٍ إعادة الاتصال...",
|
||||
reconnecting: "جارٍ إعادة الاتصال",
|
||||
timelineSyncFailed: "تعذر تحديث سجل الوكيل. جارٍ إعادة المحاولة…",
|
||||
archivingTitle: "وكيل الارشيف...",
|
||||
archivingSubtitle: "الرجاء الانتظار بينما نقوم بأرشفة هذا الوكيل.",
|
||||
|
||||
@@ -203,7 +203,7 @@ export const en = {
|
||||
states: {
|
||||
notFound: "Agent not found",
|
||||
failedToLoad: "Failed to load agent",
|
||||
reconnecting: "Reconnecting...",
|
||||
reconnecting: "Reconnecting",
|
||||
timelineSyncFailed: "Couldn't refresh agent history. Retrying…",
|
||||
archivingTitle: "Archiving agent...",
|
||||
archivingSubtitle: "Please wait while we archive this agent.",
|
||||
|
||||
@@ -205,7 +205,7 @@ export const es: TranslationResources = {
|
||||
states: {
|
||||
notFound: "Agentno encontrado",
|
||||
failedToLoad: "No se pudo cargar el agente",
|
||||
reconnecting: "Reconectando...",
|
||||
reconnecting: "Reconectando",
|
||||
timelineSyncFailed: "No se pudo actualizar el historial del agente. Reintentando…",
|
||||
archivingTitle: "Agente de archivo...",
|
||||
archivingSubtitle: "Espere mientras archivamos este agente.",
|
||||
|
||||
@@ -207,7 +207,7 @@ export const fr: TranslationResources = {
|
||||
states: {
|
||||
notFound: "Agentintrouvable",
|
||||
failedToLoad: "Échec du chargement de l'agent",
|
||||
reconnecting: "Reconnexion...",
|
||||
reconnecting: "Reconnexion",
|
||||
timelineSyncFailed: "Impossible d’actualiser l’historique de l’agent. Nouvelle tentative…",
|
||||
archivingTitle: "Agent d'archivage...",
|
||||
archivingSubtitle: "Veuillez patienter pendant que nous archivons cet agent.",
|
||||
|
||||
@@ -205,7 +205,7 @@ export const ja: TranslationResources = {
|
||||
states: {
|
||||
notFound: "エージェントが見つかりません",
|
||||
failedToLoad: "エージェントの読み込みに失敗しました",
|
||||
reconnecting: "再接続中...",
|
||||
reconnecting: "再接続中",
|
||||
timelineSyncFailed: "エージェントの履歴を更新できませんでした。再試行しています…",
|
||||
archivingTitle: "エージェントをアーカイブ中...",
|
||||
archivingSubtitle: "このエージェントをアーカイブするまでお待ちください。",
|
||||
|
||||
@@ -205,7 +205,7 @@ export const ko: TranslationResources = {
|
||||
states: {
|
||||
notFound: "에이전트를 찾을 수 없습니다",
|
||||
failedToLoad: "에이전트를 불러오지 못했습니다",
|
||||
reconnecting: "다시 연결하는 중...",
|
||||
reconnecting: "다시 연결하는 중",
|
||||
timelineSyncFailed: "에이전트 기록을 새로고침할 수 없습니다. 재시도 중…",
|
||||
archivingTitle: "에이전트 보관 중...",
|
||||
archivingSubtitle: "이 에이전트를 보관하는 동안 잠시 기다려 주세요.",
|
||||
|
||||
@@ -205,7 +205,7 @@ export const ptBR: TranslationResources = {
|
||||
states: {
|
||||
notFound: "Agente não encontrado",
|
||||
failedToLoad: "Falha ao carregar agente",
|
||||
reconnecting: "Reconectando...",
|
||||
reconnecting: "Reconectando",
|
||||
timelineSyncFailed: "Não foi possível atualizar o histórico do agente. Tentando novamente…",
|
||||
archivingTitle: "Arquivando agente...",
|
||||
archivingSubtitle: "Aguarde enquanto arquivamos este agente.",
|
||||
|
||||
@@ -205,7 +205,7 @@ export const ru: TranslationResources = {
|
||||
states: {
|
||||
notFound: "Agent не найден",
|
||||
failedToLoad: "Не удалось загрузить агент",
|
||||
reconnecting: "Повторное подключение...",
|
||||
reconnecting: "Повторное подключение",
|
||||
timelineSyncFailed: "Не удалось обновить историю агента. Повторная попытка…",
|
||||
archivingTitle: "Архивный агент...",
|
||||
archivingSubtitle: "Пожалуйста, подождите, пока мы архивируем этого агента.",
|
||||
|
||||
@@ -205,7 +205,7 @@ export const zhCN: TranslationResources = {
|
||||
states: {
|
||||
notFound: "未找到 Agent",
|
||||
failedToLoad: "加载 Agent 失败",
|
||||
reconnecting: "正在重连...",
|
||||
reconnecting: "正在重连",
|
||||
timelineSyncFailed: "无法刷新代理历史记录。正在重试…",
|
||||
archivingTitle: "正在归档 Agent...",
|
||||
archivingSubtitle: "请稍候,我们正在归档这个 Agent。",
|
||||
|
||||
@@ -52,12 +52,17 @@ import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state";
|
||||
import {
|
||||
reconcileReconnectToastState,
|
||||
type ReconnectToastState,
|
||||
} from "@/panels/reconnect-toast-state";
|
||||
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor";
|
||||
import {
|
||||
type HostRuntimeConnectionStatus,
|
||||
getHostRuntimeConnectionStatusSince,
|
||||
useHostRuntimeClient,
|
||||
useHostRuntimeConnectionStatus,
|
||||
useHostRuntimeIsConnected,
|
||||
@@ -114,6 +119,10 @@ interface ChatAgentStateShape {
|
||||
lastError?: Agent["lastError"] | null;
|
||||
}
|
||||
|
||||
const RECONNECT_TOAST_DELAY_MS = 1_000;
|
||||
|
||||
const reconnectToastStateByServerId = new Map<string, ReconnectToastState>();
|
||||
|
||||
interface ChatAgentSelectedState extends ChatAgentStateShape {
|
||||
archivedAt: Date | null;
|
||||
requiresAttention: boolean;
|
||||
@@ -768,7 +777,7 @@ function ChatAgentContent({
|
||||
const streamViewRef = useRef<AgentStreamViewHandle>(null);
|
||||
const clearOnAgentBlurRef = useRef<() => void>(() => {});
|
||||
const wasPaneFocusedRef = useRef(isPaneFocused);
|
||||
const reconnectToastArmedRef = useRef(false);
|
||||
const reconnectToastPresentedRef = useRef(false);
|
||||
const initAttemptTokenRef = useRef(0);
|
||||
const routeBottomAnchorRequestRef = useRef<{
|
||||
routeKey: string;
|
||||
@@ -857,26 +866,72 @@ function ChatAgentContent({
|
||||
const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
|
||||
mode: "translate",
|
||||
});
|
||||
const shouldPresentReconnectToast =
|
||||
isPaneVisible && connectionStatus !== "online" && connectionStatus !== "idle";
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionStatus === "online") {
|
||||
if (reconnectToastArmedRef.current) {
|
||||
reconnectToastArmedRef.current = false;
|
||||
if (connectionStatus === "online" || connectionStatus === "idle") {
|
||||
reconnectToastStateByServerId.delete(serverId);
|
||||
}
|
||||
|
||||
if (!shouldPresentReconnectToast) {
|
||||
if (reconnectToastPresentedRef.current) {
|
||||
reconnectToastPresentedRef.current = false;
|
||||
dismissToast();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (connectionStatus === "idle") {
|
||||
|
||||
const startedAt = getHostRuntimeConnectionStatusSince(serverId) ?? Date.now();
|
||||
const previousReconnectToastState = reconnectToastStateByServerId.get(serverId);
|
||||
const reconnectToastState = reconcileReconnectToastState(
|
||||
previousReconnectToastState,
|
||||
startedAt,
|
||||
);
|
||||
if (reconnectToastState !== previousReconnectToastState) {
|
||||
reconnectToastStateByServerId.set(serverId, reconnectToastState);
|
||||
}
|
||||
|
||||
if (reconnectToastState.presented) {
|
||||
if (!reconnectToastPresentedRef.current) {
|
||||
reconnectToastPresentedRef.current = true;
|
||||
toastApi.show(t("agentPanel.states.reconnecting"), {
|
||||
durationMs: null,
|
||||
icon: (
|
||||
<View
|
||||
accessible={false}
|
||||
testID="agent-reconnecting-status-dot"
|
||||
style={styles.reconnectingStatusDot}
|
||||
/>
|
||||
),
|
||||
testID: "agent-reconnecting-toast",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!reconnectToastArmedRef.current) {
|
||||
reconnectToastArmedRef.current = true;
|
||||
|
||||
const delayMs = Math.max(0, startedAt + RECONNECT_TOAST_DELAY_MS - Date.now());
|
||||
const timer = setTimeout(() => {
|
||||
if (reconnectToastStateByServerId.get(serverId) !== reconnectToastState) {
|
||||
return;
|
||||
}
|
||||
reconnectToastState.presented = true;
|
||||
reconnectToastPresentedRef.current = true;
|
||||
toastApi.show(t("agentPanel.states.reconnecting"), {
|
||||
durationMs: null,
|
||||
icon: (
|
||||
<View
|
||||
accessible={false}
|
||||
testID="agent-reconnecting-status-dot"
|
||||
style={styles.reconnectingStatusDot}
|
||||
/>
|
||||
),
|
||||
testID: "agent-reconnecting-toast",
|
||||
});
|
||||
}
|
||||
}, [connectionStatus, dismissToast, toastApi, t]);
|
||||
}, delayMs);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [connectionStatus, dismissToast, serverId, shouldPresentReconnectToast, toastApi, t]);
|
||||
|
||||
const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId }));
|
||||
|
||||
@@ -1726,6 +1781,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
reconnectingStatusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.colors.palette.amber[500],
|
||||
},
|
||||
centerState: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { reconcileReconnectToastState, type ReconnectToastState } from "./reconnect-toast-state";
|
||||
|
||||
describe("reconnect toast presentation state", () => {
|
||||
it("resets after the host recovers without a mounted agent panel", () => {
|
||||
const presented: ReconnectToastState = {
|
||||
connectionStatusStartedAt: 100,
|
||||
presented: true,
|
||||
};
|
||||
|
||||
expect(reconcileReconnectToastState(presented, 100)).toBe(presented);
|
||||
expect(reconcileReconnectToastState(presented, 200)).toEqual({
|
||||
connectionStatusStartedAt: 200,
|
||||
presented: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface ReconnectToastState {
|
||||
connectionStatusStartedAt: number;
|
||||
presented: boolean;
|
||||
}
|
||||
|
||||
export function reconcileReconnectToastState(
|
||||
state: ReconnectToastState | undefined,
|
||||
connectionStatusStartedAt: number,
|
||||
): ReconnectToastState {
|
||||
if (state?.connectionStatusStartedAt === connectionStatusStartedAt) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
connectionStatusStartedAt,
|
||||
presented: false,
|
||||
};
|
||||
}
|
||||
@@ -1639,6 +1639,50 @@ describe("HostRuntimeStore", () => {
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("tracks connection status transitions independently of agent panels", async () => {
|
||||
useHostRuntimeClock();
|
||||
const host = makeHost({
|
||||
connections: [
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
],
|
||||
});
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host: hostProfile }) => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: hostProfile.serverId,
|
||||
hostname: hostProfile.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
store.syncHosts([host]);
|
||||
await fakeClient.waitForFetches(1);
|
||||
await waitForDirectoryReady(store, host.serverId);
|
||||
await vi.advanceTimersByTimeAsync(1_500);
|
||||
|
||||
const outageStartedAt = Date.now();
|
||||
fakeClient.setConnectionState({ status: "disconnected", reason: "transport closed" });
|
||||
expect(store.getConnectionStatusSince(host.serverId)).toBe(outageStartedAt);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(store.getConnectionStatusSince(host.serverId)).toBe(outageStartedAt);
|
||||
|
||||
const reconnectStartedAt = Date.now();
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
expect(store.getConnectionStatusSince(host.serverId)).toBe(reconnectStartedAt);
|
||||
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("bootstraps agent directory subscription when host transitions online", async () => {
|
||||
const host = makeHost({
|
||||
connections: [
|
||||
|
||||
@@ -1358,6 +1358,7 @@ export class HostRuntimeStore {
|
||||
private hostRegistryStatus: HostRegistryStatus = "loading";
|
||||
private deps: HostRuntimeControllerDeps;
|
||||
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
|
||||
private connectionStatusStartedAtByServer = new Map<string, number>();
|
||||
private directoryBootstrapInFlight = new Map<string, Promise<void>>();
|
||||
private queuedAgentDrainInFlight = new Set<string>();
|
||||
private directorySyncByServer = new Map<string, DirectorySync>();
|
||||
@@ -1593,6 +1594,7 @@ export class HostRuntimeStore {
|
||||
|
||||
rekeyMap(this.controllers, oldServerId, newServerId);
|
||||
rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId);
|
||||
rekeyMap(this.connectionStatusStartedAtByServer, oldServerId, newServerId);
|
||||
rekeyMap(this.directoryBootstrapInFlight, oldServerId, newServerId);
|
||||
this.replicaCache.reconcileServerId(oldServerId, newServerId);
|
||||
this.directorySyncByServer.get(oldServerId)?.dispose();
|
||||
@@ -1956,6 +1958,7 @@ export class HostRuntimeStore {
|
||||
}
|
||||
this.controllers.delete(serverId);
|
||||
this.lastConnectionStatusByServer.delete(serverId);
|
||||
this.connectionStatusStartedAtByServer.delete(serverId);
|
||||
this.directoryBootstrapInFlight.delete(serverId);
|
||||
this.directorySyncByServer.get(serverId)?.dispose();
|
||||
this.directorySyncByServer.delete(serverId);
|
||||
@@ -1991,10 +1994,9 @@ export class HostRuntimeStore {
|
||||
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
|
||||
}),
|
||||
);
|
||||
this.lastConnectionStatusByServer.set(
|
||||
host.serverId,
|
||||
controller.getSnapshot().connectionStatus,
|
||||
);
|
||||
const initialSnapshot = controller.getSnapshot();
|
||||
this.lastConnectionStatusByServer.set(host.serverId, initialSnapshot.connectionStatus);
|
||||
this.connectionStatusStartedAtByServer.set(host.serverId, Date.now());
|
||||
controller.subscribe(() => {
|
||||
const snapshot = controller.getSnapshot();
|
||||
this.syncSessionReplica(snapshot.serverId, snapshot);
|
||||
@@ -2035,6 +2037,7 @@ export class HostRuntimeStore {
|
||||
const controller = this.controllers.get(serverId);
|
||||
if (!controller) {
|
||||
this.lastConnectionStatusByServer.delete(serverId);
|
||||
this.connectionStatusStartedAtByServer.delete(serverId);
|
||||
this.directoryBootstrapInFlight.delete(serverId);
|
||||
return;
|
||||
}
|
||||
@@ -2049,6 +2052,14 @@ export class HostRuntimeStore {
|
||||
},
|
||||
}) ?? false;
|
||||
const previousStatus = this.lastConnectionStatusByServer.get(serverId);
|
||||
const statusChanged = previousStatus !== snapshot.connectionStatus;
|
||||
const isUnavailable =
|
||||
snapshot.connectionStatus !== "online" && snapshot.connectionStatus !== "idle";
|
||||
const wasUnavailable =
|
||||
previousStatus !== undefined && previousStatus !== "online" && previousStatus !== "idle";
|
||||
if (statusChanged && (!wasUnavailable || !isUnavailable)) {
|
||||
this.connectionStatusStartedAtByServer.set(serverId, Date.now());
|
||||
}
|
||||
this.lastConnectionStatusByServer.set(serverId, snapshot.connectionStatus);
|
||||
const didTransitionOnline =
|
||||
snapshot.connectionStatus === "online" && previousStatus !== "online";
|
||||
@@ -2160,6 +2171,10 @@ export class HostRuntimeStore {
|
||||
return this.controllers.get(serverId)?.getSnapshot() ?? null;
|
||||
}
|
||||
|
||||
getConnectionStatusSince(serverId: string): number | null {
|
||||
return this.connectionStatusStartedAtByServer.get(serverId) ?? null;
|
||||
}
|
||||
|
||||
getVersion(): number {
|
||||
return this.version;
|
||||
}
|
||||
@@ -2319,6 +2334,10 @@ export function getHostRuntimeStore(): HostRuntimeStore {
|
||||
return singletonHostRuntimeStore;
|
||||
}
|
||||
|
||||
export function getHostRuntimeConnectionStatusSince(serverId: string): number | null {
|
||||
return getHostRuntimeStore().getConnectionStatusSince(serverId);
|
||||
}
|
||||
|
||||
export function useHostRuntimeSnapshot(serverId: string): HostRuntimeSnapshot | null {
|
||||
const store = getHostRuntimeStore();
|
||||
return useSyncExternalStore(
|
||||
|
||||
Reference in New Issue
Block a user