import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import type { Agent, ChatSession } from "../../api";
import * as apiModule from "../../api";
import { useAgents } from "../../hooks/useAgents";
import { QuickChatFAB } from "../QuickChatFAB";
vi.mock("../../api", () => ({
fetchChatSessions: vi.fn(),
createChatSession: vi.fn(),
fetchChatMessages: vi.fn(),
streamChatResponse: vi.fn(),
fetchModels: vi.fn(),
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
}));
vi.mock("../../hooks/useAgents", () => ({
useAgents: vi.fn(),
}));
const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
const mockCreateChatSession = vi.mocked(apiModule.createChatSession);
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
const mockFetchModels = vi.mocked(apiModule.fetchModels);
const mockUseAgents = vi.mocked(useAgents);
const mockAgents: Agent[] = [
{
id: "agent-001",
name: "Agent One",
role: "executor",
state: "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
},
{
id: "agent-002",
name: "Agent Two",
role: "reviewer",
state: "terminated",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
},
];
const mockSession: ChatSession = {
id: "session-001",
agentId: "agent-001",
status: "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const mockModels = [
{
provider: "anthropic",
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
reasoning: true,
contextWindow: 200_000,
},
{
provider: "openai",
id: "gpt-4o",
name: "GPT-4o",
reasoning: true,
contextWindow: 128_000,
},
];
function mockAgentsHook(agents: Agent[], isLoading = false) {
mockUseAgents.mockReturnValue({
agents,
activeAgents: agents.filter((agent) => agent.state === "active" || agent.state === "running"),
stats: null,
isLoading,
loadAgents: vi.fn(),
loadStats: vi.fn(),
});
}
function createMockStreamResponse() {
const handlers: {
onThinking?: (data: string) => void;
onText?: (data: string) => void;
onDone?: (data: { messageId: string }) => void;
onError?: (data: string) => void;
onConnectionStateChange?: (state: string) => void;
} = {};
const mockStream = {
close: vi.fn(),
isConnected: vi.fn(() => true),
// Allow setting handlers
setHandlers: (h: typeof handlers) => {
Object.assign(handlers, h);
},
};
// Mock streamChatResponse to capture handlers and return mock stream
mockStreamChatResponse.mockImplementation((sessionId, content, textHandlers) => {
// Store handlers for test to invoke
mockStream.setHandlers(textHandlers as typeof handlers);
// Simulate async response
setTimeout(() => {
// Simulate streaming text
textHandlers.onConnectionStateChange?.("connected");
textHandlers.onText?.("Thinking...");
textHandlers.onText?.("Here's my response.");
textHandlers.onDone?.({ messageId: `msg-${Date.now()}` });
}, 10);
return {
close: mockStream.close,
isConnected: mockStream.isConnected,
};
});
return mockStream;
}
async function selectModelOption(optionName: string) {
const trigger = screen.getByRole("button", { name: "Select model override" });
await waitFor(() => {
expect(trigger).not.toBeDisabled();
});
fireEvent.click(trigger);
const listbox = await screen.findByRole("listbox");
const optionLabel = await within(listbox).findByText(optionName);
const option = optionLabel.closest('[role="option"]') ?? optionLabel;
fireEvent.click(option);
}
describe("QuickChatFAB", () => {
const addToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockAgentsHook(mockAgents);
mockFetchChatSessions.mockResolvedValue({ sessions: [] });
mockCreateChatSession.mockResolvedValue({ session: mockSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockFetchModels.mockResolvedValue({
models: mockModels,
favoriteProviders: [],
favoriteModels: [],
});
createMockStreamResponse();
});
it("keeps FAB visible when no agents exist so model chats can start", async () => {
mockAgentsHook([]);
render();
expect(screen.getByTestId("quick-chat-fab")).toBeDefined();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// With no agents, auto-switches to model mode: model dropdown visible, no toggle, no agent select
await waitFor(() => {
expect(screen.getByTestId("quick-chat-model-select")).toBeDefined();
});
expect(screen.queryByTestId("quick-chat-mode-toggle")).toBeNull();
expect(screen.queryByTestId("quick-chat-agent-select")).toBeNull();
});
it("renders FAB button when agents exist", () => {
render();
expect(screen.getByTestId("quick-chat-fab")).toBeDefined();
});
it("opens chat panel when FAB is clicked", async () => {
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
});
});
it("renders the model dropdown when panel is open in model mode", async () => {
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Switch to model mode via the toggle
const modelModeBtn = await screen.findByTestId("quick-chat-mode-model");
fireEvent.click(modelModeBtn);
await waitFor(() => {
expect(screen.getByTestId("quick-chat-model-select")).toBeDefined();
expect(screen.getByRole("button", { name: "Select model override" })).toBeDefined();
expect(mockFetchModels).toHaveBeenCalledTimes(1);
});
});
it("switching to model mode hides agent dropdown and shows model dropdown", async () => {
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Default is agent mode — agent select visible, model select hidden
expect(await screen.findByTestId("quick-chat-agent-select")).toBeDefined();
expect(screen.queryByTestId("quick-chat-model-select")).toBeNull();
// Switch to model mode
fireEvent.click(screen.getByTestId("quick-chat-mode-model"));
// Agent select should be hidden, model select visible
expect(screen.queryByTestId("quick-chat-agent-select")).toBeNull();
expect(screen.getByTestId("quick-chat-model-select")).toBeDefined();
});
it("switching to agent mode hides model dropdown and shows agent dropdown", async () => {
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Start in agent mode, switch to model mode
const modelModeBtn = await screen.findByTestId("quick-chat-mode-model");
fireEvent.click(modelModeBtn);
expect(screen.queryByTestId("quick-chat-agent-select")).toBeNull();
expect(screen.getByTestId("quick-chat-model-select")).toBeDefined();
// Switch back to agent mode
fireEvent.click(screen.getByTestId("quick-chat-mode-agent"));
expect(screen.getByTestId("quick-chat-agent-select")).toBeDefined();
expect(screen.queryByTestId("quick-chat-model-select")).toBeNull();
});
it("closes panel via close button and Escape key", async () => {
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
});
fireEvent.click(screen.getByTestId("quick-chat-close"));
await waitFor(() => {
expect(screen.queryByTestId("quick-chat-panel")).toBeNull();
});
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
});
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByTestId("quick-chat-panel")).toBeNull();
});
});
it("shows available agents in selector", async () => {
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const select = await screen.findByTestId("quick-chat-agent-select");
expect(select).toBeDefined();
expect(screen.getByRole("option", { name: "Agent One (executor)" })).toBeDefined();
expect(screen.getByRole("option", { name: "Agent Two (reviewer)" })).toBeDefined();
});
it("shows mention popup when @ is typed in quick chat input", async () => {
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "@" } });
expect(await screen.findByTestId("agent-mention-popup")).toBeDefined();
expect(screen.getByTestId("agent-mention-item-agent-001")).toBeDefined();
});
it("selecting mention item inserts @AgentName into quick chat input", async () => {
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const input = await screen.findByTestId("quick-chat-input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "@agent" } });
const mentionItem = await screen.findByTestId("agent-mention-item-agent-001");
fireEvent.click(mentionItem);
expect((screen.getByTestId("quick-chat-input") as HTMLInputElement).value).toBe("@Agent_One ");
expect(screen.queryByTestId("agent-mention-popup")).toBeNull();
});
it("selecting a model with no agents creates a KB agent session with model override", async () => {
mockAgentsHook([]);
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await selectModelOption("Claude Sonnet 4.5");
await waitFor(() => {
expect(mockCreateChatSession).toHaveBeenCalledWith(
{
agentId: "__kb_agent__",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
},
"proj-123",
);
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Hello model" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
await waitFor(() => {
expect(mockStreamChatResponse).toHaveBeenCalledWith(
"session-001",
"Hello model",
expect.any(Object),
"proj-123",
);
});
});
it("model mode creates a KB agent session with the selected model", async () => {
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Switch to model mode
const modelModeBtn = await screen.findByTestId("quick-chat-mode-model");
fireEvent.click(modelModeBtn);
await selectModelOption("GPT-4o");
await waitFor(() => {
expect(mockCreateChatSession).toHaveBeenCalledWith(
{
agentId: "__kb_agent__",
modelProvider: "openai",
modelId: "gpt-4o",
},
"proj-123",
);
});
});
it("switching from model mode to agent mode creates session with only agentId", async () => {
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Switch to model mode and select a model
const modelModeBtn = await screen.findByTestId("quick-chat-mode-model");
fireEvent.click(modelModeBtn);
await selectModelOption("GPT-4o");
await waitFor(() => {
expect(mockCreateChatSession).toHaveBeenCalledWith(
{
agentId: "__kb_agent__",
modelProvider: "openai",
modelId: "gpt-4o",
},
"proj-123",
);
});
// Switch back to agent mode — clears model selection, uses agent's default
const agentModeBtn = screen.getByTestId("quick-chat-mode-agent");
fireEvent.click(agentModeBtn);
await waitFor(() => {
expect(mockCreateChatSession).toHaveBeenCalledWith({ agentId: "agent-001" }, "proj-123");
});
});
it("selecting a model from dropdown does not dismiss the chat panel", async () => {
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
});
// Switch to model mode by clicking the "Model" toggle button
const modelModeBtn = await screen.findByTestId("quick-chat-mode-model");
fireEvent.click(modelModeBtn);
// Click the model dropdown trigger to open the dropdown
const trigger = screen.getByRole("button", { name: "Select model override" });
fireEvent.click(trigger);
// Find a model option and click it
const optionLabel = await screen.findByText("Claude Sonnet 4.5");
const option = optionLabel.closest('[role="option"]') ?? optionLabel;
fireEvent.click(option);
// Panel should still be visible after selecting the model
await waitFor(() => {
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
});
// Assert mockCreateChatSession was called with the correct model parameters
await waitFor(() => {
expect(mockCreateChatSession).toHaveBeenCalledWith(
{
agentId: "__kb_agent__",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
},
"proj-123",
);
});
});
it("sending a message calls streamChatResponse API with expected params", async () => {
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Wait for session initialization
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Ship it" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
// Wait for streamChatResponse to be called
await waitFor(() => {
expect(mockStreamChatResponse).toHaveBeenCalledWith(
"session-001",
"Ship it",
expect.objectContaining({
onThinking: expect.any(Function),
onText: expect.any(Function),
onDone: expect.any(Function),
onError: expect.any(Function),
}),
"proj-123",
);
});
// Input should be cleared
await waitFor(() => {
expect((screen.getByTestId("quick-chat-input") as HTMLInputElement).value).toBe("");
});
});
it("streaming state shows streaming message and keeps input enabled", async () => {
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Wait for session initialization
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Hello" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
// Input should be cleared but NOT disabled during streaming
await waitFor(() => {
expect((screen.getByTestId("quick-chat-input") as HTMLInputElement).value).toBe("");
});
expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled();
// Send button should be disabled during streaming
expect(screen.getByTestId("quick-chat-send")).toBeDisabled();
});
it("user can type while streaming", async () => {
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Wait for session initialization
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Hello" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
// Input should be cleared after send
await waitFor(() => {
expect((screen.getByTestId("quick-chat-input") as HTMLInputElement).value).toBe("");
});
// User should still be able to type in the input while streaming
fireEvent.change(input, { target: { value: "Second message" } });
expect((screen.getByTestId("quick-chat-input") as HTMLInputElement).value).toBe("Second message");
});
it("after streaming completes, assistant message is shown", async () => {
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Wait for session initialization
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Hello" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
// Wait for streaming to complete and message to appear
await waitFor(() => {
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
});
// After streaming completes, input should be re-enabled
await waitFor(() => {
expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled();
});
});
it("preserves user message after assistant reply completes", async () => {
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Wait for session initialization
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Hello" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
// Wait for streaming to complete and assistant response to appear
await waitFor(
() => {
expect(screen.getByText(/Here's my response/)).toBeDefined();
},
{ timeout: 5000 },
);
// Check that user's "Hello" message is preserved
expect(screen.getByText("Hello")).toBeDefined();
// Input should be enabled after streaming completes
expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled();
// Send button should be enabled after streaming completes (input is empty so still disabled)
expect(screen.getByTestId("quick-chat-send")).toBeDisabled();
});
it("switching agents creates a new session for the selected agent", async () => {
// First session exists
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [mockSession] });
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Wait for initial session to be created
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
});
// Switch to agent-002
fireEvent.change(screen.getByTestId("quick-chat-agent-select"), {
target: { value: "agent-002" },
});
// Should create a new session for agent-002
await waitFor(() => {
expect(mockCreateChatSession).toHaveBeenCalledWith({ agentId: "agent-002" }, "proj-123");
});
});
it("switching back to a previous agent restores its conversation", async () => {
const sessionForAgent1: ChatSession = {
id: "session-agent-001",
agentId: "agent-001",
status: "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const sessionForAgent2: ChatSession = {
id: "session-agent-002",
agentId: "agent-002",
status: "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const agent1Messages = [
{
id: "msg-001",
sessionId: "session-agent-001",
role: "user" as const,
content: "Hello from agent 1",
createdAt: new Date().toISOString(),
},
{
id: "msg-002",
sessionId: "session-agent-001",
role: "assistant" as const,
content: "Hello from agent 1 assistant",
createdAt: new Date().toISOString(),
},
];
const agent2Messages = [
{
id: "msg-003",
sessionId: "session-agent-002",
role: "user" as const,
content: "Hello from agent 2",
createdAt: new Date().toISOString(),
},
{
id: "msg-004",
sessionId: "session-agent-002",
role: "assistant" as const,
content: "Agent 2 response",
createdAt: new Date().toISOString(),
},
];
// Setup: agent-001 has an existing session, agent-002 does not
// Override beforeEach's createChatSession mock (which returns session-001)
// so that creating agent-002's session returns the correct ID
mockCreateChatSession.mockResolvedValueOnce({ session: sessionForAgent2 });
mockFetchChatSessions
// Initial load: agent-001's existing session found
.mockResolvedValueOnce({ sessions: [sessionForAgent1] })
// Switch to agent-002: no session found → will create new
.mockResolvedValueOnce({ sessions: [] })
// Switch back to agent-001: should find the existing session
.mockResolvedValueOnce({ sessions: [sessionForAgent1] });
// Per-call message mocks
mockFetchChatMessages
.mockResolvedValueOnce({ messages: agent1Messages })
.mockResolvedValueOnce({ messages: agent2Messages })
.mockResolvedValueOnce({ messages: agent1Messages });
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Step 1: Open chat with agent-001 (existing session found)
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalledWith("proj-123", "active");
});
// Verify agent-001's messages are shown
await waitFor(() => {
expect(screen.getByText("Hello from agent 1")).toBeDefined();
expect(screen.getByText("Hello from agent 1 assistant")).toBeDefined();
});
// Step 2: Switch to agent-002 → messages should clear then load
fireEvent.change(screen.getByTestId("quick-chat-agent-select"), {
target: { value: "agent-002" },
});
// New session created for agent-002
await waitFor(() => {
expect(mockCreateChatSession).toHaveBeenLastCalledWith({ agentId: "agent-002" }, "proj-123");
});
// Verify agent-002's messages are shown
await waitFor(() => {
expect(screen.getByText("Hello from agent 2")).toBeDefined();
expect(screen.getByText("Agent 2 response")).toBeDefined();
});
// Step 3: Switch back to agent-001 → should restore original session
fireEvent.change(screen.getByTestId("quick-chat-agent-select"), {
target: { value: "agent-001" },
});
// Should find existing session (not create new)
await waitFor(() => {
// Verify fetchChatSessions was called with correct projectId on each switch
expect(mockFetchChatSessions.mock.calls).toEqual([
["proj-123", "active"],
["proj-123", "active"],
["proj-123", "active"],
]);
// Verify no new session was created for agent-001 (already had one)
expect(mockCreateChatSession).not.toHaveBeenLastCalledWith(
{ agentId: "agent-001" },
"proj-123",
);
});
// Verify agent-001's original messages are restored
await waitFor(() => {
expect(screen.getByText("Hello from agent 1")).toBeDefined();
expect(screen.getByText("Hello from agent 1 assistant")).toBeDefined();
});
// Verify fetchChatMessages was called with correct session IDs for each agent
expect(mockFetchChatMessages.mock.calls).toEqual([
["session-agent-001", { limit: 50 }, "proj-123"],
["session-agent-002", { limit: 50 }, "proj-123"],
["session-agent-001", { limit: 50 }, "proj-123"],
]);
});
it("shows placeholder text when conversation is empty", async () => {
mockFetchChatMessages.mockResolvedValue({ messages: [] });
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(screen.getByText("No messages yet. Start the conversation!")).toBeDefined();
});
});
it("closes panel when clicking outside", async () => {
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
});
fireEvent.mouseDown(document.body);
await waitFor(() => {
expect(screen.queryByTestId("quick-chat-panel")).toBeNull();
});
});
it("does not close panel when clicking inside portaled model dropdown", async () => {
// Render with no agents so it defaults to model mode
mockAgentsHook([]);
render();
// Open the quick chat panel
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
});
// Click the model combobox trigger to open the dropdown portal
const trigger = screen.getByRole("button", { name: "Select model override" });
fireEvent.click(trigger);
// Verify the portaled dropdown appears
const portalDropdown = await screen.findByTestId("model-combobox-portal");
expect(portalDropdown).toBeDefined();
// Click inside the portaled dropdown (on the search input)
const searchInput = portalDropdown.querySelector("input");
expect(searchInput).not.toBeNull();
fireEvent.mouseDown(searchInput!);
// Panel should still be visible (not closed by the dropdown click)
await waitFor(() => {
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
});
// Control: clicking outside the panel and dropdown should still close the panel
fireEvent.mouseDown(document.body);
await waitFor(() => {
expect(screen.queryByTestId("quick-chat-panel")).toBeNull();
});
});
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();
// 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();
// 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();
// 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();
// 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();
expect(screen.queryByTestId("quick-chat-fab")).toBeNull();
});
it("still opens panel programmatically when showFAB is false with controlled open prop", async () => {
render();
expect(screen.queryByTestId("quick-chat-fab")).toBeNull();
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
});
it("controlled open prop opens panel without clicking FAB", () => {
render();
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
});
it("controlled open prop defaults to closed when not set", () => {
render();
expect(screen.queryByTestId("quick-chat-panel")).toBeNull();
});
it("onOpenChange callback is called when panel is opened via FAB (controlled mode)", async () => {
const onOpenChange = vi.fn();
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(onOpenChange).toHaveBeenCalledWith(true);
});
});
it("onOpenChange callback is called when panel is closed via FAB", async () => {
const onOpenChange = vi.fn();
render();
// Panel should be open initially
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
it("error handling shows toast on stream error", async () => {
// Mock streamChatResponse to trigger error
mockStreamChatResponse.mockImplementationOnce((sessionId, content, textHandlers) => {
setTimeout(() => {
textHandlers.onError?.("Stream connection failed");
}, 10);
return {
close: vi.fn(),
isConnected: vi.fn(() => false),
};
});
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Wait for session initialization
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Hello" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
// Wait for error toast
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to send message", "error");
});
});
// Drag-related tests
describe("draggable behavior", () => {
const localStorageMock = {
store: {} as Record,
getItem: vi.fn((key: string) => localStorageMock.store[key] ?? null),
setItem: vi.fn((key: string, value: string) => { localStorageMock.store[key] = value; }),
removeItem: vi.fn((key: string) => { delete localStorageMock.store[key]; }),
clear: vi.fn(() => { localStorageMock.store = {}; }),
};
beforeEach(() => {
vi.stubGlobal("localStorage", localStorageMock);
localStorageMock.store = {};
localStorageMock.getItem.mockClear();
localStorageMock.setItem.mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("FAB can be dragged to a new position", async () => {
render();
const fab = screen.getByTestId("quick-chat-fab");
// Simulate drag: pointerdown -> pointermove -> pointerup
const fabRect = { left: window.innerWidth - 72, top: window.innerHeight - 72, width: 48, height: 48 };
vi.spyOn(fab, "getBoundingClientRect").mockReturnValue(fabRect as DOMRect);
// Start drag
fireEvent.pointerDown(fab, {
clientX: fabRect.left + 24,
clientY: fabRect.top + 24,
button: 0,
pointerId: 1,
});
// Verify data-dragging attribute is set
expect(fab.getAttribute("data-dragging")).toBe("true");
// Move pointer (drag 50px to the left, which means increasing right offset)
fireEvent.pointerMove(fab, {
clientX: fabRect.left + 24 - 50,
clientY: fabRect.top + 24,
pointerId: 1,
});
// End drag
fireEvent.pointerUp(fab, {
clientX: fabRect.left + 24 - 50,
clientY: fabRect.top + 24,
button: 0,
pointerId: 1,
});
// Verify localStorage was called with the new position
expect(localStorageMock.setItem).toHaveBeenCalledWith(
"fusion-quick-chat-position-proj-123",
expect.stringContaining('"x":'),
);
});
it("small movement (< 5px) is treated as click not drag", async () => {
const onOpenChange = vi.fn();
render();
const fab = screen.getByTestId("quick-chat-fab");
// Simulate click: pointerDown + small pointerMove (< 5px) + pointerUp + click
// Since movement is < 5px, this should be treated as a click, not a drag
fireEvent.pointerDown(fab, {
clientX: 100,
clientY: 100,
button: 0,
pointerId: 1,
});
// Small movement (less than 5px threshold)
fireEvent.pointerMove(fab, {
clientX: 102,
clientY: 100,
pointerId: 1,
});
fireEvent.pointerUp(fab, {
clientX: 102,
clientY: 100,
button: 0,
pointerId: 1,
});
// jsdom doesn't fire click automatically after pointerup with movement < threshold,
// so we simulate the click event that would fire in a real browser
fireEvent.click(fab);
// Should have toggled panel (treated as click)
expect(onOpenChange).toHaveBeenCalledWith(true);
// Should NOT have saved position to localStorage (was a click, not a drag)
expect(localStorageMock.setItem).not.toHaveBeenCalled();
});
it("FAB position is loaded from localStorage on mount", async () => {
// Pre-populate localStorage with saved position
localStorageMock.store["fusion-quick-chat-position-proj-123"] = JSON.stringify({ x: 100, y: 200 });
render();
const fab = screen.getByTestId("quick-chat-fab");
// Verify the FAB has the saved position
expect(fab.style.right).toBe("100px");
expect(fab.style.bottom).toBe("200px");
// Verify localStorage was read
expect(localStorageMock.getItem).toHaveBeenCalledWith("fusion-quick-chat-position-proj-123");
});
it("FAB position is clamped to viewport boundaries", async () => {
// Desktop viewport (jsdom default 1024px > 768px): 48px edge margin
render();
const fab = screen.getByTestId("quick-chat-fab");
// Simulate drag to extreme position (off viewport)
fireEvent.pointerDown(fab, {
clientX: 100,
clientY: 100,
button: 0,
pointerId: 1,
});
// Try to drag way off screen (negative coordinates)
fireEvent.pointerMove(fab, {
clientX: -1000,
clientY: -1000,
pointerId: 1,
});
fireEvent.pointerUp(fab, {
clientX: -1000,
clientY: -1000,
button: 0,
pointerId: 1,
});
// Desktop: position should be clamped to at least 8px from edges
const savedPosition = JSON.parse(localStorageMock.setItem.mock.calls[0]?.[1] || "{}");
expect(savedPosition.x).toBeGreaterThanOrEqual(8);
expect(savedPosition.y).toBeGreaterThanOrEqual(8);
});
it("on mobile viewport, FAB can be dragged to within 4px of the edge", async () => {
// Mock mobile viewport (375px wide, which is <= 768px)
Object.defineProperty(window, "innerWidth", { value: 375, writable: true });
Object.defineProperty(window, "innerHeight", { value: 812, writable: true });
render();
const fab = screen.getByTestId("quick-chat-fab");
// Simulate drag to extreme position (off viewport)
fireEvent.pointerDown(fab, {
clientX: 100,
clientY: 100,
button: 0,
pointerId: 1,
});
// Try to drag way off screen (negative coordinates)
fireEvent.pointerMove(fab, {
clientX: -1000,
clientY: -1000,
pointerId: 1,
});
fireEvent.pointerUp(fab, {
clientX: -1000,
clientY: -1000,
button: 0,
pointerId: 1,
});
// Mobile (375px <= 768px): position should be clamped to at least 4px from edges
const savedPosition = JSON.parse(localStorageMock.setItem.mock.calls[0]?.[1] || "{}");
expect(savedPosition.x).toBeGreaterThanOrEqual(4);
expect(savedPosition.y).toBeGreaterThanOrEqual(4);
});
it("on desktop viewport, FAB edge margin is 8px", async () => {
// Explicitly set desktop viewport (1024px wide, which is > 768px)
Object.defineProperty(window, "innerWidth", { value: 1024, writable: true });
Object.defineProperty(window, "innerHeight", { value: 768, writable: true });
render();
const fab = screen.getByTestId("quick-chat-fab");
// Simulate drag to extreme position (off viewport)
fireEvent.pointerDown(fab, {
clientX: 100,
clientY: 100,
button: 0,
pointerId: 1,
});
// Try to drag way off screen (negative coordinates)
fireEvent.pointerMove(fab, {
clientX: -1000,
clientY: -1000,
pointerId: 1,
});
fireEvent.pointerUp(fab, {
clientX: -1000,
clientY: -1000,
button: 0,
pointerId: 1,
});
// Desktop: position should be clamped to at least 8px from edges
const savedPosition = JSON.parse(localStorageMock.setItem.mock.calls[0]?.[1] || "{}");
expect(savedPosition.x).toBeGreaterThanOrEqual(8);
expect(savedPosition.y).toBeGreaterThanOrEqual(8);
});
it("touch events work for dragging", async () => {
render();
const fab = screen.getByTestId("quick-chat-fab");
// Simulate touch drag
fireEvent.pointerDown(fab, {
clientX: 100,
clientY: 100,
button: 0,
pointerId: 1,
pointerType: "touch",
});
// Verify data-dragging attribute is set
expect(fab.getAttribute("data-dragging")).toBe("true");
// Move touch
fireEvent.pointerMove(fab, {
clientX: 50,
clientY: 100,
pointerId: 1,
pointerType: "touch",
});
// End touch
fireEvent.pointerUp(fab, {
clientX: 50,
clientY: 100,
button: 0,
pointerId: 1,
pointerType: "touch",
});
// Position should have been saved
expect(localStorageMock.setItem).toHaveBeenCalled();
});
it("panel position anchors relative to FAB position", async () => {
render();
const panel = screen.getByTestId("quick-chat-panel");
const fab = screen.getByTestId("quick-chat-fab");
// Initial positions (FAB at x=24, y=24+footer, panel at x=24, y=84+footer = FAB.y + 60)
const fabBottom = parseFloat(fab.style.bottom);
const panelBottom = parseFloat(panel.style.bottom);
expect(panelBottom - fabBottom).toBe(60);
});
});
});