feat(FN-2018): merge fusion/fn-2018

This commit is contained in:
gsxdsm
2026-04-17 15:25:02 -07:00
parent 759075bdb0
commit 24513716e4
6 changed files with 82 additions and 17 deletions

View File

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

View File

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

View File

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

View File

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