FN-6768: show actual concurrency slider values
Command Center concurrency controls now preserve persisted scheduler values and improve mobile slider handling. - Load concurrency defaults from project settings and display persisted values without clamping the readout. - Expand range slider maxima when existing settings exceed normal UI bounds while keeping edits bounded. - Add mobile touch-drag affordance styling and regression coverage for actual values, overflow values, and CSS contracts. Files changed: .../command-center/CommandCenterControls.css | 26 +++++++- .../command-center/CommandCenterControls.tsx | 63 +++++++++++++----- .../__tests__/CommandCenterControls.test.tsx | 74 +++++++++++++++++++++- 3 files changed, 143 insertions(+), 20 deletions(-) Fusion-Task-Id: FN-6768 Fusion-Task-Lineage: 2508c2ab-9e57-4d29-934c-828a015bdfe1
This commit is contained in:
@@ -102,9 +102,16 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cc-controls-slider input[type="range"] {
|
||||
/*
|
||||
FNXC:CommandCenter 2026-06-20-00:25:
|
||||
Mobile users must be able to drag horizontal concurrency sliders without the page scroll gesture stealing the thumb movement. Scope touch-action to these range inputs and preserve desktop accent/full-width behavior while increasing the hit area with design tokens.
|
||||
*/
|
||||
.cc-controls-slider input[type="range"],
|
||||
.cc-controls-touch-slider {
|
||||
inline-size: 100%;
|
||||
min-block-size: var(--space-xl);
|
||||
accent-color: var(--accent);
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -124,6 +131,23 @@
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cc-controls-slider input[type="range"],
|
||||
.cc-controls-touch-slider {
|
||||
min-block-size: var(--space-2xl);
|
||||
}
|
||||
|
||||
.cc-controls-slider input[type="range"]::-webkit-slider-thumb,
|
||||
.cc-controls-touch-slider::-webkit-slider-thumb {
|
||||
inline-size: var(--space-xl);
|
||||
block-size: var(--space-xl);
|
||||
}
|
||||
|
||||
.cc-controls-slider input[type="range"]::-moz-range-thumb,
|
||||
.cc-controls-touch-slider::-moz-range-thumb {
|
||||
inline-size: var(--space-xl);
|
||||
block-size: var(--space-xl);
|
||||
}
|
||||
|
||||
.cc-controls-action {
|
||||
justify-content: center;
|
||||
inline-size: 100%;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Power } from "lucide-react";
|
||||
import type { ColorTheme, ThemeMode } from "@fusion/core";
|
||||
import { DEFAULT_PROJECT_SETTINGS, type ColorTheme, type ThemeMode } from "@fusion/core";
|
||||
import { fetchConfig, fetchSettings, updateSettings } from "../../api/legacy";
|
||||
import { useAppSettings } from "../../hooks/useAppSettings";
|
||||
import { ThemeDropdown } from "../ThemeDropdown";
|
||||
@@ -28,9 +28,15 @@ type ConcurrencyValues = {
|
||||
|
||||
const CONCURRENCY_SAVE_DEBOUNCE_MS = 500;
|
||||
const DEFAULT_CONCURRENCY_VALUES: ConcurrencyValues = {
|
||||
maxConcurrent: 2,
|
||||
maxTriageConcurrent: 1,
|
||||
maxWorktrees: 5,
|
||||
maxConcurrent: DEFAULT_PROJECT_SETTINGS.maxConcurrent,
|
||||
maxTriageConcurrent: DEFAULT_PROJECT_SETTINGS.maxTriageConcurrent,
|
||||
maxWorktrees: DEFAULT_PROJECT_SETTINGS.maxWorktrees,
|
||||
};
|
||||
|
||||
const CONCURRENCY_SLIDER_LIMITS: Record<keyof ConcurrencyValues, { min: number; max: number }> = {
|
||||
maxConcurrent: { min: 1, max: 10 },
|
||||
maxTriageConcurrent: { min: 1, max: 10 },
|
||||
maxWorktrees: { min: 1, max: 20 },
|
||||
};
|
||||
|
||||
/*
|
||||
@@ -41,6 +47,10 @@ function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getConcurrencySliderMax(key: keyof ConcurrencyValues, value: number) {
|
||||
return Math.max(CONCURRENCY_SLIDER_LIMITS[key].max, value);
|
||||
}
|
||||
|
||||
function StatusPill({ paused, label }: { paused: boolean; label: string }) {
|
||||
return (
|
||||
<span className="cc-controls-status-pill">
|
||||
@@ -73,9 +83,9 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, onColo
|
||||
setConcurrencyState({
|
||||
status: "loaded",
|
||||
data: {
|
||||
maxConcurrent: clamp(settings.maxConcurrent ?? config.maxConcurrent ?? DEFAULT_CONCURRENCY_VALUES.maxConcurrent, 1, 10),
|
||||
maxTriageConcurrent: clamp(settings.maxTriageConcurrent ?? DEFAULT_CONCURRENCY_VALUES.maxTriageConcurrent, 1, 10),
|
||||
maxWorktrees: clamp(settings.maxWorktrees ?? DEFAULT_CONCURRENCY_VALUES.maxWorktrees, 1, 20),
|
||||
maxConcurrent: settings.maxConcurrent ?? config.maxConcurrent ?? DEFAULT_CONCURRENCY_VALUES.maxConcurrent,
|
||||
maxTriageConcurrent: settings.maxTriageConcurrent ?? DEFAULT_CONCURRENCY_VALUES.maxTriageConcurrent,
|
||||
maxWorktrees: settings.maxWorktrees ?? DEFAULT_CONCURRENCY_VALUES.maxWorktrees,
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
@@ -128,6 +138,9 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, onColo
|
||||
const concurrencyValues = concurrencyState.data ?? DEFAULT_CONCURRENCY_VALUES;
|
||||
|
||||
/*
|
||||
FNXC:CommandCenter 2026-06-20-00:20:
|
||||
The concurrency card must reflect actual persisted scheduler settings, including values above the usual slider ranges, instead of silently clamping the readout. The slider max expands to the current persisted value so the numeric readout and input value remain truthful; user edits are still clamped into that input's current valid bounds before saving.
|
||||
|
||||
FNXC:CommandCenter 2026-06-19-12:35:
|
||||
The Command Center concurrency sliders mutate live scheduler limits through the existing /api/settings path; after each debounced save, refresh useAppSettings so the running dashboard reflects the new scheduler capacity without local shadow state drifting.
|
||||
|
||||
@@ -203,12 +216,18 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, onColo
|
||||
</span>
|
||||
<input
|
||||
id="cc-max-concurrent"
|
||||
className="cc-controls-touch-slider"
|
||||
type="range"
|
||||
min={1}
|
||||
max={10}
|
||||
min={CONCURRENCY_SLIDER_LIMITS.maxConcurrent.min}
|
||||
max={getConcurrencySliderMax("maxConcurrent", concurrencyValues.maxConcurrent)}
|
||||
value={concurrencyValues.maxConcurrent}
|
||||
disabled={concurrencyState.status === "loading"}
|
||||
onChange={(event) => updateConcurrencyValue("maxConcurrent", event.target.value, 1, 10)}
|
||||
onChange={(event) => updateConcurrencyValue(
|
||||
"maxConcurrent",
|
||||
event.target.value,
|
||||
CONCURRENCY_SLIDER_LIMITS.maxConcurrent.min,
|
||||
getConcurrencySliderMax("maxConcurrent", concurrencyValues.maxConcurrent),
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<label className="cc-controls-slider" htmlFor="cc-max-triage-concurrent">
|
||||
@@ -218,12 +237,18 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, onColo
|
||||
</span>
|
||||
<input
|
||||
id="cc-max-triage-concurrent"
|
||||
className="cc-controls-touch-slider"
|
||||
type="range"
|
||||
min={1}
|
||||
max={10}
|
||||
min={CONCURRENCY_SLIDER_LIMITS.maxTriageConcurrent.min}
|
||||
max={getConcurrencySliderMax("maxTriageConcurrent", concurrencyValues.maxTriageConcurrent)}
|
||||
value={concurrencyValues.maxTriageConcurrent}
|
||||
disabled={concurrencyState.status === "loading"}
|
||||
onChange={(event) => updateConcurrencyValue("maxTriageConcurrent", event.target.value, 1, 10)}
|
||||
onChange={(event) => updateConcurrencyValue(
|
||||
"maxTriageConcurrent",
|
||||
event.target.value,
|
||||
CONCURRENCY_SLIDER_LIMITS.maxTriageConcurrent.min,
|
||||
getConcurrencySliderMax("maxTriageConcurrent", concurrencyValues.maxTriageConcurrent),
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<label className="cc-controls-slider" htmlFor="cc-max-worktrees">
|
||||
@@ -233,12 +258,18 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, onColo
|
||||
</span>
|
||||
<input
|
||||
id="cc-max-worktrees"
|
||||
className="cc-controls-touch-slider"
|
||||
type="range"
|
||||
min={1}
|
||||
max={20}
|
||||
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, 1, 20)}
|
||||
onChange={(event) => updateConcurrencyValue(
|
||||
"maxWorktrees",
|
||||
event.target.value,
|
||||
CONCURRENCY_SLIDER_LIMITS.maxWorktrees.min,
|
||||
getConcurrencySliderMax("maxWorktrees", concurrencyValues.maxWorktrees),
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { CommandCenterControls } from "../CommandCenterControls";
|
||||
|
||||
const commandCenterControlsCss = readFileSync(
|
||||
join(process.cwd(), "app/components/command-center/CommandCenterControls.css"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchSettings: vi.fn(),
|
||||
fetchConfig: vi.fn(),
|
||||
@@ -54,7 +61,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.appSettings.globalPaused = false;
|
||||
mocks.appSettings.enginePaused = false;
|
||||
mocks.fetchSettings.mockResolvedValue({ maxConcurrent: 2, maxTriageConcurrent: 1, maxWorktrees: 5 });
|
||||
mocks.fetchSettings.mockResolvedValue({ maxConcurrent: 2, maxTriageConcurrent: 2, maxWorktrees: 4 });
|
||||
mocks.fetchConfig.mockResolvedValue({ maxConcurrent: 2, rootDir: "/repo" });
|
||||
mocks.updateSettings.mockResolvedValue({});
|
||||
mocks.refresh.mockResolvedValue(undefined);
|
||||
@@ -100,7 +107,7 @@ describe("CommandCenterControls", () => {
|
||||
});
|
||||
|
||||
expect(mocks.updateSettings).toHaveBeenCalledWith(
|
||||
{ maxConcurrent: 7, maxTriageConcurrent: 1, maxWorktrees: 5 },
|
||||
{ maxConcurrent: 7, maxTriageConcurrent: 2, maxWorktrees: 4 },
|
||||
"project-a",
|
||||
);
|
||||
expect(mocks.refresh).toHaveBeenCalledTimes(1);
|
||||
@@ -120,11 +127,72 @@ describe("CommandCenterControls", () => {
|
||||
});
|
||||
|
||||
expect(mocks.updateSettings).toHaveBeenCalledWith(
|
||||
{ maxConcurrent: 2, maxTriageConcurrent: 1, maxWorktrees: 12 },
|
||||
{ maxConcurrent: 2, maxTriageConcurrent: 2, maxWorktrees: 12 },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders persisted concurrency settings without stale default drift", async () => {
|
||||
mocks.fetchSettings.mockResolvedValueOnce({ maxConcurrent: 6, maxTriageConcurrent: 3, maxWorktrees: 9 });
|
||||
|
||||
renderControls("project-a");
|
||||
|
||||
await flushPromises();
|
||||
const section = screen.getByTestId("cc-controls-concurrency");
|
||||
const maxConcurrent = within(section).getByLabelText(/max concurrent tasks/i) as HTMLInputElement;
|
||||
const maxTriageConcurrent = within(section).getByLabelText(/max triage concurrent/i) as HTMLInputElement;
|
||||
const maxWorktrees = within(section).getByLabelText(/max worktrees/i) as HTMLInputElement;
|
||||
|
||||
expect(maxConcurrent.value).toBe("6");
|
||||
expect(maxConcurrent.closest("label")).toHaveTextContent("Max concurrent tasks6");
|
||||
expect(maxTriageConcurrent.value).toBe("3");
|
||||
expect(maxTriageConcurrent.closest("label")).toHaveTextContent("Max triage concurrent3");
|
||||
expect(maxWorktrees.value).toBe("9");
|
||||
expect(maxWorktrees.closest("label")).toHaveTextContent("Max worktrees9");
|
||||
});
|
||||
|
||||
it("keeps out-of-range persisted concurrency values visible instead of silently clamping", async () => {
|
||||
mocks.fetchSettings.mockResolvedValueOnce({ maxConcurrent: 12, maxTriageConcurrent: 13, maxWorktrees: 24 });
|
||||
|
||||
renderControls("project-a");
|
||||
|
||||
await flushPromises();
|
||||
const section = screen.getByTestId("cc-controls-concurrency");
|
||||
const maxConcurrent = within(section).getByLabelText(/max concurrent tasks/i) as HTMLInputElement;
|
||||
const maxTriageConcurrent = within(section).getByLabelText(/max triage concurrent/i) as HTMLInputElement;
|
||||
const maxWorktrees = within(section).getByLabelText(/max worktrees/i) as HTMLInputElement;
|
||||
|
||||
expect(maxConcurrent.value).toBe("12");
|
||||
expect(maxConcurrent.max).toBe("12");
|
||||
expect(maxConcurrent.closest("label")).toHaveTextContent("Max concurrent tasks12");
|
||||
expect(maxTriageConcurrent.value).toBe("13");
|
||||
expect(maxTriageConcurrent.max).toBe("13");
|
||||
expect(maxTriageConcurrent.closest("label")).toHaveTextContent("Max triage concurrent13");
|
||||
expect(maxWorktrees.value).toBe("24");
|
||||
expect(maxWorktrees.max).toBe("24");
|
||||
expect(maxWorktrees.closest("label")).toHaveTextContent("Max worktrees24");
|
||||
});
|
||||
|
||||
it("marks concurrency sliders with the mobile touch-drag affordance contract", async () => {
|
||||
renderControls("project-a");
|
||||
|
||||
await flushPromises();
|
||||
const section = screen.getByTestId("cc-controls-concurrency");
|
||||
const sliders = [
|
||||
within(section).getByLabelText(/max concurrent tasks/i),
|
||||
within(section).getByLabelText(/max triage concurrent/i),
|
||||
within(section).getByLabelText(/max worktrees/i),
|
||||
];
|
||||
|
||||
for (const slider of sliders) {
|
||||
expect(slider).toHaveClass("cc-controls-touch-slider");
|
||||
}
|
||||
// jsdom cannot simulate whether a touch drag is captured by page scrolling, so this verifies the CSS contract that enables horizontal thumb drags on mobile.
|
||||
expect(commandCenterControlsCss).toContain("touch-action: pan-y");
|
||||
expect(commandCenterControlsCss).toContain("@media (max-width: 768px)");
|
||||
expect(commandCenterControlsCss).toContain("min-block-size: var(--space-2xl)");
|
||||
});
|
||||
|
||||
it("shows save error indicator when concurrency update fails", async () => {
|
||||
mocks.updateSettings.mockRejectedValueOnce(new Error("network error"));
|
||||
renderControls("project-a");
|
||||
|
||||
Reference in New Issue
Block a user