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
990 lines
37 KiB
TypeScript
990 lines
37 KiB
TypeScript
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<Task | TaskDetail>;
|
|
}
|
|
|
|
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<HTMLDivElement | null>): 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<HTMLDivElement>(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<string | null>(null);
|
|
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(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<PreviewMode>("embedded");
|
|
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
|
|
const previewModalLauncherRef = useRef<HTMLButtonElement>(null);
|
|
const previewModalRef = useRef<HTMLDivElement>(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<HTMLElement>(
|
|
'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<void>, 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 = () => (
|
|
<>
|
|
<div className="devserver-preview-header">
|
|
<div className="devserver-preview-title">
|
|
<Eye size={14} />
|
|
<span>{t("devserver.preview", "Preview")}</span>
|
|
</div>
|
|
<span
|
|
className={`devserver-preview-url-badge ${isManualPreviewOverride ? "devserver-preview-url-badge--manual" : "devserver-preview-url-badge--auto"}`}
|
|
title={effectivePreviewUrl ?? t("devserver.noPreviewUrl", "No preview URL")}
|
|
data-testid="devserver-preview-url-badge"
|
|
>
|
|
{isManualPreviewOverride ? t("devserver.manual", "Manual") : t("devserver.auto", "Auto")}
|
|
{effectivePreviewUrl ? ` · ${effectivePreviewUrl}` : t("devserver.notAvailable", " · Not available")}
|
|
</span>
|
|
<div className="devserver-preview-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm"
|
|
onClick={() => setPreviewMode((current) => (current === "embedded" ? "external" : "embedded"))}
|
|
data-testid="devserver-preview-mode-toggle"
|
|
>
|
|
{previewMode === "embedded" ? t("devserver.externalOnly", "External only") : t("devserver.embedded", "Embedded")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-icon"
|
|
title={t("devserver.openInNewTab", "Open in new tab")}
|
|
onClick={handleOpenInNewTab}
|
|
disabled={!effectivePreviewUrl}
|
|
data-testid="devserver-preview-open-tab"
|
|
>
|
|
<ExternalLink />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-icon"
|
|
title={t("devserver.refreshPreview", "Refresh preview")}
|
|
onClick={handleRefreshPreview}
|
|
disabled={!effectivePreviewUrl}
|
|
data-testid="devserver-preview-refresh"
|
|
>
|
|
<RefreshCw />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="devserver-preview-container" data-embed-status={embedStatus} data-embedded={isEmbedded ? "true" : "false"}>
|
|
{!effectivePreviewUrl && !isRunning && (
|
|
<p className="devserver-preview-empty">{t("devserver.startDevServer", "Start a dev server to see a live preview here.")}</p>
|
|
)}
|
|
|
|
{!effectivePreviewUrl && isRunning && (
|
|
<p className="devserver-preview-empty">{t("devserver.noPreviewDetected", "No preview URL detected. Start the dev server or set a manual URL to preview your app.")}</p>
|
|
)}
|
|
|
|
{effectivePreviewUrl && previewMode === "external" && (
|
|
<div className="devserver-preview-external-only" data-testid="devserver-preview-external-only">
|
|
<p>{t("devserver.embeddedPreviewDisabled", "Embedded preview is disabled. Open your app in a separate browser tab.")}</p>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-sm touch-target"
|
|
onClick={handleOpenInNewTab}
|
|
data-testid="devserver-preview-external-open-tab"
|
|
>
|
|
{t("devserver.openInNewTab", "Open in new tab")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{effectivePreviewUrl && previewMode === "embedded" && showFallback && isBlocked && (
|
|
<div
|
|
className={embedStatus === "error" ? "devserver-preview-error-panel" : "devserver-preview-blocked-panel"}
|
|
data-testid="devserver-preview-fallback"
|
|
role="alert"
|
|
>
|
|
{embedStatus === "error"
|
|
? <AlertTriangle className="devserver-preview-blocked-icon" aria-hidden="true" />
|
|
: <ShieldAlert className="devserver-preview-blocked-icon" aria-hidden="true" />}
|
|
<div>
|
|
<p className="devserver-preview-blocked-title">
|
|
{embedStatus === "error" ? t("devserver.previewFailed", "Preview failed") : t("devserver.previewBlocked", "Preview blocked")}
|
|
</p>
|
|
{blockReason && <p className="devserver-preview-blocked-context">{blockReason}</p>}
|
|
</div>
|
|
<p className="devserver-preview-blocked-description">
|
|
{t("devserver.openPreviewOrRetry", "Open the preview in a new tab, or retry embedded mode after checking your server settings.")}
|
|
</p>
|
|
<div className="devserver-preview-blocked-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
onClick={handleOpenInNewTab}
|
|
data-testid="devserver-preview-fallback-open-tab"
|
|
>
|
|
{t("devserver.openPreviewInNewTab", "Open preview in new tab")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm"
|
|
onClick={handleRetryEmbeddedPreview}
|
|
data-testid="devserver-preview-fallback-retry"
|
|
>
|
|
{t("devserver.retryEmbeddedPreview", "Retry embedded preview")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{effectivePreviewUrl && previewMode === "embedded" && !showFallback && (
|
|
<PreviewIframe
|
|
url={effectivePreviewUrl}
|
|
embedStatus={embedStatus}
|
|
onEmbedStatusChange={setEmbedStatus}
|
|
iframeRef={iframeRef}
|
|
blockReason={blockReason}
|
|
onRetry={handleRetryEmbeddedPreview}
|
|
/>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={rootRef}
|
|
className="dev-server-view"
|
|
data-testid="dev-server-view"
|
|
data-narrow-right-dock-preview={isNarrowRightDockPreviewMode ? "true" : "false"}
|
|
>
|
|
{/*
|
|
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.
|
|
*/}
|
|
<ViewHeader
|
|
icon={Monitor}
|
|
title={t("devserver.title", "Dev Server")}
|
|
actions={(
|
|
<>
|
|
<span className="dev-server-header-title">
|
|
<span
|
|
className={`dev-server-status-badge ${statusBadge.className}`}
|
|
data-testid="dev-server-status-badge"
|
|
>
|
|
{statusBadge.label}
|
|
</span>
|
|
</span>
|
|
<div className="dev-server-header-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-sm"
|
|
onClick={handleStart}
|
|
disabled={startDisabled}
|
|
data-testid="dev-server-start-button"
|
|
>
|
|
<Play size={14} />
|
|
<span>{actionInFlight === "start" ? t("devserver.starting", "Starting...") : t("devserver.start", "Start")}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-danger btn-sm"
|
|
onClick={handleStop}
|
|
disabled={stopDisabled}
|
|
data-testid="dev-server-stop-button"
|
|
>
|
|
<Square size={14} />
|
|
<span>{actionInFlight === "stop" ? t("devserver.stopping", "Stopping...") : t("devserver.stop", "Stop")}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm"
|
|
onClick={handleRestart}
|
|
disabled={restartDisabled}
|
|
data-testid="dev-server-restart-button"
|
|
>
|
|
<RotateCw size={14} />
|
|
<span>{actionInFlight === "restart" ? t("devserver.restarting", "Restarting...") : t("devserver.restart", "Restart")}</span>
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
/>
|
|
|
|
<section className="dev-server-panel dev-server-config" aria-label={t("devserver.configurationLabel", "Dev server configuration")}>
|
|
<div className="dev-server-section-header">
|
|
<h3>{t("devserver.configuration", "Configuration")}</h3>
|
|
{isLoading && <span className="dev-server-muted">{t("devserver.loading", "Loading...")}</span>}
|
|
</div>
|
|
|
|
{isLoading && !session && detectedCommands.length === 0 && (
|
|
<div className="dev-server-loading-state" data-testid="dev-server-loading-state">
|
|
<Loader2 size={16} className="dev-server-spin" />
|
|
<span>{t("devserver.loadingConfig", "Loading dev server configuration...")}</span>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="dev-server-error-box" role="alert" data-testid="dev-server-error-box">
|
|
<p>{error}</p>
|
|
<button type="button" className="btn btn-sm" onClick={handleRetry}>{t("devserver.retry", "Retry")}</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="dev-server-section">
|
|
<h3>{t("devserver.scriptSelection", "Script Selection")}</h3>
|
|
|
|
{selectedScript && (
|
|
<div className="dev-server-selected" data-testid="dev-server-selected-summary">
|
|
<span className="dev-server-candidate-name">{selectedScript}</span>
|
|
<span className="dev-server-candidate-source">{selectedSource ?? "root"}</span>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm"
|
|
onClick={() => setShowCandidates(true)}
|
|
data-testid="dev-server-change-selection"
|
|
>
|
|
{t("devserver.change", "Change")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-danger btn-sm"
|
|
onClick={handleClearSelection}
|
|
data-testid="dev-server-clear-selection"
|
|
>
|
|
{t("devserver.clear", "Clear")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{showCandidates && detectedCommands.length === 0 && (
|
|
<p className="dev-server-empty-state" data-testid="dev-server-empty-candidates">
|
|
{t("devserver.noScriptsDetected", "No dev server scripts detected. Check that your project has a package.json with a dev, start, or similar script.")}
|
|
</p>
|
|
)}
|
|
|
|
{showCandidates && detectedCommands.length > 0 && (
|
|
<div className="dev-server-candidates" data-testid="dev-server-candidates">
|
|
{detectedCommands.map((candidate) => {
|
|
const isSelected = candidateMatchesSelection(candidate, selectedScript, selectedSource);
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={`${candidate.cwd}::${candidate.scriptName}::${candidate.command}`}
|
|
className={`dev-server-candidate ${isSelected ? "dev-server-candidate--selected" : ""}`}
|
|
onClick={() => handleSelectCandidate(candidate)}
|
|
data-testid={`dev-server-candidate-${candidate.scriptName}-${normalizeCwdToSource(candidate.cwd)}`}
|
|
>
|
|
<span className="dev-server-candidate-name">{candidate.scriptName}</span>
|
|
<span className="dev-server-candidate-command">{truncateCommand(candidate.command)}</span>
|
|
<span className="dev-server-candidate-source">{formatCandidateSource(candidate)}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="dev-server-section dev-server-executing-task-section">
|
|
<h3>{t("devserver.executingTask", "Executing Task")}</h3>
|
|
<div className="dev-server-field-group">
|
|
<label htmlFor="dev-server-task-picker" className="dev-server-label">{t("devserver.executingTask", "Executing Task")}</label>
|
|
<select
|
|
id="dev-server-task-picker"
|
|
className="input dev-server-task-picker"
|
|
value={selectedTaskId ?? ""}
|
|
onChange={(event) => handleTaskSelectionChange(event.target.value.length > 0 ? event.target.value : null)}
|
|
disabled={executingTasks.length === 0}
|
|
aria-label={t("devserver.executingTask", "Executing Task")}
|
|
data-testid="dev-server-task-picker"
|
|
>
|
|
<option value="">{t("devserver.projectRootNoTask", "Project root (no task)")}</option>
|
|
{executingTasks.map((task) => (
|
|
<option key={task.id} value={task.id}>
|
|
{task.title ? `${task.id} — ${task.title}` : task.id}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{executingTasks.length === 0 && (
|
|
<p className="dev-server-empty-state" data-testid="dev-server-no-executing-tasks">
|
|
{t("devserver.noExecutingTasks", "No executing tasks with a worktree available. Start a task to target its worktree.")}
|
|
</p>
|
|
)}
|
|
{selectedTask && (
|
|
<div className="dev-server-task-descriptor" data-testid="dev-server-task-descriptor">
|
|
{/*
|
|
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.
|
|
*/}
|
|
<div className="dev-server-task-descriptor-header">
|
|
<span className="dev-server-candidate-name">
|
|
{selectedTask.title ? `${selectedTask.id} — ${selectedTask.title}` : selectedTask.id}
|
|
</span>
|
|
</div>
|
|
<p className="dev-server-task-description">{selectedTask.description}</p>
|
|
<div className="dev-server-task-worktree">
|
|
<span className="dev-server-label">{t("devserver.targetWorktree", "Target worktree")}</span>
|
|
<code>{selectedTask.worktree}</code>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="dev-server-field-group">
|
|
<label htmlFor="dev-server-command" className="dev-server-label">{t("devserver.command", "Command")}</label>
|
|
<input
|
|
id="dev-server-command"
|
|
className="input"
|
|
value={commandInput}
|
|
onChange={(event) => setCommandInput(event.target.value)}
|
|
placeholder="pnpm dev"
|
|
data-testid="dev-server-command-input"
|
|
readOnly={status === "running" || status === "starting"}
|
|
/>
|
|
</div>
|
|
|
|
{(status === "running" || status === "starting") && session && (
|
|
<div className="dev-server-current-command" data-testid="dev-server-current-command">
|
|
<span className="dev-server-label">{t("devserver.runningCommand", "Running command")}</span>
|
|
<code>{session.config?.command ?? commandInput}</code>
|
|
</div>
|
|
)}
|
|
|
|
<div className="dev-server-preview-override">
|
|
<label htmlFor="dev-server-preview-input" className="dev-server-label">{t("devserver.previewUrlOverride", "Preview URL Override")}</label>
|
|
<input
|
|
id="dev-server-preview-input"
|
|
className="input"
|
|
type="url"
|
|
value={previewInput}
|
|
onChange={(event) => setPreviewInput(event.target.value)}
|
|
placeholder="http://localhost:3000"
|
|
data-testid="dev-server-preview-input"
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-sm"
|
|
onClick={handleSetPreview}
|
|
disabled={actionInFlight === "preview"}
|
|
data-testid="dev-server-set-preview"
|
|
>
|
|
{t("devserver.save", "Save")}
|
|
</button>
|
|
</div>
|
|
|
|
{effectivePreviewUrl && (
|
|
<p className="dev-server-preview-hint">{t("devserver.autoDetected", "Auto-detected: {{url}}", { url: effectivePreviewUrl })}</p>
|
|
)}
|
|
</section>
|
|
|
|
<div className="dev-server-content">
|
|
<section className="dev-server-panel dev-server-logs-panel" data-testid="dev-server-logs-panel" aria-label={t("devserver.logsLabel", "Dev server logs")}>
|
|
<div className="dev-server-section-header">
|
|
<h3>{t("devserver.logs", "Logs")}</h3>
|
|
<span className="dev-server-muted">{t("devserver.lines", "{{count}} lines", { count: logsTotal ?? logEntries.length })}</span>
|
|
</div>
|
|
<div className="dev-server-logs-viewer" data-testid="dev-server-log-viewer">
|
|
<DevServerLogViewer
|
|
entries={logEntries}
|
|
loading={logsLoading}
|
|
loadingMore={logsLoadingMore}
|
|
hasMore={logsHasMore}
|
|
total={logsTotal}
|
|
onLoadMore={loadMoreLogs}
|
|
isRunning={isRunning}
|
|
/>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
{isNarrowRightDockPreviewMode ? (
|
|
<section
|
|
className="dev-server-panel devserver-preview-modal-launcher"
|
|
data-testid="devserver-preview-modal-launcher"
|
|
aria-label={t("devserver.previewLabel", "Dev server preview")}
|
|
>
|
|
<div className="devserver-preview-modal-launcher__copy">
|
|
<div className="devserver-preview-title">
|
|
<Eye size={14} />
|
|
<span>{t("devserver.preview", "Preview")}</span>
|
|
</div>
|
|
<span
|
|
className={`devserver-preview-url-badge ${isManualPreviewOverride ? "devserver-preview-url-badge--manual" : "devserver-preview-url-badge--auto"}`}
|
|
title={effectivePreviewUrl ?? t("devserver.noPreviewUrl", "No preview URL")}
|
|
data-testid="devserver-preview-url-badge"
|
|
>
|
|
{effectivePreviewUrl ? effectivePreviewUrl : t("devserver.notAvailable", "Not available")}
|
|
</span>
|
|
</div>
|
|
<p className="devserver-preview-modal-launcher__description">
|
|
{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.")}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-sm"
|
|
ref={previewModalLauncherRef}
|
|
onClick={() => setIsPreviewModalOpen(true)}
|
|
data-testid="devserver-preview-modal-open"
|
|
>
|
|
{t("devserver.openPreview", "Open preview")}
|
|
</button>
|
|
</section>
|
|
) : (
|
|
<section className="dev-server-panel devserver-preview-panel" data-testid="devserver-preview-panel" aria-label={t("devserver.previewLabel", "Dev server preview")}>
|
|
{renderPreviewContent()}
|
|
</section>
|
|
)}
|
|
|
|
{isNarrowRightDockPreviewMode && isPreviewModalOpen && (
|
|
<div className="modal-overlay open devserver-preview-modal-overlay" {...previewModalOverlayDismissProps}>
|
|
<div
|
|
className="modal devserver-preview-modal"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="devserver-preview-modal-title"
|
|
tabIndex={-1}
|
|
ref={previewModalRef}
|
|
data-testid="devserver-preview-modal"
|
|
>
|
|
<div className="devserver-preview-modal__titlebar">
|
|
<h2 id="devserver-preview-modal-title">{t("devserver.preview", "Preview")}</h2>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-icon"
|
|
onClick={closePreviewModal}
|
|
aria-label={t("devserver.closePreviewModal", "Close preview modal")}
|
|
data-testid="devserver-preview-modal-close"
|
|
>
|
|
<X />
|
|
</button>
|
|
</div>
|
|
<div className="devserver-preview-modal__body">
|
|
{renderPreviewContent()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|