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

This commit is contained in:
gsxdsm
2026-04-13 15:29:41 -07:00
parent d041ddd947
commit 70f2af665a
11 changed files with 189 additions and 55 deletions

View File

@@ -139,9 +139,6 @@ export function SettingsModal({
const [memoryLoading, setMemoryLoading] = useState(false);
const [memoryDirty, setMemoryDirty] = useState(false);
// Global concurrency state
const [globalMaxConcurrent, setGlobalMaxConcurrent] = useState<number>(4);
// Import/Export state
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [, setImportFile] = useState<File | null>(null);
@@ -152,9 +149,12 @@ export function SettingsModal({
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetchSettings(projectId)
.then((s) => {
setForm(s);
Promise.all([fetchSettings(projectId), fetchGlobalConcurrency().catch(() => null)])
.then(([s, concurrency]) => {
setForm({
...s,
globalMaxConcurrent: concurrency?.globalMaxConcurrent,
});
setLoading(false);
})
.catch((err) => {
@@ -163,14 +163,6 @@ export function SettingsModal({
});
}, [addToast, projectId]);
useEffect(() => {
fetchGlobalConcurrency()
.then((state) => setGlobalMaxConcurrent(state.globalMaxConcurrent))
.catch(() => {
// Silently fail — global concurrency may not be available
});
}, []);
// Load auth status when the authentication section is active
const loadAuthStatus = useCallback(async () => {
try {
@@ -530,11 +522,17 @@ export function SettingsModal({
const projectPatch: Partial<Settings> = {};
for (const [key, value] of Object.entries(payload)) {
if (key === "githubTokenConfigured") continue; // server-only field
if (key === "globalMaxConcurrent") continue; // central-core field, saved below
if (isProjectSettingsKey(key)) {
(projectPatch as any)[key] = value;
}
}
const globalMaxConcurrent =
typeof payload.globalMaxConcurrent === "number" && Number.isFinite(payload.globalMaxConcurrent)
? Math.max(1, Math.round(payload.globalMaxConcurrent))
: undefined;
// Save both scopes in parallel if they have changes.
// Note: themeMode/colorTheme may also be write-through via useTheme callbacks
// in the Appearance section; duplicate global writes are intentional/idempotent,
@@ -542,7 +540,7 @@ 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 !== undefined ? updateGlobalConcurrency({ globalMaxConcurrent }) : Promise.resolve(),
]);
addToast("Settings saved", "success");
@@ -550,7 +548,7 @@ export function SettingsModal({
} catch (err: any) {
addToast(err.message, "error");
}
}, [form, globalMaxConcurrent, prefixError, presetDraft, onClose, addToast, projectId]);
}, [form, prefixError, presetDraft, onClose, addToast, projectId]);
const handleSaveMemory = useCallback(async () => {
try {
@@ -1297,18 +1295,6 @@ export function SettingsModal({
<>
{renderScopeBanner()}
<h4 className="settings-section-heading">Scheduling</h4>
<div className="form-group">
<label htmlFor="globalMaxConcurrent">Global Max Concurrent</label>
<input
id="globalMaxConcurrent"
type="number"
min={1}
max={50}
value={globalMaxConcurrent}
onChange={(e) => setGlobalMaxConcurrent(Number(e.target.value))}
/>
<small className="form-text text-muted">Maximum concurrent agents across all projects</small>
</div>
<div className="form-group">
<label htmlFor="maxConcurrent">Max Concurrent Tasks</label>
<input
@@ -1321,6 +1307,21 @@ export function SettingsModal({
setForm((f) => ({ ...f, maxConcurrent: Number(e.target.value) }))
}
/>
<small>Project-level agent limit for this board.</small>
</div>
<div className="form-group">
<label htmlFor="globalMaxConcurrent">Global Concurrent Agents</label>
<input
id="globalMaxConcurrent"
type="number"
min={1}
max={50}
value={form.globalMaxConcurrent ?? 4}
onChange={(e) =>
setForm((f) => ({ ...f, globalMaxConcurrent: Number(e.target.value) }))
}
/>
<small>System-wide limit shared by triage, execution, and merge agents across all registered projects.</small>
</div>
<div className="form-group">
<label htmlFor="pollIntervalMs">Poll Interval (ms)</label>

View File

@@ -255,6 +255,33 @@ describe("QuickChatFAB", () => {
});
});
it("preserves user message after assistant reply completes", async () => {
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Wait for session initialization
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Hello" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
// Wait for streaming to complete
await waitFor(() => {
expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled();
});
// Check that user's "Hello" message is preserved
expect(screen.getByText("Hello")).toBeDefined();
// Check that assistant response is shown (mock concatenates thinking + text)
// The mock sends "Thinking..." then "Here's my response." which concatenates
expect(screen.getByText(/Here's my response/)).toBeDefined();
});
it("switching agents creates a new session for the selected agent", async () => {
// First session exists
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [mockSession] });

View File

@@ -7,6 +7,7 @@ import type { Settings, ThemeMode, ColorTheme } from "@fusion/core";
const defaultSettings: Settings = {
maxConcurrent: 2,
globalMaxConcurrent: 4,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
@@ -37,6 +38,8 @@ vi.mock("../../api", () => ({
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
updateGlobalSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
fetchGlobalConcurrency: vi.fn(() => Promise.resolve({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} })),
updateGlobalConcurrency: vi.fn(() => Promise.resolve({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} })),
fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }] })),
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
@@ -80,7 +83,7 @@ vi.mock("../PluginManager", () => ({
)),
}));
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification } from "../../api";
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchGlobalConcurrency, updateGlobalConcurrency } from "../../api";
const onClose = vi.fn();
const addToast = vi.fn();
@@ -229,6 +232,7 @@ describe("SettingsModal", () => {
// Click Scheduling
fireEvent.click(screen.getByText("Scheduling"));
expect(screen.getByLabelText("Max Concurrent Tasks")).toBeTruthy();
expect(screen.getByLabelText("Global Concurrent Agents")).toBeTruthy();
expect(screen.queryByLabelText("Task Prefix")).toBeNull();
// Click Commands
@@ -309,6 +313,7 @@ describe("SettingsModal", () => {
// Scheduling
fireEvent.click(screen.getByText("Scheduling"));
expect(screen.getByLabelText("Max Concurrent Tasks")).toBeTruthy();
expect(screen.getByLabelText("Global Concurrent Agents")).toBeTruthy();
expect(screen.getByLabelText("Poll Interval (ms)")).toBeTruthy();
// Worktrees
@@ -669,6 +674,28 @@ describe("SettingsModal", () => {
expect(payload.pollIntervalMs).toBe(15000);
});
it("loads and saves the central global concurrency limit", 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(fetchSettings).toHaveBeenCalled());
const input = screen.getByLabelText("Global Concurrent Agents") as HTMLInputElement;
expect(input.value).toBe("8");
fireEvent.change(input, { target: { value: "10" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateGlobalConcurrency).toHaveBeenCalledWith({ globalMaxConcurrent: 10 }));
const projectPayload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(projectPayload.globalMaxConcurrent).toBeUndefined();
});
it("saving in General section updates project settings with task prefix", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());