mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: chat desktop frontend (#23006)
https://github.com/user-attachments/assets/26f9c210-01ad-4685-aff1-7629cf3854f1
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+16
@@ -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': {}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -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" }),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user