FN-8635: keep worktree slider visible

Keep Command Center capacity controls visible and correctly editable across settings states.

- Render Max worktrees in the shared full-width range wrapper.
- Preserve capacity values after load failures and explain disabled worktree limits.
- Add control tests, browser geometry coverage, documentation, and a release changeset.

Files changed:
 .changeset/fn-8635-worktrees-slider.md             |   7 +
 docs/dashboard-guide.md                            |   2 +-
 .../command-center/CommandCenterControls.css       |   6 +
 .../command-center/CommandCenterControls.tsx       |  63 +++++---
 .../__tests__/CommandCenterControls.test.tsx       |  76 ++++++++-
 packages/engine/e2e/fn-8635-worktrees-slider.mjs   | 180 +++++++++++++++++++++
 6 files changed, 310 insertions(+), 24 deletions(-)

Fusion-Task-Id: FN-8635

Fusion-Task-Lineage: fb9c2a46-3c5c-4871-bab4-c43af20cb4de

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-31 18:03:13 -07:00
parent 78dde73a75
commit f2f6795010
6 changed files with 310 additions and 24 deletions

View File

@@ -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.

View File

@@ -1332,7 +1332,7 @@ Features:
<!-- FNXC:CommandCenter 2026-06-27-10:03: Tokens detail charts must show every model bucket returned by analytics for accurate spend attribution; Overview remains a compact top-model summary because its copy explicitly frames those cards as top consumers/share. -->
<!-- FNXC:CommandCenterActivity 2026-06-30-00:00: Activity active-agent counts include both durable-agent usage events and ephemeral task-worker execution runs from agentRuns, because task execution can be visible without a matching usage_events row. -->
<!-- FNXC:CommandCenterActivity 2026-07-01-00:00: Graph-owned workflow step sessions publish active-to-terminal agentRuns lifecycle rows with task lineage and step metadata, so daily activity and Activity throughput charts include new workflow execution without dashboard-side recounting. -->
- **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.
<!-- FNXC:TeamArea 2026-07-18-12:30: FN-8351 moves organization export and import to the Team tab so team-level portability controls are not presented as Overview dashboard controls. -->
- **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.

View File

@@ -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,

View File

@@ -32,20 +32,22 @@ type AsyncState<T> =
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<keyof ConcurrencyValues, { min: number; max: number }> = {
const CONCURRENCY_SLIDER_LIMITS: Record<Exclude<keyof ConcurrencyValues, "worktreeLimitEnabled">, { min: number; max: number }> = {
maxConcurrent: { min: 1, max: 50 },
maxWorktrees: { min: 1, max: 50 },
};
const CONCURRENCY_SETTING_LABEL_KEYS: Record<keyof ConcurrencyValues, { key: string; defaultValue: string }> = {
const CONCURRENCY_SETTING_LABEL_KEYS: Record<Exclude<keyof ConcurrencyValues, "worktreeLimitEnabled">, { 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<keyof ConcurrencyValues, "worktreeLimitEnabled">, 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<keyof ConcurrencyValues>).filter((key) => values[key] !== persisted[key]);
return (Object.keys(CONCURRENCY_SETTING_LABEL_KEYS) as Array<Exclude<keyof ConcurrencyValues, "worktreeLimitEnabled">>)
.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<keyof ConcurrencyValues, "worktreeLimitEnabled">, 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}
</span>
</label>
{/*
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.
*/}
<label className="cc-controls-slider" htmlFor="cc-max-worktrees">
<span className="cc-controls-slider-label">
{t("commandCenter.controls.concurrency.maxWorktrees", "Max worktrees")}
<strong>{concurrencyValues.maxWorktrees}</strong>
</span>
<input
id="cc-max-worktrees"
className="cc-controls-touch-slider"
type="range"
min={CONCURRENCY_SLIDER_LIMITS.maxWorktrees.min}
max={getConcurrencySliderMax("maxWorktrees", concurrencyValues.maxWorktrees)}
value={concurrencyValues.maxWorktrees}
disabled={concurrencyState.status === "loading"}
onChange={(event) => updateConcurrencyValue(
"maxWorktrees",
event.target.value,
CONCURRENCY_SLIDER_LIMITS.maxWorktrees.min,
getConcurrencySliderMax("maxWorktrees", concurrencyValues.maxWorktrees),
)}
/>
<span className="cc-controls-range-wrap">
<input
id="cc-max-worktrees"
className="cc-controls-touch-slider"
type="range"
min={CONCURRENCY_SLIDER_LIMITS.maxWorktrees.min}
max={getConcurrencySliderMax("maxWorktrees", concurrencyValues.maxWorktrees)}
value={concurrencyValues.maxWorktrees}
disabled={!worktreesEditable}
onChange={(event) => updateConcurrencyValue(
"maxWorktrees",
event.target.value,
CONCURRENCY_SLIDER_LIMITS.maxWorktrees.min,
getConcurrencySliderMax("maxWorktrees", concurrencyValues.maxWorktrees),
)}
/>
</span>
{!concurrencyValues.worktreeLimitEnabled && concurrencyState.status === "loaded" ? (
<small className="cc-controls-slider-caption">
{t("commandCenter.controls.concurrency.worktreeLimitDisabled", "Enable the worktree limit in Settings to edit this capacity.")}
</small>
) : null}
</label>
</div>
{concurrencyState.status === "error" ? <p className="cc-controls-error" role="alert">{concurrencyState.error}</p> : null}

View File

@@ -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);",

View File

@@ -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; });