feat(FN-2018): merge fusion/fn-2018
This commit is contained in:
@@ -196,6 +196,7 @@ export function SettingsModal({
|
||||
|
||||
// Global concurrency state
|
||||
const [globalMaxConcurrent, setGlobalMaxConcurrent] = useState<number | undefined>(4);
|
||||
const initialGlobalMaxConcurrentRef = useRef<number | undefined>(4);
|
||||
|
||||
// Import/Export state
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
@@ -256,7 +257,10 @@ export function SettingsModal({
|
||||
|
||||
useEffect(() => {
|
||||
fetchGlobalConcurrency()
|
||||
.then((state) => setGlobalMaxConcurrent(state.globalMaxConcurrent))
|
||||
.then((state) => {
|
||||
setGlobalMaxConcurrent(state.globalMaxConcurrent);
|
||||
initialGlobalMaxConcurrentRef.current = state.globalMaxConcurrent;
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail — global concurrency may not be available
|
||||
});
|
||||
@@ -867,7 +871,9 @@ export function SettingsModal({
|
||||
await Promise.all([
|
||||
Object.keys(globalPatch).length > 0 ? updateGlobalSettings(globalPatch) : Promise.resolve(),
|
||||
Object.keys(projectPatch).length > 0 ? updateSettings(projectPatch, projectId) : Promise.resolve(),
|
||||
updateGlobalConcurrency({ globalMaxConcurrent: globalMaxConcurrent ?? 4 }),
|
||||
globalMaxConcurrent !== initialGlobalMaxConcurrentRef.current
|
||||
? updateGlobalConcurrency({ globalMaxConcurrent: globalMaxConcurrent ?? 4 })
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
|
||||
addToast("Settings saved", "success");
|
||||
|
||||
@@ -907,6 +907,48 @@ describe("SettingsModal", () => {
|
||||
expect(projectPayload.globalMaxConcurrent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not call updateGlobalConcurrency when value is unchanged", async () => {
|
||||
// Initial fetch returns 8, user saves without changing it
|
||||
(fetchGlobalConcurrency as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
globalMaxConcurrent: 8,
|
||||
currentlyActive: 3,
|
||||
queuedCount: 0,
|
||||
projectsActive: {},
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} initialSection="scheduling" />);
|
||||
await waitFor(() => expect(fetchGlobalConcurrency).toHaveBeenCalled());
|
||||
|
||||
// Change a project-scoped setting (not globalMaxConcurrent)
|
||||
const input = screen.getByLabelText("Max Concurrent Tasks") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "3" } });
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalled());
|
||||
expect(updateGlobalConcurrency).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls updateGlobalConcurrency when value changes from initial", async () => {
|
||||
(fetchGlobalConcurrency as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
globalMaxConcurrent: 8,
|
||||
currentlyActive: 3,
|
||||
queuedCount: 0,
|
||||
projectsActive: {},
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} initialSection="scheduling" />);
|
||||
await waitFor(() => expect(fetchGlobalConcurrency).toHaveBeenCalled());
|
||||
|
||||
// Change globalMaxConcurrent from 8 to 12
|
||||
const input = screen.getByLabelText("Global Max Concurrent") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "12" } });
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => expect(updateGlobalConcurrency).toHaveBeenCalledWith({ globalMaxConcurrent: 12 }));
|
||||
});
|
||||
|
||||
it("saving in General section updates project settings with task prefix", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
@@ -115,4 +115,18 @@ describe("useAppSettings", () => {
|
||||
// Verify fetchSettings was called again with correct projectId
|
||||
expect(mockFetchSettings).toHaveBeenCalledWith("proj_123");
|
||||
});
|
||||
|
||||
it("refresh() tolerates partial fetch failure", async () => {
|
||||
mockFetchConfig.mockRejectedValueOnce(new Error("network"));
|
||||
|
||||
const { result } = renderHook(() => useAppSettings("proj_123"));
|
||||
|
||||
// settings should still be set even though config failed
|
||||
await waitFor(() => {
|
||||
expect(result.current.autoMerge).toBe(false);
|
||||
});
|
||||
|
||||
// config defaults remain (maxConcurrent stays at initial 2)
|
||||
expect(result.current.maxConcurrent).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,24 +39,24 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
||||
* Shared between the mount-time useEffect and the refresh() function.
|
||||
*/
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const cfg = await fetchConfig(projectId);
|
||||
setMaxConcurrent(cfg.maxConcurrent);
|
||||
setRootDir(cfg.rootDir);
|
||||
} catch {
|
||||
// Keep current state on fetch failure.
|
||||
const [configResult, settingsResult] = await Promise.allSettled([
|
||||
fetchConfig(projectId),
|
||||
fetchSettings(projectId),
|
||||
]);
|
||||
|
||||
if (configResult.status === "fulfilled") {
|
||||
setMaxConcurrent(configResult.value.maxConcurrent);
|
||||
setRootDir(configResult.value.rootDir);
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await fetchSettings(projectId);
|
||||
if (settingsResult.status === "fulfilled") {
|
||||
const settings = settingsResult.value;
|
||||
setAutoMerge(Boolean(settings.autoMerge));
|
||||
setGlobalPaused(Boolean(settings.globalPause));
|
||||
setEnginePaused(Boolean(settings.enginePaused));
|
||||
setGithubTokenConfigured(Boolean(settings.githubTokenConfigured));
|
||||
setTaskStuckTimeoutMs(settings.taskStuckTimeoutMs);
|
||||
setShowQuickChatFAB(settings.showQuickChatFAB === true);
|
||||
} catch {
|
||||
// Keep current state on fetch failure.
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user