rfbRef.current?.focus({ preventScroll: true })}
+ >
+
+ {clipboardStatus && (
+
+ {clipboardStatus}
+
+ )}
+
+
+
{streamError && (
{streamError}
@@ -359,6 +449,13 @@ function DesktopStream({ sessionUrl }: { sessionUrl: string }) {
);
}
+function isDisplayEventTarget(container: HTMLElement | null, target: EventTarget | null): boolean {
+ if (!container) return false;
+ if (target instanceof Node && container.contains(target)) return true;
+ const active = document.activeElement;
+ return active instanceof Node && container.contains(active);
+}
+
function displayWebSocketConnection(sessionUrl: string): { url: string; token: string } {
const url = new URL(sessionUrl, window.location.href);
const token = new URLSearchParams(url.hash.replace(/^#/, "")).get("token") ?? "";
diff --git a/canvas/src/components/tabs/__tests__/DisplayTab.test.tsx b/canvas/src/components/tabs/__tests__/DisplayTab.test.tsx
index a5735c9cc..afd84361c 100644
--- a/canvas/src/components/tabs/__tests__/DisplayTab.test.tsx
+++ b/canvas/src/components/tabs/__tests__/DisplayTab.test.tsx
@@ -2,10 +2,12 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
-const { mockGet, mockPost, mockRFBConstructor } = vi.hoisted(() => ({
+const { mockGet, mockPost, mockRFBConstructor, mockRFBClipboardPasteFrom, mockRFBFocus } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockPost: vi.fn(),
mockRFBConstructor: vi.fn(),
+ mockRFBClipboardPasteFrom: vi.fn(),
+ mockRFBFocus: vi.fn(),
}));
vi.mock("@/lib/api", () => ({
@@ -30,6 +32,12 @@ vi.mock("@novnc/novnc", () => ({
this.options = options;
mockRFBConstructor(target, url, options);
}
+ clipboardPasteFrom(text: string) {
+ mockRFBClipboardPasteFrom(text);
+ }
+ focus(options?: FocusOptions) {
+ mockRFBFocus(options);
+ }
disconnect() {}
},
}));
@@ -42,6 +50,8 @@ describe("DisplayTab", () => {
mockGet.mockReset();
mockPost.mockReset();
mockRFBConstructor.mockReset();
+ mockRFBClipboardPasteFrom.mockReset();
+ mockRFBFocus.mockReset();
});
it("renders unavailable state for non-display workspaces", async () => {
@@ -157,6 +167,43 @@ describe("DisplayTab", () => {
expect(mockRFBConstructor.mock.calls[0][1]).not.toContain("token=");
});
+ it("forwards browser paste events into the noVNC clipboard", async () => {
+ mockGet
+ .mockResolvedValueOnce({
+ available: true,
+ mode: "desktop-control",
+ protocol: "novnc",
+ width: 1920,
+ height: 1080,
+ })
+ .mockResolvedValueOnce({
+ controller: "none",
+ });
+ mockPost.mockResolvedValueOnce({
+ controller: "user",
+ controlled_by: "admin-token",
+ expires_at: "2026-05-23T08:48:27Z",
+ session_url: "/workspaces/ws-display/display/session/websockify#token=signed",
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Take control" }));
+
+ const desktop = await screen.findByTitle("Workspace desktop");
+ fireEvent.paste(desktop, {
+ clipboardData: {
+ getData: (type: string) => (type === "text/plain" ? "Paste Me" : ""),
+ },
+ });
+
+ expect(mockRFBClipboardPasteFrom).toHaveBeenCalledWith("Paste Me");
+ expect(mockRFBFocus).toHaveBeenCalledWith({ preventScroll: true });
+ });
+
it("releases user display control", async () => {
mockGet
.mockResolvedValueOnce({
diff --git a/canvas/src/hooks/use-keyboard-shortcut.ts b/canvas/src/hooks/use-keyboard-shortcut.ts
index 298f94b82..af70e8362 100644
--- a/canvas/src/hooks/use-keyboard-shortcut.ts
+++ b/canvas/src/hooks/use-keyboard-shortcut.ts
@@ -15,6 +15,8 @@ export function useKeyboardShortcut(
if (!enabled) return;
function handler(e: KeyboardEvent) {
+ const target = e.target as HTMLElement;
+ if (target.closest?.('[data-display-stream="true"]')) return;
if (e.key !== key) return;
if (meta && !e.metaKey) return;
if (ctrl && !e.ctrlKey) return;
diff --git a/canvas/src/types/novnc.d.ts b/canvas/src/types/novnc.d.ts
index 3175ec772..1742332c6 100644
--- a/canvas/src/types/novnc.d.ts
+++ b/canvas/src/types/novnc.d.ts
@@ -4,6 +4,8 @@ declare module "@novnc/novnc" {
resizeSession: boolean;
focusOnClick: boolean;
constructor(target: HTMLElement, url: string, options?: { wsProtocols?: string[]; [key: string]: unknown });
+ clipboardPasteFrom(text: string): void;
disconnect(): void;
+ focus(options?: FocusOptions): void;
}
}