FN-7071: show concurrency utilization in the footer panel
Expose live running-agent utilization in the footer concurrency controls. - Show all-project and current-project running counts beside the matching concurrency sliders. - Add clamped current-use markers on global and project slider tracks. - Preserve shared global-concurrency hook state with utilization counts and cover the new UI behavior with tests. - Document the footer panel utilization indicators and add a release changeset. Files changed: .changeset/fn-7071-concurrency-running-counts.md | 7 ++ docs/dashboard-guide.md | 2 +- .../dashboard/app/components/EngineControlMenu.css | 23 ++++ .../dashboard/app/components/EngineControlMenu.tsx | 103 +++++++++++++----- .../__tests__/EngineControlMenu.test.tsx | 117 ++++++++++++++++++++- .../hooks/__tests__/useGlobalConcurrency.test.ts | 96 +++++++++++++++++ .../dashboard/app/hooks/useGlobalConcurrency.ts | 41 +++++++- packages/i18n/locales/en/app.json | 4 +- packages/i18n/locales/es/app.json | 4 +- packages/i18n/locales/fr/app.json | 4 +- packages/i18n/locales/ko/app.json | 4 +- packages/i18n/locales/zh-CN/app.json | 4 +- packages/i18n/locales/zh-TW/app.json | 4 +- 13 files changed, 373 insertions(+), 40 deletions(-) Fusion-Task-Id: FN-7071 Fusion-Task-Lineage: db95d50a-c042-4972-a462-1987b7bca61e
This commit is contained in:
7
.changeset/fn-7071-concurrency-running-counts.md
Normal file
7
.changeset/fn-7071-concurrency-running-counts.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Footer concurrency panel shows running-agent counts and current-use markers on global and project sliders.
|
||||
category: feature
|
||||
dev: useGlobalConcurrency now exposes currentlyActive and projectsActive from /api/global-concurrency; EngineControlMenu renders count readouts and a clamped slider-track dot.
|
||||
@@ -994,7 +994,7 @@ Use this panel when upgrading a project with pre-FN-6245/FN-6277 in-review rows
|
||||
|
||||
### Executor footer engine controls
|
||||
|
||||
The global AI engine stop/start control and triage pause/resume control live in the executor footer status bar rather than the header. Select the small engine-controls button beside the executor state badge, or select the state text such as **Running**, to open the footer popover. The popover includes **Stop AI engine** / **Start AI engine**, **Pause triage** / **Resume scheduling**, and live scheduler sliders for max concurrent tasks, max triage concurrency, and max worktrees. Slider changes save through the existing `/api/settings` path with the same debounced behavior used by Command Center controls; no separate backend route is required.
|
||||
The global AI engine stop/start control and triage pause/resume control live in the executor footer status bar rather than the header. Select the small engine-controls button beside the executor state badge, or select the state text such as **Running**, to open the footer popover. The popover includes **Stop AI engine** / **Start AI engine**, **Pause triage** / **Resume scheduling**, and live scheduler sliders for max concurrent tasks, max triage concurrency, and max worktrees. The global and current-project concurrency sliders also show how many agents are running and a dot on the slider track for current use, clamped to the track when usage exceeds the configured cap. Slider changes save through the existing `/api/settings` path with the same debounced behavior used by Command Center controls; no separate backend route is required.
|
||||
|
||||
### Engine status banner
|
||||
|
||||
|
||||
@@ -110,11 +110,34 @@
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.engine-control-menu__slider-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.engine-control-menu__range-wrap {
|
||||
--engine-control-range-thumb-size: var(--space-md);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.engine-control-menu__range {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
/* FNXC:GlobalConcurrencyControls 2026-06-26-06:26: The current-use marker reuses the global .status-dot convention and is positioned with logical inset properties so utilization is visible on LTR/RTL slider tracks without intercepting drag input. */
|
||||
.engine-control-menu__use-marker {
|
||||
--use-pct: 0%;
|
||||
--use-offset: 0%;
|
||||
position: absolute;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: var(--use-offset, var(--use-pct));
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.engine-control-menu__error {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./EngineControlMenu.css";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DEFAULT_PROJECT_SETTINGS } from "@fusion/core";
|
||||
import { Pause, Play, SlidersHorizontal, Square } from "lucide-react";
|
||||
@@ -54,6 +54,18 @@ function getErrorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
function getUseMarkerRatio(current: number, min: number, max: number) {
|
||||
if (max <= min) return 0;
|
||||
return clamp((current - min) / (max - min), 0, 1);
|
||||
}
|
||||
|
||||
function getUseMarkerStyle(ratio: number): CSSProperties {
|
||||
return {
|
||||
"--use-pct": `${ratio * 100}%`,
|
||||
"--use-offset": `calc((var(--engine-control-range-thumb-size) / 2) + ((100% - var(--engine-control-range-thumb-size)) * ${ratio}))`,
|
||||
} as CSSProperties;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:EngineControls 2026-06-21-00:00:
|
||||
Engine stop/start, triage pause/resume, and live scheduler concurrency/worktree sliders moved from the Header split button into the footer status bar. Operators open this popover from the footer trigger or running-status text, and the sliders reuse the existing /api/settings debounce flow so no backend route is added for live scheduler tuning.
|
||||
@@ -193,6 +205,11 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
||||
: concurrencySaveState === "error"
|
||||
? t("commandCenter.controls.status.saveError", "Save failed")
|
||||
: t("commandCenter.controls.status.ready", "Ready");
|
||||
const globalCountsLoaded = gc.status === "loaded";
|
||||
const projectActive = gc.projectActiveCount(projectId);
|
||||
const maxConcurrentSliderMax = getConcurrencySliderMax("maxConcurrent", concurrencyValues.maxConcurrent);
|
||||
const globalUseMarkerRatio = getUseMarkerRatio(gc.currentlyActive, gc.min, gc.sliderMax);
|
||||
const projectUseMarkerRatio = getUseMarkerRatio(projectActive, CONCURRENCY_SLIDER_LIMITS.maxConcurrent.min, maxConcurrentSliderMax);
|
||||
|
||||
return (
|
||||
<div className="engine-control-menu" ref={menuRef}>
|
||||
@@ -253,16 +270,31 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
||||
{t("settings.scheduling.maximumConcurrentAgentsAcrossAllProjects", "Maximum concurrent agents across all projects")}
|
||||
<strong>{gc.value}</strong>
|
||||
</span>
|
||||
<input
|
||||
id="engine-control-global-max-concurrent"
|
||||
className="engine-control-menu__range input"
|
||||
type="range"
|
||||
min={gc.min}
|
||||
max={gc.sliderMax}
|
||||
value={gc.value}
|
||||
disabled={!gc.interactive}
|
||||
onChange={(event) => gc.setValue(event.target.value)}
|
||||
/>
|
||||
{globalCountsLoaded ? (
|
||||
<span className="engine-control-menu__slider-meta" data-testid="engine-control-global-running">
|
||||
{t("commandCenter.controls.concurrency.runningGlobal", "{{count}} running (all projects)", { count: gc.currentlyActive })}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="engine-control-menu__range-wrap">
|
||||
<input
|
||||
id="engine-control-global-max-concurrent"
|
||||
className="engine-control-menu__range input"
|
||||
type="range"
|
||||
min={gc.min}
|
||||
max={gc.sliderMax}
|
||||
value={gc.value}
|
||||
disabled={!gc.interactive}
|
||||
onChange={(event) => gc.setValue(event.target.value)}
|
||||
/>
|
||||
{globalCountsLoaded ? (
|
||||
<span
|
||||
className="status-dot status-dot--online engine-control-menu__use-marker"
|
||||
style={getUseMarkerStyle(globalUseMarkerRatio)}
|
||||
data-testid="engine-control-global-use-marker"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
{gc.status === "error" ? <p className="engine-control-menu__error" role="alert">{t("commandCenter.controls.concurrency.error", "Unable to load concurrency settings")}</p> : null}
|
||||
</div>
|
||||
@@ -274,26 +306,45 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
||||
{saveLabel}
|
||||
</span>
|
||||
</div>
|
||||
{/**
|
||||
FNXC:GlobalConcurrencyControls 2026-06-26-06:26:
|
||||
The footer concurrency panel now shows read-only utilization counts next to the editable caps so operators can compare running agents against limits without opening another dashboard surface. Counts render only after the shared global-concurrency hook is loaded to avoid presenting a stale zero as live truth.
|
||||
*/}
|
||||
<label className="engine-control-menu__slider" htmlFor="engine-control-max-concurrent">
|
||||
<span className="engine-control-menu__slider-label">
|
||||
{t("commandCenter.controls.concurrency.maxConcurrent", "Max concurrent tasks")}
|
||||
<strong>{concurrencyValues.maxConcurrent}</strong>
|
||||
</span>
|
||||
<input
|
||||
id="engine-control-max-concurrent"
|
||||
className="engine-control-menu__range input"
|
||||
type="range"
|
||||
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,
|
||||
CONCURRENCY_SLIDER_LIMITS.maxConcurrent.min,
|
||||
getConcurrencySliderMax("maxConcurrent", concurrencyValues.maxConcurrent),
|
||||
)}
|
||||
/>
|
||||
{globalCountsLoaded ? (
|
||||
<span className="engine-control-menu__slider-meta" data-testid="engine-control-project-running">
|
||||
{t("commandCenter.controls.concurrency.runningProject", "{{count}} running (this project)", { count: projectActive })}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="engine-control-menu__range-wrap">
|
||||
<input
|
||||
id="engine-control-max-concurrent"
|
||||
className="engine-control-menu__range input"
|
||||
type="range"
|
||||
min={CONCURRENCY_SLIDER_LIMITS.maxConcurrent.min}
|
||||
max={maxConcurrentSliderMax}
|
||||
value={concurrencyValues.maxConcurrent}
|
||||
disabled={concurrencyState.status === "loading"}
|
||||
onChange={(event) => updateConcurrencyValue(
|
||||
"maxConcurrent",
|
||||
event.target.value,
|
||||
CONCURRENCY_SLIDER_LIMITS.maxConcurrent.min,
|
||||
maxConcurrentSliderMax,
|
||||
)}
|
||||
/>
|
||||
{globalCountsLoaded ? (
|
||||
<span
|
||||
className="status-dot status-dot--online engine-control-menu__use-marker"
|
||||
style={getUseMarkerStyle(projectUseMarkerRatio)}
|
||||
data-testid="engine-control-project-use-marker"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
<label className="engine-control-menu__slider" htmlFor="engine-control-max-triage-concurrent">
|
||||
<span className="engine-control-menu__slider-label">
|
||||
|
||||
@@ -23,6 +23,8 @@ const legacyMocks = vi.hoisted(() => ({
|
||||
fetchConfig: vi.fn(),
|
||||
fetchSettings: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
fetchGlobalConcurrency: vi.fn(),
|
||||
updateGlobalConcurrency: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../api", () => apiMocks);
|
||||
@@ -31,12 +33,27 @@ vi.mock("../../versionCheck", () => ({
|
||||
setAutoReloadEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
async function openMenu() {
|
||||
render(<EngineControlMenu projectId="proj_123" />);
|
||||
async function openMenu(projectId: string | undefined = "proj_123") {
|
||||
render(<EngineControlMenu projectId={projectId} />);
|
||||
fireEvent.click(screen.getByTestId("engine-control-menu-trigger"));
|
||||
await screen.findByTestId("engine-control-menu");
|
||||
}
|
||||
|
||||
function mockGlobalConcurrency(overrides: Partial<{
|
||||
globalMaxConcurrent: number;
|
||||
currentlyActive: number;
|
||||
queuedCount: number;
|
||||
projectsActive: Record<string, number>;
|
||||
}> = {}) {
|
||||
legacyMocks.fetchGlobalConcurrency.mockResolvedValue({
|
||||
globalMaxConcurrent: 6,
|
||||
currentlyActive: 3,
|
||||
queuedCount: 0,
|
||||
projectsActive: { proj_123: 2 },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("EngineControlMenu", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -47,6 +64,13 @@ describe("EngineControlMenu", () => {
|
||||
legacyMocks.fetchConfig.mockResolvedValue({ maxConcurrent: 2, rootDir: "/workspace/project" });
|
||||
legacyMocks.fetchSettings.mockResolvedValue({ ...defaultSettings });
|
||||
legacyMocks.updateSettings.mockResolvedValue({ ...defaultSettings });
|
||||
mockGlobalConcurrency();
|
||||
legacyMocks.updateGlobalConcurrency.mockResolvedValue({
|
||||
globalMaxConcurrent: 6,
|
||||
currentlyActive: 3,
|
||||
queuedCount: 0,
|
||||
projectsActive: { proj_123: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -97,7 +121,7 @@ describe("EngineControlMenu", () => {
|
||||
|
||||
await waitFor(() => expect(screen.getAllByTestId("engine-control-pause-triage-btn")).toHaveLength(2));
|
||||
const pauseButton = screen.getAllByTestId("engine-control-pause-triage-btn")[1];
|
||||
expect(pauseButton).toBeDisabled();
|
||||
await waitFor(() => expect(pauseButton).toBeDisabled());
|
||||
expect(pauseButton).toHaveTextContent(/resume scheduling/i);
|
||||
});
|
||||
|
||||
@@ -152,6 +176,93 @@ describe("EngineControlMenu", () => {
|
||||
expect(screen.getByLabelText(/max worktrees/i)).toHaveAttribute("max", "50");
|
||||
});
|
||||
|
||||
it("renders running counts and current-use markers with clamped slider positions", async () => {
|
||||
legacyMocks.fetchSettings.mockResolvedValue({
|
||||
...defaultSettings,
|
||||
maxConcurrent: 50,
|
||||
});
|
||||
mockGlobalConcurrency({
|
||||
globalMaxConcurrent: 40,
|
||||
currentlyActive: 40,
|
||||
projectsActive: { proj_123: 90 },
|
||||
});
|
||||
|
||||
await openMenu();
|
||||
|
||||
expect(await screen.findByTestId("engine-control-global-running")).toHaveTextContent("40 running (all projects)");
|
||||
expect(screen.getByTestId("engine-control-project-running")).toHaveTextContent("90 running (this project)");
|
||||
expect(screen.getByTestId("engine-control-global-use-marker")).toHaveStyle({ "--use-pct": "100%" });
|
||||
expect(screen.getByTestId("engine-control-project-use-marker")).toHaveStyle({ "--use-pct": "100%" });
|
||||
});
|
||||
|
||||
it("positions current-use markers at zero and mid-track for representative running counts", async () => {
|
||||
mockGlobalConcurrency({
|
||||
globalMaxConcurrent: 33,
|
||||
currentlyActive: 17,
|
||||
projectsActive: { proj_123: 0 },
|
||||
});
|
||||
|
||||
await openMenu();
|
||||
|
||||
expect(await screen.findByTestId("engine-control-global-use-marker")).toHaveStyle({ "--use-pct": "50%" });
|
||||
expect(screen.getByTestId("engine-control-project-use-marker")).toHaveStyle({ "--use-pct": "0%" });
|
||||
});
|
||||
|
||||
it("defaults the current-project running count to zero for empty projectsActive and missing projectId", async () => {
|
||||
mockGlobalConcurrency({
|
||||
globalMaxConcurrent: 6,
|
||||
currentlyActive: 0,
|
||||
projectsActive: {},
|
||||
});
|
||||
|
||||
await openMenu(undefined);
|
||||
|
||||
expect(await screen.findByTestId("engine-control-global-running")).toHaveTextContent("0 running (all projects)");
|
||||
expect(screen.getByTestId("engine-control-project-running")).toHaveTextContent("0 running (this project)");
|
||||
expect(screen.getByTestId("engine-control-global-use-marker")).toHaveStyle({ "--use-pct": "0%" });
|
||||
expect(screen.getByTestId("engine-control-project-use-marker")).toHaveStyle({ "--use-pct": "0%" });
|
||||
});
|
||||
|
||||
it("does not render running counts or markers while global concurrency is loading", async () => {
|
||||
let resolveGlobalConcurrency!: (value: {
|
||||
globalMaxConcurrent: number;
|
||||
currentlyActive: number;
|
||||
queuedCount: number;
|
||||
projectsActive: Record<string, number>;
|
||||
}) => void;
|
||||
legacyMocks.fetchGlobalConcurrency.mockReturnValue(new Promise((resolve) => {
|
||||
resolveGlobalConcurrency = resolve;
|
||||
}));
|
||||
|
||||
await openMenu();
|
||||
|
||||
expect(screen.queryByTestId("engine-control-global-running")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("engine-control-project-running")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("engine-control-global-use-marker")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("engine-control-project-use-marker")).not.toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
resolveGlobalConcurrency({
|
||||
globalMaxConcurrent: 6,
|
||||
currentlyActive: 3,
|
||||
queuedCount: 0,
|
||||
projectsActive: { proj_123: 2 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render running counts or markers when global concurrency fails to load", async () => {
|
||||
legacyMocks.fetchGlobalConcurrency.mockRejectedValue(new Error("global concurrency unavailable"));
|
||||
|
||||
await openMenu();
|
||||
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.queryByTestId("engine-control-global-running")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("engine-control-project-running")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("engine-control-global-use-marker")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("engine-control-project-use-marker")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("persists a slider value of 50 through the debounced settings save", async () => {
|
||||
await openMenu();
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const legacyMocks = vi.hoisted(() => ({
|
||||
fetchGlobalConcurrency: vi.fn(),
|
||||
updateGlobalConcurrency: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../api/legacy", () => legacyMocks);
|
||||
|
||||
type UseGlobalConcurrencyModule = typeof import("../useGlobalConcurrency");
|
||||
type GlobalConcurrencyApiState = {
|
||||
globalMaxConcurrent: number;
|
||||
currentlyActive: number;
|
||||
queuedCount: number;
|
||||
projectsActive: Record<string, number>;
|
||||
};
|
||||
|
||||
async function loadHook(): Promise<UseGlobalConcurrencyModule["useGlobalConcurrency"]> {
|
||||
vi.resetModules();
|
||||
const module = await import("../useGlobalConcurrency");
|
||||
return module.useGlobalConcurrency;
|
||||
}
|
||||
|
||||
function concurrencyState(overrides: Partial<GlobalConcurrencyApiState> = {}): GlobalConcurrencyApiState {
|
||||
return {
|
||||
globalMaxConcurrent: 6,
|
||||
currentlyActive: 3,
|
||||
queuedCount: 0,
|
||||
projectsActive: { proj_123: 2 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useGlobalConcurrency", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
legacyMocks.fetchGlobalConcurrency.mockResolvedValue(concurrencyState());
|
||||
legacyMocks.updateGlobalConcurrency.mockResolvedValue(concurrencyState({ globalMaxConcurrent: 8 }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("populates running counts after fetch and returns zero for absent projects", async () => {
|
||||
const useGlobalConcurrency = await loadHook();
|
||||
|
||||
const { result } = renderHook(() => useGlobalConcurrency());
|
||||
|
||||
await waitFor(() => expect(result.current.status).toBe("loaded"));
|
||||
expect(result.current.value).toBe(6);
|
||||
expect(result.current.currentlyActive).toBe(3);
|
||||
expect(result.current.projectActiveCount("proj_123")).toBe(2);
|
||||
expect(result.current.projectActiveCount("missing-project")).toBe(0);
|
||||
expect(result.current.projectActiveCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps last-known running counts after a successful PUT", async () => {
|
||||
const useGlobalConcurrency = await loadHook();
|
||||
const { result } = renderHook(() => useGlobalConcurrency());
|
||||
await waitFor(() => expect(result.current.status).toBe("loaded"));
|
||||
|
||||
vi.useFakeTimers();
|
||||
act(() => result.current.setValue("8"));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await Promise.resolve();
|
||||
});
|
||||
vi.useRealTimers();
|
||||
|
||||
await waitFor(() => expect(result.current.saveState).toBe("saved"));
|
||||
expect(legacyMocks.updateGlobalConcurrency).toHaveBeenCalledWith({ globalMaxConcurrent: 8 });
|
||||
expect(result.current.value).toBe(8);
|
||||
expect(result.current.currentlyActive).toBe(3);
|
||||
expect(result.current.projectActiveCount("proj_123")).toBe(2);
|
||||
});
|
||||
|
||||
it("does not surface stale truthy counts while loading or in error", async () => {
|
||||
const useGlobalConcurrency = await loadHook();
|
||||
const { result, rerender } = renderHook(({ activeWhen }) => useGlobalConcurrency({ activeWhen }), {
|
||||
initialProps: { activeWhen: true },
|
||||
});
|
||||
await waitFor(() => expect(result.current.status).toBe("loaded"));
|
||||
expect(result.current.currentlyActive).toBe(3);
|
||||
|
||||
legacyMocks.fetchGlobalConcurrency.mockRejectedValueOnce(new Error("offline"));
|
||||
rerender({ activeWhen: false });
|
||||
rerender({ activeWhen: true });
|
||||
|
||||
await waitFor(() => expect(result.current.status).toBe("error"));
|
||||
expect(result.current.currentlyActive).toBe(0);
|
||||
expect(result.current.projectActiveCount("proj_123")).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -20,9 +20,20 @@ const DEBOUNCE_MS = 500;
|
||||
type GlobalConcurrencyStatus = "idle" | "loading" | "loaded" | "error";
|
||||
|
||||
// FNXC:GlobalConcurrencyControls 2026-06-25-22:45: Module-level singleton cache + subscriber set is the single source of truth shared by every mounted hook instance.
|
||||
const cache: { value: number | null; status: GlobalConcurrencyStatus } = {
|
||||
/*
|
||||
FNXC:GlobalConcurrencyControls 2026-06-26-06:26:
|
||||
The shared global-concurrency store carries read-only utilization counts (`currentlyActive` and `projectsActive`) alongside the editable cap so footer consumers can show live running-agent counts without adding backend writes or breaking the Command Center's existing cap-only UI.
|
||||
*/
|
||||
const cache: {
|
||||
value: number | null;
|
||||
status: GlobalConcurrencyStatus;
|
||||
currentlyActive: number | null;
|
||||
projectsActive: Record<string, number>;
|
||||
} = {
|
||||
value: null,
|
||||
status: "idle",
|
||||
currentlyActive: null,
|
||||
projectsActive: {},
|
||||
};
|
||||
const subscribers = new Set<() => void>();
|
||||
let inFlight: Promise<void> | null = null;
|
||||
@@ -31,9 +42,16 @@ function notify() {
|
||||
for (const subscriber of subscribers) subscriber();
|
||||
}
|
||||
|
||||
function setCache(next: { value: number | null; status: GlobalConcurrencyStatus }) {
|
||||
function setCache(next: {
|
||||
value: number | null;
|
||||
status: GlobalConcurrencyStatus;
|
||||
currentlyActive?: number | null;
|
||||
projectsActive?: Record<string, number>;
|
||||
}) {
|
||||
cache.value = next.value;
|
||||
cache.status = next.status;
|
||||
if (next.currentlyActive !== undefined) cache.currentlyActive = next.currentlyActive;
|
||||
if (next.projectsActive !== undefined) cache.projectsActive = next.projectsActive;
|
||||
notify();
|
||||
}
|
||||
|
||||
@@ -45,7 +63,12 @@ function ensureFetched(force = false): Promise<void> {
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const result = await fetchGlobalConcurrency();
|
||||
setCache({ value: result.globalMaxConcurrent, status: "loaded" });
|
||||
setCache({
|
||||
value: result.globalMaxConcurrent,
|
||||
status: "loaded",
|
||||
currentlyActive: result.currentlyActive,
|
||||
projectsActive: result.projectsActive,
|
||||
});
|
||||
} catch {
|
||||
// Keep the previous value; non-interactive while in error state.
|
||||
setCache({ value: cache.value, status: "error" });
|
||||
@@ -63,6 +86,8 @@ export interface UseGlobalConcurrencyResult {
|
||||
interactive: boolean;
|
||||
status: GlobalConcurrencyStatus;
|
||||
saveState: "idle" | "saving" | "saved" | "error";
|
||||
currentlyActive: number;
|
||||
projectActiveCount: (projectId?: string) => number;
|
||||
setValue: (raw: string) => void;
|
||||
}
|
||||
|
||||
@@ -110,7 +135,7 @@ export function useGlobalConcurrency(opts?: { activeWhen?: boolean }): UseGlobal
|
||||
setSaveState("saving");
|
||||
void updateGlobalConcurrency({ globalMaxConcurrent: v })
|
||||
.then(() => {
|
||||
// Notifies ALL subscribers → both sliders re-sync to the persisted value.
|
||||
// Notifies ALL subscribers → both sliders re-sync to the persisted value while keeping last-known read-only utilization counts until the next forced revalidate.
|
||||
setCache({ value: v, status: "loaded" });
|
||||
dirtyRef.current = false;
|
||||
pendingValueRef.current = null;
|
||||
@@ -175,6 +200,12 @@ export function useGlobalConcurrency(opts?: { activeWhen?: boolean }): UseGlobal
|
||||
};
|
||||
}, [commit]);
|
||||
|
||||
const countsAreLoaded = cache.status === "loaded";
|
||||
const projectActiveCount = useCallback((projectId?: string) => {
|
||||
if (!countsAreLoaded || !projectId) return 0;
|
||||
return cache.projectsActive[projectId] ?? 0;
|
||||
}, [countsAreLoaded]);
|
||||
|
||||
return {
|
||||
value: currentValue,
|
||||
min: SLIDER_MIN,
|
||||
@@ -183,6 +214,8 @@ export function useGlobalConcurrency(opts?: { activeWhen?: boolean }): UseGlobal
|
||||
interactive: cache.status === "loaded",
|
||||
status: cache.status,
|
||||
saveState,
|
||||
currentlyActive: countsAreLoaded ? (cache.currentlyActive ?? 0) : 0,
|
||||
projectActiveCount,
|
||||
setValue,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1541,7 +1541,9 @@
|
||||
"maxConcurrent": "Max concurrent tasks",
|
||||
"maxTriageConcurrent": "Max triage concurrent",
|
||||
"maxWorktrees": "Max worktrees",
|
||||
"title": "Concurrency"
|
||||
"title": "Concurrency",
|
||||
"runningGlobal": "{{count}} running (all projects)",
|
||||
"runningProject": "{{count}} running (this project)"
|
||||
},
|
||||
"engine": {
|
||||
"description": "Stopping the engine halts all AI work.",
|
||||
|
||||
@@ -1531,7 +1531,9 @@
|
||||
"maxConcurrent": "",
|
||||
"maxTriageConcurrent": "",
|
||||
"maxWorktrees": "",
|
||||
"title": ""
|
||||
"title": "",
|
||||
"runningGlobal": "",
|
||||
"runningProject": ""
|
||||
},
|
||||
"engine": {
|
||||
"description": "",
|
||||
|
||||
@@ -1531,7 +1531,9 @@
|
||||
"maxConcurrent": "",
|
||||
"maxTriageConcurrent": "",
|
||||
"maxWorktrees": "",
|
||||
"title": ""
|
||||
"title": "",
|
||||
"runningGlobal": "",
|
||||
"runningProject": ""
|
||||
},
|
||||
"engine": {
|
||||
"description": "",
|
||||
|
||||
@@ -1531,7 +1531,9 @@
|
||||
"maxConcurrent": "",
|
||||
"maxTriageConcurrent": "",
|
||||
"maxWorktrees": "",
|
||||
"title": ""
|
||||
"title": "",
|
||||
"runningGlobal": "",
|
||||
"runningProject": ""
|
||||
},
|
||||
"engine": {
|
||||
"description": "",
|
||||
|
||||
@@ -1531,7 +1531,9 @@
|
||||
"maxConcurrent": "",
|
||||
"maxTriageConcurrent": "",
|
||||
"maxWorktrees": "",
|
||||
"title": ""
|
||||
"title": "",
|
||||
"runningGlobal": "",
|
||||
"runningProject": ""
|
||||
},
|
||||
"engine": {
|
||||
"description": "",
|
||||
|
||||
@@ -1531,7 +1531,9 @@
|
||||
"maxConcurrent": "",
|
||||
"maxTriageConcurrent": "",
|
||||
"maxWorktrees": "",
|
||||
"title": ""
|
||||
"title": "",
|
||||
"runningGlobal": "",
|
||||
"runningProject": ""
|
||||
},
|
||||
"engine": {
|
||||
"description": "",
|
||||
|
||||
Reference in New Issue
Block a user