Hugo Dutka
2026-03-13 19:01:50 +00:00
committed by GitHub
parent eacabd8390
commit 85509733f3
13 changed files with 1103 additions and 5 deletions
+2
View File
@@ -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",
+16
View File
@@ -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': {}
+7
View File
@@ -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<TypesGen.WorkspaceAgentListContainersResponse> => {
@@ -1155,6 +1155,7 @@ const AgentDetail: FC = () => {
}
urlTransform={urlTransform}
scrollContainerRef={scrollContainerRef}
desktopChatId={agentId}
/>
);
};
@@ -121,6 +121,9 @@ interface AgentDetailViewProps {
scrollContainerRef: RefObject<HTMLDivElement | null>;
urlTransform?: UrlTransform;
// Desktop chat ID (optional).
desktopChatId?: string;
}
export const AgentDetailView: FC<AgentDetailViewProps> = ({
@@ -168,6 +171,7 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
handleArchiveAndDeleteWorkspaceAction,
scrollContainerRef,
urlTransform,
desktopChatId,
}) => {
const [isRightPanelExpanded, setIsRightPanelExpanded] = useState(false);
const [dragVisualExpanded, setDragVisualExpanded] = useState<boolean | null>(
@@ -343,6 +347,7 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
chatTitle={chatTitle}
desktopChatId={desktopChatId}
/>
</RightPanel>{" "}
</div>
@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DesktopPanel } from "./DesktopPanel";
import { mockAttach, mockDesktopConnection } from "./desktopStoryUtils";
const meta: Meta<typeof DesktopPanel> = {
title: "pages/AgentsPage/DesktopPanel",
component: DesktopPanel,
args: {
isExpanded: false,
chatId: "test-chat-id",
},
decorators: [
(Story) => (
<div style={{ height: 400, width: 480, border: "1px solid #333" }}>
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof DesktopPanel>;
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,
}),
},
};
+100
View File
@@ -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<DesktopPanelProps> = ({
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<HTMLDivElement | null>(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 (
<div className="flex h-full flex-col items-center justify-center gap-2 text-content-secondary">
<Spinner loading className="h-6 w-6" />
<span className="text-sm">Connecting to desktop...</span>
</div>
);
}
if (status === "disconnected") {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-content-secondary">
<Spinner loading className="h-6 w-6" />
<span className="text-sm">Desktop disconnected. Reconnecting...</span>
</div>
);
}
if (status === "error") {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-content-secondary">
<span className="text-sm">
Failed to connect to the desktop session.
</span>
<Button variant="outline" size="sm" onClick={() => connect()}>
Reconnect
</Button>
</div>
);
}
if (status === "idle") {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-content-secondary">
<Spinner loading className="h-6 w-6" />
<span className="text-sm">Initializing desktop...</span>
</div>
);
}
// status === "connected"
return <div ref={attachToContainer} className="h-full w-full" />;
};
@@ -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" }),
},
};
+45 -4
View File
@@ -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<SidebarTabViewProps> = ({
onToggleSidebarCollapsed,
chatTitle,
onClose,
desktopChatId,
desktopConnectionOverride,
}) => {
const tabIdPrefix = useId();
const [activeTabId, setActiveTabId] = useState<string | null>(
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 (
<div className="flex h-full min-w-0 flex-col overflow-hidden bg-surface-primary">
{/* Tab bar – always visible for the expand button. */}
@@ -242,6 +259,22 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
</Button>
);
})}
{desktopChatId && (
<Button
id={`${tabIdPrefix}-tab-desktop`}
role="tab"
aria-selected={effectiveTabId === "desktop"}
onClick={() => setActiveTabId("desktop")}
variant="outline"
size="lg"
className={cn(
"shrink-0 h-6 px-3 gap-3 py-0 bg-surface-primary",
effectiveTabId === "desktop" && "bg-surface-tertiary",
)}
>
Desktop
</Button>
)}
</div>
{tabScroll.canScrollRight && (
<button
@@ -292,7 +325,15 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
}
className="min-h-0 flex-1"
>
{activeTab?.content}
{effectiveTabId === "desktop" && desktopChatId ? (
<DesktopPanel
chatId={desktopChatId}
isExpanded={isExpanded}
connectionOverride={desktopConnectionOverride}
/>
) : (
activeTab?.content
)}
</div>
</div>
);
@@ -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> = {},
): UseDesktopConnectionResult {
return {
status: "idle",
hasConnected: false,
connect: fn(),
disconnect: fn(),
attach: fn(),
rfb: null,
...overrides,
};
}
@@ -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<typeof vi.fn>;
addEventListener: ReturnType<typeof vi.fn>;
listeners: Map<string, (ev: unknown) => 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<string, (ev: unknown) => 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();
}
});
});
@@ -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<DesktopConnectionStatus>("idle");
const [hasConnected, setHasConnected] = useState(false);
const rfbRef = useRef<RFB | null>(null);
const offscreenContainerRef = useRef<HTMLElement | null>(null);
const reconnectAttemptRef = useRef(0);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | 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,
};
}
+8 -1
View File
@@ -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"],