diff --git a/src-tauri/src/commands/workspace_windows.rs b/src-tauri/src/commands/workspace_windows.rs
index 4484f94540..72870f156e 100644
--- a/src-tauri/src/commands/workspace_windows.rs
+++ b/src-tauri/src/commands/workspace_windows.rs
@@ -897,7 +897,7 @@ mod tests {
pending: vec![remote(1), remote(2)],
..SessionState::default()
};
- // Remote 2 is opened by hand before its (failed) check is processed.
+ // Remote 2 is opened by hand before its build failure is processed.
state.mark_open(remote(2));
state.complete_restore();
assert_eq!(state.snapshot(), vec![remote(1), remote(2)]);
@@ -1025,6 +1025,40 @@ mod tests {
assert_eq!(restore.plan.front, remote(b.id));
}
+ #[tokio::test]
+ async fn an_offline_remote_shell_survives_quit_but_an_explicit_close_does_not() {
+ let db = fresh_in_memory_db().await;
+ // Restoring a saved shell must not contact this unreachable host.
+ let connection = remote_workspace_connection_service::create(
+ &db.conn,
+ "offline",
+ "http://127.0.0.1:1",
+ "token",
+ &[],
+ )
+ .await
+ .unwrap();
+ let window = remote(connection.id);
+ let session = WorkspaceWindowSession::new();
+ session.apply(|state| state.mark_open(window));
+ session.freeze();
+ session.write_latest(&db.conn).await;
+ let restore = load_restore(&db.conn, false, true).await;
+ assert!(!restore.show_local());
+ assert_eq!(restore.plan.remotes, vec![connection.id]);
+
+ // A shell closing deliberately while the server remains unavailable
+ // still leaves the session. We do not revive every saved connection.
+ let closed_session = WorkspaceWindowSession::new();
+ closed_session.apply(|state| state.mark_open(window));
+ closed_session.apply(|state| state.mark_closed(window));
+ closed_session.freeze();
+ closed_session.write_latest(&db.conn).await;
+ let restore = load_restore(&db.conn, false, true).await;
+ assert!(restore.show_local());
+ assert!(restore.plan.remotes.is_empty());
+ }
+
#[tokio::test]
async fn an_unchanged_list_is_not_written_again() {
let db = fresh_in_memory_db().await;
diff --git a/src/components/connection/remote-connection-status.test.tsx b/src/components/connection/remote-connection-status.test.tsx
new file mode 100644
index 0000000000..39e57cd52d
--- /dev/null
+++ b/src/components/connection/remote-connection-status.test.tsx
@@ -0,0 +1,145 @@
+import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"
+import { NextIntlClientProvider } from "next-intl"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import enMessages from "@/i18n/messages/en.json"
+import type { RemoteConnectionState } from "@/lib/transport/remote-desktop-transport"
+import { RemoteConnectionStatus } from "./remote-connection-status"
+
+function createHealth() {
+ let state: RemoteConnectionState = "reconnecting"
+ const listeners = new Set<() => void>()
+ return {
+ listeners,
+ getConnectionSnapshot: () => state,
+ subscribeConnection: (callback: () => void) => {
+ listeners.add(callback)
+ return () => {
+ listeners.delete(callback)
+ }
+ },
+ eventStream: vi.fn(() => ({ attach: vi.fn() })),
+ reconnectNow: vi.fn(),
+ setState(next: RemoteConnectionState) {
+ state = next
+ for (const listener of listeners) listener()
+ },
+ }
+}
+
+function renderStatus(transport = createHealth()) {
+ const view = render(
+
+ Workspace stays open
+
+
+ )
+ return { ...view, transport }
+}
+
+beforeEach(() => vi.useFakeTimers())
+afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+})
+
+describe("RemoteConnectionStatus", () => {
+ it("starts the shared proxy but hides brief initial connects", () => {
+ const { transport } = renderStatus()
+ expect(transport.eventStream).toHaveBeenCalledTimes(1)
+ expect(screen.queryByRole("status")).not.toBeInTheDocument()
+ act(() => {
+ vi.advanceTimersByTime(3_999)
+ transport.setState("connected")
+ })
+ act(() => vi.advanceTimersByTime(10_000))
+ expect(screen.queryByRole("status")).not.toBeInTheDocument()
+ })
+
+ it("shows initial offline retries, then an honest terminal state and reconnect action", () => {
+ const { transport } = renderStatus()
+ act(() => vi.advanceTimersByTime(4_000))
+ expect(screen.getByRole("status")).toHaveTextContent("Connection lost")
+ expect(
+ screen.getByText(enMessages.WebConnection.reconnectingDescription)
+ ).toBeInTheDocument()
+
+ act(() => transport.setState("disconnected"))
+ expect(
+ screen.queryByText(enMessages.WebConnection.reconnectingDescription)
+ ).not.toBeInTheDocument()
+ expect(screen.queryByText("Session expired")).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole("button", { name: "Reconnect now" }))
+ expect(transport.reconnectNow).toHaveBeenCalledTimes(1)
+ expect(screen.getByText("Workspace stays open")).toBeInTheDocument()
+ })
+
+ it("shows terminal failures immediately and clears when the connection recovers", () => {
+ const { transport } = renderStatus()
+ act(() => transport.setState("disconnected"))
+ expect(screen.getByRole("status")).toHaveTextContent("Connection lost")
+ act(() => transport.setState("connected"))
+ expect(screen.queryByRole("status")).not.toBeInTheDocument()
+ })
+
+ it("starts a fresh grace window for each later outage", () => {
+ const { transport } = renderStatus()
+ act(() => vi.advanceTimersByTime(4_000))
+ expect(screen.getByRole("status")).toBeInTheDocument()
+ act(() => transport.setState("connected"))
+ act(() => transport.setState("reconnecting"))
+ act(() => vi.advanceTimersByTime(3_999))
+ expect(screen.queryByRole("status")).not.toBeInTheDocument()
+ act(() => vi.advanceTimersByTime(1))
+ expect(screen.getByRole("status")).toBeInTheDocument()
+ })
+
+ it("shows a requested retry's progress at once, without the grace window", () => {
+ const { transport } = renderStatus()
+ act(() => transport.setState("disconnected"))
+ transport.reconnectNow.mockImplementation(() =>
+ transport.setState("reconnecting")
+ )
+
+ fireEvent.click(screen.getByRole("button", { name: "Reconnect now" }))
+ expect(screen.getByRole("status")).toHaveTextContent("Connection lost")
+ expect(
+ screen.getByText(enMessages.WebConnection.reconnectingDescription)
+ ).toBeInTheDocument()
+ expect(
+ screen.queryByRole("button", { name: "Reconnect now" })
+ ).not.toBeInTheDocument()
+
+ // The retry gives up again: the action is back.
+ act(() => transport.setState("disconnected"))
+ fireEvent.click(screen.getByRole("button", { name: "Reconnect now" }))
+ expect(screen.getByRole("status")).toBeInTheDocument()
+
+ // Once a retry has connected, a later outage gets its grace again.
+ act(() => transport.setState("connected"))
+ act(() => transport.setState("reconnecting"))
+ expect(screen.queryByRole("status")).not.toBeInTheDocument()
+ act(() => vi.advanceTimersByTime(4_000))
+ expect(screen.getByRole("status")).toBeInTheDocument()
+ })
+
+ it("keeps the grace window after a retry the transport did not start", () => {
+ const { transport } = renderStatus()
+ act(() => transport.setState("disconnected"))
+
+ // The mock ignores the retry, as the transport does while one is running.
+ fireEvent.click(screen.getByRole("button", { name: "Reconnect now" }))
+ expect(transport.reconnectNow).toHaveBeenCalledTimes(1)
+ act(() => transport.setState("connected"))
+ act(() => transport.setState("reconnecting"))
+
+ expect(screen.queryByRole("status")).not.toBeInTheDocument()
+ })
+
+ it("unsubscribes and cancels the grace timer on unmount", () => {
+ const { transport, unmount } = renderStatus()
+ expect(transport.listeners.size).toBe(1)
+ unmount()
+ expect(transport.listeners.size).toBe(0)
+ expect(vi.getTimerCount()).toBe(0)
+ })
+})
diff --git a/src/components/connection/remote-connection-status.tsx b/src/components/connection/remote-connection-status.tsx
new file mode 100644
index 0000000000..6f12d5fb1f
--- /dev/null
+++ b/src/components/connection/remote-connection-status.tsx
@@ -0,0 +1,98 @@
+"use client"
+
+import { useCallback, useEffect, useState, useSyncExternalStore } from "react"
+import { Loader2, WifiOff } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { Button } from "@/components/ui/button"
+import type { RemoteDesktopTransport } from "@/lib/transport/remote-desktop-transport"
+
+type ConnectionHealth = Pick<
+ RemoteDesktopTransport,
+ | "getConnectionSnapshot"
+ | "subscribeConnection"
+ | "eventStream"
+ | "reconnectNow"
+>
+
+/** Mounted after the remote transport is configured, so it cannot subscribe
+ * to the local shell by mistake. Keep the workspace mounted during an outage,
+ * which the proxy keeps retrying, and show a manual recovery action once this
+ * window's subscription to it has stopped. */
+export function RemoteConnectionStatus({
+ transport,
+}: {
+ transport: ConnectionHealth
+}) {
+ const t = useTranslations("WebConnection")
+ const subscribe = useCallback(
+ (callback: () => void) => transport.subscribeConnection(callback),
+ [transport]
+ )
+ const getSnapshot = useCallback(
+ () => transport.getConnectionSnapshot(),
+ [transport]
+ )
+ const state = useSyncExternalStore(subscribe, getSnapshot, () => "connected")
+ const [graceElapsed, setGraceElapsed] = useState(false)
+ // A retry the user just asked for shows its progress at once: the pill is
+ // already up, and hiding it for the grace window reads as "nothing
+ // happened".
+ const [retryRequested, setRetryRequested] = useState(false)
+
+ useEffect(() => {
+ // Settings and other restored remote shells may have no conversation
+ // subscribers yet. Start the same shared proxy, not a second socket.
+ transport.eventStream()
+ }, [transport])
+
+ useEffect(() => {
+ if (state !== "reconnecting") return
+ const timer = setTimeout(() => setGraceElapsed(true), 4_000)
+ return () => {
+ clearTimeout(timer)
+ setGraceElapsed(false)
+ setRetryRequested(false)
+ }
+ }, [state])
+
+ const retry = () => {
+ transport.reconnectNow()
+ // Only a retry that started: the transport ignores one while another
+ // is still under way.
+ setRetryRequested(transport.getConnectionSnapshot() === "reconnecting")
+ }
+
+ if (
+ state === "connected" ||
+ (state === "reconnecting" && !graceElapsed && !retryRequested)
+ ) {
+ return null
+ }
+
+ const retrying = state === "reconnecting"
+ return (
+
+ {retrying ? (
+
+ ) : (
+
+ )}
+
+
{t("disconnectedTitle")}
+ {retrying && (
+
+ {t("reconnectingDescription")}
+
+ )}
+
+ {!retrying && (
+
+ )}
+
+ )
+}
diff --git a/src/contexts/remote-connection-context.tsx b/src/contexts/remote-connection-context.tsx
index 3c08a7170a..8a1c7ad82f 100644
--- a/src/contexts/remote-connection-context.tsx
+++ b/src/contexts/remote-connection-context.tsx
@@ -15,7 +15,10 @@ import { useTranslations } from "next-intl"
import {
clearRemoteDesktopTransport,
configureRemoteDesktopTransport,
+ getTransport,
} from "@/lib/transport"
+import type { RemoteDesktopTransport } from "@/lib/transport/remote-desktop-transport"
+import { RemoteConnectionStatus } from "@/components/connection/remote-connection-status"
import { resetBackendScopedStores } from "@/stores/backend-scoped-store-reset"
import { getRemoteWorkspaceConnection } from "@/lib/remote-workspace"
import { toErrorMessage } from "@/lib/app-error"
@@ -107,6 +110,8 @@ export function RemoteConnectionGate({ children }: { children: ReactNode }) {
baseUrl: next.base_url,
token: next.token,
windowInstanceId: remoteWindowId,
+ // Only a refused token. A socket that is down keeps the workspace
+ // mounted while it retries; `RemoteConnectionStatus` shows that.
onUnauthorized: () =>
setState((prev) => ({ ...prev, expired: true })),
})
@@ -213,6 +218,11 @@ export function RemoteConnectionGate({ children }: { children: ReactNode }) {
return (
{children}
+ {hasRemoteConnection && (
+
+ )}
)
}
diff --git a/src/contexts/remote-connection-gate.test.tsx b/src/contexts/remote-connection-gate.test.tsx
new file mode 100644
index 0000000000..cdffa1a467
--- /dev/null
+++ b/src/contexts/remote-connection-gate.test.tsx
@@ -0,0 +1,89 @@
+import { act, cleanup, render, screen } from "@testing-library/react"
+import { NextIntlClientProvider } from "next-intl"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import enMessages from "@/i18n/messages/en.json"
+import type { RemoteTransportConfig } from "@/lib/transport/types"
+
+const mocks = vi.hoisted(() => ({
+ configure: vi.fn<(config: RemoteTransportConfig) => void>(),
+ clear: vi.fn(),
+ readConnection: vi.fn(),
+ status: vi.fn(),
+ transport: { id: "remote-transport" },
+}))
+
+vi.mock("next/navigation", () => ({
+ useSearchParams: () =>
+ new URLSearchParams("remoteConnectionId=17&remoteWindowId=window-instance"),
+}))
+vi.mock("@/lib/transport", () => ({
+ configureRemoteDesktopTransport: mocks.configure,
+ clearRemoteDesktopTransport: mocks.clear,
+ getTransport: () => mocks.transport,
+}))
+vi.mock("@/lib/remote-workspace", () => ({
+ getRemoteWorkspaceConnection: mocks.readConnection,
+}))
+vi.mock("@/stores/backend-scoped-store-reset", () => ({
+ resetBackendScopedStores: vi.fn(),
+}))
+vi.mock("@/components/connection/remote-connection-status", () => ({
+ RemoteConnectionStatus: (props: unknown) => {
+ mocks.status(props)
+ return Remote status
+ },
+}))
+
+import { RemoteConnectionGate } from "./remote-connection-context"
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.readConnection.mockResolvedValue({
+ id: 17,
+ name: "Remote workspace",
+ base_url: "http://remote.test:3210",
+ token: "test-token",
+ })
+})
+
+afterEach(() => {
+ cleanup()
+ vi.unstubAllGlobals()
+})
+
+async function renderGate() {
+ await act(async () => {
+ render(
+
+
+ Workspace stays open
+
+
+ )
+ })
+ return mocks.configure.mock.calls[mocks.configure.mock.calls.length - 1][0]
+}
+
+describe("RemoteConnectionGate failure recovery", () => {
+ // A socket that is merely down never reaches the gate: the transport
+ // keeps retrying it in its own state, which the status pill shows.
+ it("shows credential expiry once the transport reports a refused token", async () => {
+ const config = await renderGate()
+ expect(screen.queryByText(/expired/i)).not.toBeInTheDocument()
+
+ act(() => config.onUnauthorized?.())
+
+ expect(screen.queryByText("Workspace stays open")).not.toBeInTheDocument()
+ expect(screen.queryByText("Remote status")).not.toBeInTheDocument()
+ expect(screen.getByText(/expired/i)).toBeInTheDocument()
+ })
+
+ it("binds recovery to the existing transport instead of reloading the window", async () => {
+ await renderGate()
+ const props = mocks.status.mock.calls[mocks.status.mock.calls.length - 1][0]
+ expect(props.transport).toBe(mocks.transport)
+ expect(props).not.toHaveProperty("onReconnect")
+ expect(mocks.configure).toHaveBeenCalledTimes(1)
+ expect(screen.getByText("Workspace stays open")).toBeInTheDocument()
+ })
+})
diff --git a/src/lib/transport/remote-desktop-transport-lifecycle.test.ts b/src/lib/transport/remote-desktop-transport-lifecycle.test.ts
new file mode 100644
index 0000000000..ba4f37fbff
--- /dev/null
+++ b/src/lib/transport/remote-desktop-transport-lifecycle.test.ts
@@ -0,0 +1,1002 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import type { EventEnvelope, LiveSessionSnapshot } from "@/lib/types"
+import { RemoteDesktopTransport } from "./remote-desktop-transport"
+import type { RemoteTransportConfig } from "./types"
+
+const { invokeMock, listenMock } = vi.hoisted(() => ({
+ invokeMock:
+ vi.fn<
+ (command: string, args?: Record) => Promise
+ >(),
+ listenMock:
+ vi.fn<
+ (
+ event: string,
+ handler: (event: { payload: unknown }) => void
+ ) => Promise<() => void>
+ >(),
+}))
+
+vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }))
+vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock }))
+
+let receiveFrame: (event: { payload: unknown }) => void
+let unlisten: ReturnType
+let transports: RemoteDesktopTransport[]
+
+beforeEach(() => {
+ vi.useFakeTimers()
+ vi.resetAllMocks()
+ unlisten = vi.fn()
+ transports = []
+ invokeMock.mockResolvedValue(undefined)
+ listenMock.mockImplementation(async (_event, handler) => {
+ receiveFrame = handler
+ return unlisten
+ })
+})
+
+afterEach(() => {
+ for (const transport of transports) transport.destroy()
+ vi.clearAllTimers()
+ vi.restoreAllMocks()
+ vi.useRealTimers()
+})
+
+function createTransport(overrides: Partial = {}) {
+ const transport = new RemoteDesktopTransport({
+ id: 17,
+ name: "Remote workspace",
+ baseUrl: "http://remote.test:3210/",
+ token: "test-token",
+ windowInstanceId: "window-instance",
+ ...overrides,
+ })
+ transports.push(transport)
+ return transport
+}
+
+function emit(frame: unknown) {
+ receiveFrame({ payload: frame })
+}
+
+function ready() {
+ emit({ channel: "__ready__", payload: null })
+}
+
+function disconnect() {
+ emit({ channel: "__disconnected__", payload: null })
+}
+
+/**
+ * The server refused the token on the socket's handshake. Network failures
+ * never stop the proxy, so this is how its task ends and drops the window's
+ * subscription, leaving the transport "disconnected".
+ */
+function tokenRefused() {
+ emit({ channel: "__unauthorized__", payload: null })
+}
+
+async function flush() {
+ await vi.advanceTimersByTimeAsync(0)
+}
+
+async function connectReady() {
+ const transport = createTransport()
+ const stream = transport.eventStream()
+ await flush()
+ ready()
+ return { transport, stream }
+}
+
+function sentFrames() {
+ return invokeMock.mock.calls
+ .filter(([command]) => command === "remote_ws_send_text")
+ .map(([, args]) => JSON.parse(args!.text as string))
+}
+
+function lastSentFrame() {
+ const frames = sentFrames()
+ return frames[frames.length - 1]
+}
+
+function commandCalls(command: string) {
+ return invokeMock.mock.calls.filter(([name]) => name === command)
+}
+
+function attachHandlers() {
+ return {
+ onSnapshot: vi.fn(),
+ onReplay: vi.fn(),
+ onEvent: vi.fn(),
+ onDetached: vi.fn(),
+ }
+}
+
+function snapshot(eventSeq: number): LiveSessionSnapshot {
+ return {
+ connection_id: "agent-connection",
+ conversation_id: 3,
+ folder_id: 2,
+ status: "prompting",
+ external_id: null,
+ live_message: null,
+ active_tool_calls: [],
+ pending_permission: null,
+ modes: null,
+ current_mode: null,
+ config_options: null,
+ prompt_capabilities: null,
+ usage: null,
+ fork_supported: false,
+ available_commands: [],
+ selectors_ready: true,
+ event_seq: eventSeq,
+ }
+}
+
+function envelope(seq: number): EventEnvelope {
+ return {
+ type: "content_delta",
+ connection_id: "agent-connection",
+ seq,
+ text: `chunk ${seq}`,
+ }
+}
+
+describe("RemoteDesktopTransport lifecycle", () => {
+ it("registers the window listener before subscribing and waits for __ready__", async () => {
+ const transport = createTransport()
+ const handler = vi.fn()
+ const resolved = vi.fn()
+ const subscription = transport.subscribe("acp://event", handler)
+ void subscription.then(resolved)
+ await flush()
+
+ expect(listenMock).toHaveBeenCalledWith(
+ "remote-ws-event-17",
+ expect.any(Function)
+ )
+ expect(invokeMock).toHaveBeenCalledWith("remote_ws_subscribe", {
+ connectionId: 17,
+ subscriptionId: expect.any(String),
+ windowInstanceId: "window-instance",
+ })
+ expect(listenMock.mock.invocationCallOrder[0]).toBeLessThan(
+ invokeMock.mock.invocationCallOrder[0]
+ )
+ expect(resolved).not.toHaveBeenCalled()
+
+ ready()
+ const unsubscribe = await subscription
+ emit({ channel: "acp://event", payload: { text: "hello" } })
+ expect(handler).toHaveBeenCalledWith({ text: "hello" })
+
+ unsubscribe()
+ emit({ channel: "acp://event", payload: { text: "after unsubscribe" } })
+ expect(handler).toHaveBeenCalledTimes(1)
+ })
+
+ it("notifies reconnect callbacks only after readiness is restored", async () => {
+ const transport = createTransport()
+ const onReconnect = vi.fn()
+ const unsubscribe = transport.onReconnect(onReconnect)
+ transport.eventStream()
+ await flush()
+
+ ready()
+ expect(onReconnect).not.toHaveBeenCalled()
+ disconnect()
+ const resolved = vi.fn()
+ void transport.waitForReady().then(resolved)
+ await flush()
+ expect(resolved).not.toHaveBeenCalled()
+ expect(onReconnect).not.toHaveBeenCalled()
+
+ ready()
+ await flush()
+ expect(resolved).toHaveBeenCalledTimes(1)
+ expect(onReconnect).toHaveBeenCalledTimes(1)
+
+ unsubscribe()
+ disconnect()
+ ready()
+ expect(onReconnect).toHaveBeenCalledTimes(1)
+ })
+
+ it("keeps sibling reconnect callbacks running when one throws", async () => {
+ const { transport } = await connectReady()
+ vi.spyOn(console, "error").mockImplementation(() => {})
+ transport.onReconnect(() => {
+ throw new Error("consumer failed")
+ })
+ const sibling = vi.fn()
+ transport.onReconnect(sibling)
+
+ disconnect()
+ ready()
+
+ expect(sibling).toHaveBeenCalledTimes(1)
+ })
+
+ it("preserves initial readiness waiters when connecting attempts disconnect", async () => {
+ const transport = createTransport()
+ const resolved = vi.fn()
+ void transport.subscribe("acp://event", vi.fn()).then(resolved)
+ await flush()
+
+ disconnect()
+ disconnect()
+ ready()
+ await flush()
+
+ // No 5s timeout should be needed to release the original promise.
+ expect(resolved).toHaveBeenCalledTimes(1)
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it("preserves reconnect waiters across repeated disconnect notifications", async () => {
+ const { transport } = await connectReady()
+ disconnect()
+ const subscribed = vi.fn()
+ const readyWaiter = vi.fn()
+ void transport.subscribe("acp://event", vi.fn()).then(subscribed)
+ void transport.waitForReady().then(readyWaiter)
+ await flush()
+
+ disconnect()
+ await flush()
+ expect(subscribed).not.toHaveBeenCalled()
+ expect(readyWaiter).not.toHaveBeenCalled()
+
+ ready()
+ await flush()
+
+ expect(subscribed).toHaveBeenCalledTimes(1)
+ expect(readyWaiter).toHaveBeenCalledTimes(1)
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it("bounds readiness waits for older servers that never emit __ready__", async () => {
+ vi.spyOn(console, "warn").mockImplementation(() => {})
+ const transport = createTransport()
+ const resolved = vi.fn()
+ void transport.subscribe("acp://event", vi.fn()).then(resolved)
+ await flush()
+
+ await vi.advanceTimersByTimeAsync(4_999)
+ expect(resolved).not.toHaveBeenCalled()
+ await vi.advanceTimersByTimeAsync(1)
+ expect(resolved).toHaveBeenCalledTimes(1)
+ expect(console.warn).toHaveBeenCalledTimes(1)
+ })
+
+ it("keeps reconnecting without claiming expiry through a long outage", async () => {
+ const onUnauthorized = vi.fn()
+ const transport = createTransport({ onUnauthorized })
+ const stream = transport.eventStream()
+ await flush()
+ ready()
+
+ // The proxy reports every failed attempt and never gives up on its own.
+ for (let attempt = 0; attempt < 10; attempt++) disconnect()
+
+ expect(transport.getConnectionSnapshot()).toBe("reconnecting")
+ expect(onUnauthorized).not.toHaveBeenCalled()
+ stream.attach("agent-connection", {}, attachHandlers())
+ expect(sentFrames()).toEqual([])
+ })
+
+ it("reports a token the socket handshake refused as expired credentials", async () => {
+ const onUnauthorized = vi.fn()
+ const transport = createTransport({ onUnauthorized })
+ const stream = transport.eventStream()
+ await flush()
+ ready()
+
+ disconnect()
+ expect(onUnauthorized).not.toHaveBeenCalled()
+ tokenRefused()
+
+ expect(transport.getConnectionSnapshot()).toBe("disconnected")
+ // The same callback, with the same (absent) arguments, as an HTTP 401.
+ expect(onUnauthorized.mock.calls).toEqual([[]])
+ stream.attach("agent-connection", {}, attachHandlers())
+ expect(sentFrames()).toEqual([])
+ })
+
+ it("refreshes once on first ready when initial HTTP reads failed offline", async () => {
+ const transport = createTransport()
+ const onRestored = vi.fn()
+ const onReconnect = vi.fn(() => {
+ void transport.call("list_all_folder_details").then(onRestored)
+ })
+ transport.onReconnect(onReconnect)
+ transport.eventStream()
+ await flush()
+ const error = { code: "network_error", message: "Server unreachable" }
+ invokeMock.mockRejectedValueOnce(error)
+ await expect(transport.call("list_all_folder_details")).rejects.toBe(error)
+ expect(onReconnect).not.toHaveBeenCalled()
+ const restoredFolders = [{ id: 2, path: "/remote/project" }]
+ invokeMock.mockResolvedValue(restoredFolders)
+
+ ready()
+ await flush()
+
+ expect(onReconnect).toHaveBeenCalledTimes(1)
+ expect(onRestored).toHaveBeenCalledWith(restoredFolders)
+ expect(commandCalls("remote_http_call")).toHaveLength(2)
+ expect(transport.getConnectionSnapshot()).toBe("connected")
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it.each(["__disconnected__", "__unauthorized__"])(
+ "refreshes initial state after an initial %s signal recovers",
+ async (channel) => {
+ const transport = createTransport()
+ const onReconnect = vi.fn()
+ const subscribed = vi.fn()
+ transport.onReconnect(onReconnect)
+ void transport.subscribe("acp://event", vi.fn()).then(subscribed)
+ await flush()
+ emit({ channel, payload: null })
+ if (channel === "__unauthorized__") {
+ transport.reconnectNow()
+ await flush()
+ // The ready below answers this retry's subscription, not the first.
+ expect(commandCalls("remote_ws_subscribe")).toHaveLength(2)
+ }
+ await flush()
+ expect(onReconnect).not.toHaveBeenCalled()
+ expect(subscribed).not.toHaveBeenCalled()
+
+ ready()
+ await flush()
+
+ expect(onReconnect).toHaveBeenCalledTimes(1)
+ expect(subscribed).toHaveBeenCalledTimes(1)
+ expect(vi.getTimerCount()).toBe(0)
+ }
+ )
+
+ it("refreshes after an initial HTTP failure settles later than first ready", async () => {
+ const transport = createTransport()
+ const onReconnect = vi.fn()
+ transport.onReconnect(onReconnect)
+ transport.eventStream()
+ await flush()
+ let rejectRead!: (reason: unknown) => void
+ invokeMock.mockReturnValueOnce(
+ new Promise((_resolve, reject) => {
+ rejectRead = reject
+ })
+ )
+ const error = { code: "network_error", message: "Server was offline" }
+ const failedRead = expect(transport.call("list_folders")).rejects.toBe(
+ error
+ )
+ ready()
+ expect(onReconnect).not.toHaveBeenCalled()
+
+ rejectRead(error)
+ await failedRead
+ expect(onReconnect).not.toHaveBeenCalled()
+ await flush()
+
+ expect(onReconnect).toHaveBeenCalledTimes(1)
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it("cancels a late initial-read recovery callback when destroyed", async () => {
+ const transport = createTransport()
+ const onReconnect = vi.fn()
+ transport.onReconnect(onReconnect)
+ transport.eventStream()
+ await flush()
+ let rejectRead!: (reason: unknown) => void
+ invokeMock.mockReturnValueOnce(
+ new Promise((_resolve, reject) => {
+ rejectRead = reject
+ })
+ )
+ const error = { code: "network_error", message: "Server was offline" }
+ const failedRead = expect(transport.call("list_folders")).rejects.toBe(
+ error
+ )
+ ready()
+ rejectRead(error)
+ await failedRead
+ expect(vi.getTimerCount()).toBe(1)
+
+ transport.destroy()
+ await flush()
+
+ expect(onReconnect).not.toHaveBeenCalled()
+ expect(vi.getTimerCount()).toBe(0)
+ })
+})
+
+// A retry in place starts from a "disconnected" transport. Here a refused
+// handshake puts it there, which also ends the proxy task that held the
+// subscription. Behind the status pill the usual way there is a subscription
+// the IPC layer failed to set up.
+describe("RemoteDesktopTransport manual retry", () => {
+ it("reuses handlers, stream, replay cursor, and readiness waiters when retrying in place", async () => {
+ const { transport, stream } = await connectReady()
+ const legacyHandler = vi.fn()
+ await transport.subscribe("acp://event", legacyHandler)
+ const handlers = attachHandlers()
+ const sub = stream.attach("agent-connection", {}, handlers)
+ emit({
+ type: "snapshot",
+ subscription_id: sub.subscriptionId,
+ connection_id: "agent-connection",
+ snapshot: snapshot(10),
+ event_seq: 10,
+ })
+ const onReconnect = vi.fn()
+ transport.onReconnect(onReconnect)
+ disconnect()
+ const readyWaiter = vi.fn()
+ void transport.waitForReady().then(readyWaiter)
+ tokenRefused()
+ const originalSubscription = commandCalls("remote_ws_subscribe")[0][1]
+
+ transport.reconnectNow()
+ await flush()
+
+ expect(transport.getConnectionSnapshot()).toBe("reconnecting")
+ expect(transport.eventStream()).toBe(stream)
+ expect(readyWaiter).not.toHaveBeenCalled()
+ expect(sentFrames()).toHaveLength(1)
+ expect(commandCalls("remote_ws_subscribe")).toHaveLength(2)
+ expect(commandCalls("remote_ws_subscribe")[1][1]).toEqual(
+ originalSubscription
+ )
+ expect(listenMock).toHaveBeenCalledTimes(1)
+ expect(unlisten).not.toHaveBeenCalled()
+
+ ready()
+ await flush()
+ expect(readyWaiter).toHaveBeenCalledTimes(1)
+ expect(onReconnect).toHaveBeenCalledTimes(1)
+ expect(lastSentFrame()).toEqual({
+ action: "attach",
+ subscription_id: sub.subscriptionId,
+ connection_id: "agent-connection",
+ since_seq: 10,
+ })
+ const replay = [envelope(11), envelope(12)]
+ emit({
+ type: "replay",
+ subscription_id: sub.subscriptionId,
+ connection_id: "agent-connection",
+ events: replay,
+ high_water_seq: 12,
+ })
+ emit({ channel: "acp://event", payload: "recovered event" })
+ expect(handlers.onReplay).toHaveBeenCalledWith(replay, 12)
+ expect(legacyHandler).toHaveBeenCalledWith("recovered event")
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it("awaits unsubscribe and coalesces repeated retry clicks into one subscribe", async () => {
+ const { transport } = await connectReady()
+ tokenRefused()
+ let finishUnsubscribe!: () => void
+ const unsubscribePending = new Promise((resolve) => {
+ finishUnsubscribe = resolve
+ })
+ invokeMock.mockImplementation((command) => {
+ return command === "remote_ws_unsubscribe"
+ ? unsubscribePending
+ : Promise.resolve(undefined)
+ })
+
+ transport.reconnectNow()
+ transport.reconnectNow()
+ transport.reconnectNow()
+ await flush()
+
+ expect(commandCalls("remote_ws_unsubscribe")).toHaveLength(1)
+ expect(commandCalls("remote_ws_subscribe")).toHaveLength(1)
+ expect(transport.getConnectionSnapshot()).toBe("reconnecting")
+
+ finishUnsubscribe()
+ await flush()
+ transport.reconnectNow()
+ expect(commandCalls("remote_ws_subscribe")).toHaveLength(2)
+ expect(commandCalls("remote_ws_unsubscribe")).toHaveLength(1)
+ })
+
+ it("does not replace a healthy or automatically reconnecting proxy", async () => {
+ const transport = createTransport()
+ transport.reconnectNow()
+ expect(invokeMock).not.toHaveBeenCalled()
+ transport.eventStream()
+ await flush()
+ ready()
+ transport.reconnectNow()
+ disconnect()
+ transport.reconnectNow()
+ await flush()
+
+ expect(commandCalls("remote_ws_subscribe")).toHaveLength(1)
+ expect(commandCalls("remote_ws_unsubscribe")).toHaveLength(0)
+ })
+
+ it("does not resubscribe when destroyed while retry unsubscribe is pending", async () => {
+ const { transport } = await connectReady()
+ tokenRefused()
+ let finishUnsubscribe!: () => void
+ invokeMock.mockReturnValueOnce(
+ new Promise((resolve) => {
+ finishUnsubscribe = resolve
+ })
+ )
+ transport.reconnectNow()
+ await flush()
+ // The retry, not destroy() below, is what is waiting on the unsubscribe.
+ expect(commandCalls("remote_ws_unsubscribe")).toHaveLength(1)
+ transport.destroy()
+ finishUnsubscribe()
+ await flush()
+ transport.reconnectNow()
+ await flush()
+
+ expect(commandCalls("remote_ws_subscribe")).toHaveLength(1)
+ expect(listenMock).toHaveBeenCalledTimes(1)
+ expect(unlisten).toHaveBeenCalledTimes(1)
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it.each(["remote_ws_unsubscribe", "remote_ws_subscribe"])(
+ "retains active streams and allows another retry after %s fails",
+ async (failedCommand) => {
+ const { transport, stream } = await connectReady()
+ vi.spyOn(console, "warn").mockImplementation(() => {})
+ const handlers = attachHandlers()
+ const sub = stream.attach("agent-connection", { sinceSeq: 22 }, handlers)
+ tokenRefused()
+ let failOnce = true
+ invokeMock.mockImplementation((command) => {
+ if (command === failedCommand && failOnce) {
+ failOnce = false
+ return Promise.reject(new Error("IPC unavailable"))
+ }
+ return Promise.resolve(undefined)
+ })
+
+ transport.reconnectNow()
+ await flush()
+
+ // The retry reached the failing command, and the failure ended it.
+ expect(failOnce).toBe(false)
+ expect(transport.getConnectionSnapshot()).toBe("disconnected")
+ expect(transport.eventStream()).toBe(stream)
+ expect(sentFrames()).toHaveLength(1)
+ const subscribesBefore = commandCalls("remote_ws_subscribe").length
+ transport.reconnectNow()
+ await flush()
+ expect(transport.getConnectionSnapshot()).toBe("reconnecting")
+ expect(commandCalls("remote_ws_subscribe")).toHaveLength(
+ subscribesBefore + 1
+ )
+ ready()
+ expect(transport.getConnectionSnapshot()).toBe("connected")
+ expect(lastSentFrame()).toEqual({
+ action: "attach",
+ subscription_id: sub.subscriptionId,
+ connection_id: "agent-connection",
+ since_seq: 22,
+ })
+ emit({
+ type: "event",
+ subscription_id: sub.subscriptionId,
+ envelope: envelope(23),
+ })
+ expect(handlers.onEvent).toHaveBeenCalledWith(envelope(23))
+ }
+ )
+})
+
+describe("RemoteDesktopTransport connection health", () => {
+ it("publishes lifecycle snapshots once per state change", async () => {
+ const transport = createTransport()
+ const states: string[] = []
+ transport.subscribeConnection(() => {
+ states.push(transport.getConnectionSnapshot())
+ })
+ expect(transport.getConnectionSnapshot()).toBe("reconnecting")
+ transport.eventStream()
+ await flush()
+
+ disconnect()
+ expect(states).toEqual([])
+ ready()
+ ready()
+ expect(transport.getConnectionSnapshot()).toBe("connected")
+ expect(states).toEqual(["connected"])
+
+ disconnect()
+ disconnect()
+ expect(transport.getConnectionSnapshot()).toBe("reconnecting")
+ ready()
+ tokenRefused()
+ tokenRefused()
+
+ expect(transport.getConnectionSnapshot()).toBe("disconnected")
+ expect(states).toEqual([
+ "connected",
+ "reconnecting",
+ "connected",
+ "disconnected",
+ ])
+ })
+
+ it("stops notifying a health subscriber after unsubscribe", async () => {
+ const { transport } = await connectReady()
+ const listener = vi.fn()
+ const unsubscribe = transport.subscribeConnection(listener)
+
+ disconnect()
+ expect(listener).toHaveBeenCalledTimes(1)
+ unsubscribe()
+ unsubscribe()
+ ready()
+
+ expect(transport.getConnectionSnapshot()).toBe("connected")
+ expect(listener).toHaveBeenCalledTimes(1)
+ })
+
+ it("isolates failing health listeners and ignores late lifecycle events after destroy", async () => {
+ const { transport } = await connectReady()
+ vi.spyOn(console, "error").mockImplementation(() => {})
+ transport.subscribeConnection(() => {
+ throw new Error("health listener failed")
+ })
+ const sibling = vi.fn()
+ transport.subscribeConnection(sibling)
+ disconnect()
+ expect(sibling).toHaveBeenCalledTimes(1)
+
+ transport.destroy()
+ ready()
+ emit({ channel: "__unauthorized__", payload: null })
+
+ expect(transport.getConnectionSnapshot()).toBe("reconnecting")
+ expect(sibling).toHaveBeenCalledTimes(1)
+ })
+
+ it("marks confirmed HTTP credential rejection disconnected with the default callback source", async () => {
+ const onUnauthorized = vi.fn()
+ const transport = createTransport({ onUnauthorized })
+ const stream = transport.eventStream()
+ await flush()
+ ready()
+ const listener = vi.fn()
+ transport.subscribeConnection(listener)
+ const error = {
+ code: "authentication_failed",
+ message: "Invalid remote token",
+ }
+ invokeMock.mockRejectedValueOnce(error)
+
+ await expect(transport.call("get_status")).rejects.toBe(error)
+
+ expect(transport.getConnectionSnapshot()).toBe("disconnected")
+ expect(listener).toHaveBeenCalledTimes(1)
+ expect(onUnauthorized.mock.calls).toEqual([[]])
+ stream.attach("agent-connection", {}, attachHandlers())
+ expect(sentFrames()).toEqual([])
+ })
+
+ it("does not label an ordinary HTTP failure as rejected credentials", async () => {
+ const onUnauthorized = vi.fn()
+ const transport = createTransport({ onUnauthorized })
+ transport.eventStream()
+ await flush()
+ ready()
+ const listener = vi.fn()
+ transport.subscribeConnection(listener)
+ const error = { code: "network_error", message: "Request timed out" }
+ invokeMock.mockRejectedValueOnce(error)
+
+ await expect(transport.call("get_status")).rejects.toBe(error)
+
+ expect(transport.getConnectionSnapshot()).toBe("connected")
+ expect(listener).not.toHaveBeenCalled()
+ expect(onUnauthorized).not.toHaveBeenCalled()
+ })
+})
+
+describe("RemoteDesktopTransport attach stream", () => {
+ it("queues initial attaches and catches up using snapshot, event, and replay cursors", async () => {
+ const transport = createTransport()
+ const stream = transport.eventStream()
+ expect(transport.eventStream()).toBe(stream)
+ const handlers = attachHandlers()
+ const sub = stream.attach("agent-connection", {}, handlers)
+ await flush()
+ expect(sentFrames()).toEqual([])
+
+ ready()
+ expect(sentFrames()).toEqual([
+ {
+ action: "attach",
+ subscription_id: sub.subscriptionId,
+ connection_id: "agent-connection",
+ },
+ ])
+
+ const initialSnapshot = snapshot(10)
+ emit({
+ type: "snapshot",
+ subscription_id: sub.subscriptionId,
+ connection_id: "agent-connection",
+ snapshot: initialSnapshot,
+ event_seq: 10,
+ })
+ expect(handlers.onSnapshot).toHaveBeenCalledWith(initialSnapshot, 10)
+ disconnect()
+ ready()
+ expect(lastSentFrame()).toMatchObject({ since_seq: 10 })
+
+ const events = [envelope(11), envelope(12)]
+ emit({
+ type: "replay",
+ subscription_id: sub.subscriptionId,
+ connection_id: "agent-connection",
+ events,
+ high_water_seq: 12,
+ })
+ expect(handlers.onReplay).toHaveBeenCalledWith(events, 12)
+ emit({
+ type: "event",
+ subscription_id: sub.subscriptionId,
+ envelope: envelope(13),
+ })
+ expect(handlers.onEvent).toHaveBeenCalledWith(envelope(13))
+ disconnect()
+ ready()
+ expect(lastSentFrame()).toMatchObject({ since_seq: 13 })
+
+ // Even an empty replay may advance the server's high-water mark.
+ emit({
+ type: "replay",
+ subscription_id: sub.subscriptionId,
+ connection_id: "agent-connection",
+ events: [],
+ high_water_seq: 18,
+ })
+ disconnect()
+ ready()
+ expect(lastSentFrame()).toMatchObject({ since_seq: 18 })
+ expect(
+ invokeMock.mock.calls.filter(([c]) => c === "remote_ws_subscribe")
+ ).toHaveLength(1)
+ })
+
+ it("routes attach frames by subscription and removes detached subscriptions", async () => {
+ const { stream } = await connectReady()
+ const firstHandlers = attachHandlers()
+ const secondHandlers = attachHandlers()
+ const first = stream.attach(
+ "agent-connection",
+ { sinceSeq: 3 },
+ firstHandlers
+ )
+ const second = stream.attach("other-connection", {}, secondHandlers)
+
+ emit({
+ type: "event",
+ subscription_id: first.subscriptionId,
+ envelope: envelope(4),
+ })
+ emit({ type: "event", subscription_id: "unknown", envelope: envelope(5) })
+ emit({ type: "pong" })
+ emit(null)
+ emit({ unexpected: true })
+ expect(firstHandlers.onEvent).toHaveBeenCalledTimes(1)
+ expect(secondHandlers.onEvent).not.toHaveBeenCalled()
+
+ first.detach()
+ first.detach()
+ expect(sentFrames().filter((frame) => frame.action === "detach")).toEqual([
+ { action: "detach", subscription_id: first.subscriptionId },
+ ])
+ emit({
+ type: "event",
+ subscription_id: first.subscriptionId,
+ envelope: envelope(6),
+ })
+ emit({
+ type: "detached",
+ subscription_id: second.subscriptionId,
+ reason: "connection_gone",
+ })
+ expect(firstHandlers.onEvent).toHaveBeenCalledTimes(1)
+ expect(secondHandlers.onDetached).toHaveBeenCalledWith("connection_gone")
+
+ const sendCount = sentFrames().length
+ disconnect()
+ ready()
+ expect(sentFrames()).toHaveLength(sendCount)
+ })
+
+ it("debounces rejected sends and reattaches active subscriptions with their latest cursor", async () => {
+ const { stream } = await connectReady()
+ vi.spyOn(console, "warn").mockImplementation(() => {})
+ invokeMock.mockRejectedValueOnce(new Error("queue full"))
+ invokeMock.mockRejectedValueOnce(new Error("queue full"))
+ const first = stream.attach(
+ "agent-connection",
+ { sinceSeq: 7 },
+ attachHandlers()
+ )
+ const second = stream.attach(
+ "other-connection",
+ { sinceSeq: 20 },
+ attachHandlers()
+ )
+ await flush()
+ emit({
+ type: "event",
+ subscription_id: first.subscriptionId,
+ envelope: envelope(8),
+ })
+
+ await vi.advanceTimersByTimeAsync(199)
+ expect(sentFrames()).toHaveLength(2)
+ await vi.advanceTimersByTimeAsync(1)
+ expect(sentFrames().slice(2)).toEqual([
+ {
+ action: "attach",
+ subscription_id: first.subscriptionId,
+ connection_id: "agent-connection",
+ since_seq: 8,
+ },
+ {
+ action: "attach",
+ subscription_id: second.subscriptionId,
+ connection_id: "other-connection",
+ since_seq: 20,
+ },
+ ])
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it("leaves recovery to the next ready event when a rejected send is followed by disconnect", async () => {
+ const { stream } = await connectReady()
+ vi.spyOn(console, "warn").mockImplementation(() => {})
+ invokeMock.mockRejectedValueOnce(new Error("socket closed"))
+ stream.attach("agent-connection", { sinceSeq: 9 }, attachHandlers())
+ await flush()
+
+ disconnect()
+ await vi.advanceTimersByTimeAsync(200)
+ expect(sentFrames()).toHaveLength(1)
+ ready()
+ expect(sentFrames()).toHaveLength(2)
+ expect(lastSentFrame()).toMatchObject({ since_seq: 9 })
+ })
+})
+
+describe("RemoteDesktopTransport destruction", () => {
+ it("unsubscribes once, releases readiness waiters, and ignores late frames", async () => {
+ const transport = createTransport()
+ const onEvent = vi.fn()
+ const onReconnect = vi.fn()
+ transport.onReconnect(onReconnect)
+ const subscribed = vi.fn()
+ void transport.subscribe("acp://event", onEvent).then(subscribed)
+ await flush()
+ const subscriptionArgs = invokeMock.mock.calls.find(
+ ([command]) => command === "remote_ws_subscribe"
+ )![1]!
+
+ transport.destroy()
+ transport.destroy()
+ await flush()
+
+ expect(unlisten).toHaveBeenCalledTimes(1)
+ expect(subscribed).toHaveBeenCalledTimes(1)
+ expect(
+ invokeMock.mock.calls.filter(([c]) => c === "remote_ws_unsubscribe")
+ ).toEqual([
+ [
+ "remote_ws_unsubscribe",
+ {
+ connectionId: 17,
+ subscriptionId: subscriptionArgs.subscriptionId,
+ },
+ ],
+ ])
+ ready()
+ emit({ channel: "acp://event", payload: "late event" })
+ expect(onEvent).not.toHaveBeenCalled()
+ expect(onReconnect).not.toHaveBeenCalled()
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it("cancels pending send-failure retries when destroyed", async () => {
+ const { transport, stream } = await connectReady()
+ vi.spyOn(console, "warn").mockImplementation(() => {})
+ invokeMock.mockRejectedValueOnce(new Error("queue full"))
+ const handlers = attachHandlers()
+ const sub = stream.attach("agent-connection", {}, handlers)
+ await flush()
+ expect(vi.getTimerCount()).toBe(1)
+
+ transport.destroy()
+ await vi.advanceTimersByTimeAsync(1_000)
+ emit({
+ type: "event",
+ subscription_id: sub.subscriptionId,
+ envelope: envelope(1),
+ })
+
+ expect(sentFrames()).toHaveLength(1)
+ expect(handlers.onEvent).not.toHaveBeenCalled()
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it("cleans up an in-flight listener without subscribing after destruction", async () => {
+ let finishListen!: (listener: () => void) => void
+ listenMock.mockReturnValueOnce(
+ new Promise((resolve) => {
+ finishListen = resolve
+ })
+ )
+ const transport = createTransport()
+ const resolved = vi.fn()
+ void transport.subscribe("acp://event", vi.fn()).then(resolved)
+
+ transport.destroy()
+ finishListen(unlisten)
+ await flush()
+
+ expect(unlisten).toHaveBeenCalledTimes(1)
+ expect(
+ invokeMock.mock.calls.filter(([c]) => c === "remote_ws_subscribe")
+ ).toHaveLength(0)
+ expect(resolved).toHaveBeenCalledTimes(1)
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it("reissues cleanup with the same subscription ID after an in-flight subscribe completes", async () => {
+ let finishSubscribe!: () => void
+ invokeMock.mockImplementation((command) => {
+ if (command === "remote_ws_subscribe") {
+ return new Promise((resolve) => {
+ finishSubscribe = resolve
+ })
+ }
+ return Promise.resolve(undefined)
+ })
+ const transport = createTransport()
+ const subscription = transport.subscribe("acp://event", vi.fn())
+ await flush()
+ const subscriptionArgs = invokeMock.mock.calls.find(
+ ([command]) => command === "remote_ws_subscribe"
+ )![1]!
+
+ transport.destroy()
+ finishSubscribe()
+ await subscription
+
+ const unsubscribeCalls = invokeMock.mock.calls.filter(
+ ([command]) => command === "remote_ws_unsubscribe"
+ )
+ expect(unsubscribeCalls).toHaveLength(2)
+ for (const [, args] of unsubscribeCalls) {
+ expect(args).toEqual({
+ connectionId: 17,
+ subscriptionId: subscriptionArgs.subscriptionId,
+ })
+ }
+ expect(unlisten).toHaveBeenCalledTimes(1)
+ expect(vi.getTimerCount()).toBe(0)
+ })
+})
diff --git a/src/lib/transport/remote-desktop-transport.ts b/src/lib/transport/remote-desktop-transport.ts
index 860f813208..023441c863 100644
--- a/src/lib/transport/remote-desktop-transport.ts
+++ b/src/lib/transport/remote-desktop-transport.ts
@@ -21,6 +21,7 @@ const READY_TIMEOUT_MS = 5_000
/// the wire format exactly; we re-export `__ready__` via `WS_READY_CHANNEL`
/// to keep the shared-constants invariant with `WebTransport`.
const WS_DISCONNECTED_CHANNEL = "__disconnected__"
+/// The server refused the token on the socket's handshake (HTTP 401).
const WS_UNAUTHORIZED_CHANNEL = "__unauthorized__"
// Two wire shapes flow on the same `remote-ws-event-{id}` Tauri event:
@@ -34,6 +35,11 @@ const WS_UNAUTHORIZED_CHANNEL = "__unauthorized__"
// call site rather than silently destructuring `undefined`.
type WsFrame = unknown
+export type RemoteConnectionState =
+ | "connected"
+ | "reconnecting"
+ | "disconnected"
+
const ATTACH_FRAME_TYPES = new Set([
"snapshot",
"replay",
@@ -94,12 +100,17 @@ export class RemoteDesktopTransport implements Transport {
/// Tracks whether `__ready__` has arrived since this transport was
/// constructed. The first arrival is the initial connect; subsequent
/// arrivals are reconnects (after `__disconnected__` reset the promise).
- /// A first ready after a failed initial load also triggers state resync.
+ /// A clean initial connect needs no refetch, but one preceded by failed
+ /// startup reads must refresh the workspace on its first ready too.
private hasReadiedOnce = false
// Initial loads can fail while a restored remote is offline. Its first
// eventual ready must refetch that state too, even without an earlier ready.
- private needsResync = false
+ private recoveryNeeded = false
+ private recoveryTimer: ReturnType | null = null
+ private resubscribing = false
private reconnectCallbacks = new Set<() => void>()
+ private connectionState: RemoteConnectionState = "reconnecting"
+ private connectionListeners = new Set<() => void>()
/// Listener handle for `remote-ws-event-{id}`. Null when not subscribed.
private unlistenWsEvent: UnlistenFn | null = null
/// Opaque ID generated at construction time, passed to `remote_ws_subscribe`
@@ -156,7 +167,7 @@ export class RemoteDesktopTransport implements Transport {
])
if (timeoutId !== undefined) clearTimeout(timeoutId)
if (result === "timeout") {
- this.needsResync = true
+ this.recoveryNeeded = true
console.warn(
`[RemoteDesktopTransport] WS __ready__ frame did not arrive within ${READY_TIMEOUT_MS}ms; ` +
"proceeding without server-side subscribe confirmation (initial-connect race may reopen)."
@@ -169,6 +180,7 @@ export class RemoteDesktopTransport implements Transport {
args?: Record,
options?: CallOptions
): Promise {
+ const initialRequest = !this.hasReadiedOnce
try {
// Forward `timeoutMs` through to `remote_http_call`. Without this,
// the Rust client's 30s default fires before the backend can answer
@@ -182,12 +194,33 @@ export class RemoteDesktopTransport implements Transport {
})
return result
} catch (err) {
+ if (this.destroyed) throw err
// The Rust proxy returns 401 from the remote server as
// `AppErrorCode::AuthenticationFailed`. Surface the connection-expired
// UI in just the calling window (the rest stay live until they
// themselves hit a 401 — per design we don't broadcast).
if (isAuthenticationFailed(err)) {
+ if (this.wsOpen) this.resetReady()
+ this.wsOpen = false
+ this.setConnectionState("disconnected")
this.config.onUnauthorized?.()
+ } else if (
+ initialRequest &&
+ err &&
+ typeof err === "object" &&
+ (err as RustError).code === "network_error"
+ ) {
+ // Initial folder/conversation reads are not gated on the WS. If
+ // they failed offline, the first ready must retry their hydration.
+ this.recoveryNeeded = true
+ if (this.wsOpen && this.recoveryTimer === null) {
+ // A slow failed HTTP request can settle AFTER the first ready.
+ // Let its caller finish recording the failure before refreshing.
+ this.recoveryTimer = setTimeout(() => {
+ this.recoveryTimer = null
+ if (!this.destroyed && this.wsOpen) this.notifyReconnect()
+ }, 0)
+ }
}
throw err
}
@@ -229,6 +262,82 @@ export class RemoteDesktopTransport implements Transport {
}
}
+ getConnectionSnapshot(): RemoteConnectionState {
+ return this.connectionState
+ }
+
+ subscribeConnection(callback: () => void): UnsubscribeFn {
+ this.connectionListeners.add(callback)
+ return () => {
+ this.connectionListeners.delete(callback)
+ }
+ }
+
+ /** Retry a stopped proxy in place, preserving unsaved UI state and streams. */
+ reconnectNow(): void {
+ if (
+ this.destroyed ||
+ this.resubscribing ||
+ this.connectionState !== "disconnected"
+ ) {
+ return
+ }
+ this.resubscribing = true
+ this.recoveryNeeded = true
+ this.wsStarted = true
+ this.setConnectionState("reconnecting")
+ void (async () => {
+ try {
+ // Await removal before reusing the opaque ID, or a delayed
+ // unsubscribe could remove the freshly inserted registration.
+ await invoke("remote_ws_unsubscribe", {
+ connectionId: this.config.id,
+ subscriptionId: this.subscriptionId,
+ })
+ if (this.destroyed) return
+ if (this.unlistenWsEvent) await this.subscribeWs()
+ else await this.startWs()
+ } catch (err) {
+ if (!this.destroyed) {
+ this.setConnectionState("disconnected")
+ console.warn("[RemoteDesktopTransport] reconnect failed:", err)
+ }
+ } finally {
+ this.resubscribing = false
+ }
+ })()
+ }
+
+ private notifyReconnect(): void {
+ this.recoveryNeeded = false
+ if (this.recoveryTimer !== null) {
+ clearTimeout(this.recoveryTimer)
+ this.recoveryTimer = null
+ }
+ for (const callback of this.reconnectCallbacks) {
+ try {
+ callback()
+ } catch (err) {
+ console.error("[RemoteDesktopTransport] reconnect callback threw:", err)
+ }
+ }
+ }
+
+ private setConnectionState(state: RemoteConnectionState): void {
+ if (this.connectionState === state) return
+ this.connectionState = state
+ for (const callback of this.connectionListeners) {
+ try {
+ callback()
+ } catch (err) {
+ console.error(
+ "[RemoteDesktopTransport] connection listener threw:",
+ err
+ )
+ }
+ }
+ }
+
eventStream(): EventStream {
if (!this.eventStreamInstance) {
const host: AttachTransportHost = {
@@ -300,7 +409,6 @@ export class RemoteDesktopTransport implements Transport {
private async startWs() {
this.wsStarted = true
-
try {
const unlisten = await listen(
`remote-ws-event-${this.config.id}`,
@@ -314,9 +422,15 @@ export class RemoteDesktopTransport implements Transport {
this.unlistenWsEvent = unlisten
} catch (err) {
this.wsStarted = false
+ this.recoveryNeeded = true
+ this.setConnectionState("disconnected")
throw err
}
+ await this.subscribeWs()
+ }
+
+ private async subscribeWs() {
try {
await invoke("remote_ws_subscribe", {
connectionId: this.config.id,
@@ -336,6 +450,8 @@ export class RemoteDesktopTransport implements Transport {
this.unlistenWsEvent?.()
this.unlistenWsEvent = null
this.wsStarted = false
+ this.recoveryNeeded = true
+ this.setConnectionState("disconnected")
throw err
}
}
@@ -362,11 +478,11 @@ export class RemoteDesktopTransport implements Transport {
// Rust may acknowledge another subscription in the same webview while
// the shared socket is already ready. This is not another reconnect.
if (this.wsOpen) return
- const resync = this.hasReadiedOnce || this.needsResync
+ const shouldRefresh = this.hasReadiedOnce || this.recoveryNeeded
this.hasReadiedOnce = true
- this.needsResync = false
this.wsOpen = true
this.readyResolve()
+ this.setConnectionState("connected")
// Notify EventStream so it can re-issue attach frames for any
// active subscriptions. Fires on initial connect AND every reconnect.
for (const cb of this.wsReadyCallbacks) {
@@ -376,26 +492,18 @@ export class RemoteDesktopTransport implements Transport {
console.error("[RemoteDesktopTransport] wsReady callback threw:", err)
}
}
- if (resync) {
+ if (shouldRefresh) {
// Reconnect path: server-side receiver_count was 0 during the
// disconnect window, so any event fired in that gap was dropped.
// Notify consumers to recover state (e.g. refetch snapshots).
// Errors in user callbacks must not break sibling callbacks.
- for (const cb of this.reconnectCallbacks) {
- try {
- cb()
- } catch (err) {
- console.error(
- "[RemoteDesktopTransport] reconnect callback threw:",
- err
- )
- }
- }
+ this.notifyReconnect()
}
return
}
if (channel === WS_DISCONNECTED_CHANNEL) {
- this.needsResync = true
+ this.recoveryNeeded = true
+ this.setConnectionState("reconnecting")
// Repeated failed handshakes share the same waiters. Replacing their
// promise again would strand them until the readiness timeout.
if (this.wsOpen) this.resetReady()
@@ -403,8 +511,13 @@ export class RemoteDesktopTransport implements Transport {
return
}
if (channel === WS_UNAUTHORIZED_CHANNEL) {
+ // Only a definitive authentication rejection ends Rust's retries, and
+ // it drops this window's subscription: the same expiry an HTTP 401
+ // reports.
+ this.recoveryNeeded = true
+ if (this.wsOpen) this.resetReady()
this.wsOpen = false
- // Only a definitive authentication rejection ends Rust's retries.
+ this.setConnectionState("disconnected")
this.config.onUnauthorized?.()
return
}
@@ -420,6 +533,10 @@ export class RemoteDesktopTransport implements Transport {
if (this.destroyed) return
this.destroyed = true
this.wsOpen = false
+ if (this.recoveryTimer !== null) {
+ clearTimeout(this.recoveryTimer)
+ this.recoveryTimer = null
+ }
if (this.sendFailRetryTimer !== null) {
clearTimeout(this.sendFailRetryTimer)
this.sendFailRetryTimer = null
@@ -442,6 +559,7 @@ export class RemoteDesktopTransport implements Transport {
}
this.handlers.clear()
this.reconnectCallbacks.clear()
+ this.connectionListeners.clear()
this.wsReadyCallbacks.clear()
this.eventStreamInstance?.destroy()
this.eventStreamInstance = null
diff --git a/src/lib/transport/types.ts b/src/lib/transport/types.ts
index 562bc89994..16c7100424 100644
--- a/src/lib/transport/types.ts
+++ b/src/lib/transport/types.ts
@@ -8,6 +8,11 @@ export interface RemoteTransportConfig {
baseUrl: string
token: string
windowInstanceId: string
+ /**
+ * The remote server refused the connection's token: a 401 on an HTTP call
+ * or on the event socket's handshake. Network failures never call this:
+ * the event socket keeps retrying them.
+ */
onUnauthorized?: () => void
}
@@ -121,7 +126,9 @@ export interface Transport {
* `receiver_count == 0`, so anything fired between `onclose` and the next
* `__ready__` is lost. Re-fetching backend snapshots is the recovery path.
*
- * Not fired on the initial connect (consumers handle that separately).
+ * Not fired on a clean initial connect (consumers handle that separately).
+ * A transport may notify on its first successful connection if offline
+ * startup reads failed and the consumers need to retry hydration.
* Returns an unsubscribe function. Optional — IPC-only transports (e.g.
* Tauri) leave this undefined.
*/
diff --git a/src/stores/app-workspace-store-offline.test.tsx b/src/stores/app-workspace-store-offline.test.tsx
new file mode 100644
index 0000000000..f5c3bea0aa
--- /dev/null
+++ b/src/stores/app-workspace-store-offline.test.tsx
@@ -0,0 +1,149 @@
+import { act, cleanup, render, waitFor } from "@testing-library/react"
+import { afterEach, beforeEach, expect, it, vi } from "vitest"
+import { TabProvider } from "@/contexts/tab-context"
+import {
+ buildNewConversationDraftStorageKey,
+ loadMessageInputDraftV2,
+ saveMessageInputDraftV2,
+} from "@/lib/message-input-draft"
+import type { FolderDetail } from "@/lib/types"
+import {
+ resetAppWorkspaceStore,
+ useAppWorkspaceStore,
+} from "./app-workspace-store"
+import { resetTabStore, useTabStore } from "./tab-store"
+
+const mocks = vi.hoisted(() => ({
+ listOpenFolders: vi.fn(),
+ listAllFolders: vi.fn(),
+ listGroups: vi.fn(),
+ listTabs: vi.fn(),
+ saveTabs: vi.fn(),
+ activate: vi.fn(),
+ disconnect: vi.fn(),
+ translate: (key: string) => key,
+}))
+
+vi.mock("next-intl", () => ({ useTranslations: () => mocks.translate }))
+vi.mock("@/lib/api", () => ({
+ listOpenFolderDetails: mocks.listOpenFolders,
+ listAllFolderDetails: mocks.listAllFolders,
+ listFolderGroups: mocks.listGroups,
+ listOpenedTabs: mocks.listTabs,
+ saveOpenedTabs: mocks.saveTabs,
+}))
+vi.mock("@/lib/platform", () => ({
+ subscribe: async () => () => {},
+ onTransportReconnect: () => () => {},
+}))
+vi.mock("@/contexts/workspace-context", () => ({
+ useWorkspaceActions: () => ({ activateConversationPane: mocks.activate }),
+}))
+vi.mock("@/contexts/acp-connections-context", () => ({
+ useAcpActions: () => ({ disconnect: mocks.disconnect }),
+}))
+vi.mock("@/hooks/use-sorted-available-agents", () => ({
+ useSortedAvailableAgents: () => ({ sortedTypes: [], fresh: false }),
+}))
+
+const draftId = "new-offline-draft"
+const draftKey = buildNewConversationDraftStorageKey(draftId)
+let errors: ReturnType
+
+beforeEach(() => {
+ vi.resetAllMocks()
+ localStorage.clear()
+ resetAppWorkspaceStore()
+ localStorage.setItem(
+ "workspace:tab-groups:v1",
+ JSON.stringify({
+ layout: { type: "group", id: "g-main" },
+ assignments: {},
+ selection: { "g-main": draftId },
+ tileByGroup: {},
+ drafts: [
+ {
+ id: draftId,
+ group: "g-main",
+ index: 0,
+ folderId: 1,
+ workingDir: "/repo",
+ agentType: "codex",
+ },
+ ],
+ activeDraft: draftId,
+ })
+ )
+ saveMessageInputDraftV2(draftKey, {
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [{ type: "text", text: "Unsent work survives offline" }],
+ },
+ ],
+ })
+ resetTabStore()
+ mocks.listOpenFolders.mockRejectedValue(new Error("remote offline"))
+ mocks.listAllFolders.mockRejectedValue(new Error("remote offline"))
+ mocks.listGroups.mockRejectedValue(new Error("remote offline"))
+ mocks.listTabs.mockRejectedValue(new Error("remote offline"))
+ errors = vi.spyOn(console, "error").mockImplementation(() => {})
+})
+
+afterEach(() => {
+ cleanup()
+ resetTabStore()
+ errors.mockRestore()
+ localStorage.clear()
+})
+
+it("preserves a restored composer draft until an authoritative folder snapshot arrives", async () => {
+ const savedText = loadMessageInputDraftV2(draftKey)
+ const storageKey = `codeg:message-input-draft:v2:${draftKey}`
+ const persistedText = localStorage.getItem(storageKey)
+ expect(persistedText).not.toBeNull()
+ render(
+
+ Offline workspace
+
+ )
+ await act(async () => {
+ await useAppWorkspaceStore.getState().fetchFolders()
+ })
+ await waitFor(() => expect(useTabStore.getState().tabsHydrated).toBe(true))
+
+ // Both HTTP reads failed, but the real TabProvider must not interpret an
+ // unknown folder set as deletion and clear the persisted composer key.
+ expect(useAppWorkspaceStore.getState().foldersHydrated).toBe(false)
+ expect(useTabStore.getState().rawTabs.map((tab) => tab.id)).toContain(draftId)
+ expect(loadMessageInputDraftV2(draftKey)).toEqual(savedText)
+ expect(localStorage.getItem(storageKey)).toBe(persistedText)
+
+ const folder: FolderDetail = {
+ id: 1,
+ name: "repo",
+ path: "/repo",
+ git_branch: null,
+ default_agent_type: "codex",
+ last_opened_at: "2026-01-01T00:00:00.000Z",
+ sort_order: 0,
+ color: "inherit",
+ parent_id: null,
+ kind: "regular",
+ alias: null,
+ group_id: null,
+ }
+ mocks.listOpenFolders.mockResolvedValue([folder])
+ mocks.listAllFolders.mockResolvedValue([folder])
+ mocks.listGroups.mockResolvedValue([])
+ await act(async () => {
+ await useAppWorkspaceStore.getState().fetchFolders()
+ })
+
+ expect(useAppWorkspaceStore.getState().foldersHydrated).toBe(true)
+ expect(useTabStore.getState().rawTabs.map((tab) => tab.id)).toContain(draftId)
+ expect(loadMessageInputDraftV2(draftKey)).toEqual(savedText)
+ expect(localStorage.getItem(storageKey)).toBe(persistedText)
+ expect(mocks.saveTabs).not.toHaveBeenCalled()
+})
diff --git a/src/stores/app-workspace-store.test.ts b/src/stores/app-workspace-store.test.ts
index 53065bedd8..73c82252a7 100644
--- a/src/stores/app-workspace-store.test.ts
+++ b/src/stores/app-workspace-store.test.ts
@@ -1,4 +1,4 @@
-import { beforeEach, describe, expect, it, vi } from "vitest"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import {
resetAppWorkspaceStore,
useAppWorkspaceStore,
@@ -23,12 +23,18 @@ vi.mock("@/lib/api", () => ({
setFolderGroup: vi.fn(),
}))
-const { getFolder, getGitHead, listAllFolderDetails, listOpenFolderDetails } =
- await import("@/lib/api")
+const {
+ getFolder,
+ getGitHead,
+ listAllFolderDetails,
+ listOpenFolderDetails,
+ listFolderGroups: readFolderGroups,
+} = await import("@/lib/api")
const mockGetFolder = vi.mocked(getFolder)
const mockGetGitHead = vi.mocked(getGitHead)
const mockListAllFolders = vi.mocked(listAllFolderDetails)
const mockListOpenFolders = vi.mocked(listOpenFolderDetails)
+const mockListFolderGroups = vi.mocked(readFolderGroups)
function makeSummary(
overrides: Partial & { id: number }
@@ -146,6 +152,82 @@ function makeFolder(
}
}
+describe("fetchFolders — offline hydration safety", () => {
+ let errors: ReturnType
+
+ beforeEach(() => {
+ mockListOpenFolders.mockReset().mockResolvedValue([])
+ mockListAllFolders.mockReset().mockResolvedValue([])
+ mockListFolderGroups.mockReset().mockResolvedValue([])
+ errors = vi.spyOn(console, "error").mockImplementation(() => {})
+ })
+
+ afterEach(() => errors.mockRestore())
+
+ it.each(["open", "all", "groups"] as const)(
+ "keeps folders unknown when the initial %s read fails, then hydrates on recovery",
+ async (failedRead) => {
+ const reads = {
+ open: mockListOpenFolders,
+ all: mockListAllFolders,
+ groups: mockListFolderGroups,
+ }
+ reads[failedRead].mockRejectedValueOnce(new Error("remote offline"))
+
+ await useAppWorkspaceStore.getState().fetchFolders()
+
+ // TabProvider gates orphan-draft pruning on foldersHydrated. A failed
+ // snapshot must keep that gate closed, even though loading has settled.
+ expect(useAppWorkspaceStore.getState()).toMatchObject({
+ foldersLoading: false,
+ foldersHydrated: false,
+ allFolders: [],
+ })
+
+ const folder = makeFolder({ id: 1 })
+ mockListOpenFolders.mockResolvedValueOnce([folder])
+ mockListAllFolders.mockResolvedValueOnce([folder])
+ await useAppWorkspaceStore.getState().fetchFolders()
+
+ expect(useAppWorkspaceStore.getState()).toMatchObject({
+ foldersLoading: false,
+ foldersHydrated: true,
+ folders: [folder],
+ allFolders: [folder],
+ })
+ }
+ )
+
+ it("keeps the last authoritative snapshot hydrated when a refresh fails", async () => {
+ const folder = makeFolder({ id: 1 })
+ mockListOpenFolders.mockResolvedValueOnce([folder])
+ mockListAllFolders.mockResolvedValueOnce([folder])
+ await useAppWorkspaceStore.getState().fetchFolders()
+ const before = useAppWorkspaceStore.getState()
+
+ mockListAllFolders.mockRejectedValueOnce(new Error("remote offline"))
+ await useAppWorkspaceStore.getState().fetchFolders()
+
+ const after = useAppWorkspaceStore.getState()
+ expect(after.foldersLoading).toBe(false)
+ expect(after.foldersHydrated).toBe(true)
+ expect(after.folders).toBe(before.folders)
+ expect(after.allFolders).toBe(before.allFolders)
+ expect(after.folderGroups).toBe(before.folderGroups)
+ })
+
+ it("accepts a successful empty snapshot as authoritative", async () => {
+ await useAppWorkspaceStore.getState().fetchFolders()
+
+ expect(useAppWorkspaceStore.getState()).toMatchObject({
+ foldersLoading: false,
+ foldersHydrated: true,
+ folders: [],
+ allFolders: [],
+ })
+ })
+})
+
describe("refreshFolder — branch null-guard", () => {
it("keeps the poll-resolved branch when the refreshed row's git_branch is null", async () => {
// Git-head polling has populated the display branch; the folder row's
diff --git a/src/stores/app-workspace-store.ts b/src/stores/app-workspace-store.ts
index 315aac9f89..727911385a 100644
--- a/src/stores/app-workspace-store.ts
+++ b/src/stores/app-workspace-store.ts
@@ -52,6 +52,8 @@ export interface AppWorkspaceStoreState {
* group hasn't arrived — would render a half-built sidebar.
*/
folderGroups: FolderGroupDetail[]
+ /** True only after a successful folder snapshot, so an offline start cannot
+ * mistake an unknown folder list for deleted folders and prune drafts. */
foldersHydrated: boolean
foldersLoading: boolean
@@ -388,11 +390,12 @@ export const useAppWorkspaceStore = create()(
allFolders: allList,
folderGroups: groupList,
branches,
+ foldersHydrated: true,
})
} catch (err) {
console.error("[AppWorkspace] fetchFolders failed:", err)
} finally {
- set({ foldersLoading: false, foldersHydrated: true })
+ set({ foldersLoading: false })
}
},