diff --git a/.changeset/fn-8635-worktrees-slider.md b/.changeset/fn-8635-worktrees-slider.md new file mode 100644 index 0000000000..d57a3c3917 --- /dev/null +++ b/.changeset/fn-8635-worktrees-slider.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Restore the visible Max worktrees control in Command Center concurrency settings. +category: fix +dev: Both per-project capacity sliders now share the range layout invariant and explicit loading/error behavior. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index cef1866022..8d9c55cdea 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1332,7 +1332,7 @@ Features: -- **Overview controls dashboard** includes AI engine stop/start backed by `globalPause`, the shared Global Max Concurrent slider, and current-project **Max concurrency** plus **Max worktrees** controls. Max concurrency caps top-level working agents across planning, execution, and review/merge; free capacity is admitted oldest-first within the project. The footer reports **Waiting**, **Running (N/max)**, and **Blocked**; column headers report executing/total (live agents in the lane over card count). Nested helper agents remain parent-internal and may temporarily exceed the displayed top-level count. +- **Overview controls dashboard** includes AI engine stop/start backed by `globalPause` and current-project **Max concurrent tasks** plus **Max worktrees** controls. Both capacity sliders remain visible while settings load or fail, but are disabled until settings are editable; a failed load shows its error and an intentionally disabled worktree limit explains how to enable it in Settings. Max concurrency caps top-level working agents across planning, execution, and review/merge; free capacity is admitted oldest-first within the project. The footer reports **Waiting**, **Running (N/max)**, and **Blocked**; column headers report executing/total (live agents in the lane over card count). Nested helper agents remain parent-internal and may temporarily exceed the displayed top-level count. - **Team tab — Org export / import** lets an operator download a portable organization JSON bundle or paste one for a dry-run preview before confirming the apply step. Exports are secret-scrubbed by default: credentials and tokens are never included, while safe secret references can remain for setup in the destination project. - **Configuration versions** lives in **Settings → Project → Configuration Versions**. It lists recorded project-setting revisions newest first; select **Roll back** on any revision and confirm once to restore it. The restore is recorded as a new forward revision, so it can itself be undone without manually reconstructing settings. diff --git a/packages/dashboard/app/components/command-center/CommandCenterControls.css b/packages/dashboard/app/components/command-center/CommandCenterControls.css index 666d5defd1..bdbe754f94 100644 --- a/packages/dashboard/app/components/command-center/CommandCenterControls.css +++ b/packages/dashboard/app/components/command-center/CommandCenterControls.css @@ -118,12 +118,18 @@ cannot desynchronize the range tracks at desktop or tablet widths. /* FNXC:GlobalConcurrencyControls 2026-07-15-17:30: FN-8007 makes the current-use dot's half-thumb edge inset match the native desktop range thumb. This preserves the min-relative thumb alignment used by the marker: one running agent stays visible at the start and over-cap use stays on the cap thumb. + +FNXC:CommandCenter 2026-08-01-00:14: +Both capacity controls use this wrapper so their range sizing and native thumb token remain +identical. A disabled capacity is still a visible control; only its editability changes. */ .cc-controls-range-wrap { --cc-controls-range-thumb-size: calc(var(--space-lg) + var(--space-xs) / 2); position: relative; display: flex; align-items: center; + inline-size: 100%; + min-inline-size: 0; } .cc-controls-range-wrap, diff --git a/packages/dashboard/app/components/command-center/CommandCenterControls.tsx b/packages/dashboard/app/components/command-center/CommandCenterControls.tsx index b39898ab39..fcf6f4da0a 100644 --- a/packages/dashboard/app/components/command-center/CommandCenterControls.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenterControls.tsx @@ -32,20 +32,22 @@ type AsyncState = type ConcurrencyValues = { maxConcurrent: number; maxWorktrees: number; + worktreeLimitEnabled: boolean; }; const CONCURRENCY_SAVE_DEBOUNCE_MS = 500; const DEFAULT_CONCURRENCY_VALUES: ConcurrencyValues = { maxConcurrent: DEFAULT_PROJECT_SETTINGS.maxConcurrent, maxWorktrees: DEFAULT_PROJECT_SETTINGS.maxWorktrees, + worktreeLimitEnabled: Boolean(DEFAULT_PROJECT_SETTINGS.worktreeLimitEnabled), }; -const CONCURRENCY_SLIDER_LIMITS: Record = { +const CONCURRENCY_SLIDER_LIMITS: Record, { min: number; max: number }> = { maxConcurrent: { min: 1, max: 50 }, maxWorktrees: { min: 1, max: 50 }, }; -const CONCURRENCY_SETTING_LABEL_KEYS: Record = { +const CONCURRENCY_SETTING_LABEL_KEYS: Record, { key: string; defaultValue: string }> = { maxConcurrent: { key: "commandCenter.controls.concurrency.maxConcurrent", defaultValue: "Max concurrent tasks" }, maxWorktrees: { key: "commandCenter.controls.concurrency.maxWorktrees", defaultValue: "Max worktrees" }, }; @@ -61,7 +63,7 @@ function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } -function getConcurrencySliderMax(key: keyof ConcurrencyValues, value: number) { +function getConcurrencySliderMax(key: Exclude, value: number) { return Math.max(CONCURRENCY_SLIDER_LIMITS[key].max, value); } @@ -82,7 +84,8 @@ function getUseMarkerStyle(ratio: number): CSSProperties { } function getChangedConcurrencyKeys(values: ConcurrencyValues, persisted: ConcurrencyValues) { - return (Object.keys(values) as Array).filter((key) => values[key] !== persisted[key]); + return (Object.keys(CONCURRENCY_SETTING_LABEL_KEYS) as Array>) + .filter((key) => values[key] !== persisted[key]); } function StatusPill({ paused, label }: { paused: boolean; label: string }) { @@ -123,6 +126,7 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn const persistedValues = { maxConcurrent: settings.maxConcurrent ?? config.maxConcurrent ?? DEFAULT_CONCURRENCY_VALUES.maxConcurrent, maxWorktrees: settings.maxWorktrees ?? DEFAULT_CONCURRENCY_VALUES.maxWorktrees, + worktreeLimitEnabled: settings.worktreeLimitEnabled !== false, }; persistedConcurrencyRef.current = persistedValues; pendingConcurrencyKeyRef.current = null; @@ -137,7 +141,7 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn if (!cancelled) { setConcurrencyState({ status: "error", - data: DEFAULT_CONCURRENCY_VALUES, + data: persistedConcurrencyRef.current, error: error instanceof Error ? error.message : t("commandCenter.controls.concurrency.error", "Unable to load concurrency settings"), }); } @@ -219,7 +223,7 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn return () => clearTimeout(timeoutId); }, [confirm, concurrencyDirty, concurrencyState.data, projectId, refresh, t]); - const updateConcurrencyValue = (key: keyof ConcurrencyValues, rawValue: string, min: number, max: number) => { + const updateConcurrencyValue = (key: Exclude, rawValue: string, min: number, max: number) => { const nextValue = clamp(Number(rawValue), min, max); pendingConcurrencyKeyRef.current = key; setConcurrencyState((current) => ({ @@ -236,6 +240,8 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn const globalCountsLoaded = gc.status === "loaded"; const projectActive = gc.projectActiveCount(projectId); const maxConcurrentSliderMax = getConcurrencySliderMax("maxConcurrent", concurrencyValues.maxConcurrent); + const worktreesEditable = concurrencyState.status === "loaded" && concurrencyValues.worktreeLimitEnabled; + const slidersEditable = concurrencyState.status === "loaded"; const projectUseMarkerRatio = getUseMarkerRatio( projectActive, concurrencyValues.maxConcurrent, @@ -369,7 +375,7 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn min={CONCURRENCY_SLIDER_LIMITS.maxConcurrent.min} max={maxConcurrentSliderMax} value={concurrencyValues.maxConcurrent} - disabled={concurrencyState.status === "loading"} + disabled={!slidersEditable} onChange={(event) => updateConcurrencyValue( "maxConcurrent", event.target.value, @@ -387,26 +393,39 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn ) : null} + {/* + FNXC:CommandCenter 2026-08-01-00:14: + The Concurrency card must always show both per-project capacity sliders. When + settings are loading, failed, or intentionally disable the worktree limit, keep + the native control visible but disabled with an explanation rather than hiding it. + */} {concurrencyState.status === "error" ?

{concurrencyState.error}

: null} diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenterControls.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenterControls.test.tsx index 57ad2e26e4..a6646eef6e 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenterControls.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenterControls.test.tsx @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; import { CommandCenterControls } from "../CommandCenterControls"; import { ConfirmDialogProvider } from "../../../hooks/useConfirm"; import { readAppFile } from "../../../test/cssFixture"; @@ -165,6 +165,80 @@ describe("CommandCenterControls concurrency markers", () => { } }); + /* + FNXC:CommandCenter 2026-08-01-00:14: + jsdom cannot calculate grid layout or detect a visually suppressed native range + thumb. The browser e2e verification owns geometry; these tests lock the DOM, + label, range, state, and save invariants for both capacity controls. + */ + it("keeps both capacity sliders visible and disabled while concurrency settings load", () => { + legacyMocks.fetchConfig.mockReturnValue(new Promise(() => {})); + legacyMocks.fetchSettings.mockReturnValue(new Promise(() => {})); + renderControls(); + + for (const name of [/Max concurrent tasks/i, /Max worktrees/i]) { + const slider = screen.getByRole("slider", { name }); + expect(slider).toHaveAttribute("min", "1"); + expect(slider).toBeVisible(); + expect(slider).toBeDisabled(); + } + }); + + it("keeps both capacity sliders enabled and saves a loaded worktree edit", async () => { + renderControls(); + const worktrees = await screen.findByRole("slider", { name: /Max worktrees/i }); + vi.useFakeTimers(); + const concurrent = screen.getByRole("slider", { name: /Max concurrent tasks/i }); + + expect(concurrent).toBeEnabled(); + expect(worktrees).toBeEnabled(); + expect(worktrees).toHaveAttribute("min", "1"); + expect(worktrees).toHaveAttribute("max", "50"); + expect(worktrees).toHaveValue("4"); + + try { + fireEvent.change(worktrees, { target: { value: "5" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + fireEvent.click(screen.getByRole("button", { name: "Save change" })); + await act(async () => {}); + expect(legacyMocks.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ maxWorktrees: 5 }), + "proj_123", + ); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps both sliders visible and disabled with an error explanation when loading fails", async () => { + legacyMocks.fetchSettings.mockRejectedValue(new Error("settings unavailable")); + renderControls(); + + expect(await screen.findByRole("alert")).toHaveTextContent("settings unavailable"); + expect(screen.getByRole("slider", { name: /Max concurrent tasks/i })).toBeDisabled(); + expect(screen.getByRole("slider", { name: /Max worktrees/i })).toBeDisabled(); + expect(screen.getByRole("slider", { name: /Max worktrees/i })).toHaveValue("4"); + }); + + it("uses the default for missing worktrees, expands persisted values, and explains an intentionally disabled limit", async () => { + legacyMocks.fetchSettings.mockResolvedValue({ maxConcurrent: 12 }); + renderControls(); + expect(await screen.findByRole("slider", { name: /Max worktrees/i })).toHaveValue("4"); + expect(screen.getByRole("slider", { name: /Max worktrees/i })).toBeEnabled(); + + document.body.innerHTML = ""; + cleanup(); + legacyMocks.fetchSettings.mockResolvedValue({ maxConcurrent: 12, maxWorktrees: 80, worktreeLimitEnabled: false }); + renderControls(); + const worktrees = await screen.findByRole("slider", { name: /Max worktrees/i }); + expect(worktrees).toHaveValue("80"); + expect(worktrees).toHaveAttribute("max", "80"); + expect(worktrees).toBeDisabled(); + expect(screen.getByText("Enable the worktree limit in Settings to edit this capacity.")).toBeInTheDocument(); + }); + it("matches the desktop and mobile native thumb-size CSS contract", () => { expect(commandCenterControlsCss).toContain( "--cc-controls-range-thumb-size: calc(var(--space-lg) + var(--space-xs) / 2);", diff --git a/packages/engine/e2e/fn-8635-worktrees-slider.mjs b/packages/engine/e2e/fn-8635-worktrees-slider.mjs new file mode 100644 index 0000000000..aa12b7d41c --- /dev/null +++ b/packages/engine/e2e/fn-8635-worktrees-slider.mjs @@ -0,0 +1,180 @@ +#!/usr/bin/env node +/* global process, fetch, AbortSignal, setTimeout, console */ +/** + * FN-8635 browser geometry verification. + * + * Invocation from the repository root: `pnpm build`, then + * `node packages/engine/e2e/fn-8635-worktrees-slider.mjs`. + * The built CLI is required; this script never downloads a browser. + */ +import { spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const cliBin = path.join(repoRoot, "packages/cli/bin.mjs"); +const screenshotsDir = path.join(repoRoot, "packages/dashboard/e2e/__screenshots__/fn-8635"); +const reservedPorts = new Set([4040, ...String(process.env.FUSION_RESERVED_PORTS ?? "").split(",").map(Number).filter(Number.isInteger)]); +const healthTimeoutMs = 180_000; +let cleanup = () => {}; + +/* +FNXC:CommandCenter 2026-08-01-00:14: +FN-8635 requires browser-computed geometry at desktop and mobile because jsdom cannot +observe a native range thumb or a grid-clipped control. This isolated smoke starts no +operator project and avoids the production dashboard port. +*/ +async function getEphemeralPort() { + for (let attempt = 0; attempt < 10; attempt++) { + const port = await new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close(() => resolve(address.port)); + }); + }); + if (!reservedPorts.has(port)) return port; + } + throw new Error("could not obtain a non-reserved ephemeral port"); +} + +async function pollHealth(port, deadline) { + let lastError = "no response"; + while (Date.now() < deadline) { + try { + const response = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(2_000) }); + if (response.status === 200) return; + lastError = `HTTP ${response.status}`; + } catch (error) { + lastError = error?.cause?.code ?? error?.name ?? String(error); + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`health check never returned 200 (last: ${lastError})`); +} + +function removeTempDir(directory) { + try { + rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } catch (error) { + console.warn(`cleanup failed for ${directory}: ${error?.message ?? error}`); + } +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function bootServer(attempt) { + const port = await getEphemeralPort(); + const isolatedHome = mkdtempSync(path.join(tmpdir(), "fusion-fn8635-home-")); + const isolatedProject = mkdtempSync(path.join(tmpdir(), "fusion-fn8635-project-")); + let output = ""; + const child = spawn(process.execPath, [cliBin, "serve", "--port", String(port), "--host", "127.0.0.1", "--paused"], { + cwd: isolatedProject, + env: { ...process.env, HOME: isolatedHome, FUSION_SKIP_ONBOARDING: "1", DATABASE_URL: undefined, FUSION_NO_EMBEDDED_PG: undefined, PORT: undefined }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout.on("data", (chunk) => { output += chunk; }); + child.stderr.on("data", (chunk) => { output += chunk; }); + const exited = new Promise((resolve) => child.once("exit", (code, signal) => resolve({ code, signal }))); + cleanup = () => { + try { if (child.exitCode === null && !child.killed) child.kill("SIGKILL"); } catch { /* child already exited */ } + removeTempDir(isolatedHome); + removeTempDir(isolatedProject); + }; + try { + await Promise.race([ + pollHealth(port, Date.now() + healthTimeoutMs), + exited.then(({ code, signal }) => { throw new Error(`server exited before becoming healthy (${code ?? `signal ${signal}`})`); }), + ]); + return { child, port, exited }; + } catch (error) { + cleanup(); + if (/EADDRINUSE/.test(output) && attempt === 0) return bootServer(1); + throw new Error(`${error.message}\n--- server output ---\n${output.split("\n").slice(-200).join("\n")}`); + } +} + +async function seed(port) { + const response = await fetch(`http://127.0.0.1:${port}/api/settings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ maxConcurrent: 12, maxWorktrees: 4, worktreeLimitEnabled: true }), + }); + assert(response.ok, `settings seed failed: HTTP ${response.status}`); + const settings = await response.json(); + assert(settings.maxWorktrees === 4, "settings seed did not echo maxWorktrees === 4"); +} + +async function assertViewport(browser, port, viewport, screenshotName) { + const page = await browser.newPage({ viewport }); + await page.goto(`http://127.0.0.1:${port}/?view=command-center`, { waitUntil: "networkidle" }); + const card = page.locator(".cc-controls-card--concurrency"); + await card.waitFor({ state: "attached" }); + await card.getByText("Ready", { exact: true }).waitFor({ state: "visible" }); + for (const id of ["cc-max-concurrent", "cc-max-worktrees"]) { + const slider = page.locator(`#${id}`); + await slider.waitFor({ state: "visible" }); + assert(await slider.isDisabled() === false, `${id} is disabled in loaded state`); + const [sliderBox, containerBox] = await Promise.all([slider.boundingBox(), page.locator(".cc-controls-sliders").boundingBox()]); + assert(sliderBox && sliderBox.width > 0 && sliderBox.height > 0, `${id} has a zero-size bounding box`); + assert(containerBox && sliderBox.x >= containerBox.x && sliderBox.y >= containerBox.y && sliderBox.x + sliderBox.width <= containerBox.x + containerBox.width && sliderBox.y + sliderBox.height <= containerBox.y + containerBox.height, `${id} is clipped or overflows its slider container`); + } + const worktrees = page.locator("#cc-max-worktrees"); + const originalValue = await worktrees.inputValue(); + await worktrees.focus(); + await worktrees.press("ArrowRight"); + assert(await worktrees.inputValue() === String(Number(originalValue) + 1), "worktrees slider did not update synchronously on ArrowRight"); + await page.getByRole("button", { name: "Save change" }).waitFor({ state: "visible" }); + await page.getByRole("button", { name: "Save change" }).click(); + // FNXC:CommandCenter 2026-08-01-00:29: Waiting for Saved, rather than the already-visible Ready label, proves the confirmed worktree edit completed without a save error. + await card.getByText("Saved", { exact: true }).waitFor({ state: "visible" }); + mkdirSync(screenshotsDir, { recursive: true }); + await page.screenshot({ path: path.join(screenshotsDir, screenshotName), fullPage: true }); + await page.close(); +} + +async function main() { + if (!await import("node:fs").then(({ existsSync }) => existsSync(cliBin))) { + throw new Error(`built CLI not found at ${cliBin}; run pnpm build first`); + } + let chromium; + try { + ({ chromium } = await import("playwright-core")); + } catch { + console.log("SKIP: playwright-core not resolvable from this package"); + return; + } + const { child, port, exited } = await bootServer(0); + let browser; + try { + browser = await chromium.launch(); + } catch (error) { + cleanup(); + if (/executable|browser.*install|chromium/i.test(String(error.message))) { + console.log("SKIP: no Chromium binary available"); + return; + } + throw error; + } + try { + await seed(port); + await assertViewport(browser, port, { width: 1280, height: 900 }, "desktop-after.png"); + await assertViewport(browser, port, { width: 390, height: 844 }, "mobile-after.png"); + child.kill("SIGTERM"); + await exited; + console.log("FN-8635 browser verification: PASS"); + } finally { + await browser.close(); + cleanup(); + } +} + +process.on("exit", () => cleanup()); +for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => { cleanup(); process.exit(signal === "SIGINT" ? 130 : 143); }); +main().catch((error) => { console.error(`FN-8635 browser verification: FAIL — ${error.message ?? error}`); process.exitCode = 1; });