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

This commit is contained in:
gsxdsm
2026-04-12 19:46:06 -07:00
parent d14cba949b
commit f810774f6c
3 changed files with 164 additions and 104 deletions

View File

@@ -18,41 +18,51 @@ import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPr
* Sections have a `scope` to indicate where their settings are stored:
* - "global": User-level settings stored in ~/.pi/fusion/settings.json (shared across projects)
* - "project": Project-specific settings stored in .fusion/config.json
* - "mixed": Section contains both global and project settings (rendered specially)
* - undefined: Section operates independently of settings storage (e.g. authentication)
*
* Group headers (isGroupHeader: true) are non-clickable labels that visually group sections.
*
* To add a new section:
* 1. Add an entry to SETTINGS_SECTIONS with a unique id, label, and scope
* 2. Add a corresponding case in renderSectionFields()
*
* Sections:
* - general: Task prefix configuration (project)
* - models: Mixed scope — default/fallback models (global), planning & validator models,
* model presets, and AI summarization (project). Rendered as sub-sections on one screen.
* The sidebar shows both global and project icons for this section.
* - authentication: OAuth provider status, login/logout (independent)
* - appearance: Theme and color settings (global)
* - notifications: ntfy.sh notification settings (global)
* - global-models: Default/fallback models and thinking level (global)
* - general: Task prefix configuration (project)
* - project-models: Planning & validator models, model presets, and AI summarization (project)
* - scheduling: Concurrency, poll interval, file overlap serialization, and step execution
* settings (runStepsInNewSessions, maxParallelSteps) (project)
* - worktrees: Worktree limits, init commands, recycling (project)
* - commands: Test and build command configuration (project)
* - merge: Auto-merge settings (project)
* - notifications: ntfy.sh notification settings (global)
* - authentication: OAuth provider status, login/logout (independent)
* - memory: Project memory settings (project)
* - prompts: Agent prompt customization (project)
* - backups: Database backup settings (project)
* - plugins: Plugin management (project)
*/
/** Section entry type with optional icon */
type SettingsSection = {
id: string;
label: string;
scope: "global" | "project" | "mixed" | undefined;
scope: "global" | "project" | undefined;
icon?: typeof Globe;
isGroupHeader?: boolean;
};
const SETTINGS_SECTIONS: SettingsSection[] = [
// Global group
{ id: "authentication", label: "Authentication", scope: undefined, icon: Globe },
{ id: "appearance", label: "Appearance", scope: "global" },
{ id: "notifications", label: "Notifications", scope: "global" },
{ id: "global-models", label: "Models", scope: "global" },
{ id: "__global_header", label: "Global", scope: undefined, isGroupHeader: true },
// Project group
{ id: "__project_header", label: "Project", scope: undefined, isGroupHeader: true },
{ id: "general", label: "General", scope: "project" },
{ id: "models", label: "Models", scope: "mixed" },
{ id: "project-models", label: "Project Models", scope: "project" },
{ id: "scheduling", label: "Scheduling", scope: "project" },
{ id: "worktrees", label: "Worktrees", scope: "project" },
{ id: "commands", label: "Commands", scope: "project" },
@@ -69,7 +79,7 @@ interface SettingsModalProps {
onClose: () => void;
addToast: (message: string, type?: ToastType) => void;
projectId?: string;
/** Optional section to show when the modal first opens. Defaults to "general". */
/** Optional section to show when the modal first opens. Defaults to first non-group-header section. */
initialSection?: SectionId;
/** Current theme mode */
themeMode?: ThemeMode;
@@ -93,7 +103,9 @@ export function SettingsModal({
}: SettingsModalProps) {
const [form, setForm] = useState<Settings & { worktreeInitCommand?: string }>({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: true, autoMerge: true, mergeStrategy: "direct", recycleWorktrees: false, worktreeNaming: "random", includeTaskIdInCommit: true, worktreeInitCommand: "", ntfyEnabled: false, ntfyTopic: undefined });
const [loading, setLoading] = useState(true);
const [activeSection, setActiveSection] = useState<SectionId>(initialSection ?? SETTINGS_SECTIONS[0].id);
// Find the first non-group-header section for default active section
const firstNonHeaderSection = SETTINGS_SECTIONS.find((s) => !s.isGroupHeader);
const [activeSection, setActiveSection] = useState<SectionId>(initialSection ?? firstNonHeaderSection?.id ?? "authentication");
const [prefixError, setPrefixError] = useState<string | null>(null);
/** Get the scope of the currently active section */
@@ -159,7 +171,7 @@ export function SettingsModal({
}, []);
useEffect(() => {
if (activeSection === "models") {
if (activeSection === "global-models" || activeSection === "project-models") {
setModelsLoading(true);
fetchModels()
.then((response) => {
@@ -596,18 +608,6 @@ export function SettingsModal({
</div>
);
}
if (activeSectionScope === "mixed") {
return (
<div className="settings-scope-banner settings-scope-mixed">
<span className="settings-scope-icon"><Globe size={14} /></span>
<span className="settings-scope-icon"><Folder size={14} /></span>
<span>
This section contains both global settings (default &amp; fallback models) and project
settings (planning, validator, presets, and AI summarization).
</span>
</div>
);
}
return null;
};
@@ -668,19 +668,10 @@ export function SettingsModal({
</div>
</>
);
case "models": {
case "global-models": {
const selectedValue = form.defaultProvider && form.defaultModelId
? `${form.defaultProvider}/${form.defaultModelId}`
: "";
const planningValue = form.planningProvider && form.planningModelId
? `${form.planningProvider}/${form.planningModelId}`
: "";
const validatorValue = form.validatorProvider && form.validatorModelId
? `${form.validatorProvider}/${form.validatorModelId}`
: "";
const presets = form.modelPresets || [];
const presetOptions = presets.map((preset) => ({ id: preset.id, name: preset.name }));
const inUsePresetIds = new Set(Object.values(form.defaultPresetBySize || {}).filter(Boolean));
return (
<>
@@ -688,7 +679,6 @@ export function SettingsModal({
{/* --- Default Model --- */}
<h4 className="settings-section-heading">Default Model</h4>
<small className="settings-note">🌐 Default model is shared across all projects.</small>
{modelsLoading ? (
<div className="settings-empty-state">Loading available models</div>
) : availableModels.length === 0 ? (
@@ -782,6 +772,45 @@ export function SettingsModal({
);
})()}
{/* --- OpenRouter Model Sync --- */}
<h4 className="settings-section-heading" style={{ marginTop: "1.5rem" }}>OpenRouter Models</h4>
<div className="form-group">
<label htmlFor="openrouterModelSync" className="checkbox-label">
<input
id="openrouterModelSync"
type="checkbox"
checked={form.openrouterModelSync !== false}
onChange={(e) => setForm((f) => ({ ...f, openrouterModelSync: e.target.checked }))}
/>
Sync OpenRouter model list at dashboard startup
</label>
<small>
When enabled, the dashboard fetches the latest available models from the OpenRouter
API on startup, so the model picker always shows the most up-to-date catalog. Disable
to skip the initial API call and use only the built-in model list.
</small>
</div>
</>
);
}
case "project-models": {
const planningValue = form.planningProvider && form.planningModelId
? `${form.planningProvider}/${form.planningModelId}`
: "";
const validatorValue = form.validatorProvider && form.validatorModelId
? `${form.validatorProvider}/${form.validatorModelId}`
: "";
const presets = form.modelPresets || [];
const presetOptions = presets.map((preset) => ({ id: preset.id, name: preset.name }));
const inUsePresetIds = new Set(Object.values(form.defaultPresetBySize || {}).filter(Boolean));
return (
<>
{renderScopeBanner()}
{/* --- Token Cap --- */}
<h4 className="settings-section-heading">Token Cap</h4>
<div className="form-group">
<label htmlFor="tokenCap">Token Cap</label>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
@@ -1124,25 +1153,6 @@ export function SettingsModal({
</>
) : null}
{/* --- OpenRouter Model Sync --- */}
<h4 className="settings-section-heading" style={{ marginTop: "1.5rem" }}>OpenRouter Models</h4>
<div className="form-group">
<label htmlFor="openrouterModelSync" className="checkbox-label">
<input
id="openrouterModelSync"
type="checkbox"
checked={form.openrouterModelSync !== false}
onChange={(e) => setForm((f) => ({ ...f, openrouterModelSync: e.target.checked }))}
/>
Sync OpenRouter model list at dashboard startup
</label>
<small>
When enabled, the dashboard fetches the latest available models from the OpenRouter
API on startup, so the model picker always shows the most up-to-date catalog. Disable
to skip the initial API call and use only the built-in model list.
</small>
</div>
{/* --- AI Summarization --- */}
<h4 className="settings-section-heading" style={{ marginTop: "1.5rem" }}>AI Summarization</h4>
<div className="form-group">
@@ -2153,35 +2163,37 @@ export function SettingsModal({
) : (
<div className="settings-layout">
<nav className="settings-sidebar">
{SETTINGS_SECTIONS.map((section) => (
<button
key={section.id}
className={`settings-nav-item${activeSection === section.id ? " active" : ""}`}
onClick={() => setActiveSection(section.id)}
title={
section.scope === "global"
? "Shared across all projects"
: section.scope === "project"
? "Specific to this project"
: section.scope === "mixed"
? "Global and project settings"
{SETTINGS_SECTIONS.map((section) => {
// Render group headers as non-clickable styled divs
if (section.isGroupHeader) {
return (
<div key={section.id} className="settings-group-header">
{section.label}
</div>
);
}
return (
<button
key={section.id}
className={`settings-nav-item${activeSection === section.id ? " active" : ""}`}
onClick={() => setActiveSection(section.id)}
title={
section.scope === "global"
? "Shared across all projects"
: section.scope === "project"
? "Specific to this project"
: undefined
}
>
{section.scope === "global" && <Globe className="settings-scope-icon" aria-label="Global setting" size={16} />}
{section.scope === "project" && <Folder className="settings-scope-icon" aria-label="Project setting" size={16} />}
{section.scope === "mixed" && (
<>
<Globe className="settings-scope-icon" aria-label="Global setting" size={16} />
<Folder className="settings-scope-icon" aria-label="Project setting" size={16} />
</>
)}
{section.icon && section.scope !== "global" && section.scope !== "project" && section.scope !== "mixed" && (
<section.icon className="settings-scope-icon" aria-label="Global setting" size={16} />
)}
{section.label}
</button>
))}
}
>
{section.scope === "global" && <Globe className="settings-scope-icon" aria-label="Global setting" size={16} />}
{section.scope === "project" && <Folder className="settings-scope-icon" aria-label="Project setting" size={16} />}
{section.icon && !section.scope && (
<section.icon className="settings-scope-icon" aria-label="Global setting" size={16} />
)}
{section.label}
</button>
);
})}
</nav>
<div className="settings-content">
{renderSectionFields()}

View File

@@ -685,6 +685,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Click on "Models" (global models section)
fireEvent.click(screen.getByText("Models"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
@@ -707,6 +708,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Click on "Models" (global models section)
fireEvent.click(screen.getByText("Models"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
@@ -734,6 +736,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Click on "Models" (global models section)
fireEvent.click(screen.getByText("Models"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
@@ -741,12 +744,13 @@ describe("SettingsModal", () => {
expect(screen.getByLabelText("Default Model")).toBeTruthy();
});
it("saving in Models section updates project settings with planning and validator models", async () => {
it("saving in Project Models section updates project settings with planning and validator models", async () => {
const user = userEvent.setup();
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Models"));
// Click on "Project Models" (project-scoped models section)
fireEvent.click(screen.getByText("Project Models"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Select planning model
@@ -770,12 +774,13 @@ describe("SettingsModal", () => {
expect(projectPayload.validatorModelId).toBe("gpt-4o");
});
it("saving in Models section updates planning and validator fallback models", async () => {
it("saving in Project Models section updates planning and validator fallback models", async () => {
const user = userEvent.setup();
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Models"));
// Click on "Project Models" (project-scoped models section)
fireEvent.click(screen.getByText("Project Models"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
const planningFallbackTrigger = screen.getByLabelText("Planning Fallback Model");
@@ -796,11 +801,12 @@ describe("SettingsModal", () => {
expect(projectPayload.validatorFallbackModelId).toBe("gpt-4o");
});
it("shows Models in sidebar", async () => {
it("shows Models and Project Models in sidebar", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
expect(screen.getAllByText("Models").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Project Models").length).toBeGreaterThanOrEqual(1);
});
it("supports creating and saving a model preset", async () => {
@@ -808,7 +814,8 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Models"));
// Model presets are in Project Models section
fireEvent.click(screen.getByText("Project Models"));
await user.click(screen.getByText("Add Preset"));
await user.type(screen.getByLabelText("Name"), "Budget");
@@ -837,7 +844,8 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Models"));
// Auto-select presets are in Project Models section
fireEvent.click(screen.getByText("Project Models"));
await user.click(screen.getByLabelText("Auto-select preset based on task size"));
fireEvent.change(screen.getByLabelText("Small tasks (S):"), { target: { value: "budget" } });
fireEvent.change(screen.getByLabelText("Medium tasks (M):"), { target: { value: "normal" } });
@@ -856,6 +864,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Models section has default model dropdown
fireEvent.click(screen.getByText("Models"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
@@ -881,6 +890,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Models section has default model dropdown
fireEvent.click(screen.getByText("Models"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
@@ -909,6 +919,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Models section has default model dropdown
fireEvent.click(screen.getByText("Models"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
@@ -940,6 +951,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Models section shows empty state when no models available
fireEvent.click(screen.getByText("Models"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
@@ -950,11 +962,12 @@ describe("SettingsModal", () => {
// --- Planning & Validation model tests ---
it("shows Models section with planning and validator model dropdowns", async () => {
it("shows Project Models section with planning and validator model dropdowns", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Models"));
// Project Models section has planning and validator model dropdowns
fireEvent.click(screen.getByText("Project Models"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Both dropdowns should be present
@@ -967,7 +980,8 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Models"));
// Project Models section has planning model dropdown
fireEvent.click(screen.getByText("Project Models"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Open planning model dropdown and select a model
@@ -989,7 +1003,8 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Models"));
// Project Models section has validator model dropdown
fireEvent.click(screen.getByText("Project Models"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Open validator model dropdown and select a model
@@ -1568,14 +1583,15 @@ describe("SettingsModal", () => {
expect(layout!.querySelector(".settings-content")).toBeTruthy();
});
it("has .settings-sidebar with 12 .settings-nav-item buttons for all sections", async () => {
it("has .settings-sidebar with 14 .settings-nav-item buttons for all sections", async () => {
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const sidebar = container.querySelector(".settings-sidebar");
expect(sidebar).toBeTruthy();
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
expect(navItems.length).toBe(13);
// 14 nav items (group headers are not nav items)
expect(navItems.length).toBe(14);
// Labels include scope icons (Globe for global, Folder for project)
const labels = Array.from(navItems).map((el) => el.textContent?.trim());
@@ -1583,8 +1599,9 @@ describe("SettingsModal", () => {
"Authentication",
"Appearance",
"Notifications",
"General",
"Models",
"General",
"Project Models",
"Scheduling",
"Worktrees",
"Commands",
@@ -1705,6 +1722,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// OpenRouter model sync is in Models section (global settings)
fireEvent.click(screen.getByText("Models"));
const checkbox = screen.getByLabelText("Sync OpenRouter model list at dashboard startup");
expect(checkbox).toBeTruthy();
@@ -2501,15 +2519,25 @@ describe("SettingsModal", () => {
const projectIcon = projectBanner!.querySelector(".settings-scope-icon svg");
expect(projectIcon).toBeTruthy();
// Switch to Models → should show mixed scope banner with both icons (SVG, not emoji)
// Switch to Models → should show global scope banner (Models is now a global-only section)
fireEvent.click(screen.getAllByText("Models")[0]);
const mixedBanner = container.querySelector(".settings-scope-mixed");
expect(mixedBanner).toBeTruthy();
expect(mixedBanner?.textContent).toContain("global");
expect(mixedBanner?.textContent).toContain("project");
// Verify mixed banner uses both icons as SVG elements, not emoji
const mixedIcons = mixedBanner!.querySelectorAll(".settings-scope-icon svg");
expect(mixedIcons.length).toBe(2);
const modelsBanner = container.querySelector(".settings-scope-global");
expect(modelsBanner).toBeTruthy();
expect(modelsBanner?.textContent).toContain("Fusion");
expect(container.querySelector(".settings-scope-project")).toBeNull();
// Verify Models banner uses Globe icon as SVG element
const modelsIcon = modelsBanner!.querySelector(".settings-scope-icon svg");
expect(modelsIcon).toBeTruthy();
// Switch to Project Models → should show project scope banner
fireEvent.click(screen.getByText("Project Models"));
const projectModelsBanner = container.querySelector(".settings-scope-project");
expect(projectModelsBanner).toBeTruthy();
expect(projectModelsBanner?.textContent).toContain("project");
expect(container.querySelector(".settings-scope-global")).toBeNull();
// Verify Project Models banner uses Folder icon as SVG element
const projectModelsIcon = projectModelsBanner!.querySelector(".settings-scope-icon svg");
expect(projectModelsIcon).toBeTruthy();
});
// --- Settings save error handling tests ---