From ebf56ebd12c662bb6e34eecab976a86f2b2efe80 Mon Sep 17 00:00:00 2001 From: TJ Date: Thu, 28 May 2026 09:26:05 -0700 Subject: [PATCH] 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 --- .../AgentsPage/DesktopPopoutPage.stories.tsx | 69 +++++++ .../pages/AgentsPage/DesktopPopoutPage.tsx | 161 +++++++++++++++++ .../RightPanel/DesktopPanel.stories.tsx | 3 + .../components/RightPanel/DesktopPanel.tsx | 170 ++++++++++++------ .../RightPanel/DesktopToolbar.stories.tsx | 75 ++++++++ .../components/RightPanel/DesktopToolbar.tsx | 100 +++++++++++ .../hooks/useDesktopConnection.test.ts | 36 +++- .../AgentsPage/hooks/useDesktopConnection.ts | 47 +++-- .../AgentsPage/hooks/useZoomShortcuts.ts | 23 +++ site/src/router.tsx | 15 ++ 10 files changed, 625 insertions(+), 74 deletions(-) create mode 100644 site/src/pages/AgentsPage/DesktopPopoutPage.stories.tsx create mode 100644 site/src/pages/AgentsPage/DesktopPopoutPage.tsx create mode 100644 site/src/pages/AgentsPage/components/RightPanel/DesktopToolbar.stories.tsx create mode 100644 site/src/pages/AgentsPage/components/RightPanel/DesktopToolbar.tsx create mode 100644 site/src/pages/AgentsPage/hooks/useZoomShortcuts.ts diff --git a/site/src/pages/AgentsPage/DesktopPopoutPage.stories.tsx b/site/src/pages/AgentsPage/DesktopPopoutPage.stories.tsx new file mode 100644 index 0000000000..583e89e034 --- /dev/null +++ b/site/src/pages/AgentsPage/DesktopPopoutPage.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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(); + }, +}; diff --git a/site/src/pages/AgentsPage/DesktopPopoutPage.tsx b/site/src/pages/AgentsPage/DesktopPopoutPage.tsx new file mode 100644 index 0000000000..2377e0e147 --- /dev/null +++ b/site/src/pages/AgentsPage/DesktopPopoutPage.tsx @@ -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("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 ( + 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 = ({ + status, + reconnect, + attach, + scaleMode, + onScaleModeChange, + isControlling, + onTakeControl, + onReleaseControl, +}) => { + if (status === "idle" || status === "connecting") { + return ( +
+
+ + + {status === "idle" + ? "Initializing desktop..." + : "Connecting to desktop..."} + +
+
+ ); + } + + if (status === "error") { + return ( +
+
+ + Failed to connect to the desktop session. The agent may not be + connected or the desktop environment may not be available. + + +
+
+ ); + } + + if (status === "disconnected") { + return ( +
+
+ + Desktop disconnected. Reconnecting... +
+
+ ); + } + + return ( +
+ +
{ + 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)" + } + /> +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/RightPanel/DesktopPanel.stories.tsx b/site/src/pages/AgentsPage/components/RightPanel/DesktopPanel.stories.tsx index 02228c8678..b48c93ff17 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DesktopPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/RightPanel/DesktopPanel.stories.tsx @@ -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 = { diff --git a/site/src/pages/AgentsPage/components/RightPanel/DesktopPanel.tsx b/site/src/pages/AgentsPage/components/RightPanel/DesktopPanel.tsx index ef20dcd86d..7e8f74b9f9 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DesktopPanel.tsx +++ b/site/src/pages/AgentsPage/components/RightPanel/DesktopPanel.tsx @@ -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 = ({ 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 = ({ chatId, isVisible }) => { setIsControlling(false); } + const [scaleMode, setScaleMode] = useState("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 ( +
+ + Desktop is open in a separate window. + +
+ ); + } + return ( 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 = ({ status, reconnect, attach, + scaleMode, + onScaleModeChange, isControlling, onTakeControl, onReleaseControl, + onPopOut, }) => { if (status === "connecting") { return ( @@ -109,43 +173,31 @@ export const DesktopPanelView: FC = ({ // status === "connected" return ( -
- {/* "Release Control" button — top-right, only when controlling */} - {isControlling && ( - - )} - {/* VNC container — pointer-events toggled */} -
{ - if (el) attach(el); - }} - className={cn("size-full", !isControlling && "pointer-events-none")} +
+ - {/* "Take Control" hover overlay — only when NOT controlling */} - {!isControlling && ( -
- - - -
- )} + +
+
{ + 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)" + } + /> +
); }; diff --git a/site/src/pages/AgentsPage/components/RightPanel/DesktopToolbar.stories.tsx b/site/src/pages/AgentsPage/components/RightPanel/DesktopToolbar.stories.tsx new file mode 100644 index 0000000000..36be9a9475 --- /dev/null +++ b/site/src/pages/AgentsPage/components/RightPanel/DesktopToolbar.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/RightPanel/DesktopToolbar.tsx b/site/src/pages/AgentsPage/components/RightPanel/DesktopToolbar.tsx new file mode 100644 index 0000000000..a7c733f1fc --- /dev/null +++ b/site/src/pages/AgentsPage/components/RightPanel/DesktopToolbar.tsx @@ -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 = ({ + scaleMode, + onScaleModeChange, + isControlling, + onTakeControl, + onReleaseControl, + onPopOut, + isPoppedOut, +}) => { + return ( +
+ {/* Take/Release control */} + + + {/* Zoom toggle */} + + + {/* Detach button */} + {onPopOut && !isPoppedOut && ( + + )} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/hooks/useDesktopConnection.test.ts b/site/src/pages/AgentsPage/hooks/useDesktopConnection.test.ts index 34cfd36eaa..410ae38758 100644 --- a/site/src/pages/AgentsPage/hooks/useDesktopConnection.test.ts +++ b/site/src/pages/AgentsPage/hooks/useDesktopConnection.test.ts @@ -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", () => { diff --git a/site/src/pages/AgentsPage/hooks/useDesktopConnection.ts b/site/src/pages/AgentsPage/hooks/useDesktopConnection.ts index 8c9c7fa61c..71fb86216b 100644 --- a/site/src/pages/AgentsPage/hooks/useDesktopConnection.ts +++ b/site/src/pages/AgentsPage/hooks/useDesktopConnection.ts @@ -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("idle"); + const [hasConnected, setHasConnected] = useState(false); const [remoteClipboardText, setRemoteClipboardText] = useState( 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, diff --git a/site/src/pages/AgentsPage/hooks/useZoomShortcuts.ts b/site/src/pages/AgentsPage/hooks/useZoomShortcuts.ts new file mode 100644 index 0000000000..46d05eea2c --- /dev/null +++ b/site/src/pages/AgentsPage/hooks/useZoomShortcuts.ts @@ -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]); +} diff --git a/site/src/router.tsx b/site/src/router.tsx index 96871176c6..6ac39cec75 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -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( } /> + + } + > + + + } + />