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:
TJ
2026-05-28 09:26:05 -07:00
committed by github-actions[bot]
parent 85d39b3dbe
commit 96d3be4407
10 changed files with 625 additions and 74 deletions
@@ -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]);
}
+15
View File
@@ -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