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:
gsxdsm
2026-05-29 10:02:58 -07:00
parent a312da63e4
commit 86c98d3593
2 changed files with 65 additions and 78 deletions

View File

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

View File

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