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, 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"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; import { ViewHeader } from "./ViewHeader"; interface DevServerViewProps { addToast: (msg: string, type?: ToastType) => void; projectId?: string; tasks?: Array; } type PreviewMode = "embedded" | "external"; interface StatusBadgeConfig { className: string; label: string; } function getStatusBadgeConfig(t: TFunction<"app">): Record<"stopped" | "starting" | "running" | "failed" | "stopping", StatusBadgeConfig> { return { stopped: { className: "dev-server-status-badge--stopped", label: t("devserver.status.stopped", "Stopped") }, starting: { className: "dev-server-status-badge--starting", label: t("devserver.status.starting", "Starting...") }, running: { className: "dev-server-status-badge--running", label: t("devserver.status.running", "Running") }, stopping: { className: "dev-server-status-badge--starting", label: t("devserver.status.stopping", "Stopping...") }, failed: { className: "dev-server-status-badge--failed", label: t("devserver.status.failed", "Failed") }, }; } 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 { return error instanceof Error ? error.message : String(error); } function normalizeCwdToSource(cwd: string): string { return cwd === "." ? "root" : cwd; } function normalizeSourceToCwd(source: string | null | undefined): string | null { if (!source) { return null; } return source === "root" ? "." : source; } function candidateMatchesSelection(candidate: DetectedDevServerCommand, selectedScript: string | null, selectedSource: string | null): boolean { if (!selectedScript) { return false; } if (candidate.scriptName !== selectedScript) { return false; } if (!selectedSource) { return true; } return normalizeCwdToSource(candidate.cwd) === selectedSource; } function formatCandidateSource(candidate: DetectedDevServerCommand): string { // DetectedDevServerCommand doesn't have source/workspaceName, so use cwd-based approach if (candidate.cwd === ".") { return "root"; } return candidate.cwd; } function truncateCommand(command: string): string { const maxLength = 60; if (command.length <= maxLength) { return command; } return `${command.slice(0, maxLength)}…`; } export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps) { const { t } = useTranslation("app"); useEffect(() => { recordResumeEvent({ view: "DevServerView", trigger: devServerViewWasPreviouslyInactive ? "route-active" : "remount", projectId, replayAttempted: false, }); devServerViewWasPreviouslyInactive = false; return () => { devServerViewWasPreviouslyInactive = true; recordResumeEvent({ view: "DevServerView", trigger: "route-inactive", projectId, replayAttempted: false, }); }; }, [projectId]); const { session, detectedCommands, previewUrl, isLoading, error, startServer, stopServer, restartServer, setPreviewUrl, detectCommands, refresh, } = useDevServer(projectId); const status = session?.status ?? "stopped"; const isRunning = status === "running" || status === "starting"; const statusBadgeConfig = getStatusBadgeConfig(t); const statusBadge = statusBadgeConfig[status] ?? statusBadgeConfig.stopped; const { entries: logEntries, loading: logsLoading, loadingMore: logsLoadingMore, hasMore: logsHasMore, total: logsTotal, loadMore: loadMoreLogs, } = useDevServerLogs(projectId, Boolean(projectId)); 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(""); const [selectedScript, setSelectedScript] = useState(null); const [selectedTaskId, setSelectedTaskId] = useState(null); const [actionInFlight, setActionInFlight] = useState<"start" | "stop" | "restart" | "preview" | null>(null); /* FNXC:DevServer 2026-06-23-00:00: The board and right dock pass live task data into DevServerView so the dev server can target the checked-out worktree of an executing task instead of only the integration worktree. Only in-progress tasks with concrete worktree paths are targetable because a missing cwd cannot be safely passed to the start endpoint. */ const executingTasks = useMemo( () => (tasks ?? []).filter((task) => task.column === "in-progress" && typeof task.worktree === "string" && task.worktree.length > 0), [tasks], ); const selectedTask = useMemo( () => executingTasks.find((task) => task.id === selectedTaskId) ?? null, [executingTasks, selectedTaskId], ); useEffect(() => { if (selectedTaskId && !executingTasks.some((task) => task.id === selectedTaskId)) { setSelectedTaskId(null); } }, [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 { embedStatus, setEmbedStatus, resetEmbedStatus, iframeRef, isEmbedded, isBlocked, blockReason, retry, } = usePreviewEmbed(previewEmbedUrl); const [showFallback, setShowFallback] = useState(false); const prevStatusRef = useRef(embedStatus); useEffect(() => { const hasTransitioned = prevStatusRef.current !== embedStatus; if (isBlocked && hasTransitioned) { setShowFallback(true); } if (embedStatus === "embedded") { setShowFallback(false); } prevStatusRef.current = embedStatus; }, [embedStatus, isBlocked]); useEffect(() => { setShowFallback(false); }, [effectivePreviewUrl]); const selectedCandidate = useMemo(() => { if (!selectedScript) { return null; } const selectedCwd = normalizeSourceToCwd(selectedSource); return detectedCommands.find((candidate) => { if (candidate.scriptName !== selectedScript) { return false; } if (selectedCwd && candidate.cwd !== selectedCwd) { return false; } if (session?.config?.command && candidate.command !== session.config.command) { return false; } return true; }) ?? detectedCommands.find((candidate) => candidateMatchesSelection(candidate, selectedScript, selectedSource)) ?? null; }, [detectedCommands, session?.config?.command, selectedScript, selectedSource]); useEffect(() => { if (typeof detectCommands !== "function") { return; } void detectCommands().catch((detectError: unknown) => { addToast(normalizeError(detectError), "error"); }); }, [addToast, detectCommands]); useEffect(() => { if (selectedScript) { setShowCandidates(false); return; } setShowCandidates(true); }, [selectedScript]); useEffect(() => { if (session?.status === "running" || session?.status === "starting") { if (session.config?.command?.trim().length > 0) { setCommandInput(session.config.command); } return; } if (selectedCandidate) { setCommandInput(selectedCandidate.command); return; } if (detectedCommands.length > 0) { setCommandInput((current) => (current.trim().length > 0 ? current : detectedCommands[0]?.command ?? "")); } }, [detectedCommands, selectedCandidate, session?.config?.command, session?.status]); useEffect(() => { 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; } window.open(effectivePreviewUrl, "_blank", "noopener,noreferrer"); }, [effectivePreviewUrl]); const handleRetryEmbeddedPreview = useCallback(() => { setShowFallback(false); retry(); }, [retry]); const handleRefreshPreview = useCallback(() => { try { const iframeElement = iframeRef.current; if (iframeElement?.contentWindow) { iframeElement.contentWindow.location.reload(); setShowFallback(false); resetEmbedStatus(); return; } } catch { // Cross-origin reload access can throw. Fall through to cache-buster reload. } if (!effectivePreviewUrl || !iframeRef.current) { return; } try { const refreshedUrl = new URL(effectivePreviewUrl); refreshedUrl.searchParams.set("_t", Date.now().toString()); iframeRef.current.src = refreshedUrl.toString(); setShowFallback(false); resetEmbedStatus(); } catch { iframeRef.current.src = effectivePreviewUrl; setShowFallback(false); resetEmbedStatus(); } }, [effectivePreviewUrl, iframeRef, resetEmbedStatus]); const runAction = useCallback(async (kind: "start" | "stop" | "restart" | "preview", action: () => Promise, successMessage: string) => { setActionInFlight(kind); try { await action(); addToast(successMessage, "success"); } catch (actionError) { addToast(normalizeError(actionError), "error"); } finally { setActionInFlight(null); } }, [addToast]); const handleSelectCandidate = useCallback((candidate: DetectedDevServerCommand) => { setSelectedScript(candidate.scriptName); setShowCandidates(false); setCommandInput(candidate.command); addToast(t("devserver.toast.selectedScript", "Selected {{name}} script.", { name: candidate.scriptName }), "success"); }, [addToast]); const handleClearSelection = useCallback(() => { setSelectedScript(null); setShowCandidates(true); addToast(t("devserver.toast.clearedScript", "Cleared selected dev server script."), "success"); }, [addToast]); const handleTaskSelectionChange = useCallback((nextTaskId: string | null) => { if (isRunning && nextTaskId && nextTaskId !== selectedTaskId) { addToast(t("devserver.restartToApplyTask", "Restart the dev server to apply the selected task's worktree."), "info"); } setSelectedTaskId(nextTaskId); }, [addToast, isRunning, selectedTaskId, t]); const handleStart = () => { const trimmedCommand = commandInput.trim(); if (trimmedCommand.length === 0) { addToast(t("devserver.toast.enterCommand", "Enter a command before starting the dev server."), "warning"); return; } const fallbackCwd = normalizeSourceToCwd(selectedSource) ?? "."; /* FNXC:DevServer 2026-06-23-00:00: A selected executing task's worktree takes precedence over the detected script cwd so the preview process reflects in-progress task work instead of the integration branch. */ const targetedCwd = selectedTask?.worktree ?? null; const cwd = targetedCwd ?? selectedCandidate?.cwd ?? fallbackCwd; void runAction( "start", () => startServer(trimmedCommand, cwd), t("devserver.toast.started", "Dev server started."), ); }; const handleStop = () => { void runAction("stop", stopServer, t("devserver.toast.stopped", "Dev server stopped.")); }; const handleRestart = () => { void runAction("restart", restartServer, t("devserver.toast.restarted", "Dev server restarted.")); }; const handleSetPreview = () => { const trimmed = previewInput.trim(); const nextUrl = trimmed.length > 0 ? trimmed : null; void runAction( "preview", () => setPreviewUrl(nextUrl), nextUrl ? t("devserver.toast.previewUpdated", "Preview URL updated.") : t("devserver.toast.previewCleared", "Preview URL override cleared."), ); }; const handleRetry = useCallback(() => { if (error) { void refresh(); } }, [error, refresh]); const isManualPreviewOverride = false; // With session model, previewUrl is always auto-detected const startDisabled = status === "starting" || status === "running" || actionInFlight !== null; 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. */} {statusBadge.label}
)} />

{t("devserver.configuration", "Configuration")}

{isLoading && {t("devserver.loading", "Loading...")}}
{isLoading && !session && detectedCommands.length === 0 && (
{t("devserver.loadingConfig", "Loading dev server configuration...")}
)} {error && (

{error}

)}

{t("devserver.scriptSelection", "Script Selection")}

{selectedScript && (
{selectedScript} {selectedSource ?? "root"}
)} {showCandidates && detectedCommands.length === 0 && (

{t("devserver.noScriptsDetected", "No dev server scripts detected. Check that your project has a package.json with a dev, start, or similar script.")}

)} {showCandidates && detectedCommands.length > 0 && (
{detectedCommands.map((candidate) => { const isSelected = candidateMatchesSelection(candidate, selectedScript, selectedSource); return ( ); })}
)}

{t("devserver.executingTask", "Executing Task")}

{executingTasks.length === 0 && (

{t("devserver.noExecutingTasks", "No executing tasks with a worktree available. Start a task to target its worktree.")}

)} {selectedTask && (
{/* FNXC:DevServer 2026-06-23-00:00: The selected executing task descriptor is shown next to the worktree picker so users know which in-progress task the preview reflects before they start or restart the dev server. */}
{selectedTask.title ? `${selectedTask.id} — ${selectedTask.title}` : selectedTask.id}

{selectedTask.description}

{t("devserver.targetWorktree", "Target worktree")} {selectedTask.worktree}
)}
setCommandInput(event.target.value)} placeholder="pnpm dev" data-testid="dev-server-command-input" readOnly={status === "running" || status === "starting"} />
{(status === "running" || status === "starting") && session && (
{t("devserver.runningCommand", "Running command")} {session.config?.command ?? commandInput}
)}
setPreviewInput(event.target.value)} placeholder="http://localhost:3000" data-testid="dev-server-preview-input" />
{effectivePreviewUrl && (

{t("devserver.autoDetected", "Auto-detected: {{url}}", { url: effectivePreviewUrl })}

)}

{t("devserver.logs", "Logs")}

{t("devserver.lines", "{{count}} lines", { count: logsTotal ?? logEntries.length })}
{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.")}

) : (
{renderPreviewContent()}
)} {isNarrowRightDockPreviewMode && isPreviewModalOpen && (

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

{renderPreviewContent()}
)}
); }