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:
gsxdsm
2026-05-29 08:44:56 -07:00
parent 71e2aec5b7
commit ab16307750
5 changed files with 111 additions and 80 deletions

View File

@@ -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>
) : (

View File

@@ -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>

View File

@@ -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: {

View File

@@ -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}`,
);
}),
);
},
}));

View File

@@ -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;