feat(FN-752): wire up model favorites across dashboard components

- Add favorite model selection to QuickEntryBox and InlineCreateCard components
- Wire up model favorites in TaskForm and TaskDetailModal
- Add model favorites management UI to SettingsModal
- Add tests for favorites in InlineCreateCard, QuickEntryBox, TaskForm, ListView, and SettingsModal
- Remove unused CommitDiffTab component and related tests
This commit is contained in:
gsxdsm
2026-04-02 20:26:35 -07:00
parent d4f0af29a9
commit 160a370117
10 changed files with 249 additions and 10 deletions

View File

@@ -89,6 +89,7 @@ export function InlineCreateCard({
const [modelsError, setModelsError] = useState<string | null>(null);
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>(availableModels ?? []);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const [isExpanded, setIsExpanded] = useState(false);
@@ -118,6 +119,7 @@ export function InlineCreateCard({
const response = await fetchModels();
setLoadedModels(response.models);
setFavoriteProviders(response.favoriteProviders);
setFavoriteModels(response.favoriteModels);
} catch (err: any) {
setModelsError(err?.message || "Failed to load models");
} finally {
@@ -150,6 +152,7 @@ export function InlineCreateCard({
if (!cancelled) {
setLoadedModels(response.models);
setFavoriteProviders(response.favoriteProviders);
setFavoriteModels(response.favoriteModels);
}
})
.catch((err: any) => {
@@ -422,12 +425,29 @@ export function InlineCreateCard({
setFavoriteProviders(newFavorites);
try {
await updateGlobalSettings({ favoriteProviders: newFavorites });
await updateGlobalSettings({ favoriteProviders: newFavorites, favoriteModels });
} catch {
// Revert on error
setFavoriteProviders(currentFavorites);
}
}, [favoriteProviders]);
}, [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);
}
}, [favoriteModels, favoriteProviders]);
const handleModelDropdownMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target;
@@ -750,6 +770,8 @@ export function InlineCreateCard({
onRetry={loadModels}
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>,
document.body,
)

View File

@@ -35,6 +35,10 @@ interface ListViewProps {
onNewTask?: () => void;
onQuickCreate?: (input: TaskCreateInput) => Promise<void>;
availableModels?: ModelInfo[];
favoriteProviders?: string[];
favoriteModels?: string[];
onToggleFavorite?: (provider: string) => void;
onToggleModelFavorite?: (modelId: string) => void;
/**
* Called when the user clicks the "Plan" button in the quick entry box.
*/
@@ -75,6 +79,10 @@ export function ListView({
onNewTask,
onQuickCreate,
availableModels,
favoriteProviders = [],
favoriteModels = [],
onToggleFavorite,
onToggleModelFavorite,
onPlanningMode,
onSubtaskBreakdown,
onTasksUpdated,
@@ -668,6 +676,10 @@ export function ListView({
onChange={(value) => setExecutorModel(value === "" ? "__no_change__" : value)}
label="Executor Model"
placeholder="No change"
favoriteProviders={favoriteProviders}
onToggleFavorite={onToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={onToggleModelFavorite}
/>
</div>
<div className="bulk-edit-dropdown">
@@ -677,6 +689,10 @@ export function ListView({
onChange={(value) => setValidatorModel(value === "" ? "__no_change__" : value)}
label="Validator Model"
placeholder="No change"
favoriteProviders={favoriteProviders}
onToggleFavorite={onToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={onToggleModelFavorite}
/>
</div>
<button

View File

@@ -87,6 +87,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const [modelsError, setModelsError] = useState<string | null>(null);
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>(availableModels ?? []);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
// AI Refinement state
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
@@ -113,6 +114,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
if (!cancelled) {
setLoadedModels(response.models);
setFavoriteProviders(response.favoriteProviders);
setFavoriteModels(response.favoriteModels);
}
})
.catch((err: any) => {
@@ -388,12 +390,29 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setFavoriteProviders(newFavorites);
try {
await updateGlobalSettings({ favoriteProviders: newFavorites });
await updateGlobalSettings({ favoriteProviders: newFavorites, favoriteModels });
} catch {
// Revert on error
setFavoriteProviders(currentFavorites);
}
}, [favoriteProviders]);
}, [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);
}
}, [favoriteModels, favoriteProviders]);
const handlePlanClick = useCallback(() => {
const trimmed = description.trim();
@@ -462,6 +481,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const response = await fetchModels();
setLoadedModels(response.models);
setFavoriteProviders(response.favoriteProviders);
setFavoriteModels(response.favoriteModels);
} catch (err: any) {
setModelsError(err?.message || "Failed to load models");
} finally {
@@ -702,6 +722,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
onRetry={loadModels}
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>,
document.body,
)

View File

@@ -91,6 +91,8 @@ export function SettingsModal({
// Model state
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
// Test notification state
const [testNotificationLoading, setTestNotificationLoading] = useState(false);
@@ -137,7 +139,11 @@ export function SettingsModal({
if (activeSection === "models") {
setModelsLoading(true);
fetchModels()
.then((response) => setAvailableModels(response.models))
.then((response) => {
setAvailableModels(response.models);
setFavoriteProviders(response.favoriteProviders);
setFavoriteModels(response.favoriteModels);
})
.catch(() => setAvailableModels([]))
.finally(() => setModelsLoading(false));
}
@@ -327,6 +333,38 @@ export function SettingsModal({
}
}, [addToast, importPreview, importScope, importMerge, projectId]);
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);
}
}, [favoriteModels, favoriteProviders]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
@@ -536,6 +574,10 @@ export function SettingsModal({
}
}}
placeholder="Use default"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
<small>Default AI model used for task execution when no per-task override is set. &quot;Use default&quot; lets the engine choose automatically.</small>
</div>
@@ -598,6 +640,10 @@ export function SettingsModal({
}
}}
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>
@@ -621,6 +667,10 @@ export function SettingsModal({
}
}}
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>
@@ -760,6 +810,10 @@ export function SettingsModal({
} : current);
}}
placeholder="Use default"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
</div>
<div className="form-group">
@@ -782,6 +836,10 @@ export function SettingsModal({
} : current);
}}
placeholder="Use default"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
</div>
</>
@@ -889,6 +947,10 @@ export function SettingsModal({
}));
}}
placeholder="Use fallback model"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
)}
<small>

View File

@@ -90,6 +90,7 @@ export function TaskForm({
const [depSearch, setDepSearch] = useState("");
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [settings, setSettings] = useState<Settings | null>(null);
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
@@ -112,6 +113,7 @@ export function TaskForm({
.then((response) => {
setAvailableModels(response.models);
setFavoriteProviders(response.favoriteProviders);
setFavoriteModels(response.favoriteModels);
})
.catch(() => {/* silently fail */})
.finally(() => setModelsLoading(false));
@@ -269,11 +271,27 @@ export function TaskForm({
setFavoriteProviders(newFavorites);
try {
await updateGlobalSettings({ favoriteProviders: newFavorites });
await updateGlobalSettings({ favoriteProviders: newFavorites, favoriteModels });
} catch {
setFavoriteProviders(currentFavorites);
}
}, [favoriteProviders]);
}, [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);
}
}, [favoriteModels, favoriteProviders]);
const availableDeps = tasks
.filter((t) => !dependencies.includes(t.id))
@@ -501,6 +519,8 @@ export function TaskForm({
disabled={disabled || presetMode === "preset"}
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
</div>
<div className="model-select-row">
@@ -518,6 +538,8 @@ export function TaskForm({
disabled={disabled || presetMode === "preset"}
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
</div>
</>

View File

@@ -35,6 +35,8 @@ vi.mock("../ModelSelectionModal", () => ({
modelsLoading,
modelsError,
onRetry,
favoriteModels,
onToggleModelFavorite,
}: {
isOpen: boolean;
onClose: () => void;
@@ -46,6 +48,8 @@ vi.mock("../ModelSelectionModal", () => ({
modelsLoading: boolean;
modelsError: string | null;
onRetry: () => void;
favoriteModels?: string[];
onToggleModelFavorite?: (modelId: string) => void;
}) => {
if (!isOpen) return null;
return (
@@ -54,6 +58,8 @@ vi.mock("../ModelSelectionModal", () => ({
<div data-testid="modal-props-validator-value">{validatorValue}</div>
<div data-testid="modal-props-loading">{modelsLoading ? "loading" : "not-loading"}</div>
<div data-testid="modal-props-error">{modelsError || "no-error"}</div>
<div data-testid="modal-props-favorite-models">{JSON.stringify(favoriteModels ?? [])}</div>
<div data-testid="modal-props-has-toggle-model-favorite">{onToggleModelFavorite ? "yes" : "no"}</div>
<button data-testid="modal-close" onClick={onClose}>Close</button>
<button data-testid="modal-select-executor" onClick={() => onExecutorChange("anthropic/claude-sonnet-4-5")}>Select Executor</button>
<button data-testid="modal-select-validator" onClick={() => onValidatorChange("openai/gpt-4o")}>Select Validator</button>
@@ -245,6 +251,14 @@ describe("InlineCreateCard model selector", () => {
expect(screen.getByTestId("model-selection-modal")).toBeTruthy();
});
it("passes favoriteModels and onToggleModelFavorite to ModelSelectionModal", () => {
renderCard();
openModelModal();
expect(screen.getByTestId("modal-props-favorite-models").textContent).toBe("[]");
expect(screen.getByTestId("modal-props-has-toggle-model-favorite").textContent).toBe("yes");
});
it("closes the model modal via the close button", () => {
renderCard();

View File

@@ -5,7 +5,7 @@ import type { Task, TaskDetail } from "@fusion/core";
// Mock the API
vi.mock("../../api", () => ({
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [] }),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
fetchTaskDetail: vi.fn(),
batchUpdateTaskModels: vi.fn(),
}));
@@ -1838,6 +1838,35 @@ describe("ListView - Bulk Selection", () => {
expect(screen.getByText("2 selected")).toBeDefined();
});
it("accepts favoriteProviders and favoriteModels props", () => {
const availableModels = [
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
];
const tasks = [createMockTask({ id: "FN-001" })];
const onToggleFavorite = vi.fn();
const onToggleModelFavorite = vi.fn();
render(
<ListView
tasks={tasks}
onMoveTask={vi.fn()}
onOpenDetail={vi.fn()}
addToast={mockAddToast}
availableModels={availableModels}
favoriteProviders={["openai"]}
favoriteModels={["openai/gpt-4o"]}
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
/>
);
// Select a task to show bulk edit toolbar with dropdowns
const checkbox = screen.getByLabelText("Select FN-001");
fireEvent.click(checkbox);
expect(screen.getByText("Bulk Edit Models:")).toBeDefined();
});
it("shows bulk edit toolbar when tasks are selected", () => {
const availableModels = [
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },

View File

@@ -64,7 +64,7 @@ vi.mock("../../api", () => ({
reasoning: true,
contextWindow: 128_000,
},
], favoriteProviders: [] }),
], favoriteProviders: [], favoriteModels: [] }),
refineText: vi.fn(),
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
}));
@@ -95,6 +95,8 @@ vi.mock("../ModelSelectionModal", () => ({
modelsLoading,
modelsError,
onRetry,
favoriteModels,
onToggleModelFavorite,
}: {
isOpen: boolean;
onClose: () => void;
@@ -106,6 +108,8 @@ vi.mock("../ModelSelectionModal", () => ({
modelsLoading: boolean;
modelsError: string | null;
onRetry: () => void;
favoriteModels?: string[];
onToggleModelFavorite?: (modelId: string) => void;
}) => {
if (!isOpen) return null;
return (
@@ -115,6 +119,8 @@ vi.mock("../ModelSelectionModal", () => ({
<div data-testid="modal-props-validator-value">{validatorValue}</div>
<div data-testid="modal-props-loading">{modelsLoading ? "loading" : "not-loading"}</div>
<div data-testid="modal-props-error">{modelsError || "no-error"}</div>
<div data-testid="modal-props-favorite-models">{JSON.stringify(favoriteModels ?? [])}</div>
<div data-testid="modal-props-has-toggle-model-favorite">{onToggleModelFavorite ? "yes" : "no"}</div>
<button data-testid="modal-close" onClick={onClose}>
Close
</button>
@@ -594,6 +600,18 @@ describe("QuickEntryBox", () => {
expect(screen.getByTestId("modal-props-error").textContent).toBe("no-error");
});
it("passes favoriteModels and onToggleModelFavorite to ModelSelectionModal", () => {
renderQuickEntryBox({});
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with models" } });
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
expect(screen.getByTestId("modal-props-favorite-models").textContent).toBe("[]");
expect(screen.getByTestId("modal-props-has-toggle-model-favorite").textContent).toBe("yes");
});
it("selects dependencies and includes them in submit payload", async () => {
const { props } = renderQuickEntryBox({});
expandQuickEntry();

View File

@@ -413,6 +413,25 @@ describe("SettingsModal", () => {
expect(globalPayload.defaultModelId).toBe("claude-sonnet-4-5");
});
it("stores favoriteModels from fetchModels response", async () => {
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
models: [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: false, contextWindow: 200000 },
],
favoriteProviders: ["anthropic"],
favoriteModels: ["anthropic/claude-sonnet-4-5"],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Models"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// No error thrown means favoriteModels was accepted
expect(screen.getByLabelText("Default Model")).toBeTruthy();
});
it("saving in Models section updates project settings with planning and validator models", async () => {
const user = userEvent.setup();
render(<SettingsModal onClose={onClose} addToast={addToast} />);

View File

@@ -14,7 +14,7 @@ vi.mock("../../api", () => ({
fetchModels: vi.fn().mockResolvedValue({ models: [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
], favoriteProviders: [] }),
], favoriteProviders: [], favoriteModels: [] }),
fetchSettings: vi.fn().mockResolvedValue({
modelPresets: [],
autoSelectModelPreset: false,
@@ -116,6 +116,21 @@ describe("TaskForm", () => {
expect(screen.getByText(/Model Configuration/i)).toBeTruthy();
});
it("fetches and stores favoriteModels from fetchModels response", async () => {
const { fetchModels } = await import("../../api");
vi.mocked(fetchModels).mockResolvedValueOnce({
models: [],
favoriteProviders: ["anthropic"],
favoriteModels: ["anthropic/claude-sonnet-4-5"],
});
renderTaskForm();
// The component fetches models on mount when isActive=true
// If no error is thrown, the favoriteModels state is accepted
await vi.waitFor(() => {
expect(fetchModels).toHaveBeenCalled();
});
});
it("renders workflow step checkboxes with browser verification", () => {
renderTaskForm();