FN-5680: migrate model selector favorites to shared hooks
Move ModelSelectorTab to shared favorites/model-cache state while keeping selector behavior aligned with shared data flow. - replace local model/favorites fetching and persistence in ModelSelectorTab with useFavorites and useModelsCache - delegate provider/model favorite toggles to shared hook handlers and keep existing toast error handling - update ModelSelectorTab tests to cover shared favorites rendering, shared global-settings toggle calls, and fetch-failure empty-state behavior Files changed: .../dashboard/app/components/ModelSelectorTab.tsx | 88 ++++------------------ .../components/__tests__/ModelSelectorTab.test.tsx | 55 ++++++++++++-- 2 files changed, 65 insertions(+), 78 deletions(-) Fusion-Task-Id: FN-5680 Fusion-Task-Lineage: 8df227ce-3dfd-4c60-9212-d0d5078edfc4
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import "./ModelSelectorTab.css";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { fetchModels, updateTask, updateGlobalSettings } from "../api";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { updateTask } from "../api";
|
||||
import type { Settings, Task, TaskDetail } from "@fusion/core";
|
||||
import {
|
||||
getErrorMessage,
|
||||
@@ -10,6 +9,8 @@ import {
|
||||
resolveTaskValidatorModel,
|
||||
} from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useFavorites } from "../hooks/useFavorites";
|
||||
import { useModelsCache } from "../hooks/useModelsCache";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
|
||||
@@ -109,11 +110,14 @@ function getSuccessToastMessage(target: "executor" | "validator" | "planning", s
|
||||
}
|
||||
|
||||
export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: ModelSelectorTabProps) {
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
const {
|
||||
availableModels,
|
||||
favoriteProviders,
|
||||
favoriteModels,
|
||||
toggleFavoriteProvider,
|
||||
toggleFavoriteModel,
|
||||
} = useFavorites();
|
||||
const { loading: modelsLoading } = useModelsCache();
|
||||
|
||||
const [selectedExecutor, setSelectedExecutor] = useState<ModelSelection>(() => getExecutorSelection(task));
|
||||
const [savedExecutor, setSavedExecutor] = useState<ModelSelection>(() => getExecutorSelection(task));
|
||||
@@ -127,61 +131,21 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: Mo
|
||||
|
||||
const activeTaskIdRef = useRef(task.id);
|
||||
|
||||
// Load available models on mount
|
||||
useEffect(() => {
|
||||
setModelsLoading(true);
|
||||
setModelsError(null);
|
||||
fetchModels()
|
||||
.then((response) => {
|
||||
setAvailableModels(response.models);
|
||||
setFavoriteProviders(response.favoriteProviders);
|
||||
setFavoriteModels(response.favoriteModels);
|
||||
})
|
||||
.catch((err) => {
|
||||
setModelsError(err.message || "Failed to load models");
|
||||
})
|
||||
.finally(() => {
|
||||
setModelsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle toggle favorite
|
||||
const handleToggleFavorite = useCallback(async (provider: string) => {
|
||||
const currentFavorites = favoriteProviders;
|
||||
const isFavorite = currentFavorites.includes(provider);
|
||||
const newFavorites = isFavorite
|
||||
? currentFavorites.filter((p) => p !== provider)
|
||||
: [provider, ...currentFavorites]; // Add to front
|
||||
|
||||
setFavoriteProviders(newFavorites);
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders: newFavorites, favoriteModels });
|
||||
await toggleFavoriteProvider(provider);
|
||||
} catch {
|
||||
// Revert on error
|
||||
setFavoriteProviders(currentFavorites);
|
||||
addToast("Failed to update favorites", "error");
|
||||
}
|
||||
}, [favoriteProviders, favoriteModels, addToast]);
|
||||
}, [toggleFavoriteProvider, addToast]);
|
||||
|
||||
// Handle toggle model favorite
|
||||
const handleToggleModelFavorite = useCallback(async (modelId: string) => {
|
||||
const currentFavorites = favoriteModels;
|
||||
const isFavorite = currentFavorites.includes(modelId);
|
||||
const newFavorites = isFavorite
|
||||
? currentFavorites.filter((m) => m !== modelId)
|
||||
: [modelId, ...currentFavorites]; // Add to front
|
||||
|
||||
setFavoriteModels(newFavorites);
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders, favoriteModels: newFavorites });
|
||||
await toggleFavoriteModel(modelId);
|
||||
} catch {
|
||||
// Revert on error
|
||||
setFavoriteModels(currentFavorites);
|
||||
addToast("Failed to update model favorites", "error");
|
||||
}
|
||||
}, [favoriteModels, favoriteProviders, addToast]);
|
||||
}, [toggleFavoriteModel, addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
activeTaskIdRef.current = task.id;
|
||||
@@ -402,28 +366,6 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: Mo
|
||||
|
||||
{modelsLoading ? (
|
||||
<div className="model-selector-loading">Loading available models…</div>
|
||||
) : modelsError ? (
|
||||
<div className="model-selector-error">
|
||||
Error loading models: {modelsError}
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
setModelsLoading(true);
|
||||
setModelsError(null);
|
||||
fetchModels()
|
||||
.then((response) => {
|
||||
setAvailableModels(response.models);
|
||||
setFavoriteProviders(response.favoriteProviders);
|
||||
setFavoriteModels(response.favoriteModels);
|
||||
})
|
||||
.catch((err) => setModelsError(err.message))
|
||||
.finally(() => setModelsLoading(false));
|
||||
}}
|
||||
style={{ marginLeft: "8px" }}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : availableModels.length === 0 ? (
|
||||
<div className="model-selector-empty">
|
||||
No models available. Configure authentication in Settings to enable model selection.
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock("../../api", async () => {
|
||||
...actual,
|
||||
fetchModels: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
updateGlobalSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -32,6 +33,7 @@ vi.mock("../ProviderIcon", () => ({
|
||||
|
||||
const mockFetchModels = api.fetchModels as ReturnType<typeof vi.fn>;
|
||||
const mockUpdateTask = api.updateTask as ReturnType<typeof vi.fn>;
|
||||
const mockUpdateGlobalSettings = api.updateGlobalSettings as ReturnType<typeof vi.fn>;
|
||||
|
||||
const FAKE_TASK: Task = {
|
||||
id: "FN-001",
|
||||
@@ -119,6 +121,7 @@ describe("ModelSelectorTab", () => {
|
||||
...FAKE_TASK,
|
||||
...updates,
|
||||
}));
|
||||
mockUpdateGlobalSettings.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("renders loading state initially", () => {
|
||||
@@ -384,6 +387,50 @@ describe("ModelSelectorTab", () => {
|
||||
expect(screen.getByTestId("provider-icon-openai")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders favorites from shared models response", async () => {
|
||||
mockFetchModels.mockResolvedValueOnce({
|
||||
models: MOCK_MODELS,
|
||||
favoriteProviders: ["openai"],
|
||||
favoriteModels: ["anthropic/claude-opus-4"],
|
||||
});
|
||||
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
await waitForSelectors();
|
||||
await openSelector("Executor Model");
|
||||
|
||||
expect(screen.getByLabelText("Remove openai from favorites")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Remove Claude Opus 4 from favorites")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles provider/model favorites through shared global settings flow", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFetchModels.mockResolvedValueOnce({
|
||||
models: MOCK_MODELS,
|
||||
favoriteProviders: ["openai"],
|
||||
favoriteModels: ["anthropic/claude-opus-4"],
|
||||
});
|
||||
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
await waitForSelectors();
|
||||
await user.click(getSelector("Executor Model"));
|
||||
|
||||
await user.click(screen.getByLabelText("Add anthropic to favorites"));
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({
|
||||
favoriteProviders: ["anthropic", "openai"],
|
||||
favoriteModels: ["anthropic/claude-opus-4"],
|
||||
});
|
||||
});
|
||||
|
||||
await user.click(screen.getByLabelText("Add Claude Sonnet 4.5 to favorites"));
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({
|
||||
favoriteProviders: ["anthropic", "openai"],
|
||||
favoriteModels: ["anthropic/claude-sonnet-4-5", "anthropic/claude-opus-4"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-saves executor and validator changes immediately", async () => {
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
@@ -510,20 +557,18 @@ describe("ModelSelectorTab", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error state when fetchModels fails", async () => {
|
||||
it("shows empty state when fetchModels fails", async () => {
|
||||
mockFetchModels.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Error loading models:/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/No models available/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Retry")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state when no models available", async () => {
|
||||
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [] });
|
||||
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user