FN-5669: unify agent model favorites with shared selector state
Use the shared favorites hook so agent model pickers stay in sync with project favorites. - Switch AgentDetailView config tab model favorites to use useFavorites instead of local settings update logic. - Switch NewAgentDialog model favorites to the same shared favorites flow and remove duplicated toggle handlers. - Extend AgentDetailView/NewAgentDialog tests and dropdown test helpers to exercise provider/model favorite toggles through the shared global settings path. Files changed: .../dashboard/app/components/AgentDetailView.tsx | 44 +++------------------ .../dashboard/app/components/NewAgentDialog.tsx | 45 +++------------------- .../AgentDetailView.advanced-settings.test.tsx | 41 ++++++++++++++++++++ .../__tests__/AgentDetailView.test-helpers.ts | 25 +++++++++++- .../components/__tests__/NewAgentDialog.test.tsx | 36 +++++++++++++++++ 5 files changed, 111 insertions(+), 80 deletions(-) Fusion-Task-Id: FN-5669 Fusion-Task-Lineage: 8ace8ea1-1c88-409c-a9ae-6b5deb391f46
This commit is contained in:
@@ -12,7 +12,7 @@ import {
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo, SkillContent, AgentOnboardingSummary, AgentMailboxResponse, AgentPromptSizePoint } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, fetchSettingsByScope, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar, fetchAgentMailbox, markMessageRead, fetchAgentPromptSizes } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, fetchSettingsByScope, upgradeAgentHeartbeatProcedure, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar, fetchAgentMailbox, markMessageRead, fetchAgentPromptSizes } from "../api";
|
||||
import type { Agent } from "../api";
|
||||
import type { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules } from "@fusion/core";
|
||||
import { getErrorMessage, isEphemeralAgent } from "@fusion/core";
|
||||
@@ -31,6 +31,7 @@ import { AgentAvatar } from "./AgentAvatar";
|
||||
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
|
||||
import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal";
|
||||
import { AgentPermissionPolicyEditor } from "./AgentPermissionPolicyEditor";
|
||||
import { useFavorites } from "../hooks/useFavorites";
|
||||
|
||||
/**
|
||||
* Simple className utility - joins class names conditionally
|
||||
@@ -3684,10 +3685,9 @@ function ConfigTab({
|
||||
);
|
||||
|
||||
// Model/runtime selector state
|
||||
const { favoriteProviders, favoriteModels, toggleFavoriteProvider, toggleFavoriteModel } = useFavorites();
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
|
||||
const [availableRuntimes, setAvailableRuntimes] = useState<PluginRuntimeInfo[]>([]);
|
||||
const [runtimesLoading, setRuntimesLoading] = useState(false);
|
||||
|
||||
@@ -3854,8 +3854,6 @@ function ConfigTab({
|
||||
fetchModels()
|
||||
.then((response) => {
|
||||
setAvailableModels(response.models);
|
||||
setFavoriteProviders(response.favoriteProviders);
|
||||
setFavoriteModels(response.favoriteModels);
|
||||
})
|
||||
.catch(() => {
|
||||
// Gracefully handle unavailable models endpoint
|
||||
@@ -3863,38 +3861,6 @@ function ConfigTab({
|
||||
.finally(() => setModelsLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleToggleFavorite = useCallback(async (provider: string) => {
|
||||
const currentFavorites = favoriteProviders;
|
||||
const isFavorite = currentFavorites.includes(provider);
|
||||
const newFavorites = isFavorite
|
||||
? currentFavorites.filter((p) => p !== provider)
|
||||
: [provider, ...currentFavorites];
|
||||
|
||||
setFavoriteProviders(newFavorites);
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders: newFavorites, favoriteModels });
|
||||
} catch {
|
||||
setFavoriteProviders(currentFavorites);
|
||||
}
|
||||
}, [favoriteProviders, favoriteModels]);
|
||||
|
||||
const handleToggleModelFavorite = useCallback(async (modelId: string) => {
|
||||
const currentFavorites = favoriteModels;
|
||||
const isFavorite = currentFavorites.includes(modelId);
|
||||
const newFavorites = isFavorite
|
||||
? currentFavorites.filter((m) => m !== modelId)
|
||||
: [modelId, ...currentFavorites];
|
||||
|
||||
setFavoriteModels(newFavorites);
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders, favoriteModels: newFavorites });
|
||||
} catch {
|
||||
setFavoriteModels(currentFavorites);
|
||||
}
|
||||
}, [favoriteProviders, favoriteModels]);
|
||||
|
||||
useEffect(() => {
|
||||
setRuntimesLoading(true);
|
||||
fetchPluginRuntimes(projectId)
|
||||
@@ -4655,9 +4621,9 @@ function ConfigTab({
|
||||
label="Agent Model"
|
||||
disabled={modelsLoading}
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onToggleFavorite={toggleFavoriteProvider}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleModelFavorite={handleToggleModelFavorite}
|
||||
onToggleModelFavorite={toggleFavoriteModel}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -2,7 +2,7 @@ import "./NewAgentDialog.css";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { Agent, AgentCapability, ModelInfo, AgentGenerationSpec, PluginRuntimeInfo, AgentOnboardingSummary } from "../api";
|
||||
import { createAgent, fetchAgents, fetchModels, updateGlobalSettings } from "../api";
|
||||
import { createAgent, fetchAgents, fetchModels } from "../api";
|
||||
import * as apiModule from "../api";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
@@ -11,6 +11,7 @@ import { AGENT_PRESETS, type AgentPreset } from "./agent-presets";
|
||||
import { SkillMultiselect } from "./SkillMultiselect";
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal";
|
||||
import { useFavorites } from "../hooks/useFavorites";
|
||||
|
||||
export interface NewAgentDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -81,10 +82,9 @@ export function NewAgentDialog({
|
||||
const [isInterviewOpen, setIsInterviewOpen] = useState(false);
|
||||
|
||||
// Model dropdown state
|
||||
const { favoriteProviders, favoriteModels, toggleFavoriteProvider, toggleFavoriteModel } = useFavorites();
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
|
||||
const [runtimeMode, setRuntimeMode] = useState<"model" | "runtime">("model");
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState("");
|
||||
const [availableRuntimes, setAvailableRuntimes] = useState<PluginRuntimeInfo[]>([]);
|
||||
@@ -101,8 +101,6 @@ export function NewAgentDialog({
|
||||
fetchModels()
|
||||
.then((response) => {
|
||||
setAvailableModels(response.models);
|
||||
setFavoriteProviders(response.favoriteProviders);
|
||||
setFavoriteModels(response.favoriteModels);
|
||||
})
|
||||
.catch(() => {
|
||||
// Gracefully handle — dropdown will show empty list
|
||||
@@ -188,39 +186,6 @@ export function NewAgentDialog({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleFavorite = useCallback(async (provider: string) => {
|
||||
const currentFavorites = favoriteProviders;
|
||||
const isFavorite = currentFavorites.includes(provider);
|
||||
const newFavorites = isFavorite
|
||||
? currentFavorites.filter(p => p !== provider)
|
||||
: [provider, ...currentFavorites];
|
||||
|
||||
setFavoriteProviders(newFavorites);
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders: newFavorites, favoriteModels });
|
||||
} catch {
|
||||
// Revert on error
|
||||
setFavoriteProviders(currentFavorites);
|
||||
}
|
||||
}, [favoriteProviders, favoriteModels]);
|
||||
|
||||
const handleToggleModelFavorite = useCallback(async (modelId: string) => {
|
||||
const currentFavorites = favoriteModels;
|
||||
const isFavorite = currentFavorites.includes(modelId);
|
||||
const newFavorites = isFavorite
|
||||
? currentFavorites.filter(m => m !== modelId)
|
||||
: [modelId, ...currentFavorites];
|
||||
|
||||
setFavoriteModels(newFavorites);
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders, favoriteModels: newFavorites });
|
||||
} catch {
|
||||
// Revert on error
|
||||
setFavoriteModels(currentFavorites);
|
||||
}
|
||||
}, [favoriteProviders, favoriteModels]);
|
||||
|
||||
const handlePresetSelect = useCallback((preset: AgentPreset) => {
|
||||
setSelectedPresetId(preset.id);
|
||||
@@ -382,9 +347,9 @@ export function NewAgentDialog({
|
||||
models={availableModels}
|
||||
placeholder="Select a model…"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onToggleFavorite={toggleFavoriteProvider}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleModelFavorite={handleToggleModelFavorite}
|
||||
onToggleModelFavorite={toggleFavoriteModel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -404,6 +404,47 @@ describe("Advanced Settings", () => {
|
||||
expect(dropdown).toHaveAttribute("data-favorite-models", "openai/gpt-4o");
|
||||
});
|
||||
|
||||
it("toggles provider and model favorites through shared favorites flow", async () => {
|
||||
mockFetchModels.mockResolvedValue({
|
||||
models: [
|
||||
{ provider: "openai", id: "gpt-4o", name: "gpt-4o", reasoning: false, contextWindow: 128000 },
|
||||
],
|
||||
favoriteProviders: ["openai"],
|
||||
favoriteModels: ["openai/gpt-4o"],
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
await user.click(await screen.findByTestId("toggle-provider-favorite-openai"));
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
favoriteProviders: [],
|
||||
favoriteModels: ["openai/gpt-4o"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await user.click(await screen.findByTestId("toggle-model-favorite-openai/gpt-4o"));
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
favoriteProviders: expect.any(Array),
|
||||
favoriteModels: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows runtime mode selected when agent runtimeConfig has runtimeHint", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
runtimeConfig: {
|
||||
|
||||
@@ -109,7 +109,7 @@ vi.mock("../AgentLogViewer", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../CustomModelDropdown", () => ({
|
||||
CustomModelDropdown: ({ models, value, onChange, disabled, label, placeholder, id, favoriteProviders = [], favoriteModels = [] }: {
|
||||
CustomModelDropdown: ({ models, value, onChange, disabled, label, placeholder, id, favoriteProviders = [], onToggleFavorite, favoriteModels = [], onToggleModelFavorite }: {
|
||||
models: Array<{ provider: string; id: string }>;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
@@ -146,6 +146,29 @@ vi.mock("../CustomModelDropdown", () => ({
|
||||
return createElement("option", { key: modelValue, value: modelValue }, modelValue);
|
||||
}),
|
||||
),
|
||||
...Array.from(new Set(models.map((model) => model.provider))).map((provider) => createElement(
|
||||
"button",
|
||||
{
|
||||
key: `provider-${provider}`,
|
||||
type: "button",
|
||||
"data-testid": `toggle-provider-favorite-${provider}`,
|
||||
onClick: () => onToggleFavorite?.(provider),
|
||||
},
|
||||
`toggle provider ${provider}`,
|
||||
)),
|
||||
...models.map((model) => {
|
||||
const modelValue = `${model.provider}/${model.id}`;
|
||||
return createElement(
|
||||
"button",
|
||||
{
|
||||
key: `model-${modelValue}`,
|
||||
type: "button",
|
||||
"data-testid": `toggle-model-favorite-${modelValue}`,
|
||||
onClick: () => onToggleModelFavorite?.(modelValue),
|
||||
},
|
||||
`toggle model ${modelValue}`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -527,6 +527,42 @@ describe("NewAgentDialog", () => {
|
||||
expect(mockFetchModels).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders shared favorites and toggles through global favorites flow", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockFetchModels).toHaveBeenCalled());
|
||||
|
||||
await user.type(getStepZeroField(/Name/), "Favorite Toggle Agent");
|
||||
await user.click(screen.getByText("Next"));
|
||||
|
||||
const portal = await openModelDropdown();
|
||||
expect(within(portal).getByLabelText("Remove anthropic from favorites")).toBeInTheDocument();
|
||||
expect(within(portal).getByLabelText("Remove Claude Sonnet 4.5 from favorites")).toBeInTheDocument();
|
||||
|
||||
await user.click(within(portal).getByLabelText("Remove anthropic from favorites"));
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
favoriteProviders: [],
|
||||
favoriteModels: ["anthropic/claude-sonnet-4-5"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await user.click(within(portal).getByLabelText("Remove Claude Sonnet 4.5 from favorites"));
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
favoriteProviders: expect.any(Array),
|
||||
favoriteModels: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state then model dropdown on step 1", async () => {
|
||||
// Create a slow promise to see loading state
|
||||
let resolveModels: (v: any) => void;
|
||||
|
||||
Reference in New Issue
Block a user