feat(FN-1604): merge fusion/fn-1604
This commit is contained in:
@@ -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 & 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()}
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user