feat(FN-2044): propagate model favorites through QuickChatFAB

- Extend QuickChatFAB props with favorite providers/models and favorite toggle callbacks
- Forward favorite state and handlers to the model override CustomModelDropdown
- Wire App.tsx favorite provider/model state and toggle handlers into QuickChatFAB
- Add QuickChatFAB tests for pinned favorite models, provider favorites, and toggle callback behavior
This commit is contained in:
Fusion
2026-04-18 00:08:27 -07:00
committed by gsxdsm
parent 5bc2ffb9dc
commit a486b5c15c
4 changed files with 170 additions and 10 deletions

View File

@@ -680,6 +680,10 @@ function AppInner() {
showFAB={showQuickChatFAB}
open={quickChatOpen}
onOpenChange={setQuickChatOpen}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
/>
)}
<AppModals

View File

@@ -794,15 +794,6 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
// Render empty state (no active session)
const renderEmptyState = () => {
if (showNewDialog) {
return (
<NewChatDialog
onClose={() => setShowNewDialog(false)}
onCreate={handleCreateSession}
/>
);
}
return (
<div className="chat-empty-state">
<MessageSquare size={48} strokeWidth={1.5} />

View File

@@ -23,6 +23,14 @@ interface QuickChatFABProps {
open?: boolean;
/** Callback when the panel should be opened/closed */
onOpenChange?: (open: boolean) => void;
/** List of favorite provider names in preferred order */
favoriteProviders?: string[];
/** List of favorited model identifiers in format "{provider}/{modelId}" */
favoriteModels?: string[];
/** Called when user toggles a provider's favorite status */
onToggleFavorite?: (provider: string) => void;
/** Called when user toggles a model's favorite status */
onToggleModelFavorite?: (modelId: string) => void;
}
interface ParsedModelSelection {
@@ -284,7 +292,17 @@ function useDraggable(projectId?: string, externalDidDragRef?: React.MutableRefO
};
}
export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpenChange }: QuickChatFABProps) {
export function QuickChatFAB({
projectId,
addToast,
showFAB = true,
open,
onOpenChange,
favoriteProviders = [],
favoriteModels = [],
onToggleFavorite,
onToggleModelFavorite,
}: QuickChatFABProps) {
const { agents } = useAgents(projectId);
// Internal state for uncontrolled mode, controlled state when open prop is provided
const [internalOpen, setInternalOpen] = useState(false);
@@ -860,6 +878,10 @@ export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpen
label="Select model override"
placeholder={modelsLoading ? "Loading models…" : "Select a model"}
disabled={modelsLoading || models.length === 0}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
/>
</div>
)}

View File

@@ -746,6 +746,149 @@ describe("QuickChatFAB", () => {
});
});
it("shows favorited models as pinned rows in the model dropdown when favoriteModels prop is provided", async () => {
// Render with no agents to default to model mode
mockAgentsHook([]);
// Provide a favorited model
const favoriteModels = ["openai/gpt-4o"];
render(<QuickChatFAB addToast={addToast} favoriteModels={favoriteModels} />);
// Open the quick chat panel
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Switch to model mode (should already be default with no agents)
await waitFor(() => {
expect(screen.getByTestId("quick-chat-model-select")).toBeDefined();
});
// Open the model dropdown
const trigger = screen.getByRole("button", { name: "Select model override" });
fireEvent.click(trigger);
// The favorited model should appear as a pinned row (before the provider groups)
const portalDropdown = await screen.findByTestId("model-combobox-portal");
expect(portalDropdown).toBeDefined();
// Find the favorited model option
const gpt4oOption = portalDropdown.querySelector('.model-combobox-option--favorite');
expect(gpt4oOption).not.toBeNull();
// Verify it contains the model name
const gpt4oText = gpt4oOption?.querySelector(".model-combobox-option-text");
expect(gpt4oText?.textContent).toBe("GPT-4o");
});
it("shows favorited providers sorted first in the model dropdown when favoriteProviders prop is provided", async () => {
// Render with no agents to default to model mode
mockAgentsHook([]);
// Provide a favorited provider (and onToggleFavorite so the star button is rendered)
const favoriteProviders = ["openai"];
const onToggleFavorite = vi.fn();
render(<QuickChatFAB addToast={addToast} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} />);
// Open the quick chat panel
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-model-select")).toBeDefined();
});
// Open the model dropdown
const trigger = screen.getByRole("button", { name: "Select model override" });
fireEvent.click(trigger);
// The portaled dropdown should show
const portalDropdown = await screen.findByTestId("model-combobox-portal");
expect(portalDropdown).toBeDefined();
// Check that the "Use default" option is first
const options = portalDropdown.querySelectorAll('[role="option"]');
expect(options.length).toBeGreaterThan(0);
expect(options[0].textContent).toContain("Use default");
// OpenAI should be marked as favorite with an active star button
const openaiOptgroup = portalDropdown.querySelector(".model-combobox-optgroup");
expect(openaiOptgroup).not.toBeNull();
const favoriteBtn = openaiOptgroup?.querySelector(".model-combobox-optgroup-favorite--active");
expect(favoriteBtn).not.toBeNull();
expect(favoriteBtn?.textContent).toBe("★");
});
it("calls onToggleModelFavorite when the favorite button on a model is clicked", async () => {
// Render with no agents to default to model mode
mockAgentsHook([]);
const onToggleModelFavorite = vi.fn();
render(<QuickChatFAB addToast={addToast} onToggleModelFavorite={onToggleModelFavorite} />);
// Open the quick chat panel
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-model-select")).toBeDefined();
});
// Open the model dropdown
const trigger = screen.getByRole("button", { name: "Select model override" });
fireEvent.click(trigger);
const portalDropdown = await screen.findByTestId("model-combobox-portal");
expect(portalDropdown).toBeDefined();
// Find a model's favorite toggle button (☆ means not favorited)
const unfavoritedBtn = portalDropdown.querySelector(".model-combobox-option-favorite:not(.model-combobox-option-favorite--active)");
expect(unfavoritedBtn).not.toBeNull();
// Click the toggle button (stopPropagation prevents dropdown close)
fireEvent.click(unfavoritedBtn!);
// The callback should have been called with the model ID
expect(onToggleModelFavorite).toHaveBeenCalledTimes(1);
const calledWith = onToggleModelFavorite.mock.calls[0][0];
// Should be in "{provider}/{modelId}" format
expect(calledWith).toMatch(/\w+\/\w+/);
});
it("calls onToggleFavorite when the favorite button on a provider group is clicked", async () => {
// Render with no agents to default to model mode
mockAgentsHook([]);
const onToggleFavorite = vi.fn();
render(<QuickChatFAB addToast={addToast} onToggleFavorite={onToggleFavorite} />);
// Open the quick chat panel
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-model-select")).toBeDefined();
});
// Open the model dropdown
const trigger = screen.getByRole("button", { name: "Select model override" });
fireEvent.click(trigger);
const portalDropdown = await screen.findByTestId("model-combobox-portal");
expect(portalDropdown).toBeDefined();
// Find the provider optgroup
const openaiOptgroup = portalDropdown.querySelector(".model-combobox-optgroup");
expect(openaiOptgroup).not.toBeNull();
// Click the provider's favorite toggle button
const favoriteBtn = openaiOptgroup?.querySelector(".model-combobox-optgroup-favorite");
expect(favoriteBtn).not.toBeNull();
fireEvent.click(favoriteBtn!);
// The callback should have been called with the provider name
expect(onToggleFavorite).toHaveBeenCalledTimes(1);
const calledWith = onToggleFavorite.mock.calls[0][0];
expect(typeof calledWith).toBe("string");
expect(calledWith.length).toBeGreaterThan(0);
});
it("hides FAB button when showFAB is false", () => {
render(<QuickChatFAB addToast={addToast} showFAB={false} />);