From e9a6955b497e868626b6deb9d95a49fd841e33f1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 14:01:19 -0700 Subject: [PATCH] FN-6939: add narrow dock preview modal Adds an accessible preview modal path for constrained Dev Server right-dock layouts. - Detect narrow direct right-dock hosts while preserving inline previews for full-page, mobile, and expanded modal hosts. - Replace the crowded inline preview with a compact Open preview launcher and accessible modal controls in narrow docks. - Add focused preview/mobile coverage, documentation, styling, and a published package changeset. Files changed: .../fn-6939-dev-server-narrow-preview-modal.md | 5 + docs/dashboard-guide.md | 3 + .../dashboard/app/components/DevServerView.css | 100 ++++- .../dashboard/app/components/DevServerView.tsx | 449 +++++++++++++++------ .../__tests__/DevServerView.mobile.test.tsx | 17 +- .../__tests__/DevServerView.preview.test.tsx | 148 +++++++ 6 files changed, 603 insertions(+), 119 deletions(-) Fusion-Task-Id: FN-6939 Fusion-Task-Lineage: 745ea56d-16bf-4246-bfe7-0461754466d9 --- ...fn-6939-dev-server-narrow-preview-modal.md | 5 + docs/dashboard-guide.md | 3 + .../app/components/DevServerView.css | 100 +++- .../app/components/DevServerView.tsx | 447 +++++++++++++----- .../__tests__/DevServerView.mobile.test.tsx | 17 +- .../__tests__/DevServerView.preview.test.tsx | 148 ++++++ 6 files changed, 602 insertions(+), 118 deletions(-) create mode 100644 .changeset/fn-6939-dev-server-narrow-preview-modal.md diff --git a/.changeset/fn-6939-dev-server-narrow-preview-modal.md b/.changeset/fn-6939-dev-server-narrow-preview-modal.md new file mode 100644 index 0000000000..2133d343fb --- /dev/null +++ b/.changeset/fn-6939-dev-server-narrow-preview-modal.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix narrow right-sidebar Dev Server preview overlap by replacing the inline preview with an accessible modal launcher when the dock is very narrow, while keeping inline preview for full-page, mobile viewport, and expanded pop-out hosts. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index de1e9379d9..0ce4b15586 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -866,6 +866,9 @@ Features: - Start, stop, and restart the current server session - Manage preview URLs with embedded preview and **Open in new tab** fallback - Tail live logs, load older history, and refresh session status +- When Dev Server is hosted in a very narrow right sidebar, open the preview from the compact **Open preview** launcher; the modal keeps preview actions available while configuration and logs stay usable in the sidebar. + + For module-level behavior and API surfaces, see [Dev Server modules](./dev-server-modules.md). diff --git a/packages/dashboard/app/components/DevServerView.css b/packages/dashboard/app/components/DevServerView.css index 5bb722fc38..437f83087e 100644 --- a/packages/dashboard/app/components/DevServerView.css +++ b/packages/dashboard/app/components/DevServerView.css @@ -582,6 +582,65 @@ exactly when the surrounding chrome is gone. margin: 0; } +.devserver-preview-modal-launcher { + align-items: stretch; +} + +.devserver-preview-modal-launcher__copy { + display: flex; + align-items: center; + gap: var(--space-sm); + min-width: 0; +} + +.devserver-preview-modal-launcher__copy .devserver-preview-url-badge { + max-width: none; +} + +.devserver-preview-modal-launcher__description { + margin: 0; + color: var(--text-muted); + line-height: 1.5; +} + +.devserver-preview-modal-overlay { + align-items: center; + padding: var(--space-xl); +} + +.devserver-preview-modal { + width: min(calc(var(--space-2xl) * 28), calc(100vw - var(--space-xl) * 2)); + max-height: calc(100vh - var(--space-xl) * 2); +} + +.devserver-preview-modal__titlebar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + padding: var(--space-md); + border-bottom: 1px solid var(--border); +} + +.devserver-preview-modal__titlebar h2 { + margin: 0; + font-size: 1rem; +} + +.devserver-preview-modal__body { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.devserver-preview-modal__body .devserver-preview-container { + flex: 1; + min-height: min(60vh, calc(var(--space-2xl) * 14)); + max-height: none; +} + /* Legacy selector compatibility for static CSS tests */ .dev-server-preview-fallback { border: 1px solid color-mix(in srgb, var(--color-warning) 40%, transparent); @@ -655,7 +714,8 @@ exactly when the surrounding chrome is gone. max-width: none; } - .devserver-preview-header { + .devserver-preview-header, + .devserver-preview-modal-launcher__copy { flex-wrap: wrap; } @@ -703,6 +763,20 @@ exactly when the surrounding chrome is gone. max-height: calc(var(--space-2xl) * 3); } + .devserver-preview-modal-overlay { + align-items: stretch; + padding: var(--space-md); + } + + .devserver-preview-modal { + width: 100%; + max-height: calc(100vh - var(--space-md) * 2); + } + + .devserver-preview-modal__body .devserver-preview-container { + min-height: calc(var(--space-2xl) * 7); + } + .dev-server-config { max-height: min(48vh, calc(var(--space-2xl) * 13)); } @@ -852,7 +926,8 @@ vertically (.dev-server-view overflow-y:auto), so each panel just needs to be fu flex-direction: column; } - .devserver-preview-panel { + .devserver-preview-panel, + .devserver-preview-modal-launcher { grid-column: auto; grid-row: auto; } @@ -862,10 +937,25 @@ vertically (.dev-server-view overflow-y:auto), so each panel just needs to be fu max-width: none; } - .devserver-preview-header { + .devserver-preview-header, + .devserver-preview-modal-launcher__copy { flex-wrap: wrap; } + .devserver-preview-modal-overlay { + align-items: stretch; + padding: var(--space-md); + } + + .devserver-preview-modal { + width: min(calc(var(--space-2xl) * 20), calc(100vw - var(--space-md) * 2)); + max-height: calc(100vh - var(--space-md) * 2); + } + + .devserver-preview-modal__body .devserver-preview-container { + min-height: calc(var(--space-2xl) * 7); + } + .devserver-preview-url-badge { order: 2; flex: 1 1 100%; @@ -915,8 +1005,8 @@ vertically (.dev-server-view overflow-y:auto), so each panel just needs to be fu } .dev-server-logs, - .devserver-preview-container, - .devserver-preview-iframe { + .devserver-preview-panel .devserver-preview-container, + .devserver-preview-panel .devserver-preview-iframe { min-height: calc(var(--space-2xl) * 4 + var(--space-md)); max-height: none; } diff --git a/packages/dashboard/app/components/DevServerView.tsx b/packages/dashboard/app/components/DevServerView.tsx index e88bf63bf8..b3b88febad 100644 --- a/packages/dashboard/app/components/DevServerView.tsx +++ b/packages/dashboard/app/components/DevServerView.tsx @@ -1,13 +1,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { RefObject } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; -import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square } from "lucide-react"; +import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square, X } from "lucide-react"; import type { Task, TaskDetail } from "@fusion/core"; import "./DevServerView.css"; import type { DetectedDevServerCommand } from "../api"; import { useDevServer } from "../hooks/useDevServer"; import { useDevServerLogs } from "../hooks/useDevServerLogs"; import { usePreviewEmbed } from "../hooks/usePreviewEmbed"; +import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import type { ToastType } from "../hooks/useToast"; import { DevServerLogViewer } from "./DevServerLogViewer"; import { PreviewIframe } from "./PreviewIframe"; @@ -37,6 +39,85 @@ function getStatusBadgeConfig(t: TFunction<"app">): Record<"stopped" | "starting }; } + +const NARROW_RIGHT_DOCK_PREVIEW_THRESHOLD = 480; + +function isTrueMobileViewport(): boolean { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return false; + } + + return window.matchMedia("(max-width: 768px)").matches; +} + +function getDirectRightDockBodyHost(element: HTMLElement): HTMLElement | null { + if (element.closest(".right-dock-expand-modal__body")) { + return null; + } + + const parent = element.parentElement; + if (!parent?.classList.contains("right-dock__body")) { + return null; + } + + return parent; +} + +function readHostInlineSize(host: HTMLElement): number { + if (host.clientWidth > 0) { + return host.clientWidth; + } + + const rect = host.getBoundingClientRect(); + return rect.width; +} + +function shouldUseNarrowRightDockPreviewMode(root: HTMLElement | null): boolean { + if (!root || isTrueMobileViewport()) { + return false; + } + + const host = getDirectRightDockBodyHost(root); + if (!host) { + return false; + } + + return readHostInlineSize(host) <= NARROW_RIGHT_DOCK_PREVIEW_THRESHOLD; +} + +function useNarrowRightDockPreviewMode(rootRef: RefObject): boolean { + const [isNarrowRightDockPreviewMode, setIsNarrowRightDockPreviewMode] = useState(false); + + useEffect(() => { + const root = rootRef.current; + if (!root) { + setIsNarrowRightDockPreviewMode(false); + return; + } + + const host = getDirectRightDockBodyHost(root); + const updateMode = () => setIsNarrowRightDockPreviewMode(shouldUseNarrowRightDockPreviewMode(root)); + + updateMode(); + + if (!host || typeof ResizeObserver === "undefined") { + window.addEventListener("resize", updateMode); + return () => window.removeEventListener("resize", updateMode); + } + + const observer = new ResizeObserver(updateMode); + observer.observe(host); + window.addEventListener("resize", updateMode); + + return () => { + observer.disconnect(); + window.removeEventListener("resize", updateMode); + }; + }, [rootRef]); + + return isNarrowRightDockPreviewMode; +} + let devServerViewWasPreviouslyInactive = false; function normalizeError(error: unknown): string { @@ -142,6 +223,14 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps const effectivePreviewUrl = previewUrl; const selectedSource = session?.config?.cwd ?? null; + const rootRef = useRef(null); + const isNarrowRightDockPreviewMode = useNarrowRightDockPreviewMode(rootRef); + + /* + FNXC:DevServer 2026-06-23-00:00: + The Dev Server preview must escape into a modal when the direct right-dock host is very narrow so preview chrome does not crowd logs and configuration in the same dock column. + The 480px threshold catches the dock's compact range before preview chrome becomes unusable while preserving full-page, true mobile viewport, and expanded pop-out inline previews. + */ const [showCandidates, setShowCandidates] = useState(true); const [commandInput, setCommandInput] = useState(""); const [previewInput, setPreviewInput] = useState(""); @@ -170,6 +259,9 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps }, [executingTasks, selectedTaskId]); const [previewMode, setPreviewMode] = useState("embedded"); + const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false); + const previewModalLauncherRef = useRef(null); + const previewModalRef = useRef(null); const previewEmbedUrl = previewMode === "embedded" ? effectivePreviewUrl : null; const { @@ -271,6 +363,60 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps setPreviewInput(effectivePreviewUrl ?? ""); }, [effectivePreviewUrl]); + const closePreviewModal = useCallback(() => { + setIsPreviewModalOpen(false); + window.requestAnimationFrame(() => previewModalLauncherRef.current?.focus()); + }, []); + const previewModalOverlayDismissProps = useOverlayDismiss(closePreviewModal); + + useEffect(() => { + if (!isPreviewModalOpen) { + return; + } + + previewModalRef.current?.focus(); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + closePreviewModal(); + return; + } + + if (event.key !== "Tab") { + return; + } + + const focusableElements = Array.from( + previewModalRef.current?.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + ) ?? [], + ).filter((element) => !element.hasAttribute("disabled") && element.getAttribute("aria-hidden") !== "true"); + + const firstElement = focusableElements[0]; + const lastElement = focusableElements.at(-1); + if (!firstElement || !lastElement) { + return; + } + + if (event.shiftKey && document.activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } else if (!event.shiftKey && document.activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [closePreviewModal, isPreviewModalOpen]); + + useEffect(() => { + if (!isNarrowRightDockPreviewMode && isPreviewModalOpen) { + setIsPreviewModalOpen(false); + } + }, [isNarrowRightDockPreviewMode, isPreviewModalOpen]); + const handleOpenInNewTab = useCallback(() => { if (!effectivePreviewUrl) { return; @@ -399,8 +545,136 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps const stopDisabled = status === "stopped" || actionInFlight !== null; const restartDisabled = status === "stopped" || status === "starting" || actionInFlight !== null; + const renderPreviewContent = () => ( + <> +
+
+ + {t("devserver.preview", "Preview")} +
+ + {isManualPreviewOverride ? t("devserver.manual", "Manual") : t("devserver.auto", "Auto")} + {effectivePreviewUrl ? ` · ${effectivePreviewUrl}` : t("devserver.notAvailable", " · Not available")} + +
+ + + +
+
+ +
+ {!effectivePreviewUrl && !isRunning && ( +

{t("devserver.startDevServer", "Start a dev server to see a live preview here.")}

+ )} + + {!effectivePreviewUrl && isRunning && ( +

{t("devserver.noPreviewDetected", "No preview URL detected. Start the dev server or set a manual URL to preview your app.")}

+ )} + + {effectivePreviewUrl && previewMode === "external" && ( +
+

{t("devserver.embeddedPreviewDisabled", "Embedded preview is disabled. Open your app in a separate browser tab.")}

+ +
+ )} + + {effectivePreviewUrl && previewMode === "embedded" && showFallback && isBlocked && ( +
+ {embedStatus === "error" + ?
+ )} + + {effectivePreviewUrl && previewMode === "embedded" && !showFallback && ( + + )} +
+ + ); + return ( -
+
{/* FNXC:DevServer 2026-06-22-01:00: Migrated to the shared ViewHeader for cross-view consistency. The status badge sits next to the title inside the actions slot (wrapped in .dev-server-header-title so the existing mobile flex-wrap rule still applies), and the Start/Stop/Restart controls follow in .dev-server-header-actions. ViewHeader supplies the standard view padding; the view body must not repeat the top padding. @@ -641,126 +915,75 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
-
-
-
- - {t("devserver.preview", "Preview")} + {isNarrowRightDockPreviewMode ? ( +
+
+
+ + {t("devserver.preview", "Preview")} +
+ + {effectivePreviewUrl ? effectivePreviewUrl : t("devserver.notAvailable", "Not available")} +
- + {effectivePreviewUrl + ? t("devserver.previewModalLauncherDescription", "Open the live preview in a modal so logs and configuration stay usable in this narrow dock.") + : t("devserver.previewModalLauncherUnavailable", "Start the dev server or set a preview URL to open the preview modal.")} +

+ - - -
-
+ {t("devserver.openPreview", "Open preview")} + +
+ ) : ( +
+ {renderPreviewContent()} +
+ )} -
- {!effectivePreviewUrl && !isRunning && ( -

{t("devserver.startDevServer", "Start a dev server to see a live preview here.")}

- )} - - {!effectivePreviewUrl && isRunning && ( -

{t("devserver.noPreviewDetected", "No preview URL detected. Start the dev server or set a manual URL to preview your app.")}

- )} - - {effectivePreviewUrl && previewMode === "external" && ( -
-

{t("devserver.embeddedPreviewDisabled", "Embedded preview is disabled. Open your app in a separate browser tab.")}

+ {isNarrowRightDockPreviewMode && isPreviewModalOpen && ( +
+
+
+

{t("devserver.preview", "Preview")}

- )} - - {effectivePreviewUrl && previewMode === "embedded" && showFallback && isBlocked && ( -
- {embedStatus === "error" - ?
- + )}
); } diff --git a/packages/dashboard/app/components/__tests__/DevServerView.mobile.test.tsx b/packages/dashboard/app/components/__tests__/DevServerView.mobile.test.tsx index 09d285ec67..cf828f3b7e 100644 --- a/packages/dashboard/app/components/__tests__/DevServerView.mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/DevServerView.mobile.test.tsx @@ -51,12 +51,27 @@ describe("DevServerView mobile CSS/structure", () => { const mobileBlockMatch = css.match(/@media[^{]*\(max-width: 768px\)[^{]*\{([\s\S]*?)\n\}/g) ?? []; const mobileCss = mobileBlockMatch.join("\n"); - const headerRuleCount = (mobileCss.match(/\.devserver-preview-header\s*\{/g) ?? []).length; + const headerRuleCount = (mobileCss.match(/\.devserver-preview-header,\s*\.devserver-preview-modal-launcher__copy\s*\{/g) ?? []).length; expect(headerRuleCount).toBe(1); expect(mobileCss).toMatch(/\.devserver-preview-url-badge\s*\{[\s\S]*max-width:\s*100%/); expect(mobileCss).toMatch(/\.dev-server-header-title\s*\{[\s\S]*flex-wrap:\s*wrap/); }); + it("defines narrow right-dock launcher and modal rules without duplicating mobile media rules", () => { + const css = loadAllAppCss(); + const containerStart = css.indexOf("@container right-dock-body (max-width: 768px)"); + expect(containerStart).toBeGreaterThan(-1); + const containerCss = css.slice(containerStart); + + expect(containerCss).toMatch(/\.devserver-preview-panel,\s*\.devserver-preview-modal-launcher\s*\{[\s\S]*grid-column:\s*auto/); + expect(containerCss).toMatch(/\.devserver-preview-modal\s*\{[\s\S]*width:\s*min\(calc\(var\(--space-2xl\) \* 20\), calc\(100vw - var\(--space-md\) \* 2\)\)/); + expect(containerCss).toMatch(/\.devserver-preview-panel \.devserver-preview-container/); + expect(containerCss).not.toMatch(/\.dev-server-logs,\s*\.devserver-preview-container,\s*\.devserver-preview-iframe/); + + expect(css).toMatch(/@media[^{]*\(max-width: 768px\)/); + expect(css).toMatch(/@container right-dock-body \(max-width: 768px\)/); + }); + it("renders preview header elements and keeps URL badge outside preview actions", () => { mockUseDevServer.mockReturnValue(createDevServerHookState()); mockUseDevServerLogs.mockReturnValue({ diff --git a/packages/dashboard/app/components/__tests__/DevServerView.preview.test.tsx b/packages/dashboard/app/components/__tests__/DevServerView.preview.test.tsx index fa9f8df30f..d40f5de30f 100644 --- a/packages/dashboard/app/components/__tests__/DevServerView.preview.test.tsx +++ b/packages/dashboard/app/components/__tests__/DevServerView.preview.test.tsx @@ -44,6 +44,7 @@ vi.mock("lucide-react", () => ({ Search: () => , ShieldAlert: () => , Square: () => , + X: () => , })); function createState(overrides: Partial = {}): DevServerState { @@ -201,6 +202,153 @@ describe("DevServerView preview panel", () => { afterEach(() => { window.open = originalWindowOpen; + vi.unstubAllGlobals(); + }); + + function renderInRightDock(width: number) { + const host = document.createElement("div"); + host.className = "right-dock__body"; + Object.defineProperty(host, "clientWidth", { configurable: true, value: width }); + document.body.appendChild(host); + + return render(, { container: host }); + } + + it("activates narrow right-dock preview mode only below the dock threshold", async () => { + mockUseDevServer.mockReturnValue( + createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }), + ); + + const narrow = renderInRightDock(420); + + await waitFor(() => { + expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "true"); + }); + + narrow.unmount(); + document.body.innerHTML = ""; + + renderInRightDock(640); + + await waitFor(() => { + expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "false"); + }); + expect(screen.queryByTestId("devserver-preview-modal-launcher")).not.toBeInTheDocument(); + expect(screen.getByTestId("devserver-preview-panel")).toBeInTheDocument(); + }); + + it("replaces the narrow right-dock inline preview with an accessible modal launcher", async () => { + mockUseDevServer.mockReturnValue( + createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }), + ); + mockUseDevServerLogs.mockReturnValue(createDevServerLogsHookState({ + entries: [{ id: "log-1", timestamp: "2026-06-23T00:00:00.000Z", stream: "stdout", text: "ready" }], + total: 1, + })); + previewEmbedState = createPreviewEmbedState({ embedStatus: "embedded", isEmbedded: true }); + + renderInRightDock(420); + + await waitFor(() => { + expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "true"); + }); + + expect(screen.getByTestId("dev-server-logs-panel")).toBeInTheDocument(); + expect(screen.queryByTestId("devserver-preview-panel")).not.toBeInTheDocument(); + expect(screen.queryByTitle("Dev server preview")).not.toBeInTheDocument(); + expect(screen.getByTestId("devserver-preview-modal-launcher")).toHaveTextContent("http://localhost:3000"); + expect(screen.getByTestId("devserver-preview-url-badge")).toHaveTextContent("http://localhost:3000"); + + fireEvent.click(screen.getByTestId("devserver-preview-modal-open")); + + const modal = await screen.findByTestId("devserver-preview-modal"); + expect(modal).toHaveAttribute("role", "dialog"); + expect(modal).toHaveAttribute("aria-modal", "true"); + expect(screen.getByTitle("Dev server preview")).toBeInTheDocument(); + expect(screen.getByTestId("devserver-preview-open-tab")).toBeInTheDocument(); + expect(screen.getByTestId("devserver-preview-refresh")).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: "Escape" }); + + await waitFor(() => { + expect(screen.queryByTestId("devserver-preview-modal")).not.toBeInTheDocument(); + }); + }); + + it("keeps preview modes and fallback actions inside the narrow dock modal", async () => { + const retry = vi.fn(); + mockUseDevServer.mockReturnValue( + createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }), + ); + previewEmbedState = createPreviewEmbedState({ embedStatus: "embedded", isEmbedded: true }); + + const { rerender } = renderInRightDock(420); + + await waitFor(() => { + expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "true"); + }); + + fireEvent.click(screen.getByTestId("devserver-preview-modal-open")); + + previewEmbedState = createPreviewEmbedState({ + embedStatus: "blocked", + isBlocked: true, + embedContext: "The server may block iframe embedding...", + retry, + }); + rerender(); + + await waitFor(() => { + expect(screen.getByTestId("devserver-preview-fallback")).toBeInTheDocument(); + }); + expect(screen.getByText("Preview blocked")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("devserver-preview-fallback-retry")); + expect(retry).toHaveBeenCalledTimes(1); + + previewEmbedState = createPreviewEmbedState({ embedStatus: "embedded", isEmbedded: true }); + rerender(); + fireEvent.click(screen.getByTestId("devserver-preview-mode-toggle")); + + expect(screen.getByTestId("devserver-preview-external-only")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("devserver-preview-external-open-tab")); + expect(window.open).toHaveBeenCalledWith("http://localhost:3000", "_blank", "noopener,noreferrer"); + }); + + it("keeps inline preview mode for true mobile viewport and expanded right-dock hosts", async () => { + vi.stubGlobal("matchMedia", vi.fn().mockImplementation((query: string) => ({ + matches: query === "(max-width: 768px)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }))); + mockUseDevServer.mockReturnValue( + createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }), + ); + + const mobile = renderInRightDock(420); + + await waitFor(() => { + expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "false"); + }); + + mobile.unmount(); + document.body.innerHTML = ""; + vi.unstubAllGlobals(); + + const expandedHost = document.createElement("div"); + expandedHost.className = "right-dock-expand-modal__body"; + Object.defineProperty(expandedHost, "clientWidth", { configurable: true, value: 420 }); + document.body.appendChild(expandedHost); + + render(, { container: expandedHost }); + + await waitFor(() => { + expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "false"); + }); }); it("shows start-empty state when server is not configured", () => {