Files
fusion/packages/dashboard/app/components/DevServerView.tsx
gsxdsm ba40942a10 batch-dashboard-app: 75 → 2 across packages/dashboard/app — the last two are deliberate, not missed (#2772)
**Batch branch is live: `batch-dashboard-app`.** Push conversions here
as commits rather than opening per-file PRs — that is the CI-run
bottleneck this model removes.

**One-line ownership note for you to arbitrate:** you have addressed me
as U11, U12 and U7 at different points, so the `u12 worker ->
batch-dashboard-app` mapping is ambiguous from my side. I claimed it
because `dashboard/app` is where I have done the most work this session
(TaskContextMenu, Column, TaskCard, TaskDetailModal, columnRoles,
taskActivity) and I know which of its guards are load-bearing fallbacks.
**If another worker is the intended owner, say so and I will hand the
branch over rather than both of us pushing to it** — two workers on one
shared branch is exactly what silently discarded a reviewed fix in #2645
today.

## The work order (measured at branch point, tests excluded)

**75 guards across 32 files.** Largest: `TaskContextMenu.tsx` 9 ·
`Column.tsx` 7 · `ListView.tsx` 6 · `TaskDetailModal.tsx` 4 · then a
long tail of 3s, 2s and 1s. Full per-file list is in the committed work
order so feeders can claim without re-measuring.

## Two rules this surface keeps tripping on

**1. A literal after `??`, or in the `else` of a `flags ?` ternary, is a
DEGRADED-MODE answer — not an unconverted guard.** Two real states reach
it: the **pre-load window** (board renders before the workflows fetch
resolves) and a card stranded on an id its workflow no longer declares.
In both, `columnFlagsById` has no entry at all. Deleting the fallback
does not remove a decision — it substitutes "no role" silently, and
affordances vanish during first paint.

Those sites reach 0 by **marking**, not deleting. Expect
`TaskContextMenu.tsx` and the `utils` files to be **mostly marks**. A "9
→ 0" that deleted 9 fallbacks is a regression wearing a green census.

**2. A marker excuses ONLY the construct it is attached to** — the
statement or function holding the literal, not a sibling declaration.
This has cost three passes, two of them mine; my first attempt on
`reliability-metrics.ts` scored **1 of 6**. **Verify by the count
moving, not by the comment existing.** With the ratchet gate-blocking, a
mis-marked batch either wedges the gate or locks the miss into a
re-recorded baseline.

## Status

Opening commit is the work order only — **0 of 75 converted so far.** I
am near the end of my context, so I am establishing the branch and the
shared list rather than starting conversions I cannot finish cleanly.
Feeders can begin immediately; I will keep the branch rebased.

My other PR **#2762** (`live-agent-count.ts` 6 → 0) is green and
unconflicted — per your rule it should land rather than fold into a
batch, and it is `packages/core` so it belongs to batch-core anyway.

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

* **New Features**
* Task UI now resolves workflow “column roles” per task to drive
diffs/merge details, routing/steering, progress/runtime visibility, and
review badges.
* Right-dock/overflow views and dev-server now use per-task column
traits for “executing” behavior and dependency-based “Up Next”
eligibility.
* **Bug Fixes**
* Fixed bulk action selection/delete/archive eligibility and prevented
cross-workflow role leakage.
* Made in-review/stale-paused-review, stuck, and effective
executor/validator model logic role-aware.
* **Tests**
* Added regression coverage for degraded-flag behavior and ensured
resolved-flag props aren’t ignored.
  * Added a static check to fail builds on inert optional flag seams.
* **Documentation**
  * Updated batch work-order and mega-batch branch guidance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---

## Late addition: the seam gate was masking a real offender

`scripts/check-inert-flag-seams.mjs` matched call sites by NAME, so two
same-named functions in
different modules were conflated. I had documented that as a known
false-positive source and moved
on — reports mentioning `sortTasksForDisplayColumn` are noise, read past
them.

That annotation was the damage. Core's `sortTasksForDisplayColumn`
genuinely never receives its
`columnFlags` argument outside its own tests. The dashboard's separate
function of the same name
(`app/components/taskSorting.ts`), called with up to five arguments from
`Lane`/`Board`/`ListView`,
was raising the arg-count max and clearing core's seam. The offender was
behind a row everyone had
been told to skip.

The gate now records the module each callee is imported from and matches
it against the seam's
declaring module.

**Measured, by reverting the change:** the scan prints `17 seams, all
supplied` and emits **no row**
for the function. With the change, it is reported. Both directions
watched.

Reported on #2783 rather than fixed from outside — core owns it, and
"wire the flags" vs "drop the
parameter and let the literal stay counted" is their judgment call.
TEMPORARY allow-list entry
carries it meanwhile; the existing staleness check fails the moment the
site becomes supplied, so
the entry cannot outlive the fix.

Two known limits remain, both inherent to name matching and both
documented in the script: the
one-supplier floor, and the `__tests__` exclusion (hence the two
permanent `ALLOWED` entries).


## And the one-supplier floor, closed the same way

I wrote in the section above that the floor "hasn't cost anything yet."
That is verbatim the
reasoning that kept the imported-shadow bug alive, so I closed it
instead of leaving the note.

`best < arity` asked only whether SOME caller supplied the argument. One
correct call site cleared
the seam while every sibling took the legacy fallback — the
`isTaskStuck` defect class, where two of
three sites omitted the flags and the gate stayed green because the
third was right. Review caught
that one. A partially-supplied seam is the harder of the two:
wholly-unsupplied is uniformly wrong,
this works on the board you tested and degrades on the column you did
not.

**Measured:** dropping the flags argument at `Column.tsx`'s supplied
call site produces
`supplied by 5/6 call sites; omitted at
packages/dashboard/app/components/Column.tsx:1 (of 2)`;
restoring returns `all supplied at every call site`. Red and green both
watched.

Two real omissions found, both on `isNearDuplicateCanonicalInactive`:

- **`TaskDetailModal.tsx`** — deliberate, and it **corrects a note I
left at that site**. The old
note said hoisting the flags state was "the actual fix." It is not, for
this call: the flags in
scope describe the *modal's* task, and the canonical is a **different
task** on a column this
component never resolves. Passing them would type-check, read as a
conversion, and answer about
the wrong task — exactly what `column-role-degraded-flags.test.ts`
exists to catch. Supplying it
  correctly needs a fetch, which is a data change and out of scope.
- **`core/task-store/branch-group-ops.ts`** — genuinely wireable (the
impl is async and already
holds `store` and `canonicalId`). Reported on #2783, not edited from
outside.

Exemptions for this class are keyed by **call site**
(`<file>::<function>`), not by function name.
A name-level entry would waive every site of a partially-supplied seam,
which is backwards — its
other sites are correct and are the reason the omission is worth
reporting. Both entries carry the
same staleness check as the name-level list and cannot outlive their
fix.

Remaining known limit, now the only one: the `__tests__` exclusion,
which makes a test-only export
read as having no callers. That is what the two permanent `ALLOWED`
entries are.


## The `__tests__` exclusion, and two allow-list entries built on false
reasons

Named as the "last remaining limit" above, so it got closed too. The
scan now reads test files for
call sites — but counts them **separately**, and a test never clears a
seam. That direction is the
dangerous one: counting test callers as suppliers would have re-hidden
core's
`sortTasksForDisplayColumn`, whose only suppliers are its own tests.
Measured by lifting its
exemption: still reported.

Both permanent allow-list entries claimed the scanner couldn't see their
callers. **Both reasons
were false**, and reading tests is what proved it:

- **`evaluateMergeBlockerGuard`** — zero callers in tests either. Its
only reference in the repo is
its own declaration; never registered as a trait hook; the
`evaluateDefaultWorkflowGuards` reader
its file header credits does not exist. The `lifecycleColumns`
conversion went onto dead code, and
its note describes a crossing the guard cannot make. Reported on #2783,
including the two things I
am explicitly *not* concluding (no `"guard"` hook is registered in
production; whether that is
  residue or a dropped registration needs core's intent).
- **`isRecoverableMissingWorktreeReviewFailure`** — 5 test call sites.
It wraps
`...WithProgress`/`...NoProgress`, the live pair called from
`self-healing.ts`, both supplying
  `reviewColumns`. Entry kept, true reason recorded.

### A wrong turn, recorded because it is the failure mode this PR is
about

I first classified no-production-caller seams as *informational* when
they weren't re-exported from
a package index, reasoning that a public export might be called
externally. That silently downgraded
`sortTasksForDisplayColumn` — a confirmed real offender — from failing
to a footnote. Publication
status has nothing to do with whether there is production behaviour to
be wrong. Reverted to the
simple rule: no production caller means inert, and it fails.

It is worth stating plainly because it is the exact shape of everything
else in this PR: a change
that made the gate read *cleaner* while making it catch *less*, and it
type-checked, passed every
test, and would have reviewed fine.

### Where that leaves the check

Every blind spot named in this PR has now been closed, and **each one
produced a real defect within
minutes of closing it** — imported shadows, the one-supplier floor, the
`__tests__` exclusion. Four
verified findings went to core, one to engine. I would not read the
remaining ~240 guards' green
gates as evidence that they are clean; I would read them as untested.


## Two guards for one question, one of them worse

Having hardened the script, I checked its older twin rather than
assuming it was fine.
`resolved-flags-seams-have-suppliers.test.ts` carried its own copy of
the trailing-flags-parameter
check — written before the script existed — with **all three** holes the
script has since closed.

**Measured on one reintroduced defect** (dropping the flags argument at
`Column.tsx`'s supplied
`isNearDuplicateCanonicalInactive` call):

| | result |
|---|---|
| `scripts/check-inert-flag-seams.mjs` | `supplied by 5/6 call sites;
omitted at .../Column.tsx:1 (of 2)` |
| this test's arity half | **3 passed** |

Deleted the arity half. Redundancy between a strong and a weak check
isn't redundancy — it's a green
result available to whoever runs the weak one, and there was no signal
at the call site telling you
which you were looking at.

The **props-shape half stays**: it has no twin in the script, and I
confirmed it still fires by
reintroducing the original `PrPanel` defect (outer component stops
destructuring `taskColumnFlags`)
— it reports `PrPanel declares taskColumnFlags but never takes it`.

Dashboard app suite: **113 files / 3921 tests** (was 3922 — the deleted
case is the difference).


## The gate started catching defects as they landed

Syncing with main brought in three fresh conversions from other workers.
The hardened check flagged
all three immediately — the first time these guards have fired on
someone else's landed code rather
than on my own.

- **`TaskCard`** — `getRunningOptionalGateBadge(task)` omitted flags
while *both* `ListView` sites
supplied. Fixed, and `taskColumnFlags` added to the `useMemo` deps: no
`exhaustive-deps` rule here,
so a memo that reads flags without listing them keeps the first-paint
`undefined` answer and
  reproduces the bug through staleness instead of omission.
- **`TaskTokenStatsPanel`** — `getTotalAgentActiveMs` omitted while
`TaskCard` supplied, so the same
runtime number came from the real column on a card and from legacy ids
in the detail modal. Now
takes `columnFlags`, supplied from `detailColumnFlags` — correct here
because the panel renders the
  modal's **own** task, unlike the near-duplicate canonical above.
- **`ListView` ×2** — passed `columnFlagsById.get(task.column)`, the
cross-workflow **union**. A task
whose own workflow doesn't declare that column gets a *neighbour
workflow's* traits. The landed
comment justified it as "this list already owns `columnFlagsById`" —
exactly the reasoning
`column-role-degraded-flags.test.ts` exists to reject. It failed on
merge and is how I found this.

Also: the `getTotalAgentActiveMs` exemption I was carrying
**self-retired**. Main wired the seam, the
staleness check failed the entry, and I removed it. That mechanism has
now paid for itself once.

### Pre-existing, NOT from this PR: `App.test.tsx` is red on main

`app/components/__tests__/App.test.tsx` fails **10 of 141** identically
with my changes, with my
changes stashed, and with main's own `App.tsx` restored. Not mine, and
not in the merge gate.

**Bisected on clean `main` checkouts, so this is measured rather than
inferred:**

| commit | date | result |
|---|---|---|
| `main~400` (`41d60f0355`) | 2026-07-25 | **140 passed** (140 tests) |
| `main~275` (`74d6513fae`) | 2026-07-27 | 3 failed / 141 |
| `main~210` (`d2ce1ba8b5`) | 2026-07-29 | 10 failed / 141 |
| `main` (`6fc98fd6c7`) | 2026-07-30 | 10 failed / 141 |

So it is **not one regression** — it degraded in two stages across
2026-07-25 → 07-29, and the test
file itself changed in that window (140 → 141 tests). Three commits
touched it there:
`73b2a32e2b`, `f26cbedf4f`, `f157bf7460`. That window overlaps the
workflow-owned lifecycle
migration, which is suggestive but not something I confirmed.

The failures are render-level, not assertion-level — `Unable to find an
element with the text: + New
Task`, `Unable to find role="dialog"`, `Unable to find ... Back nav
task`. The board appears to
render nothing. That reads like a real regression or a harness mismatch
after the lifecycle
migration, not a flake, so I have deliberately **not** quarantined it —
quarantine is for flakes, and
using it here would hide the signal. Flagging for whoever owns
`App.tsx`.

My suites: `app/__tests__` **113 files / 3921 tests** green, `tsc` 0,
lint 0, census `--strict` 0,
seam gate 0.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:46:12 -07:00

1011 lines
38 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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);
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.
*/
/*
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) {
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>
);
}