diff --git a/site/package.json b/site/package.json index 008ba1fe5d..8f1f6742ec 100644 --- a/site/package.json +++ b/site/package.json @@ -53,6 +53,7 @@ "@mui/material": "5.18.0", "@mui/system": "5.18.0", "@mui/x-tree-view": "7.29.10", + "@novnc/novnc": "^1.5.0", "@pierre/diffs": "1.1.0-beta.19", "@radix-ui/react-avatar": "1.1.11", "@radix-ui/react-checkbox": "1.3.3", @@ -153,6 +154,7 @@ "@types/jest": "29.5.14", "@types/lodash": "4.17.21", "@types/node": "20.19.25", + "@types/novnc__novnc": "1.5.0", "@types/react": "19.2.7", "@types/react-color": "3.0.13", "@types/react-date-range": "1.4.4", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 5e8faa0716..46150db386 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -73,6 +73,9 @@ importers: '@mui/x-tree-view': specifier: 7.29.10 version: 7.29.10(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@mui/material@5.18.0(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2))(@mui/system@5.18.0(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) + '@novnc/novnc': + specifier: ^1.5.0 + version: 1.5.0 '@pierre/diffs': specifier: 1.1.0-beta.19 version: 1.1.0-beta.19(react-dom@19.2.2(react@19.2.2))(react@19.2.2) @@ -368,6 +371,9 @@ importers: '@types/node': specifier: 20.19.25 version: 20.19.25 + '@types/novnc__novnc': + specifier: 1.5.0 + version: 1.5.0 '@types/react': specifier: 19.2.7 version: 19.2.7 @@ -1493,6 +1499,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==, tarball: https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz} engines: {node: '>= 8'} + '@novnc/novnc@1.5.0': + resolution: {integrity: sha512-4yGHOtUCnEJUCsgEt/L78eeJu00kthurLBWXFiaXfonNx0pzbs6R/3gJb1byZe6iAE8V9MF0syQb0xIL8MSOtQ==, tarball: https://registry.npmjs.org/@novnc/novnc/-/novnc-1.5.0.tgz} + '@octokit/openapi-types@20.0.0': resolution: {integrity: sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==, tarball: https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-20.0.0.tgz} @@ -2702,6 +2711,9 @@ packages: '@types/node@22.19.1': resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==, tarball: https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz} + '@types/novnc__novnc@1.5.0': + resolution: {integrity: sha512-9DrDJK1hUT6Cbp4t03IsU/DsR6ndnIrDgZVrzITvspldHQ7n81F3wUDfq89zmPM3wg4GErH11IQa0QuTgLMf+w==, tarball: https://registry.npmjs.org/@types/novnc__novnc/-/novnc__novnc-1.5.0.tgz} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==, tarball: https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz} @@ -7906,6 +7918,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@novnc/novnc@1.5.0': {} + '@octokit/openapi-types@20.0.0': {} '@octokit/types@12.6.0': @@ -9089,6 +9103,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/novnc__novnc@1.5.0': {} + '@types/parse-json@4.0.2': {} '@types/prop-types@15.7.15': {} diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 515681a2a6..c9f0965a08 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -163,6 +163,13 @@ export const watchChatGit = (chatId: string): WebSocket => { return createWebSocket(`/api/experimental/chats/${chatId}/git/watch`); }; +export const watchChatDesktop = (chatId: string): WebSocket => { + const socket = createWebSocket(`/api/experimental/chats/${chatId}/desktop`); + // RFB is a binary protocol — noVNC expects arraybuffer, not blob. + socket.binaryType = "arraybuffer"; + return socket; +}; + export const watchAgentContainers = ( agentId: string, ): OneWayWebSocket => { diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index 4b9b10a76f..faba544825 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -1155,6 +1155,7 @@ const AgentDetail: FC = () => { } urlTransform={urlTransform} scrollContainerRef={scrollContainerRef} + desktopChatId={agentId} /> ); }; diff --git a/site/src/pages/AgentsPage/AgentDetailView.tsx b/site/src/pages/AgentsPage/AgentDetailView.tsx index af7c127c79..565fef4ba2 100644 --- a/site/src/pages/AgentsPage/AgentDetailView.tsx +++ b/site/src/pages/AgentsPage/AgentDetailView.tsx @@ -121,6 +121,9 @@ interface AgentDetailViewProps { scrollContainerRef: RefObject; urlTransform?: UrlTransform; + + // Desktop chat ID (optional). + desktopChatId?: string; } export const AgentDetailView: FC = ({ @@ -168,6 +171,7 @@ export const AgentDetailView: FC = ({ handleArchiveAndDeleteWorkspaceAction, scrollContainerRef, urlTransform, + desktopChatId, }) => { const [isRightPanelExpanded, setIsRightPanelExpanded] = useState(false); const [dragVisualExpanded, setDragVisualExpanded] = useState( @@ -343,6 +347,7 @@ export const AgentDetailView: FC = ({ isSidebarCollapsed={isSidebarCollapsed} onToggleSidebarCollapsed={onToggleSidebarCollapsed} chatTitle={chatTitle} + desktopChatId={desktopChatId} /> {" "} diff --git a/site/src/pages/AgentsPage/DesktopPanel.stories.tsx b/site/src/pages/AgentsPage/DesktopPanel.stories.tsx new file mode 100644 index 0000000000..e8fa5afe67 --- /dev/null +++ b/site/src/pages/AgentsPage/DesktopPanel.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DesktopPanel } from "./DesktopPanel"; +import { mockAttach, mockDesktopConnection } from "./desktopStoryUtils"; + +const meta: Meta = { + title: "pages/AgentsPage/DesktopPanel", + component: DesktopPanel, + args: { + isExpanded: false, + chatId: "test-chat-id", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Connected: Story = { + args: { + connectionOverride: mockDesktopConnection({ + status: "connected", + hasConnected: true, + attach: mockAttach(), + }), + }, +}; + +export const Connecting: Story = { + args: { + connectionOverride: mockDesktopConnection({ status: "connecting" }), + }, +}; + +export const ErrorState: Story = { + args: { + connectionOverride: mockDesktopConnection({ status: "error" }), + }, +}; + +export const Disconnected: Story = { + args: { + connectionOverride: mockDesktopConnection({ + status: "disconnected", + hasConnected: true, + }), + }, +}; diff --git a/site/src/pages/AgentsPage/DesktopPanel.tsx b/site/src/pages/AgentsPage/DesktopPanel.tsx new file mode 100644 index 0000000000..3bdfaeefe8 --- /dev/null +++ b/site/src/pages/AgentsPage/DesktopPanel.tsx @@ -0,0 +1,100 @@ +import { Button } from "components/Button/Button"; +import { Spinner } from "components/Spinner/Spinner"; +import { type FC, useCallback, useEffect, useRef } from "react"; +import { + type UseDesktopConnectionResult, + useDesktopConnection, +} from "./useDesktopConnection"; + +interface DesktopPanelProps { + chatId: string; + isExpanded: boolean; + /** Optional override for the desktop connection. Used in stories. */ + connectionOverride?: UseDesktopConnectionResult; +} + +export const DesktopPanel: FC = ({ + chatId, + isExpanded: _isExpanded, + connectionOverride, +}) => { + // When an override is provided, pass undefined chatId to prevent + // the real hook from attempting any WebSocket connections. + const hookResult = useDesktopConnection({ + chatId: connectionOverride ? undefined : chatId, + }); + const { status, connect, disconnect, attach } = + connectionOverride ?? hookResult; + const containerRef = useRef(null); + + const attachToContainer = useCallback( + (el: HTMLDivElement | null) => { + containerRef.current = el; + if (el) { + attach(el); + } + }, + [attach], + ); + + // Connect on mount, disconnect on unmount. This drives the + // visibility-based lifecycle: DesktopPanel is only rendered + // when the Desktop tab is active, so mounting/unmounting + // naturally starts and stops the WebSocket connection. + useEffect(() => { + connect(); + return () => { + disconnect(); + }; + }, [connect, disconnect]); + + // Re-attach when status changes to connected (e.g., after reconnect). + useEffect(() => { + if (status === "connected" && containerRef.current) { + attach(containerRef.current); + } + }, [status, attach]); + + if (status === "connecting") { + return ( +
+ + Connecting to desktop... +
+ ); + } + + if (status === "disconnected") { + return ( +
+ + Desktop disconnected. Reconnecting... +
+ ); + } + + if (status === "error") { + return ( +
+ + Failed to connect to the desktop session. + + +
+ ); + } + + if (status === "idle") { + return ( +
+ + Initializing desktop... +
+ ); + } + + // status === "connected" + return
; +}; diff --git a/site/src/pages/AgentsPage/SidebarTabView.stories.tsx b/site/src/pages/AgentsPage/SidebarTabView.stories.tsx index ea4ae351c4..936cc5380a 100644 --- a/site/src/pages/AgentsPage/SidebarTabView.stories.tsx +++ b/site/src/pages/AgentsPage/SidebarTabView.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { fn } from "storybook/test"; +import { mockAttach, mockDesktopConnection } from "./desktopStoryUtils"; import type { SidebarTab } from "./SidebarTabView"; import { SidebarTabView } from "./SidebarTabView"; @@ -100,3 +101,31 @@ export const NarrowPanel: Story = { ), ], }; + +export const DesktopConnecting: Story = { + args: { + tabs: [], + desktopChatId: "test-chat-id", + desktopConnectionOverride: mockDesktopConnection({ status: "connecting" }), + }, +}; + +export const DesktopConnected: Story = { + args: { + tabs: [], + desktopChatId: "test-chat-id", + desktopConnectionOverride: mockDesktopConnection({ + status: "connected", + hasConnected: true, + attach: mockAttach(), + }), + }, +}; + +export const DesktopError: Story = { + args: { + tabs: [], + desktopChatId: "test-chat-id", + desktopConnectionOverride: mockDesktopConnection({ status: "error" }), + }, +}; diff --git a/site/src/pages/AgentsPage/SidebarTabView.tsx b/site/src/pages/AgentsPage/SidebarTabView.tsx index d3303dec0f..bab931d41f 100644 --- a/site/src/pages/AgentsPage/SidebarTabView.tsx +++ b/site/src/pages/AgentsPage/SidebarTabView.tsx @@ -17,6 +17,8 @@ import { useState, } from "react"; import { cn } from "utils/cn"; +import { DesktopPanel } from "./DesktopPanel"; +import type { UseDesktopConnectionResult } from "./useDesktopConnection"; /** A single tab definition for the sidebar panel. */ export interface SidebarTab { @@ -46,6 +48,10 @@ interface SidebarTabViewProps { chatTitle?: string; /** Callback to close the panel (used on mobile). */ onClose?: () => void; + /** Desktop chat ID. Omitted if desktop is not available. */ + desktopChatId?: string; + /** Optional override for the desktop connection. Used in stories. */ + desktopConnectionOverride?: UseDesktopConnectionResult; } /** How far (px) each chevron click scrolls the tab strip. */ @@ -112,26 +118,37 @@ export const SidebarTabView: FC = ({ onToggleSidebarCollapsed, chatTitle, onClose, + desktopChatId, + desktopConnectionOverride, }) => { const tabIdPrefix = useId(); const [activeTabId, setActiveTabId] = useState( tabs.length > 0 ? tabs[0].id : null, ); + // Build the full list of tab IDs including the desktop tab + // so that effectiveTabId validation covers it. + const allTabIds = new Set(tabs.map((t) => t.id)); + if (desktopChatId) { + allTabIds.add("desktop"); + } + // Derive the effective tab. Fall back to the first tab if // the stored activeTabId no longer matches any tab in the list. const effectiveTabId = - activeTabId !== null && tabs.some((t) => t.id === activeTabId) + activeTabId !== null && allTabIds.has(activeTabId) ? activeTabId : tabs.length > 0 ? tabs[0].id - : null; + : desktopChatId + ? "desktop" + : null; const activeTab = tabs.find((t) => t.id === effectiveTabId) ?? null; const tabScroll = useTabScroll(); - if (tabs.length === 0) { + if (tabs.length === 0 && !desktopChatId) { return (
{/* Tab bar – always visible for the expand button. */} @@ -242,6 +259,22 @@ export const SidebarTabView: FC = ({ ); })} + {desktopChatId && ( + + )}
{tabScroll.canScrollRight && (
); diff --git a/site/src/pages/AgentsPage/desktopStoryUtils.ts b/site/src/pages/AgentsPage/desktopStoryUtils.ts new file mode 100644 index 0000000000..ee89cb32b5 --- /dev/null +++ b/site/src/pages/AgentsPage/desktopStoryUtils.ts @@ -0,0 +1,46 @@ +import { fn } from "storybook/test"; +import type { UseDesktopConnectionResult } from "./useDesktopConnection"; + +/** + * Creates a mock attach function that inserts a placeholder element + * simulating a noVNC canvas so the "connected" state is visible in + * stories. + */ +export function mockAttach(): (container: HTMLElement) => void { + const placeholder = document.createElement("div"); + Object.assign(placeholder.style, { + width: "100%", + height: "100%", + background: + "linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)", + display: "flex", + alignItems: "center", + justifyContent: "center", + color: "#888", + fontSize: "14px", + fontFamily: "monospace", + }); + placeholder.textContent = "VNC canvas placeholder"; + + const attachFn = fn((container: HTMLElement) => { + if (placeholder.parentElement !== container) { + container.appendChild(placeholder); + } + }); + + return attachFn; +} + +export function mockDesktopConnection( + overrides: Partial = {}, +): UseDesktopConnectionResult { + return { + status: "idle", + hasConnected: false, + connect: fn(), + disconnect: fn(), + attach: fn(), + rfb: null, + ...overrides, + }; +} diff --git a/site/src/pages/AgentsPage/useDesktopConnection.test.ts b/site/src/pages/AgentsPage/useDesktopConnection.test.ts new file mode 100644 index 0000000000..03849352be --- /dev/null +++ b/site/src/pages/AgentsPage/useDesktopConnection.test.ts @@ -0,0 +1,584 @@ +import { renderHook } from "@testing-library/react"; +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useDesktopConnection } from "./useDesktopConnection"; + +vi.mock("api/api", () => ({ + watchChatDesktop: vi.fn(), +})); + +// ---- Mock RFB -------------------------------------------------------------- +// vi.mock is hoisted, so the factory cannot reference module-scoped variables. +// We use vi.hoisted() to define the mock class in the hoisted scope, then +// reference it from the factory and from the tests. + +interface MockRFBInstance { + scaleViewport: boolean; + resizeSession: boolean; + disconnect: ReturnType; + addEventListener: ReturnType; + listeners: Map void>; + simulateEvent: (type: string, detail?: unknown) => void; +} + +const { FakeRFB, lastInstance } = vi.hoisted(() => { + const ref: { current: MockRFBInstance | null } = { current: null }; + + class FakeRFB { + scaleViewport = false; + resizeSession = true; + disconnect = vi.fn(); + addEventListener = vi.fn((type: string, handler: (ev: unknown) => void) => { + (this as unknown as MockRFBInstance).listeners.set(type, handler); + }); + listeners = new Map void>(); + + simulateEvent(type: string, detail?: unknown) { + const handler = this.listeners.get(type); + if (handler) { + handler( + detail ? new CustomEvent(type, { detail }) : new CustomEvent(type), + ); + } + } + + constructor() { + ref.current = this as unknown as MockRFBInstance; + } + } + + return { FakeRFB, lastInstance: ref }; +}); + +vi.mock("@novnc/novnc/lib/rfb", () => ({ + default: FakeRFB, +})); + +import { watchChatDesktop } from "api/api"; + +const mockWatchChatDesktop = vi.mocked(watchChatDesktop); + +// ---- helpers --------------------------------------------------------------- + +function getLastRFBInstance(): MockRFBInstance { + if (!lastInstance.current) { + throw new Error("No RFB instance was constructed"); + } + return lastInstance.current; +} + +function createMockSocket(): WebSocket { + return { binaryType: "arraybuffer" } as unknown as WebSocket; +} + +// ---- tests ----------------------------------------------------------------- + +describe("useDesktopConnection", () => { + beforeEach(() => { + mockWatchChatDesktop.mockReset(); + mockWatchChatDesktop.mockReturnValue(createMockSocket()); + lastInstance.current = null; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("starts in idle status and does not connect automatically", () => { + const { result } = renderHook(() => + useDesktopConnection({ chatId: "chat-1" }), + ); + + expect(result.current.status).toBe("idle"); + expect(result.current.hasConnected).toBe(false); + expect(result.current.rfb).toBeNull(); + expect(mockWatchChatDesktop).not.toHaveBeenCalled(); + }); + + it("does nothing when chatId is undefined and connect() is called", () => { + const { result } = renderHook(() => + useDesktopConnection({ chatId: undefined }), + ); + + act(() => result.current.connect()); + + expect(result.current.status).toBe("idle"); + expect(mockWatchChatDesktop).not.toHaveBeenCalled(); + }); + + it("transitions to connecting then connected on connect()", () => { + const { result } = renderHook(() => + useDesktopConnection({ chatId: "chat-1" }), + ); + + act(() => result.current.connect()); + const rfb = getLastRFBInstance(); + + expect(mockWatchChatDesktop).toHaveBeenCalledWith("chat-1"); + expect(result.current.status).toBe("connecting"); + expect(result.current.hasConnected).toBe(false); + + act(() => rfb.simulateEvent("connect")); + + expect(result.current.status).toBe("connected"); + expect(result.current.hasConnected).toBe(true); + }); + + it("sets scaleViewport and resizeSession on the RFB instance", () => { + const { result } = renderHook(() => + useDesktopConnection({ chatId: "chat-1" }), + ); + + act(() => result.current.connect()); + const rfb = getLastRFBInstance(); + + expect(rfb.scaleViewport).toBe(true); + expect(rfb.resizeSession).toBe(false); + }); + + it("connect() is a no-op when already connecting", () => { + const { result } = renderHook(() => + useDesktopConnection({ chatId: "chat-1" }), + ); + + act(() => result.current.connect()); + expect(result.current.status).toBe("connecting"); + + mockWatchChatDesktop.mockClear(); + + act(() => result.current.connect()); + expect(mockWatchChatDesktop).not.toHaveBeenCalled(); + }); + + it("connect() is a no-op when already connected", () => { + const { result } = renderHook(() => + useDesktopConnection({ chatId: "chat-1" }), + ); + + act(() => result.current.connect()); + const rfb = getLastRFBInstance(); + act(() => rfb.simulateEvent("connect")); + expect(result.current.status).toBe("connected"); + + mockWatchChatDesktop.mockClear(); + + act(() => result.current.connect()); + expect(mockWatchChatDesktop).not.toHaveBeenCalled(); + }); + + it("transitions to error on securityfailure", () => { + const { result } = renderHook(() => + useDesktopConnection({ chatId: "chat-1" }), + ); + + act(() => result.current.connect()); + const rfb = getLastRFBInstance(); + act(() => + rfb.simulateEvent("securityfailure", { + status: 1, + reason: "auth failed", + }), + ); + + expect(result.current.status).toBe("error"); + }); + + it("reconnects with exponential backoff on disconnect", () => { + vi.useFakeTimers(); + + try { + const { result } = renderHook(() => + useDesktopConnection({ chatId: "chat-1" }), + ); + + act(() => result.current.connect()); + const rfb1 = getLastRFBInstance(); + act(() => rfb1.simulateEvent("connect")); + expect(result.current.status).toBe("connected"); + + // Disconnect — attempt 0 → 1000ms delay. + act(() => rfb1.simulateEvent("disconnect", { clean: false })); + expect(result.current.status).toBe("disconnected"); + + mockWatchChatDesktop.mockClear(); + act(() => vi.advanceTimersByTime(999)); + expect(mockWatchChatDesktop).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(1)); + expect(mockWatchChatDesktop).toHaveBeenCalledTimes(1); + const rfb2 = getLastRFBInstance(); + + // Reconnect attempt fails (no "connect" event) but desktop + // was previously reachable, so it retries. + // attempt 1 → 2000ms delay. + act(() => rfb2.simulateEvent("disconnect", { clean: false })); + expect(result.current.status).toBe("disconnected"); + + mockWatchChatDesktop.mockClear(); + act(() => vi.advanceTimersByTime(1999)); + expect(mockWatchChatDesktop).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(1)); + expect(mockWatchChatDesktop).toHaveBeenCalledTimes(1); + const rfb3 = getLastRFBInstance(); + + // attempt 2 → 4000ms delay. + act(() => rfb3.simulateEvent("disconnect", { clean: false })); + + mockWatchChatDesktop.mockClear(); + act(() => vi.advanceTimersByTime(3999)); + expect(mockWatchChatDesktop).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(1)); + expect(mockWatchChatDesktop).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it("resets backoff counter after a successful reconnect", () => { + vi.useFakeTimers(); + + try { + const { result } = renderHook(() => + useDesktopConnection({ chatId: "chat-1" }), + ); + + act(() => result.current.connect()); + const rfb1 = getLastRFBInstance(); + act(() => rfb1.simulateEvent("connect")); + + // First disconnect — 1000ms backoff. + act(() => rfb1.simulateEvent("disconnect", { clean: false })); + act(() => vi.advanceTimersByTime(1000)); + const rfb2 = getLastRFBInstance(); + + // Reconnect succeeds — counter should reset. + act(() => rfb2.simulateEvent("connect")); + expect(result.current.status).toBe("connected"); + + // Next disconnect should use 1000ms again (not 2000ms). + act(() => rfb2.simulateEvent("disconnect", { clean: false })); + + mockWatchChatDesktop.mockClear(); + act(() => vi.advanceTimersByTime(999)); + expect(mockWatchChatDesktop).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(1)); + expect(mockWatchChatDesktop).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it("caps backoff at 30 seconds", () => { + vi.useFakeTimers(); + + try { + const { result } = renderHook(() => + useDesktopConnection({ chatId: "chat-1" }), + ); + + act(() => result.current.connect()); + let rfb = getLastRFBInstance(); + act(() => rfb.simulateEvent("connect")); + + // Burn through attempts: 1s, 2s, 4s, 8s, 16s. + // Reconnect attempts fail (no "connect" event) but the + // desktop was previously reachable so backoff accumulates. + const delays = [1000, 2000, 4000, 8000, 16000]; + for (const delay of delays) { + act(() => rfb.simulateEvent("disconnect", { clean: false })); + act(() => vi.advanceTimersByTime(delay)); + rfb = getLastRFBInstance(); + } + + // Attempt 5 — should be capped at 30_000, not 32_000. + act(() => rfb.simulateEvent("disconnect", { clean: false })); + + mockWatchChatDesktop.mockClear(); + act(() => vi.advanceTimersByTime(29_999)); + expect(mockWatchChatDesktop).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(1)); + expect(mockWatchChatDesktop).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it("disconnect() cleans up and resets to idle", () => { + const { result } = renderHook(() => + useDesktopConnection({ chatId: "chat-1" }), + ); + + act(() => result.current.connect()); + const rfb = getLastRFBInstance(); + act(() => rfb.simulateEvent("connect")); + expect(result.current.status).toBe("connected"); + + act(() => result.current.disconnect()); + + expect(rfb.disconnect).toHaveBeenCalled(); + expect(result.current.status).toBe("idle"); + }); + + it("disconnect() cancels pending reconnect timers", () => { + vi.useFakeTimers(); + + try { + const { result } = renderHook(() => + useDesktopConnection({ chatId: "chat-1" }), + ); + + act(() => result.current.connect()); + const rfb = getLastRFBInstance(); + act(() => rfb.simulateEvent("connect")); + + // Trigger reconnect timer. + act(() => rfb.simulateEvent("disconnect", { clean: false })); + expect(result.current.status).toBe("disconnected"); + + // Manually disconnect before timer fires. + act(() => result.current.disconnect()); + expect(result.current.status).toBe("idle"); + + // Timer should be cancelled — no reconnect. + mockWatchChatDesktop.mockClear(); + act(() => vi.advanceTimersByTime(60_000)); + expect(mockWatchChatDesktop).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("cleans up on unmount and does not reconnect", () => { + vi.useFakeTimers(); + + try { + const { result, unmount } = renderHook(() => + useDesktopConnection({ chatId: "chat-1" }), + ); + + act(() => result.current.connect()); + const rfb = getLastRFBInstance(); + act(() => rfb.simulateEvent("connect")); + + unmount(); + + expect(rfb.disconnect).toHaveBeenCalled(); + + mockWatchChatDesktop.mockClear(); + act(() => vi.advanceTimersByTime(60_000)); + expect(mockWatchChatDesktop).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("resets state when chatId changes", () => { + const { result, rerender } = renderHook( + ({ chatId }: { chatId: string | undefined }) => + useDesktopConnection({ chatId }), + { initialProps: { chatId: "chat-aaa" as string | undefined } }, + ); + + act(() => result.current.connect()); + const rfb1 = getLastRFBInstance(); + act(() => rfb1.simulateEvent("connect")); + expect(result.current.status).toBe("connected"); + expect(result.current.hasConnected).toBe(true); + + rerender({ chatId: "chat-bbb" }); + + expect(rfb1.disconnect).toHaveBeenCalled(); + expect(result.current.status).toBe("idle"); + expect(result.current.hasConnected).toBe(false); + }); + + it("attach() appends the offscreen container to the target", () => { + const { result } = renderHook(() => + useDesktopConnection({ chatId: "chat-1" }), + ); + + act(() => result.current.connect()); + const rfb = getLastRFBInstance(); + act(() => rfb.simulateEvent("connect")); + + const container = document.createElement("div"); + act(() => result.current.attach(container)); + + expect(container.children.length).toBe(1); + expect(container.children[0]).toBeInstanceOf(HTMLDivElement); + }); + + it("attach() moves the canvas between containers without reconnecting", () => { + const { result } = renderHook(() => + useDesktopConnection({ chatId: "chat-1" }), + ); + + act(() => result.current.connect()); + const rfb = getLastRFBInstance(); + act(() => rfb.simulateEvent("connect")); + + const container1 = document.createElement("div"); + const container2 = document.createElement("div"); + + act(() => result.current.attach(container1)); + expect(container1.children.length).toBe(1); + const screen = container1.children[0]; + + act(() => result.current.attach(container2)); + expect(container2.children.length).toBe(1); + expect(container2.children[0]).toBe(screen); + expect(container1.children.length).toBe(0); + + // WebSocket was only opened once. + expect(mockWatchChatDesktop).toHaveBeenCalledTimes(1); + }); + + it("does not retry when disconnect fires before connect (desktop unavailable)", () => { + vi.useFakeTimers(); + + try { + const { result } = renderHook(() => + useDesktopConnection({ chatId: "chat-1" }), + ); + + act(() => result.current.connect()); + const rfb = getLastRFBInstance(); + + // Disconnect fires before connect — e.g. agent returned 424 + // because portabledesktop is not installed. + act(() => rfb.simulateEvent("disconnect", { clean: false })); + + expect(result.current.status).toBe("error"); + + // No reconnect timer should fire. + mockWatchChatDesktop.mockClear(); + act(() => vi.advanceTimersByTime(60_000)); + expect(mockWatchChatDesktop).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("retries when reconnect attempt fails but desktop was previously reachable", () => { + vi.useFakeTimers(); + + try { + const { result } = renderHook(() => + useDesktopConnection({ chatId: "chat-1" }), + ); + + // Establish a successful connection first. + act(() => result.current.connect()); + const rfb1 = getLastRFBInstance(); + act(() => rfb1.simulateEvent("connect")); + expect(result.current.status).toBe("connected"); + + // Drop the connection — triggers a reconnect timer. + act(() => rfb1.simulateEvent("disconnect", { clean: false })); + expect(result.current.status).toBe("disconnected"); + + // Timer fires and a new RFB is created. + act(() => vi.advanceTimersByTime(1000)); + const rfb2 = getLastRFBInstance(); + + // Reconnect attempt fails (disconnect before connect), but + // desktop was previously reachable — should keep retrying. + act(() => rfb2.simulateEvent("disconnect", { clean: false })); + expect(result.current.status).toBe("disconnected"); + + // Another retry should be scheduled. + mockWatchChatDesktop.mockClear(); + act(() => vi.advanceTimersByTime(2000)); + expect(mockWatchChatDesktop).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it("attach() is a no-op when the screen is already in the container", () => { + const { result } = renderHook(() => + useDesktopConnection({ chatId: "chat-1" }), + ); + + act(() => result.current.connect()); + const rfb = getLastRFBInstance(); + act(() => rfb.simulateEvent("connect")); + + const container = document.createElement("div"); + act(() => result.current.attach(container)); + expect(container.children.length).toBe(1); + + act(() => result.current.attach(container)); + expect(container.children.length).toBe(1); + }); + + it("gives up after 10 consecutive reconnect failures", () => { + vi.useFakeTimers(); + + try { + const { result } = renderHook(() => + useDesktopConnection({ chatId: "chat-1" }), + ); + + act(() => result.current.connect()); + let rfb = getLastRFBInstance(); + act(() => rfb.simulateEvent("connect")); + expect(result.current.status).toBe("connected"); + + // Burn through 10 reconnect attempts that all fail before + // the handshake completes. + for (let i = 0; i < 10; i++) { + act(() => rfb.simulateEvent("disconnect", { clean: false })); + const delay = Math.min(1000 * 2 ** i, 30_000); + act(() => vi.advanceTimersByTime(delay)); + rfb = getLastRFBInstance(); + } + + // The 11th disconnect should give up. + act(() => rfb.simulateEvent("disconnect", { clean: false })); + expect(result.current.status).toBe("error"); + + // No more retries. + mockWatchChatDesktop.mockClear(); + act(() => vi.advanceTimersByTime(60_000)); + expect(mockWatchChatDesktop).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("resets attempt counter on successful connect so cap does not carry over", () => { + vi.useFakeTimers(); + + try { + const { result } = renderHook(() => + useDesktopConnection({ chatId: "chat-1" }), + ); + + act(() => result.current.connect()); + let rfb = getLastRFBInstance(); + act(() => rfb.simulateEvent("connect")); + + // 9 failed reconnects (just under the cap). + for (let i = 0; i < 9; i++) { + act(() => rfb.simulateEvent("disconnect", { clean: false })); + const delay = Math.min(1000 * 2 ** i, 30_000); + act(() => vi.advanceTimersByTime(delay)); + rfb = getLastRFBInstance(); + } + + // This reconnect succeeds — counter resets. + act(() => rfb.simulateEvent("connect")); + expect(result.current.status).toBe("connected"); + + // Another drop + failed reconnect should NOT hit the cap + // because the counter was reset. + act(() => rfb.simulateEvent("disconnect", { clean: false })); + act(() => vi.advanceTimersByTime(1000)); + rfb = getLastRFBInstance(); + act(() => rfb.simulateEvent("disconnect", { clean: false })); + expect(result.current.status).toBe("disconnected"); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/site/src/pages/AgentsPage/useDesktopConnection.ts b/site/src/pages/AgentsPage/useDesktopConnection.ts new file mode 100644 index 0000000000..34547b6660 --- /dev/null +++ b/site/src/pages/AgentsPage/useDesktopConnection.ts @@ -0,0 +1,208 @@ +import RFB from "@novnc/novnc/lib/rfb"; +import { watchChatDesktop } from "api/api"; +import { useCallback, useEffect, useRef, useState } from "react"; + +interface UseDesktopConnectionOptions { + chatId: string | undefined; +} + +type DesktopConnectionStatus = + | "idle" + | "connecting" + | "connected" + | "disconnected" + | "error"; + +export interface UseDesktopConnectionResult { + /** Current connection status. */ + status: DesktopConnectionStatus; + /** Whether the connection has ever been established. */ + hasConnected: boolean; + /** + * Start the connection. No-op if already connected/connecting. + * Called when the user first opens the Desktop tab. + */ + connect: () => void; + /** + * Disconnect and clean up. Called on unmount. + */ + disconnect: () => void; + /** + * Attach the noVNC canvas to a container element. Can be called + * multiple times (e.g., when the tab is re-selected). The RFB + * instance moves its existing canvas into the new container + * without reconnecting. + */ + attach: (container: HTMLElement) => void; + /** The underlying RFB instance, if connected. */ + rfb: RFB | null; +} + +const MAX_BACKOFF_MS = 30_000; +const MAX_RECONNECT_ATTEMPTS = 10; + +export function useDesktopConnection({ + chatId, +}: UseDesktopConnectionOptions): UseDesktopConnectionResult { + const [status, setStatus] = useState("idle"); + const [hasConnected, setHasConnected] = useState(false); + + const rfbRef = useRef(null); + const offscreenContainerRef = useRef(null); + const reconnectAttemptRef = useRef(0); + const reconnectTimerRef = useRef | null>(null); + const disposedRef = useRef(false); + // Track whether connect() has been called at least once. + const connectRequestedRef = useRef(false); + // Ref mirror of hasConnected so disconnect handlers can read + // the latest value without stale closures. + const hasConnectedRef = useRef(false); + + const cleanupRfb = useCallback(() => { + if (rfbRef.current) { + try { + rfbRef.current.disconnect(); + } catch { + // Ignore errors during disconnect. + } + rfbRef.current = null; + } + }, []); + + const doConnect = useCallback(() => { + if (!chatId || disposedRef.current) { + return; + } + + cleanupRfb(); + setStatus("connecting"); + + // Temporary offscreen container for the RFB canvas; moved into + // the visible panel by `attach()`. + offscreenContainerRef.current = document.createElement("div"); + offscreenContainerRef.current.style.width = "100%"; + offscreenContainerRef.current.style.height = "100%"; + + const socket = watchChatDesktop(chatId); + + try { + const rfb = new RFB(offscreenContainerRef.current, socket, { + shared: true, + }); + + rfb.scaleViewport = true; + rfb.resizeSession = false; + + // Track whether this particular RFB instance completed the + // VNC handshake. + let sessionConnected = false; + + rfb.addEventListener("connect", () => { + if (disposedRef.current) return; + sessionConnected = true; + setStatus("connected"); + setHasConnected(true); + hasConnectedRef.current = true; + reconnectAttemptRef.current = 0; + }); + + rfb.addEventListener("disconnect", () => { + if (disposedRef.current) return; + rfbRef.current = null; + + if (!sessionConnected && !hasConnectedRef.current) { + // The VNC handshake never completed and the desktop + // has never been reachable. The endpoint is not + // available (e.g. portabledesktop not installed, + // no workspace, agent down). Don't retry. + setStatus("error"); + return; + } + + const attempt = reconnectAttemptRef.current; + + if (attempt >= MAX_RECONNECT_ATTEMPTS) { + // Too many consecutive failures. Give up. + setStatus("error"); + return; + } + + setStatus("disconnected"); + + // Either this session was connected and dropped, or a + // previous session was connected (transient reconnect + // failure). Retry with exponential backoff. + const delay = Math.min(1000 * 2 ** attempt, MAX_BACKOFF_MS); + reconnectAttemptRef.current = attempt + 1; + reconnectTimerRef.current = setTimeout(doConnect, delay); + }); + + rfb.addEventListener("securityfailure", () => { + if (disposedRef.current) return; + rfbRef.current = null; + setStatus("error"); + }); + + rfbRef.current = rfb; + } catch { + setStatus("error"); + } + }, [chatId, cleanupRfb]); + + const connect = useCallback(() => { + if (connectRequestedRef.current) { + return; + } + connectRequestedRef.current = true; + doConnect(); + }, [doConnect]); + + const disconnect = useCallback(() => { + if (reconnectTimerRef.current !== null) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + cleanupRfb(); + offscreenContainerRef.current = null; + setStatus("idle"); + connectRequestedRef.current = false; + reconnectAttemptRef.current = 0; + }, [cleanupRfb]); + + const attach = useCallback((container: HTMLElement) => { + const screen = offscreenContainerRef.current; + if (screen && screen.parentElement !== container) { + container.appendChild(screen); + } + }, []); + + // Cleanup on unmount or chatId change. + // biome-ignore lint/correctness/useExhaustiveDependencies: chatId is an intentional trigger to reset state for a new conversation + useEffect(() => { + disposedRef.current = false; + + return () => { + disposedRef.current = true; + if (reconnectTimerRef.current !== null) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + cleanupRfb(); + offscreenContainerRef.current = null; + setStatus("idle"); + setHasConnected(false); + hasConnectedRef.current = false; + connectRequestedRef.current = false; + reconnectAttemptRef.current = 0; + }; + }, [chatId, cleanupRfb]); + + return { + status, + hasConnected, + connect, + disconnect, + attach, + rfb: rfbRef.current, + }; +} diff --git a/site/tsconfig.json b/site/tsconfig.json index 65d615f5b0..69807f94b8 100644 --- a/site/tsconfig.json +++ b/site/tsconfig.json @@ -16,7 +16,14 @@ "skipLibCheck": true, "strict": true, "target": "es2020", - "types": ["node", "react", "react-dom", "vite/client", "vitest/globals"], + "types": [ + "node", + "react", + "react-dom", + "vite/client", + "vitest/globals", + "novnc__novnc" + ], "baseUrl": "src/" }, "include": ["**/*.ts", "**/*.tsx"],