mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat(site): desktop panel toolbar, zoom modes, and pop-out window (#25585)
Redesigns the agent desktop panel with a persistent toolbar, zoom modes,
and a detachable pop-out window.
## Changes
**Toolbar** (`DesktopToolbar`)
- Persistent top bar with right-aligned controls: Take/Release control,
Zoom toggle, Detach
- All buttons use consistent `subtle` variant with icon + label
- `h-8` height, `bg-surface-primary` background with bottom border
**Zoom modes**
- Defaults to fit-to-window (`scaleViewport = true`) so the full
1920x1080 desktop is visible
- Toggle to native 100% resolution via toolbar button or keyboard
shortcuts (`Ctrl+0` fit, `Ctrl+1` native)
- noVNC background color overridden from hardcoded `rgb(40,40,40)` to
`--surface-secondary` so letterbox margins match the app theme in light
and dark mode
**Pop-out window**
- New route at `/agents/:agentId/desktop` for a dedicated desktop window
- Opens via toolbar "Detach" button at 50% of screen size, centered
- BroadcastChannel coordination: sidebar shows placeholder with "Bring
back" button
- Closing the pop-out window automatically restores the sidebar panel
**Other**
- `useDesktopConnection` hook accepts `scaleViewport` option, synced to
the RFB instance via a secondary effect
- `DesktopPanelContext` extended with `agent` and `workspace` fields
- Replaces the previous hover-overlay take/release control UX with
toolbar buttons
> Generated by Coder Agents on behalf of @tracyjohnsonux
(cherry picked from commit ebf56ebd12)
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, within } from "storybook/test";
|
||||
import { DesktopPopoutPageView } from "./DesktopPopoutPage";
|
||||
|
||||
const meta = {
|
||||
title: "pages/AgentsPage/DesktopPopoutPage",
|
||||
component: DesktopPopoutPageView,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
} satisfies Meta<typeof DesktopPopoutPageView>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Connecting: Story = {
|
||||
args: {
|
||||
status: "connecting",
|
||||
reconnect: fn(),
|
||||
attach: fn(),
|
||||
scaleMode: "fit",
|
||||
onScaleModeChange: fn(),
|
||||
isControlling: false,
|
||||
onTakeControl: fn(),
|
||||
onReleaseControl: fn(),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.getByText("Connecting to desktop..."),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const Connected: Story = {
|
||||
args: {
|
||||
...Connecting.args,
|
||||
status: "connected",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("Take control")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("Zoom to 100%")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorState: Story = {
|
||||
args: {
|
||||
...Connecting.args,
|
||||
status: "error",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("Reconnect")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const Disconnected: Story = {
|
||||
args: {
|
||||
...Connecting.args,
|
||||
status: "disconnected",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.getByText("Desktop disconnected. Reconnecting..."),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import {
|
||||
DesktopToolbar,
|
||||
type ScaleMode,
|
||||
} from "./components/RightPanel/DesktopToolbar";
|
||||
import {
|
||||
type DesktopConnectionStatus,
|
||||
useDesktopConnection,
|
||||
} from "./hooks/useDesktopConnection";
|
||||
import { useZoomShortcuts } from "./hooks/useZoomShortcuts";
|
||||
|
||||
export default function DesktopPopoutPage() {
|
||||
const { agentId } = useParams() as { agentId: string };
|
||||
const [scaleMode, setScaleMode] = useState<ScaleMode>("fit");
|
||||
const [isControlling, setIsControlling] = useState(false);
|
||||
|
||||
const { status, reconnect, attach } = useDesktopConnection({
|
||||
chatId: agentId,
|
||||
activated: true,
|
||||
scaleViewport: scaleMode === "fit",
|
||||
});
|
||||
|
||||
// BroadcastChannel for parent window communication.
|
||||
useEffect(() => {
|
||||
const channel = new BroadcastChannel(`coder-desktop-${agentId}`);
|
||||
|
||||
channel.postMessage({ type: "popout-opened" });
|
||||
|
||||
// Retry in case the parent's listener registered after this message.
|
||||
const retryTimer = setTimeout(() => {
|
||||
channel.postMessage({ type: "popout-opened" });
|
||||
}, 300);
|
||||
|
||||
channel.addEventListener("message", (event) => {
|
||||
if (event.data?.type === "bring-back") {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
const handleBeforeUnload = () => {
|
||||
channel.postMessage({ type: "popout-closed" });
|
||||
};
|
||||
addEventListener("beforeunload", handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
clearTimeout(retryTimer);
|
||||
handleBeforeUnload();
|
||||
removeEventListener("beforeunload", handleBeforeUnload);
|
||||
channel.close();
|
||||
};
|
||||
}, [agentId]);
|
||||
|
||||
useZoomShortcuts(setScaleMode);
|
||||
|
||||
return (
|
||||
<DesktopPopoutPageView
|
||||
status={status}
|
||||
reconnect={reconnect}
|
||||
attach={attach}
|
||||
scaleMode={scaleMode}
|
||||
onScaleModeChange={setScaleMode}
|
||||
isControlling={isControlling}
|
||||
onTakeControl={() => setIsControlling(true)}
|
||||
onReleaseControl={() => setIsControlling(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DesktopPopoutPageViewProps {
|
||||
status: DesktopConnectionStatus;
|
||||
reconnect: () => void;
|
||||
attach: (container: HTMLElement) => void;
|
||||
scaleMode: ScaleMode;
|
||||
onScaleModeChange: (mode: ScaleMode) => void;
|
||||
isControlling: boolean;
|
||||
onTakeControl: () => void;
|
||||
onReleaseControl: () => void;
|
||||
}
|
||||
|
||||
export const DesktopPopoutPageView: FC<DesktopPopoutPageViewProps> = ({
|
||||
status,
|
||||
reconnect,
|
||||
attach,
|
||||
scaleMode,
|
||||
onScaleModeChange,
|
||||
isControlling,
|
||||
onTakeControl,
|
||||
onReleaseControl,
|
||||
}) => {
|
||||
if (status === "idle" || status === "connecting") {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-surface-primary">
|
||||
<div className="flex flex-col items-center gap-2 text-content-secondary">
|
||||
<Spinner loading className="h-6 w-6" />
|
||||
<span className="text-sm">
|
||||
{status === "idle"
|
||||
? "Initializing desktop..."
|
||||
: "Connecting to desktop..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-surface-primary">
|
||||
<div className="flex flex-col items-center gap-3 text-content-secondary">
|
||||
<span className="text-center text-sm">
|
||||
Failed to connect to the desktop session. The agent may not be
|
||||
connected or the desktop environment may not be available.
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={reconnect}>
|
||||
Reconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "disconnected") {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-surface-primary">
|
||||
<div className="flex flex-col items-center gap-2 text-content-secondary">
|
||||
<Spinner loading className="h-6 w-6" />
|
||||
<span className="text-sm">Desktop disconnected. Reconnecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col overflow-hidden bg-surface-secondary">
|
||||
<DesktopToolbar
|
||||
scaleMode={scaleMode}
|
||||
onScaleModeChange={onScaleModeChange}
|
||||
isControlling={isControlling}
|
||||
onTakeControl={onTakeControl}
|
||||
onReleaseControl={onReleaseControl}
|
||||
isPoppedOut
|
||||
/>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) attach(el);
|
||||
}}
|
||||
className="min-h-0 flex-1 overflow-hidden bg-surface-secondary"
|
||||
inert={!isControlling ? true : undefined}
|
||||
role="application"
|
||||
aria-label={
|
||||
isControlling
|
||||
? "Remote desktop (interactive)"
|
||||
: "Remote desktop (view only, take control to interact)"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,9 +7,12 @@ const defaults: DesktopPanelViewProps = {
|
||||
status: "idle",
|
||||
reconnect: fn(),
|
||||
attach: fn(),
|
||||
scaleMode: "native",
|
||||
onScaleModeChange: fn(),
|
||||
isControlling: false,
|
||||
onTakeControl: fn(),
|
||||
onReleaseControl: fn(),
|
||||
onPopOut: fn(),
|
||||
};
|
||||
|
||||
const meta: Meta<typeof DesktopPanelView> = {
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { HandIcon, MousePointer2Icon } from "lucide-react";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { useDesktopConnection } from "../../hooks/useDesktopConnection";
|
||||
|
||||
type DesktopConnectionStatus =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "disconnected"
|
||||
| "error";
|
||||
import {
|
||||
type DesktopConnectionStatus,
|
||||
useDesktopConnection,
|
||||
} from "../../hooks/useDesktopConnection";
|
||||
import { useZoomShortcuts } from "../../hooks/useZoomShortcuts";
|
||||
import { DesktopToolbar, type ScaleMode } from "./DesktopToolbar";
|
||||
|
||||
interface DesktopPanelProps {
|
||||
chatId: string;
|
||||
@@ -19,19 +17,10 @@ interface DesktopPanelProps {
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
export interface DesktopPanelViewProps {
|
||||
status: DesktopConnectionStatus;
|
||||
reconnect: () => void;
|
||||
attach: (container: HTMLElement) => void;
|
||||
isControlling: boolean;
|
||||
onTakeControl: () => void;
|
||||
onReleaseControl: () => void;
|
||||
}
|
||||
|
||||
export const DesktopPanel: FC<DesktopPanelProps> = ({ chatId, isVisible }) => {
|
||||
// Delay the VNC connection until the desktop tab is first selected.
|
||||
// Once activated, the connection stays alive even when the tab is
|
||||
// switched away — mirrors the terminal panel pattern from PR #23231.
|
||||
// switched away.
|
||||
const [activated, setActivated] = useState(false);
|
||||
if (isVisible && !activated) {
|
||||
setActivated(true);
|
||||
@@ -42,29 +31,104 @@ export const DesktopPanel: FC<DesktopPanelProps> = ({ chatId, isVisible }) => {
|
||||
setIsControlling(false);
|
||||
}
|
||||
|
||||
const [scaleMode, setScaleMode] = useState<ScaleMode>("fit");
|
||||
const [isPoppedOut, setIsPoppedOut] = useState(false);
|
||||
|
||||
const { status, reconnect, attach } = useDesktopConnection({
|
||||
chatId,
|
||||
activated,
|
||||
chatId: isPoppedOut ? undefined : chatId,
|
||||
activated: activated && !isPoppedOut,
|
||||
scaleViewport: scaleMode === "fit",
|
||||
});
|
||||
|
||||
useZoomShortcuts(setScaleMode, isVisible);
|
||||
|
||||
// Listen for BroadcastChannel messages from the pop-out window.
|
||||
useEffect(() => {
|
||||
const channel = new BroadcastChannel(`coder-desktop-${chatId}`);
|
||||
|
||||
channel.addEventListener("message", (event) => {
|
||||
if (event.data?.type === "popout-opened") {
|
||||
setIsPoppedOut(true);
|
||||
setIsControlling(false);
|
||||
} else if (event.data?.type === "popout-closed") {
|
||||
setIsPoppedOut(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => channel.close();
|
||||
}, [chatId]);
|
||||
|
||||
const handlePopOut = () => {
|
||||
const width = Math.round(screen.availWidth * 0.5);
|
||||
const height = Math.round(screen.availHeight * 0.5);
|
||||
const left = Math.round((screen.availWidth - width) / 2);
|
||||
const top = Math.round((screen.availHeight - height) / 2);
|
||||
open(
|
||||
`/agents/${chatId}/desktop`,
|
||||
`coder-desktop-${chatId}`,
|
||||
`popup,width=${width},height=${height},left=${left},top=${top}`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleBringBack = () => {
|
||||
const channel = new BroadcastChannel(`coder-desktop-${chatId}`);
|
||||
channel.postMessage({ type: "bring-back" });
|
||||
channel.close();
|
||||
setIsPoppedOut(false);
|
||||
};
|
||||
|
||||
if (isPoppedOut) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-full flex-col items-center justify-center gap-3 text-content-secondary"
|
||||
role="status"
|
||||
>
|
||||
<ExternalLinkIcon className="h-8 w-8" />
|
||||
<span className="text-sm">Desktop is open in a separate window.</span>
|
||||
<Button variant="outline" size="sm" onClick={handleBringBack}>
|
||||
Bring back
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DesktopPanelView
|
||||
status={status}
|
||||
reconnect={reconnect}
|
||||
attach={attach}
|
||||
scaleMode={scaleMode}
|
||||
onScaleModeChange={setScaleMode}
|
||||
isControlling={isControlling}
|
||||
onTakeControl={() => setIsControlling(true)}
|
||||
onReleaseControl={() => setIsControlling(false)}
|
||||
onPopOut={handlePopOut}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export interface DesktopPanelViewProps {
|
||||
status: DesktopConnectionStatus;
|
||||
reconnect: () => void;
|
||||
attach: (container: HTMLElement) => void;
|
||||
scaleMode: ScaleMode;
|
||||
onScaleModeChange: (mode: ScaleMode) => void;
|
||||
isControlling: boolean;
|
||||
onTakeControl: () => void;
|
||||
onReleaseControl: () => void;
|
||||
onPopOut?: () => void;
|
||||
}
|
||||
|
||||
export const DesktopPanelView: FC<DesktopPanelViewProps> = ({
|
||||
status,
|
||||
reconnect,
|
||||
attach,
|
||||
scaleMode,
|
||||
onScaleModeChange,
|
||||
isControlling,
|
||||
onTakeControl,
|
||||
onReleaseControl,
|
||||
onPopOut,
|
||||
}) => {
|
||||
if (status === "connecting") {
|
||||
return (
|
||||
@@ -109,43 +173,31 @@ export const DesktopPanelView: FC<DesktopPanelViewProps> = ({
|
||||
|
||||
// status === "connected"
|
||||
return (
|
||||
<div className="relative size-full">
|
||||
{/* "Release Control" button — top-right, only when controlling */}
|
||||
{isControlling && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onReleaseControl}
|
||||
className="absolute top-2 right-2 z-20 shadow-xl drop-shadow-lg"
|
||||
>
|
||||
<HandIcon className="size-4" />
|
||||
Release control
|
||||
</Button>
|
||||
)}
|
||||
{/* VNC container — pointer-events toggled */}
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) attach(el);
|
||||
}}
|
||||
className={cn("size-full", !isControlling && "pointer-events-none")}
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<DesktopToolbar
|
||||
scaleMode={scaleMode}
|
||||
onScaleModeChange={onScaleModeChange}
|
||||
isControlling={isControlling}
|
||||
onTakeControl={onTakeControl}
|
||||
onReleaseControl={onReleaseControl}
|
||||
onPopOut={onPopOut}
|
||||
/>
|
||||
{/* "Take Control" hover overlay — only when NOT controlling */}
|
||||
{!isControlling && (
|
||||
<div className="group/desktop absolute inset-0 z-10 flex items-center justify-center bg-black/0 transition-all duration-200 ease-in-out group-hover/desktop:bg-black/40">
|
||||
<span className="opacity-0 transition-opacity duration-200 ease-in-out group-hover/desktop:opacity-100">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onTakeControl}
|
||||
aria-label="Take control of desktop"
|
||||
className="shadow-xl drop-shadow-lg"
|
||||
>
|
||||
<MousePointer2Icon className="size-4" />
|
||||
Take control
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden bg-surface-secondary">
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) attach(el);
|
||||
}}
|
||||
className="h-full w-full"
|
||||
inert={!isControlling ? true : undefined}
|
||||
role="application"
|
||||
aria-label={
|
||||
isControlling
|
||||
? "Remote desktop (interactive)"
|
||||
: "Remote desktop (view only, take control to interact)"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import { DesktopToolbar } from "./DesktopToolbar";
|
||||
|
||||
const meta = {
|
||||
title: "pages/AgentsPage/DesktopToolbar",
|
||||
component: DesktopToolbar,
|
||||
} satisfies Meta<typeof DesktopToolbar>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const ViewOnly: Story = {
|
||||
args: {
|
||||
scaleMode: "fit",
|
||||
onScaleModeChange: fn(),
|
||||
isControlling: false,
|
||||
onTakeControl: fn(),
|
||||
onReleaseControl: fn(),
|
||||
onPopOut: fn(),
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const takeControl = canvas.getByText("Take control");
|
||||
await userEvent.click(takeControl);
|
||||
await expect(args.onTakeControl).toHaveBeenCalled();
|
||||
|
||||
const zoom = canvas.getByText("Zoom to 100%");
|
||||
await userEvent.click(zoom);
|
||||
await expect(args.onScaleModeChange).toHaveBeenCalledWith("native");
|
||||
|
||||
const detach = canvas.getByText("Detach");
|
||||
await userEvent.click(detach);
|
||||
await expect(args.onPopOut).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const Controlling: Story = {
|
||||
args: {
|
||||
...ViewOnly.args,
|
||||
isControlling: true,
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const release = canvas.getByText("Release control");
|
||||
await userEvent.click(release);
|
||||
await expect(args.onReleaseControl).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const NativeZoom: Story = {
|
||||
args: {
|
||||
...ViewOnly.args,
|
||||
scaleMode: "native",
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const zoom = canvas.getByText("Zoom to fit");
|
||||
await userEvent.click(zoom);
|
||||
await expect(args.onScaleModeChange).toHaveBeenCalledWith("fit");
|
||||
},
|
||||
};
|
||||
|
||||
export const PoppedOut: Story = {
|
||||
args: {
|
||||
...ViewOnly.args,
|
||||
isPoppedOut: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
// Detach button should not render when popped out.
|
||||
const detach = canvas.queryByText("Detach");
|
||||
await expect(detach).toBeNull();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
HandIcon,
|
||||
MaximizeIcon,
|
||||
MousePointer2Icon,
|
||||
ScalingIcon,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
export type ScaleMode = "native" | "fit";
|
||||
|
||||
interface DesktopToolbarProps {
|
||||
scaleMode: ScaleMode;
|
||||
onScaleModeChange: (mode: ScaleMode) => void;
|
||||
isControlling: boolean;
|
||||
onTakeControl: () => void;
|
||||
onReleaseControl: () => void;
|
||||
onPopOut?: () => void;
|
||||
isPoppedOut?: boolean;
|
||||
}
|
||||
|
||||
export const DesktopToolbar: FC<DesktopToolbarProps> = ({
|
||||
scaleMode,
|
||||
onScaleModeChange,
|
||||
isControlling,
|
||||
onTakeControl,
|
||||
onReleaseControl,
|
||||
onPopOut,
|
||||
isPoppedOut,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="flex h-8 shrink-0 items-center justify-end gap-1 border-0 border-b border-solid border-border-default bg-surface-primary px-1.5"
|
||||
role="group"
|
||||
aria-label="Desktop controls"
|
||||
>
|
||||
{/* Take/Release control */}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={isControlling ? onReleaseControl : onTakeControl}
|
||||
aria-pressed={isControlling}
|
||||
className="h-6 gap-1.5 px-2 text-xs"
|
||||
>
|
||||
{isControlling ? (
|
||||
<>
|
||||
<HandIcon className="size-3.5" />
|
||||
Release control
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MousePointer2Icon className="size-3.5" />
|
||||
Take control
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Zoom toggle */}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onScaleModeChange(scaleMode === "native" ? "fit" : "native")
|
||||
}
|
||||
aria-label={
|
||||
scaleMode === "native"
|
||||
? "Zoom to fit (Ctrl+0)"
|
||||
: "Zoom to 100% (Ctrl+1)"
|
||||
}
|
||||
className="h-6 gap-1.5 px-2 text-xs"
|
||||
>
|
||||
{scaleMode === "native" ? (
|
||||
<>
|
||||
<ScalingIcon className="size-3.5" />
|
||||
Zoom to fit
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MaximizeIcon className="size-3.5" />
|
||||
Zoom to 100%
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Detach button */}
|
||||
{onPopOut && !isPoppedOut && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={onPopOut}
|
||||
aria-label="Detach desktop to new window"
|
||||
className="h-6 gap-1.5 px-2 text-xs"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
Detach
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -186,7 +186,11 @@ describe("useDesktopConnection", () => {
|
||||
|
||||
it("sets scaleViewport and resizeSession on the RFB instance", () => {
|
||||
renderHook(() =>
|
||||
useDesktopConnection({ chatId: "chat-1", activated: true }),
|
||||
useDesktopConnection({
|
||||
chatId: "chat-1",
|
||||
activated: true,
|
||||
scaleViewport: true,
|
||||
}),
|
||||
);
|
||||
const rfb = getLastRFBInstance();
|
||||
|
||||
@@ -194,6 +198,26 @@ describe("useDesktopConnection", () => {
|
||||
expect(rfb.resizeSession).toBe(false);
|
||||
});
|
||||
|
||||
it("syncs scaleViewport changes to the RFB instance", () => {
|
||||
const { rerender } = renderHook(
|
||||
({ scaleViewport }) =>
|
||||
useDesktopConnection({
|
||||
chatId: "chat-1",
|
||||
activated: true,
|
||||
scaleViewport,
|
||||
}),
|
||||
{ initialProps: { scaleViewport: true } },
|
||||
);
|
||||
const rfb = getLastRFBInstance();
|
||||
expect(rfb.scaleViewport).toBe(true);
|
||||
|
||||
rerender({ scaleViewport: false });
|
||||
expect(rfb.scaleViewport).toBe(false);
|
||||
|
||||
rerender({ scaleViewport: true });
|
||||
expect(rfb.scaleViewport).toBe(true);
|
||||
});
|
||||
|
||||
it("transitions to error on securityfailure", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDesktopConnection({ chatId: "chat-1", activated: true }),
|
||||
@@ -906,7 +930,11 @@ describe("useDesktopConnection", () => {
|
||||
|
||||
it("forces scaleViewport on hidden→visible transition", () => {
|
||||
renderHook(() =>
|
||||
useDesktopConnection({ chatId: "chat-1", activated: true }),
|
||||
useDesktopConnection({
|
||||
chatId: "chat-1",
|
||||
activated: true,
|
||||
scaleViewport: true,
|
||||
}),
|
||||
);
|
||||
const rfb = getLastRFBInstance();
|
||||
act(() => rfb.simulateEvent("connect"));
|
||||
@@ -920,12 +948,12 @@ describe("useDesktopConnection", () => {
|
||||
act(() => observer.simulateResize(0, 0));
|
||||
|
||||
// Reset so we can detect re-assignment.
|
||||
rfb.scaleViewport = false;
|
||||
const spy = vi.spyOn(rfb, "scaleViewport", "set");
|
||||
|
||||
// Container visible again — should force rescale.
|
||||
act(() => observer.simulateResize(800, 600));
|
||||
|
||||
expect(rfb.scaleViewport).toBe(true);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not force scaleViewport on normal nonzero→nonzero resize", () => {
|
||||
|
||||
@@ -3,20 +3,21 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { watchChatDesktop } from "#/api/api";
|
||||
import { useClipboard } from "#/hooks/useClipboard";
|
||||
|
||||
interface UseDesktopConnectionOptions {
|
||||
chatId: string | undefined;
|
||||
/** When false the hook stays dormant — no WebSocket, no RFB. */
|
||||
activated: boolean;
|
||||
}
|
||||
|
||||
type DesktopConnectionStatus =
|
||||
export type DesktopConnectionStatus =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "disconnected"
|
||||
| "error";
|
||||
|
||||
interface UseDesktopConnectionOptions {
|
||||
chatId: string | undefined;
|
||||
/** When false the hook stays dormant, no WebSocket, no RFB. */
|
||||
activated: boolean;
|
||||
/** When true the viewport is scaled to fit the container. Default: false (native 100%). */
|
||||
scaleViewport?: boolean;
|
||||
}
|
||||
|
||||
export interface UseDesktopConnectionResult {
|
||||
/** Current connection status. */
|
||||
status: DesktopConnectionStatus;
|
||||
@@ -82,8 +83,10 @@ const isMacCutShortcut = (event: KeyboardEvent): boolean => {
|
||||
export function useDesktopConnection({
|
||||
chatId,
|
||||
activated,
|
||||
scaleViewport = false,
|
||||
}: UseDesktopConnectionOptions): UseDesktopConnectionResult {
|
||||
const [status, setStatus] = useState<DesktopConnectionStatus>("idle");
|
||||
|
||||
const [hasConnected, setHasConnected] = useState(false);
|
||||
const [remoteClipboardText, setRemoteClipboardText] = useState<string | null>(
|
||||
null,
|
||||
@@ -228,6 +231,7 @@ export function useDesktopConnection({
|
||||
offscreenContainerRef.current.style.width = "100%";
|
||||
offscreenContainerRef.current.style.height = "100%";
|
||||
offscreenContainerRef.current.style.position = "relative";
|
||||
offscreenContainerRef.current.style.overflow = "hidden";
|
||||
|
||||
const socket = watchChatDesktop(chatId);
|
||||
|
||||
@@ -236,10 +240,20 @@ export function useDesktopConnection({
|
||||
shared: true,
|
||||
});
|
||||
|
||||
rfb.scaleViewport = true;
|
||||
rfb.scaleViewport = false;
|
||||
rfb.resizeSession = false;
|
||||
rfb.focusOnClick = true;
|
||||
|
||||
// Override the noVNC default background (rgb(40,40,40))
|
||||
// so the letterbox margins match the app surface color
|
||||
// in both light and dark themes.
|
||||
const surfaceHsl = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--surface-secondary")
|
||||
.trim();
|
||||
if (surfaceHsl) {
|
||||
rfb.background = `hsl(${surfaceHsl})`;
|
||||
}
|
||||
|
||||
// Per-session flags scoped to this RFB instance.
|
||||
// NOT refs — each doConnect() gets fresh copies so
|
||||
// state from a previous session cannot leak.
|
||||
@@ -458,7 +472,7 @@ export function useDesktopConnection({
|
||||
// shrinks to 0×0. When the container becomes visible
|
||||
// again, noVNC may skip rescaling because it believes
|
||||
// the viewport size hasn't changed. Re-assigning
|
||||
// scaleViewport = true forces a fresh scale pass
|
||||
// scaleViewport forces a fresh scale pass
|
||||
// regardless.
|
||||
let prevContainerW = 0;
|
||||
let prevContainerH = 0;
|
||||
@@ -472,7 +486,12 @@ export function useDesktopConnection({
|
||||
prevContainerW = width;
|
||||
prevContainerH = height;
|
||||
if (wasHidden && isVisible && rfbRef.current) {
|
||||
rfbRef.current.scaleViewport = true;
|
||||
// Re-assign the current value to force noVNC to
|
||||
// recalculate the viewport. The setter triggers an
|
||||
// internal rescale regardless of whether the value
|
||||
// actually changed.
|
||||
const current = rfbRef.current.scaleViewport;
|
||||
rfbRef.current.scaleViewport = current;
|
||||
}
|
||||
});
|
||||
visibilityObserver.observe(offscreenContainerRef.current);
|
||||
@@ -500,6 +519,12 @@ export function useDesktopConnection({
|
||||
};
|
||||
}, [activated, chatId, syncRemoteClipboardToLocal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rfbInstance && rfbRef.current) {
|
||||
rfbRef.current.scaleViewport = scaleViewport;
|
||||
}
|
||||
}, [rfbInstance, scaleViewport]);
|
||||
|
||||
return {
|
||||
status,
|
||||
hasConnected,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect } from "react";
|
||||
import type { ScaleMode } from "../components/RightPanel/DesktopToolbar";
|
||||
|
||||
export function useZoomShortcuts(
|
||||
setScaleMode: (mode: ScaleMode) => void,
|
||||
enabled = true,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (mod && e.key === "0") {
|
||||
e.preventDefault();
|
||||
setScaleMode("fit");
|
||||
} else if (mod && e.key === "1") {
|
||||
e.preventDefault();
|
||||
setScaleMode("native");
|
||||
}
|
||||
};
|
||||
addEventListener("keydown", handleKeyDown);
|
||||
return () => removeEventListener("keydown", handleKeyDown);
|
||||
}, [enabled, setScaleMode]);
|
||||
}
|
||||
@@ -351,6 +351,9 @@ const ProvisionerJobsPage = lazy(
|
||||
const AgentsPage = lazy(() => import("./pages/AgentsPage/AgentsPage"));
|
||||
const AgentChatPage = lazy(() => import("./pages/AgentsPage/AgentChatPage"));
|
||||
const AgentEmbedPage = lazy(() => import("./pages/AgentsPage/AgentEmbedPage"));
|
||||
const DesktopPopoutPage = lazy(
|
||||
() => import("./pages/AgentsPage/DesktopPopoutPage"),
|
||||
);
|
||||
const AgentCreatePage = lazy(
|
||||
() => import("./pages/AgentsPage/AgentCreatePage"),
|
||||
);
|
||||
@@ -810,6 +813,18 @@ export const router = createBrowserRouter(
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path="/agents/:agentId/desktop"
|
||||
element={
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-screen w-screen items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<DesktopPopoutPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route
|
||||
|
||||
Reference in New Issue
Block a user