Files
fusion/packages/dashboard/app/components/DevServerView.tsx
gsxdsm 4f929acc10 fix(dashboard): stop over-aggressive component unmounts (keep-alive for planning, terminals, popups) (#2420)
Implements
docs/plans/2026-07-22-001-fix-dashboard-remount-churn-plan.md: every
confirmed source of unnecessary unmount/remount churn in the dashboard,
plus a keep-alive layer for conversation- and terminal-bearing surfaces.

## What changed

**Keying / component identity (U1–U3)**
- Streaming chat segment key no longer embeds `entries.length` — an
expanded thinking block stays expanded while entries stream into it
(R1).
- Dock task list keys `TaskCard` rows by `task.id` (occurrence suffix
only for the duplicate-id anomaly) instead of `id-index` — no remount on
reorder/filter/status change (R2).
- `ProviderStatusBadge` / `GitHubStatusBadge` hoisted out of
ModelOnboardingModal's render body (R3); MCP server rows key by
`server.name` alone (R4).

**Keep-alive layer (U4–U6)**
- New shared `KeepAliveView` wrapper: visible = in-flow flex child;
hidden = out-of-flow `position:absolute; inset:0` with
`visibility:hidden; pointer-events:none` + `aria-hidden` (never
`display:none`, so xterm geometry never collapses).
- Planning Mode renders as a kept-alive sibling of the MainContent
switch after first open (per-project latch mirroring Quick Chat). While
hidden, the session-list SSE, recovery poll, and elapsed ticker suspend
via a new `active` prop; reveal re-subscribes and refreshes the sessions
list once. Payload-carrying entry points (initial-plan handoff, resume)
and project switches remount via a new
`modalManager.planningEntryGeneration` key, preserving pre-keep-alive
fresh-open semantics. `recordResumeEvent` instrumentation records
`remount` on first activation and `route-active` on reveal.
- Task-detail Terminal / Worktree-terminal / Planner-chat tabs stay
mounted-but-hidden after first open (per-task latches; task switch/close
still disposes fully). `SessionTerminal` gains `active`: reveal refits +
forces a font remeasure, and if the WS died while hidden it re-runs the
full attach lifecycle (dead-socket recovery).
- Popped-out task windows hide via FloatingWindow `hidden` instead of
leaving the render array; `TaskDetailContent` gains `active` so hidden
popups close their SSE/EventSource channels while the terminal WS stays
open. `visiblePoppedOutTaskEntries` remains the Escape-shortcut
consumer.

**Planning Mode internal-transition audit (U7)**
- Audit findings: session-list mode and mobile list/detail flips are
CSS-class transitions over one always-mounted detail pane (no
state-discarding unmounts); re-selecting the active session is an
early-return visibility restore; session switching intentionally reloads
from the session row (stream re-attach for generating sessions);
remaining index keys are on stateless lists. No product-code defects
found; regression tests now lock the always-mounted invariant on desktop
+ mobile.

**Cheap-view state (U8)**
- CommandCenter (active sub-tab + date range) and DevServerView
(selected script/task + typed-but-unsent command) persist per project
via `modalPersistence` and restore after their (intentional) unmount
round-trips. Also fixed the candidate auto-fill effect clobbering a
customized non-empty command.

## Symptom Verification
- **Original symptom:** streaming thinking blocks collapsed mid-stream;
terminals reconnected and lost scroll/input on tab flips; Planning Mode
lost in-flight interviews on navigation; popped-out windows vanished
off-view; dock cards remounted on reorder.
- **Exact reproduction:** (1) expand a thinking block during a stream;
(2) run a command in the Terminal tab, flip to Plan and back; (3) start
a planning interview, navigate Board and back; (4) pop out a task with
board/list-only scoping and switch views; (5) change a dock task's
status.
- **Assertion it is gone:** component-identity/instrumentation tests in
TaskChatTab, SessionTerminal, TaskDetailModal
(worktree/planner-chat/tabs), PlanningModeModal keep-alive +
internal-transitions, App keep-alive round-trip, and
App.taskPopupViewGating assert no remount and preserved state for each
repro, across desktop and mobile breakpoints.

## Verification
- File-scoped vitest: 23 files / 1091 tests green (all touched suites
plus FloatingWindow, TerminalModal, TaskPlannerChatTab,
lazy-loaded-views guard, App suites).
- `pnpm verify:fast`: PASS (13 steps — scoped typecheck/build, CLI
build, boot smoke).
- `pnpm check:changesets`: passes; changeset
`fix-dashboard-remount-churn` (`@runfusion/fusion` patch, labeled
format).
- Known pre-existing failures NOT caused by this branch (verified
failing at base a224c1111 in a clean worktree): 7 tests in
`TaskDetailModal.oversight-controls/oversight-mobile/models-progress-workflow`.
- jsdom cannot prove rendered-grid correctness for xterm reveal; per the
plan's risk note, manual browser verification of terminal reveal remains
recommended.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Switching views or tabs no longer resets Planning Mode, task details,
terminals, planner chats, or popped-out task windows.
  * Streaming content remains expanded and stable as new entries arrive.
* Hidden views suspend background activity and resume correctly when
shown.
  * Terminal sessions reconnect automatically when needed.

* **Improvements**
  * Command Center and Dev Server selections persist per project.
  * Custom Dev Server commands are preserved while browsing suggestions.
* Improved stability when reordering task lists and updating server
states.

* **Documentation**
* Updated dashboard guidance for hidden, retained task pop-ups and view
transitions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 21:50:47 -07:00

1033 lines
40 KiB
TypeScript

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<string, Parameters<typeof isWipColumnRole>[0]>;
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, 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<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);
/*
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<string | null>(() => getDevServerState(projectId)?.selectedScript ?? null);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(() => 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<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) {
/*
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<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>
);
}