feat(ui): replace detach mode with embed mode (#1378)

Remove the detach window feature and replace it with a simpler `?embed`
query parameter that hides the header bar and status bar using the same
settings levers. Embed mode latches into session state so it persists
across in-app navigation.

- Delete useDetachedWindow hook and window tracking logic
- Add `isEmbedMode` to UI store, latched from `?embed` query param
- Embed mode forces hideHeaderBar and hideStatusBar via same code path
  as the existing appearance settings
- Replace detach/close buttons with fullscreen split button containing
  "Compact Window" option that opens embed view in new window
- Hide settings button and show close button in embed mode
- Simplify useAppNavigation by removing query param threading
- Add action_bar_compact_window i18n key to all 14 locales
This commit is contained in:
Adam Shiervani
2026-03-30 17:25:43 +02:00
committed by GitHub
parent c7344ed673
commit 6a87a481d4
20 changed files with 15393 additions and 15413 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21 -23
View File
@@ -1,5 +1,4 @@
import { Fragment, useCallback, useEffect, useRef } from "react";
import { useParams } from "react-router";
import { MdOutlineContentPasteGo } from "react-icons/md";
import {
LuCable,
@@ -27,7 +26,6 @@ import {
useVideoStore,
} from "@hooks/stores";
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
import { useDetachedWindow } from "@hooks/useDetachedWindow";
import { Button } from "@components/Button";
import Container from "@components/Container";
import PasteModal from "@components/popovers/PasteModal";
@@ -39,13 +37,9 @@ import { m } from "@localizations/messages.js";
export default function Actionbar({
requestFullscreen,
isDetachedWindow,
}: {
requestFullscreen: () => Promise<void>;
isDetachedWindow?: boolean;
}) {
const params = useParams() as { id?: string };
const deviceId = params.id || "local";
const { navigateTo } = useDeviceUiNavigation();
const { isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore();
const {
@@ -57,11 +51,11 @@ export default function Actionbar({
setOcrMode,
usbSerialConsoleEnabled,
setUsbSerialConsoleEnabled,
isEmbedMode,
} = useUiStore();
const { remoteVirtualMediaState } = useMountMediaStore();
const { width: videoWidth, height: videoHeight } = useVideoStore();
const { developerMode } = useSettingsStore();
const { openDetachedWindow } = useDetachedWindow();
const { send } = useJsonRpc();
useEffect(() => {
@@ -327,7 +321,7 @@ export default function Actionbar({
}}
/>
</div>
{!isDetachedWindow && (
{!isEmbedMode && (
<div>
<Button
size="XS"
@@ -344,7 +338,7 @@ export default function Actionbar({
<div className="hidden items-center gap-x-2 lg:flex">
<div className="h-4 w-px bg-slate-300 dark:bg-slate-600" />
{isDetachedWindow ? (
{isEmbedMode ? (
<Button
size="XS"
theme="light"
@@ -353,22 +347,26 @@ export default function Actionbar({
onClick={() => window.close()}
/>
) : (
<>
<Button
size="XS"
theme="light"
text={m.detach()}
LeadingIcon={LuExternalLink}
onClick={() => openDetachedWindow(deviceId)}
/>
<Button
size="XS"
theme="light"
text={m.action_bar_fullscreen()}
LeadingIcon={LuMaximize}
<SplitButtonGroup>
<SplitButtonPrimary
icon={LuMaximize}
label={m.action_bar_fullscreen()}
onClick={() => requestFullscreen()}
/>
</>
<SplitButtonCaret
menuItems={[
{
label: m.action_bar_compact_window(),
icon: LuExternalLink,
onClick: () => {
const url = new URL(window.location.href);
url.searchParams.set("embed", "");
window.open(url.toString(), "_blank", "noopener");
},
},
]}
/>
</SplitButtonGroup>
)}
</div>
</div>
+25 -35
View File
@@ -23,12 +23,10 @@ import { m } from "@localizations/messages.js";
export default function WebRTCVideo({
hasConnectionIssues,
hideActionBar,
isDetachedWindow,
hideStatusBar,
}: {
hasConnectionIssues: boolean;
hideActionBar?: boolean;
isDetachedWindow?: boolean;
hideStatusBar?: boolean;
}) {
// Video and stream related refs and states
const videoElm = useRef<HTMLVideoElement>(null);
@@ -606,35 +604,30 @@ export default function WebRTCVideo({
return (
<div className="grid h-full w-full grid-rows-(--grid-layout)">
{!hideActionBar && (
<div className="flex min-h-[39.5px] flex-col">
<div className="flex flex-col">
<fieldset
disabled={peerConnection?.connectionState !== "connected"}
className="contents"
>
<Actionbar
requestFullscreen={requestFullscreen}
isDetachedWindow={isDetachedWindow}
/>
<MacroBar />
</fieldset>
</div>
<div className="flex min-h-[39.5px] flex-col">
<div className="flex flex-col">
<fieldset
disabled={peerConnection?.connectionState !== "connected"}
className="contents"
>
<Actionbar
requestFullscreen={requestFullscreen}
/>
<MacroBar />
</fieldset>
</div>
)}
</div>
<div ref={containerRef} className="h-full overflow-hidden">
<div className="relative h-full">
{!isDetachedWindow && (
<div
className={cx(
"absolute inset-0 z-0 bg-blue-50/40 opacity-80 dark:bg-slate-800/40",
"bg-[radial-gradient(var(--color-blue-300)_0.5px,transparent_0.5px),radial-gradient(var(--color-blue-300)_0.5px,transparent_0.5px)] dark:bg-[radial-gradient(var(--color-slate-700)_0.5px,transparent_0.5px),radial-gradient(var(--color-slate-700)_0.5px,transparent_0.5px)]",
"bg-position-[0_0,10px_10px]",
"bg-size-[20px_20px]",
)}
/>
)}
<div
className={cx(
"absolute inset-0 z-0 bg-blue-50/40 opacity-80 dark:bg-slate-800/40",
"bg-[radial-gradient(var(--color-blue-300)_0.5px,transparent_0.5px),radial-gradient(var(--color-blue-300)_0.5px,transparent_0.5px)] dark:bg-[radial-gradient(var(--color-slate-700)_0.5px,transparent_0.5px),radial-gradient(var(--color-slate-700)_0.5px,transparent_0.5px)]",
"bg-position-[0_0,10px_10px]",
"bg-size-[20px_20px]",
)}
/>
<div className="flex h-full flex-col">
<div className="relative grow overflow-hidden">
<div className="flex h-full flex-col">
@@ -642,9 +635,7 @@ export default function WebRTCVideo({
{/* In relative mouse mode and under https, we enable the pointer lock, and to do so we need a bar to show the user to click on the video to enable mouse control */}
<PointerLockBar show={showPointerLockBar} />
<div
className={cx("relative flex items-center justify-center overflow-hidden", {
"mx-4 my-2": !isDetachedWindow,
})}
className="relative mx-4 my-2 flex items-center justify-center overflow-hidden"
>
<div
ref={fullscreenContainerRef}
@@ -672,8 +663,7 @@ export default function WebRTCVideo({
hasConnectionIssues ||
peerConnectionState !== "connected",
"opacity-60!": showPointerLockBar,
"animate-slideUpFade":
isPlaying && !isDetachedWindow,
"animate-slideUpFade": isPlaying,
},
)}
/>
@@ -704,7 +694,7 @@ export default function WebRTCVideo({
</div>
</div>
</div>
{!hideActionBar && !settings.hideStatusBar && (
{!hideStatusBar && (
<div>
<InfoBar />
</div>
+6
View File
@@ -81,6 +81,9 @@ export interface UIState {
usbSerialConsoleEnabled: boolean;
setUsbSerialConsoleEnabled: (enabled: boolean) => void;
isEmbedMode: boolean;
setEmbedMode: (enabled: boolean) => void;
}
export const useUiStore = create<UIState>(set => ({
@@ -117,6 +120,9 @@ export const useUiStore = create<UIState>(set => ({
usbSerialConsoleEnabled: false,
setUsbSerialConsoleEnabled: (enabled: boolean) => set({ usbSerialConsoleEnabled: enabled }),
isEmbedMode: false,
setEmbedMode: (enabled: boolean) => set({ isEmbedMode: enabled }),
}));
export interface RTCState {
+3 -8
View File
@@ -1,4 +1,4 @@
import { useNavigate, useParams, useSearchParams } from "react-router";
import { useNavigate, useParams } from "react-router";
import type { NavigateOptions } from "react-router";
import { useCallback, useMemo } from "react";
@@ -41,7 +41,6 @@ export function getDeviceUiPath(path: string, deviceId?: string): string {
export function useDeviceUiNavigation() {
const navigate = useNavigate();
const params = useParams();
const [searchParams] = useSearchParams();
// Get the device ID from params
const deviceId = useMemo(() => params.id, [params.id]);
@@ -57,13 +56,9 @@ export function useDeviceUiNavigation() {
// Function to navigate to the correct path with all options
const navigateTo = useCallback(
(path: string, options?: NavigateOptions) => {
let computedPath = getPath(path);
if (searchParams.get("detached") === "true") {
computedPath += computedPath.includes("?") ? "&detached=true" : "?detached=true";
}
navigate(computedPath, options);
void navigate(getPath(path), options);
},
[getPath, navigate, searchParams],
[getPath, navigate],
);
return {
-37
View File
@@ -1,37 +0,0 @@
import { isOnDevice } from "@/main";
// Module-level Map to track windows (avoids serialization issues)
const windowMap = new Map<string, Window>();
export function useDetachedWindow() {
const openDetachedWindow = (deviceId: string) => {
// Check existing window
const existing = windowMap.get(deviceId);
if (existing && !existing.closed) {
existing.focus();
return;
}
const width = 1280;
const height = 720;
const left = Math.max(0, (window.screen.width - width) / 2);
const top = Math.max(0, (window.screen.height - height) / 2);
const features = `width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,resizable=yes`;
const url = isOnDevice ? "/?detached=true" : `/devices/${deviceId}?detached=true`;
const win = window.open(url, `jetkvm-${deviceId}`, features);
if (win) {
win.document.title = "JetKVM";
windowMap.set(deviceId, win);
// Cleanup on close
const interval = setInterval(() => {
if (win.closed) {
windowMap.delete(deviceId);
clearInterval(interval);
}
}, 1000);
}
};
return { openDetachedWindow };
}
+21 -7
View File
@@ -124,10 +124,24 @@ export default function KvmIdRoute() {
setDisableVideoFocusTrap,
rebootState,
setRebootState,
isEmbedMode,
setEmbedMode,
} = useUiStore();
const [queryParams, setQueryParams] = useSearchParams();
const isDetachedWindow = queryParams.get("detached") === "true";
const hideHeaderBar = useSettingsStore(state => state.hideHeaderBar);
const hasEmbedParam = queryParams.has("embed");
// Latch embed mode once from ?embed query param — persists across in-app
// navigation even if the query param is lost (e.g. opening settings)
useEffect(() => {
if (hasEmbedParam && !isEmbedMode) {
setEmbedMode(true);
}
}, [hasEmbedParam, isEmbedMode, setEmbedMode]);
const settingsHideHeaderBar = useSettingsStore(state => state.hideHeaderBar);
const settingsHideStatusBar = useSettingsStore(state => state.hideStatusBar);
const hideHeaderBar = isEmbedMode || settingsHideHeaderBar;
const hideStatusBar = isEmbedMode || settingsHideStatusBar;
const {
peerConnection,
@@ -977,7 +991,7 @@ export default function KvmIdRoute() {
return (
<FeatureFlagProvider appVersion={appVersion}>
<title>{displayHostname ? `${displayHostname} - JetKVM` : "JetKVM"}</title>
{!isDetachedWindow && !outlet && otaState.updating && (
{!isEmbedMode && !outlet && otaState.updating && (
<AnimatePresence>
<motion.div
className="pointer-events-none fixed inset-0 top-16 z-10 mx-auto flex h-full w-full max-w-xl translate-y-8 items-start justify-center"
@@ -991,7 +1005,7 @@ export default function KvmIdRoute() {
</AnimatePresence>
)}
<div className="relative h-full">
{!isDetachedWindow && (
{!hideHeaderBar && (
<FocusTrap
paused={disableVideoFocusTrap}
focusTrapOptions={{
@@ -1008,10 +1022,10 @@ export default function KvmIdRoute() {
<div
className={cx("grid h-full select-none", {
"grid-rows-(--grid-headerBody)": !isDetachedWindow && !hideHeaderBar,
"grid-rows-(--grid-headerBody)": !hideHeaderBar,
})}
>
{!isDetachedWindow && !hideHeaderBar && (
{!hideHeaderBar && (
<DashboardNavbar
primaryLinks={isOnDevice ? [] : [{ title: "Cloud Devices", to: "/devices" }]}
showConnectionStatus={true}
@@ -1027,7 +1041,7 @@ export default function KvmIdRoute() {
{isFailsafeMode && failsafeReason === "video" ? null : (
<WebRTCVideo
hasConnectionIssues={!!ConnectionStatusElement}
isDetachedWindow={isDetachedWindow}
hideStatusBar={hideStatusBar}
/>
)}
<div