import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getDevServerState, saveDevServerState } from "../hooks/modalPersistence"; import { isWipColumnRole } from "../utils/columnRoles"; 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 { /** Per-task resolved column flags, from MainContent. */ columnFlagsByTaskId?: ReadonlyMap[0]>; 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, columnFlagsByTaskId }: 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); /* FNXC:DevServer 2026-07-22-13:40: FN remount-churn fix R12: this view unmounts on navigation by design (no keep-alive), so the selected script/task target and a typed-but-unsent command restore from per-project persisted state on remount (modalPersistence precedent). Log pagination/scroll intentionally re-derives live. */ const [commandInput, setCommandInput] = useState(() => getDevServerState(projectId)?.commandInput ?? ""); const [previewInput, setPreviewInput] = useState(""); const [selectedScript, setSelectedScript] = useState(() => getDevServerState(projectId)?.selectedScript ?? null); const [selectedTaskId, setSelectedTaskId] = useState(() => getDevServerState(projectId)?.selectedTaskId ?? null); const devServerPersistProjectRef = useRef(projectId); useEffect(() => { if (devServerPersistProjectRef.current === projectId) return; devServerPersistProjectRef.current = projectId; const stored = getDevServerState(projectId); setCommandInput(stored?.commandInput ?? ""); setSelectedScript(stored?.selectedScript ?? null); setSelectedTaskId(stored?.selectedTaskId ?? null); }, [projectId]); useEffect(() => { if (devServerPersistProjectRef.current !== projectId) return; saveDevServerState({ selectedScript, selectedTaskId, commandInput }, projectId); }, [commandInput, projectId, selectedScript, selectedTaskId]); 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. */ /* FNXC:WorkflowResolvedColumns 2026-07-30-18:30 (batch-dashboard-app): WIP role, resolved PER TASK. This list is the dev-server's set of live worktrees to attach to; keyed on the literal it was EMPTY on a renamed board, so the view offered nothing to attach to while agents were running with worktrees on disk. Per-task rather than per-column id: `columnFlagsByTaskId` is what MainContent already threads to its other children, and an id-keyed map would answer with a neighbouring workflow's traits when two workflows reuse a column id. BOTH RENDER SURFACES ARE NOW COVERED. This view also mounts through `overflowViewRegistry` into the right dock, and that path used to answer on the legacy id because the registry's render props carried no flags — recorded here as unfixed while it was. `OverflowViewRenderProps` now carries `columnFlagsByTaskId`, threaded from App through `useRightDockController`, so the dock surface resolves the same way this one does. */ const executingTasks = useMemo( () => (tasks ?? []).filter((task) => isWipColumnRole(columnFlagsByTaskId?.get(task.id), task.column) && typeof task.worktree === "string" && task.worktree.length > 0), [tasks, columnFlagsByTaskId], ); 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) { /* FNXC:DevServer 2026-07-22-13:50: Never clobber a non-empty command with the candidate default: the user may have customized the command after selecting the script (explicit candidate clicks still sync it via handleSelectCandidate), and R12's restored typed-but-unsent command must survive the remount this effect runs on. */ setCommandInput((current) => (current.trim().length > 0 ? current : 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()}
)}
); }