feat(FN-1712): merge fusion/fn-1712
This commit is contained in:
@@ -6,6 +6,7 @@ import type { SettingsExportData } from "../api";
|
||||
|
||||
// --- API mocks ---
|
||||
const mockFetchSettings = vi.fn();
|
||||
const mockFetchSettingsByScope = vi.fn();
|
||||
const mockExportSettings = vi.fn();
|
||||
const mockUpdateSettings = vi.fn();
|
||||
const mockUpdateGlobalSettings = vi.fn();
|
||||
@@ -25,6 +26,7 @@ const mockFetchMemoryBackendStatus = vi.fn();
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
|
||||
fetchSettingsByScope: (...args: unknown[]) => mockFetchSettingsByScope(...args),
|
||||
updateSettings: (...args: unknown[]) => mockUpdateSettings(...args),
|
||||
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
|
||||
exportSettings: (...args: unknown[]) => mockExportSettings(...args),
|
||||
@@ -80,6 +82,7 @@ describe("SettingsModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchSettings.mockResolvedValue(defaultSettings);
|
||||
mockFetchSettingsByScope.mockResolvedValue({ global: defaultSettings, project: {} });
|
||||
mockFetchAuthStatus.mockResolvedValue({ providers: [] });
|
||||
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||
mockFetchBackups.mockResolvedValue({ backups: [], totalSize: 0 });
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Globe, Folder } from "lucide-react";
|
||||
import { THINKING_LEVELS, PROMPT_KEY_CATALOG, isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core";
|
||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, PromptKey, AgentPromptsConfig } from "@fusion/core";
|
||||
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemory, saveMemory, fetchGlobalConcurrency, updateGlobalConcurrency } from "../api";
|
||||
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemory, saveMemory, fetchGlobalConcurrency, updateGlobalConcurrency } from "../api";
|
||||
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryBackendCapabilities } from "../api";
|
||||
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
@@ -111,6 +111,11 @@ export function SettingsModal({
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Track initial values to detect explicit clears for null-as-delete semantics
|
||||
const [initialValues, setInitialValues] = useState<Settings | null>(null);
|
||||
// Track scoped settings for inheritance detection (fetched alongside merged settings)
|
||||
// This stores the raw { global, project } structure from the API
|
||||
const [scopedSettings, setScopedSettings] = useState<{ global: GlobalSettings; project: Partial<Settings> } | null>(null);
|
||||
// Track initial scoped values for null-as-delete semantics on project overrides
|
||||
const [initialScopedValues, setInitialScopedValues] = useState<{ global: GlobalSettings; project: Partial<Settings> } | null>(null);
|
||||
// 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");
|
||||
@@ -160,10 +165,13 @@ export function SettingsModal({
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings(projectId)
|
||||
.then((s) => {
|
||||
// Load both merged and scoped settings to enable inheritance detection
|
||||
Promise.all([fetchSettings(projectId), fetchSettingsByScope(projectId)])
|
||||
.then(([s, scoped]) => {
|
||||
setForm(s);
|
||||
setInitialValues(s); // Store initial values to detect explicit clears
|
||||
setScopedSettings(scoped);
|
||||
setInitialScopedValues(scoped); // Store initial scoped values for null-as-delete
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -515,6 +523,149 @@ export function SettingsModal({
|
||||
[onClose],
|
||||
);
|
||||
|
||||
/**
|
||||
* Lane status types:
|
||||
* - "overridden": Both provider and model keys are explicitly set in project scope
|
||||
* - "inherited": Provider/model keys are not set in project scope (fallback to global)
|
||||
*/
|
||||
type LaneStatus = "overridden" | "inherited";
|
||||
|
||||
/**
|
||||
* Model lane keys that can be overridden at the project level.
|
||||
* Each lane has global baseline keys and project override keys.
|
||||
*/
|
||||
interface ModelLane {
|
||||
laneId: string;
|
||||
label: string;
|
||||
globalProviderKey: keyof GlobalSettings;
|
||||
globalModelKey: keyof GlobalSettings;
|
||||
projectProviderKey: keyof Settings;
|
||||
projectModelKey: keyof Settings;
|
||||
helperText: string;
|
||||
fallbackOrder: string;
|
||||
}
|
||||
|
||||
/** All five model lanes with their global and project override keys */
|
||||
const MODEL_LANES: ModelLane[] = [
|
||||
{
|
||||
laneId: "default",
|
||||
label: "Default Model",
|
||||
globalProviderKey: "defaultProvider",
|
||||
globalModelKey: "defaultModelId",
|
||||
projectProviderKey: "defaultProviderOverride",
|
||||
projectModelKey: "defaultModelIdOverride",
|
||||
helperText: "Default AI model used for task execution when no per-task override is set.",
|
||||
fallbackOrder: "Project override → Global default lane → Automatic resolution",
|
||||
},
|
||||
{
|
||||
laneId: "execution",
|
||||
label: "Execution Model",
|
||||
globalProviderKey: "executionGlobalProvider",
|
||||
globalModelKey: "executionGlobalModelId",
|
||||
projectProviderKey: "executionProvider",
|
||||
projectModelKey: "executionModelId",
|
||||
helperText: "AI model used for task implementation (executor agent).",
|
||||
fallbackOrder: "Project override → Global execution lane → Global default lane → Automatic resolution",
|
||||
},
|
||||
{
|
||||
laneId: "planning",
|
||||
label: "Planning Model",
|
||||
globalProviderKey: "planningGlobalProvider",
|
||||
globalModelKey: "planningGlobalModelId",
|
||||
projectProviderKey: "planningProvider",
|
||||
projectModelKey: "planningModelId",
|
||||
helperText: "AI model used for task specification (triage).",
|
||||
fallbackOrder: "Project override → Global planning lane → Global default lane → Automatic resolution",
|
||||
},
|
||||
{
|
||||
laneId: "validator",
|
||||
label: "Validator Model",
|
||||
globalProviderKey: "validatorGlobalProvider",
|
||||
globalModelKey: "validatorGlobalModelId",
|
||||
projectProviderKey: "validatorProvider",
|
||||
projectModelKey: "validatorModelId",
|
||||
helperText: "AI model used for code and specification review.",
|
||||
fallbackOrder: "Project override → Global validator lane → Global default lane → Automatic resolution",
|
||||
},
|
||||
{
|
||||
laneId: "summarization",
|
||||
label: "Title Summarization Model",
|
||||
globalProviderKey: "titleSummarizerGlobalProvider",
|
||||
globalModelKey: "titleSummarizerGlobalModelId",
|
||||
projectProviderKey: "titleSummarizerProvider",
|
||||
projectModelKey: "titleSummarizerModelId",
|
||||
helperText: "AI model used for auto-generating task titles from descriptions.",
|
||||
fallbackOrder: "Project override → Global summarization lane → Global planning lane → Global default lane → Automatic resolution",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Compute the status of a model lane from scoped project data.
|
||||
* Returns "overridden" when both project lane keys are explicitly set,
|
||||
* "inherited" when they are absent (fallback to global lane).
|
||||
*/
|
||||
function getLaneStatus(lane: ModelLane): LaneStatus {
|
||||
if (!scopedSettings?.project) return "inherited";
|
||||
const provider = scopedSettings.project[lane.projectProviderKey as keyof Settings];
|
||||
const model = scopedSettings.project[lane.projectModelKey as keyof Settings];
|
||||
return provider !== undefined || model !== undefined ? "overridden" : "inherited";
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the display value for a model lane dropdown.
|
||||
* Returns the provider/model pair when explicitly set, or empty string for inherited.
|
||||
*/
|
||||
function getLaneValue(lane: ModelLane): string {
|
||||
const provider = form[lane.projectProviderKey as keyof Settings] as string | undefined;
|
||||
const model = form[lane.projectModelKey as keyof Settings] as string | undefined;
|
||||
if (provider && model) {
|
||||
return `${provider}/${model}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a model lane's provider and model values in the form.
|
||||
*/
|
||||
function updateLaneValue(lane: ModelLane, value: string): void {
|
||||
if (!value) {
|
||||
// Clearing the dropdown - check if this is an inherited lane
|
||||
const status = getLaneStatus(lane);
|
||||
if (status === "inherited") {
|
||||
// Don't write anything to form for inherited lanes
|
||||
return;
|
||||
}
|
||||
// For overridden lanes, setting to undefined clears the override (null-as-delete)
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
[lane.projectProviderKey]: undefined,
|
||||
[lane.projectModelKey]: undefined,
|
||||
}));
|
||||
} else {
|
||||
const slashIdx = value.indexOf("/");
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
[lane.projectProviderKey]: value.slice(0, slashIdx),
|
||||
[lane.projectModelKey]: value.slice(slashIdx + 1),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a model lane back to inherited state (null-as-delete for project override).
|
||||
*/
|
||||
function resetLaneValue(lane: ModelLane): void {
|
||||
const status = getLaneStatus(lane);
|
||||
if (status === "inherited") return; // Nothing to reset
|
||||
|
||||
// Set to undefined to trigger null-as-delete on save
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
[lane.projectProviderKey]: undefined,
|
||||
[lane.projectModelKey]: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (prefixError || presetDraft) return;
|
||||
try {
|
||||
@@ -524,10 +675,15 @@ export function SettingsModal({
|
||||
taskPrefix: form.taskPrefix?.trim() || undefined,
|
||||
};
|
||||
|
||||
// Always save both global and project settings.
|
||||
// The backend filters each appropriately (updateSettings ignores global keys,
|
||||
// updateGlobalSettings ignores project keys). This ensures fields in sections
|
||||
// are persisted correctly based on their scope.
|
||||
// Always save both global and project settings with strict scope separation.
|
||||
//
|
||||
// SCOPE RULES:
|
||||
// - Global lane keys (executionGlobalProvider, planningGlobalProvider, etc.)
|
||||
// go to updateGlobalSettings
|
||||
// - Project override lane keys (executionProvider, planningProvider, etc.)
|
||||
// go to updateSettings ONLY when explicitly changed from initial state
|
||||
// - Inherited project lanes (unset in project scope) are NOT written to project payload
|
||||
// - Resetting a project lane sends null to delete it from project scope
|
||||
|
||||
const globalPatch: Partial<GlobalSettings> = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
@@ -546,10 +702,40 @@ export function SettingsModal({
|
||||
}
|
||||
}
|
||||
|
||||
// Project settings: Only include keys that were explicitly changed.
|
||||
// This prevents inherited effective values from being persisted as explicit overrides.
|
||||
const projectPatch: Partial<Settings> = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (key === "githubTokenConfigured") continue; // server-only field
|
||||
if (isProjectSettingsKey(key)) {
|
||||
if (!isProjectSettingsKey(key)) continue;
|
||||
|
||||
// Get the initial project-scoped value (null if not set)
|
||||
const initialProjectValue = initialScopedValues?.project?.[key as keyof Settings];
|
||||
|
||||
// Check if this value is a model lane key that tracks inheritance
|
||||
const isModelLaneKey = [
|
||||
"planningProvider", "planningModelId",
|
||||
"validatorProvider", "validatorModelId",
|
||||
"executionProvider", "executionModelId",
|
||||
"titleSummarizerProvider", "titleSummarizerModelId",
|
||||
"defaultProviderOverride", "defaultModelIdOverride",
|
||||
"planningFallbackProvider", "planningFallbackModelId",
|
||||
"validatorFallbackProvider", "validatorFallbackModelId",
|
||||
"titleSummarizerFallbackProvider", "titleSummarizerFallbackModelId",
|
||||
].includes(key);
|
||||
|
||||
if (isModelLaneKey) {
|
||||
// For model lanes: only write if explicitly changed from initial project state
|
||||
if (value !== initialProjectValue) {
|
||||
// Detect explicit reset: current is undefined/null but initial was set
|
||||
if ((value === undefined || value === null) && initialProjectValue !== undefined && initialProjectValue !== null) {
|
||||
(projectPatch as any)[key] = null; // null-as-delete
|
||||
} else if (value !== undefined) {
|
||||
(projectPatch as any)[key] = value;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For non-model settings: existing behavior
|
||||
(projectPatch as any)[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -569,7 +755,7 @@ export function SettingsModal({
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
}, [form, globalMaxConcurrent, prefixError, presetDraft, initialValues, onClose, addToast, projectId]);
|
||||
}, [form, globalMaxConcurrent, prefixError, presetDraft, initialValues, initialScopedValues, onClose, addToast, projectId]);
|
||||
|
||||
const handleSaveMemory = useCallback(async () => {
|
||||
try {
|
||||
@@ -826,16 +1012,16 @@ export function SettingsModal({
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
// Filter model lanes to show in project scope (planning, validator, summarization)
|
||||
// Default and execution lanes are global-only
|
||||
const projectModelLanes = MODEL_LANES.filter(
|
||||
(lane) => lane.laneId === "planning" || lane.laneId === "validator" || lane.laneId === "summarization",
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderScopeBanner()}
|
||||
@@ -870,8 +1056,12 @@ export function SettingsModal({
|
||||
<small>Automatically compact context when approaching this token count. Leave empty for no cap (compact only on overflow errors). Set a number to proactively compact when reaching this token count.</small>
|
||||
</div>
|
||||
|
||||
{/* --- Planning & Validation --- */}
|
||||
<h4 className="settings-section-heading" style={{ marginTop: "1.5rem" }}>Planning & Validation</h4>
|
||||
{/* --- Project Model Lanes --- */}
|
||||
<h4 className="settings-section-heading" style={{ marginTop: "1.5rem" }}>Model Lanes</h4>
|
||||
<p className="settings-description">
|
||||
Override global model settings at the project level. Each lane controls a specific AI usage context.
|
||||
Unset lanes inherit from the corresponding global lane.
|
||||
</p>
|
||||
{modelsLoading ? (
|
||||
<div className="settings-empty-state">Loading available models…</div>
|
||||
) : availableModels.length === 0 ? (
|
||||
@@ -880,33 +1070,68 @@ export function SettingsModal({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="planningModel">Planning Model</label>
|
||||
<CustomModelDropdown
|
||||
id="planningModel"
|
||||
label="Planning Model"
|
||||
models={availableModels}
|
||||
value={planningValue}
|
||||
onChange={(val) => {
|
||||
if (!val) {
|
||||
setForm((f) => ({ ...f, planningProvider: undefined, planningModelId: undefined }));
|
||||
} else {
|
||||
const slashIdx = val.indexOf("/");
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
planningProvider: val.slice(0, slashIdx),
|
||||
planningModelId: val.slice(slashIdx + 1),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
placeholder="Use default"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleModelFavorite={handleToggleModelFavorite}
|
||||
/>
|
||||
<small>AI model used for task planning and specification (triage). Falls back to Default Model when not set.</small>
|
||||
</div>
|
||||
{projectModelLanes.map((lane) => {
|
||||
const status = getLaneStatus(lane);
|
||||
const value = getLaneValue(lane);
|
||||
const isOverridden = status === "overridden";
|
||||
|
||||
return (
|
||||
<div className="form-group" key={lane.laneId}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.25rem" }}>
|
||||
<label htmlFor={`${lane.laneId}Model`}>{lane.label}</label>
|
||||
<span
|
||||
className={`settings-lane-badge ${isOverridden ? "settings-lane-badge--override" : "settings-lane-badge--inherited"}`}
|
||||
title={isOverridden ? "Explicitly set for this project" : "Inherited from global settings"}
|
||||
>
|
||||
{isOverridden ? "Override (Project)" : "Inherited (Global)"}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<CustomModelDropdown
|
||||
id={`${lane.laneId}Model`}
|
||||
label={lane.label}
|
||||
models={availableModels}
|
||||
value={value}
|
||||
onChange={(val) => updateLaneValue(lane, val)}
|
||||
placeholder="Use global"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleModelFavorite={handleToggleModelFavorite}
|
||||
/>
|
||||
</div>
|
||||
{isOverridden && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
title="Reset to inherit from global"
|
||||
onClick={() => resetLaneValue(lane)}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<small>
|
||||
{lane.helperText} Falls back to: {lane.fallbackOrder}.
|
||||
</small>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* --- Fallback Models --- */}
|
||||
<h4 className="settings-section-heading" style={{ marginTop: "1.5rem" }}>Fallback Models</h4>
|
||||
{modelsLoading ? (
|
||||
<div className="settings-empty-state">Loading available models…</div>
|
||||
) : availableModels.length === 0 ? (
|
||||
<div className="settings-empty-state settings-muted">
|
||||
No models available.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="planningFallbackModel">Planning Fallback Model</label>
|
||||
<CustomModelDropdown
|
||||
@@ -934,33 +1159,6 @@ export function SettingsModal({
|
||||
/>
|
||||
<small>Used if the planning model fails due to rate limits or provider overload. Defaults to the global fallback model.</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="validatorModel">Validator Model</label>
|
||||
<CustomModelDropdown
|
||||
id="validatorModel"
|
||||
label="Validator Model"
|
||||
models={availableModels}
|
||||
value={validatorValue}
|
||||
onChange={(val) => {
|
||||
if (!val) {
|
||||
setForm((f) => ({ ...f, validatorProvider: undefined, validatorModelId: undefined }));
|
||||
} else {
|
||||
const slashIdx = val.indexOf("/");
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
validatorProvider: val.slice(0, slashIdx),
|
||||
validatorModelId: val.slice(slashIdx + 1),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
placeholder="Use default"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleModelFavorite={handleToggleModelFavorite}
|
||||
/>
|
||||
<small>AI model used for code and specification review. Falls back to Default Model when not set.</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="validatorFallbackModel">Validator Fallback Model</label>
|
||||
<CustomModelDropdown
|
||||
|
||||
@@ -35,6 +35,7 @@ const defaultSettings: Settings = {
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||
fetchSettingsByScope: vi.fn(() => Promise.resolve({ global: { ...defaultSettings }, project: {} })),
|
||||
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||
updateGlobalSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||
fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }] })),
|
||||
@@ -81,7 +82,7 @@ vi.mock("../PluginManager", () => ({
|
||||
)),
|
||||
}));
|
||||
|
||||
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchGlobalConcurrency, updateGlobalConcurrency } from "../../api";
|
||||
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchGlobalConcurrency, updateGlobalConcurrency } from "../../api";
|
||||
|
||||
const onClose = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
@@ -1152,6 +1153,138 @@ describe("SettingsModal", () => {
|
||||
expect(payload.validatorModelId).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
describe("scope-safe save payloads", () => {
|
||||
it("global lane edits go to updateGlobalSettings and NOT to project settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Navigate to Models (global section) and change default model
|
||||
fireEvent.click(screen.getByText("Models"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
const defaultTrigger = screen.getByLabelText("Default Model");
|
||||
await user.click(defaultTrigger);
|
||||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Global settings should have the new default model
|
||||
const globalPayload = vi.mocked(updateGlobalSettings).mock.calls[0][0];
|
||||
expect(globalPayload.defaultProvider).toBe("anthropic");
|
||||
expect(globalPayload.defaultModelId).toBe("claude-sonnet-4-5");
|
||||
|
||||
// Project settings should NOT contain global lane keys
|
||||
const projectPayload = vi.mocked(updateSettings).mock.calls[0][0];
|
||||
expect(projectPayload).not.toHaveProperty("defaultProvider");
|
||||
expect(projectPayload).not.toHaveProperty("defaultModelId");
|
||||
});
|
||||
|
||||
it("project lane overrides go to updateSettings and NOT to global settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Navigate to Project Models section and change planning model
|
||||
fireEvent.click(screen.getByText("Project Models"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
const planningTrigger = screen.getByLabelText("Planning Model");
|
||||
await user.click(planningTrigger);
|
||||
await user.click(screen.getByText("GPT-4o"));
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Project settings should have the planning override
|
||||
const projectPayload = vi.mocked(updateSettings).mock.calls[0][0];
|
||||
expect(projectPayload.planningProvider).toBe("openai");
|
||||
expect(projectPayload.planningModelId).toBe("gpt-4o");
|
||||
|
||||
// Global settings should NOT contain project override keys
|
||||
const globalPayload = vi.mocked(updateGlobalSettings).mock.calls[0][0];
|
||||
expect(globalPayload).not.toHaveProperty("planningProvider");
|
||||
expect(globalPayload).not.toHaveProperty("planningModelId");
|
||||
});
|
||||
|
||||
it("resetting a project lane sends null-as-delete payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
// Set up with existing planning override in the merged settings
|
||||
const settingsWithOverride = {
|
||||
...defaultSettings,
|
||||
planningProvider: "anthropic",
|
||||
planningModelId: "claude-sonnet-4-5",
|
||||
};
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce(settingsWithOverride);
|
||||
// And in scoped settings
|
||||
(fetchSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
global: { ...defaultSettings },
|
||||
project: {
|
||||
planningProvider: "anthropic",
|
||||
planningModelId: "claude-sonnet-4-5",
|
||||
},
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Navigate to Project Models section
|
||||
fireEvent.click(screen.getByText("Project Models"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Verify the planning lane shows as overridden
|
||||
const planningBadge = screen.getByText("Override (Project)");
|
||||
expect(planningBadge).toBeTruthy();
|
||||
|
||||
// Click reset button for planning lane
|
||||
const resetButtons = screen.getAllByText("Reset");
|
||||
await user.click(resetButtons[0]);
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Project settings should have null for the reset lane
|
||||
const projectPayload = vi.mocked(updateSettings).mock.calls[0][0];
|
||||
expect(projectPayload.planningProvider).toBeNull();
|
||||
expect(projectPayload.planningModelId).toBeNull();
|
||||
});
|
||||
|
||||
it("inherited lanes are not written to project payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Navigate to Project Models section
|
||||
fireEvent.click(screen.getByText("Project Models"));
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Verify lanes show as inherited (don't change anything)
|
||||
const inheritedBadges = screen.getAllByText("Inherited (Global)");
|
||||
expect(inheritedBadges.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Just save without changing anything
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Project settings should NOT contain model lane keys (they're inherited)
|
||||
const projectPayload = vi.mocked(updateSettings).mock.calls[0][0];
|
||||
expect(projectPayload).not.toHaveProperty("planningProvider");
|
||||
expect(projectPayload).not.toHaveProperty("planningModelId");
|
||||
expect(projectPayload).not.toHaveProperty("validatorProvider");
|
||||
expect(projectPayload).not.toHaveProperty("validatorModelId");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty state in Models section when no models available", async () => {
|
||||
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ const defaultSettings = {
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||
fetchSettingsByScope: vi.fn(() => Promise.resolve({ global: { ...defaultSettings }, project: {} })),
|
||||
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||
updateGlobalSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||
fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }] })),
|
||||
|
||||
Reference in New Issue
Block a user