Files
fusion/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx
Fusion 065dad8cdb feat(FN-3004): merge fusion/fn-3004
- **New `fn custom-provider` CLI commands** (`register`, `unregister`, `list`) for managing custom AI providers; `dashboard.ts` and `serve.ts` now initialize the registry, and `packages/engine/src/custom-providers.ts` exposes the engine integration
- **Custom provider registration API** in `packages/dashboard/src/routes/register-agent-core-routes.ts` with `POST /api/agent-core/register-custom-provider`; providers are persisted in `store.ts` and exposed via `GET /api/agent-core/custom-providers`
- **Agent heartbeat improvements** (`packages/engine/src/agent-heartbeat.ts`): clearer conflict semantics when task is checked out by another agent, refactored `WakeContext` handling, and budget enforcement at heartbeat time
- **Inline quick chat composer** (FN-3004): `QuickChatFAB` reworked with inline attachment row, improved prompt template logic, and full TypeScript coverage in `QuickChatFAB.test.tsx`
- **Settings and onboarding**: `SettingsModal.tsx` surfaces custom provider management; `ModelOnboardingModal.tsx` updated to use the new registry
- **Agent instructions expanded** (`packages/engine/src/agent-instructions.ts`): richer system prompt injection for spawned agents including agent personality/soul, ratings context, and parent assignment details
- **`agent-store.ts`** now tracks `reportsTo` for spawned agent hierarchy and exposes `list_agents` and `delegate_task` tools via the pi extension
- **Database layer**: `db.ts` uses `prepareSelect()` helpers for query consistency; WAL mode enforced; foreign-key checks enabled in test suite
- **New tests**: `custom-provider-registry.test.ts`, `pi-create-fn-agent.test.ts`, `agent-heartbeat.test.ts`, `notifier.test.ts`, `routes.test.ts` for dashboard core routes, plus coverage for `QuickChatFAB`, `TaskCard`, and `UpdateAvailableBanner`
- **TaskCard**: status badge now uses `--card-status-badge--{status}` CSS classes instead of inline style helpers; `TaskCard.test.tsx` updated to match

Commits merged:
- fix(FN-3004): restore dashboard typecheck for custom provider state
- test(FN-3004): complete Step 2 — cover inline composer attachment row
- feat(FN-3004): complete Step 1 — inline quick chat composer row
- feat(FN-3003): merge fusion/fn-3003
- feat(FN-2962): merge fusion/fn-2962
- fix(FN-XXX): update release notes link in update notification

Files changed:
.changeset/fix-update-release-notes-link.md        |   5 +
 .changeset/register-custom-providers.md            |   5 +
 docs/architecture.md                               |   2 +
 .../__tests__/custom-provider-registry.test.ts     | 167 +++++++++++++++++++++
 .../cli/src/commands/__tests__/dashboard.test.ts   |   4 +
 packages/cli/src/commands/__tests__/serve.test.ts  |   3 +
 .../cli/src/commands/custom-provider-registry.ts   |  96 ++++++++++++
 packages/cli/src/commands/dashboard.ts             |  28 ++++
 packages/cli/src/commands/serve.ts                 |  28 ++++
 packages/core/src/__tests__/db.test.ts             |  26 ++--
 packages/core/src/__tests__/insight-store.test.ts  |   8 +-
 packages/core/src/__tests__/mission-store.test.ts  |   2 +-
 packages/core/src/__tests__/roadmap-store.test.ts  |   2 +-
 packages/core/src/__tests__/run-audit.test.ts      |   2 +-
 packages/core/src/__tests__/task-documents.test.ts |   2 +-
 packages/core/src/agent-store.ts                   |  16 +-
 packages/core/src/db.ts                            |  13 +-
 packages/core/src/index.ts                         |   2 +-
 packages/core/src/store.ts                         |  35 ++++-
 packages/core/src/types.ts                         |  33 ++++
 packages/dashboard/app/api/legacy.ts               |  11 ++
 .../dashboard/app/components/AgentDetailView.tsx   |  92 +++++++++++-
 .../app/components/ModelOnboardingModal.tsx        |  15 +-
 packages/dashboard/app/components/QuickChatFAB.css |  19 ++-
 packages/dashboard/app/components/QuickChatFAB.tsx |  95 ++++++------
 .../dashboard/app/components/SettingsModal.tsx     |  39 ++++-
 packages/dashboard/app/components/TaskCard.tsx     |  46 ++++--
 .../app/components/UpdateAvailableBanner.tsx       |   2 +-
 .../app/components/__tests__/QuickChatFAB.test.tsx |  23 +++
 .../app/components/__tests__/TaskCard.test.tsx     |   2 +-
 .../__tests__/UpdateAvailableBanner.test.tsx       |   2 +-
 .../src/__tests__/routes-agent-skills.test.ts      |   1 +
 packages/dashboard/src/__tests__/routes.test.ts    |  34 +++++
 .../src/routes/register-agent-core-routes.ts       |  78 ++++++++++
 .../dashboard/src/routes/register-git-github.ts    |  16 +-
 .../engine/src/__tests__/agent-heartbeat.test.ts   |  59 ++++++++
 packages/engine/src/__tests__/notifier.test.ts     |  33 ++++
 .../src/__tests__/pi-create-fn-agent.test.ts       |  36 +++++
 .../engine/src/__tests__/project-engine.test.ts    |  53 ++++++-
 packages/engine/src/agent-heartbeat.ts             |  86 ++++++++++-
 packages/engine/src/agent-instructions.ts          | 103 ++++++++++++-
 packages/engine/src/custom-providers.ts            |  15 ++
 packages/engine/src/index.ts                       |   3 +
 packages/engine/src/notifier.ts                    |   3 +-
 packages/engine/src/pi.ts                          |  30 ++++
 packages/engine/src/project-engine.ts              |  12 +-
 46 files changed, 1254 insertions(+), 133 deletions(-)

Fusion-Task-Id: FN-3004
2026-04-29 21:53:55 -07:00

2444 lines
84 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import type { Agent } from "../../api";
import type { ChatSession } from "@fusion/core";
import * as apiModule from "../../api";
import { useAgents } from "../../hooks/useAgents";
import { QuickChatFAB } from "../QuickChatFAB";
vi.mock("../../api", () => ({
fetchResumeChatSession: vi.fn(),
fetchChatSessions: vi.fn(),
createChatSession: vi.fn(),
fetchChatMessages: vi.fn(),
streamChatResponse: vi.fn(),
cancelChatResponse: vi.fn(),
fetchModels: vi.fn(),
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
}));
vi.mock("../../hooks/useAgents", () => ({
useAgents: vi.fn(),
}));
const mockFetchResumeChatSession = vi.mocked(apiModule.fetchResumeChatSession);
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 mockCancelChatResponse = vi.mocked(apiModule.cancelChatResponse);
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",
title: null,
projectId: null,
modelProvider: null,
modelId: null,
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;
onToolStart?: (data: { toolName: string; args?: Record<string, unknown> }) => void;
onToolEnd?: (data: { toolName: string; isError: boolean; result?: unknown }) => 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();
vi.stubGlobal("URL", {
...URL,
createObjectURL: vi.fn(() => "blob:mock-preview"),
revokeObjectURL: vi.fn(),
});
mockAgentsHook(mockAgents);
mockFetchResumeChatSession.mockResolvedValue({ session: null });
mockFetchChatSessions.mockResolvedValue({ sessions: [] });
mockCreateChatSession.mockResolvedValue({ session: mockSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockCancelChatResponse.mockResolvedValue({ success: true });
mockFetchModels.mockResolvedValue({
models: mockModels,
favoriteProviders: [],
favoriteModels: [],
defaultProvider: undefined,
defaultModelId: undefined,
});
createMockStreamResponse();
});
it("keeps FAB visible when no agents exist so model chats can start", async () => {
mockAgentsHook([]);
render(<QuickChatFAB addToast={addToast} />);
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();
const headerActions = document.querySelector(".quick-chat-panel-header-actions");
expect(headerActions).toBeTruthy();
if (!headerActions) return;
expect(within(headerActions).queryByTestId("quick-chat-mode-toggle")).toBeNull();
expect(within(headerActions).getByTestId("quick-chat-new-thread")).toBeDefined();
expect(within(headerActions).getByTestId("quick-chat-close")).toBeDefined();
expect(within(headerActions).getAllByRole("button")).toHaveLength(2);
});
it("auto-selects configured default model when no agents exist and enables input", async () => {
mockAgentsHook([]);
mockFetchModels.mockResolvedValue({
models: mockModels,
favoriteProviders: [],
favoriteModels: [],
defaultProvider: "openai",
defaultModelId: "gpt-4o",
});
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-model-tag")).toHaveTextContent("GPT-4o");
expect(mockCreateChatSession).toHaveBeenCalledWith(
{
agentId: "__fn_agent__",
modelProvider: "openai",
modelId: "gpt-4o",
},
"proj-123",
);
expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled();
});
});
it("auto-selects configured default model with agents present and enables input", async () => {
mockAgentsHook(mockAgents);
mockFetchModels.mockResolvedValue({
models: mockModels,
favoriteProviders: [],
favoriteModels: [],
defaultProvider: "openai",
defaultModelId: "gpt-4o",
});
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-model-select")).toBeDefined();
expect(screen.getByTestId("quick-chat-model-tag")).toHaveTextContent("GPT-4o");
expect(mockCreateChatSession).toHaveBeenCalledWith(
{
agentId: "__fn_agent__",
modelProvider: "openai",
modelId: "gpt-4o",
},
"proj-123",
);
expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled();
});
});
it("keeps input disabled until default-model session initialization completes", async () => {
mockAgentsHook([]);
mockFetchModels.mockResolvedValue({
models: mockModels,
favoriteProviders: [],
favoriteModels: [],
defaultProvider: "openai",
defaultModelId: "gpt-4o",
});
let resolveSessionCreation: ((value: { session: ChatSession }) => void) | null = null;
mockCreateChatSession.mockImplementationOnce(
() => new Promise((resolve) => {
resolveSessionCreation = resolve;
}),
);
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-model-tag")).toHaveTextContent("GPT-4o");
});
expect(screen.getByTestId("quick-chat-input")).toBeDisabled();
await act(async () => {
resolveSessionCreation?.({
session: {
...mockSession,
id: "session-002",
agentId: "__fn_agent__",
modelProvider: "openai",
modelId: "gpt-4o",
},
});
});
await waitFor(() => {
expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled();
});
});
it("preserves existing behavior when no default model is configured and agents exist", async () => {
mockAgentsHook(mockAgents);
mockFetchModels.mockResolvedValue({
models: mockModels,
favoriteProviders: [],
favoriteModels: [],
defaultProvider: undefined,
defaultModelId: undefined,
});
render(<QuickChatFAB addToast={addToast} />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-agent-select")).toBeDefined();
});
expect(screen.queryByTestId("quick-chat-model-select")).toBeNull();
expect(screen.queryByTestId("quick-chat-model-tag")).toBeNull();
});
it("renders FAB button when agents exist", () => {
render(<QuickChatFAB addToast={addToast} />);
expect(screen.getByTestId("quick-chat-fab")).toBeDefined();
});
it("opens chat panel when FAB is clicked", async () => {
render(<QuickChatFAB addToast={addToast} />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
});
});
it("does not render markdown/plain render toggle in quick chat header", async () => {
render(<QuickChatFAB addToast={addToast} />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
});
expect(screen.queryByTestId("quick-chat-render-mode-markdown")).not.toBeInTheDocument();
expect(screen.queryByTestId("quick-chat-render-mode-plain")).not.toBeInTheDocument();
});
it("shows eye toggles only on received messages and toggles per message", async () => {
mockFetchChatMessages.mockResolvedValueOnce({
messages: [
{
id: "msg-user",
sessionId: "session-001",
role: "user",
content: "**User** plain",
thinkingOutput: null,
metadata: null,
createdAt: new Date().toISOString(),
},
{
id: "msg-assistant-1",
sessionId: "session-001",
role: "assistant",
content: "**Bold** one",
thinkingOutput: null,
metadata: null,
createdAt: new Date().toISOString(),
},
{
id: "msg-assistant-2",
sessionId: "session-001",
role: "assistant",
content: "**Bold** two",
thinkingOutput: null,
metadata: null,
createdAt: new Date().toISOString(),
},
],
});
render(<QuickChatFAB addToast={addToast} />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const userBubble = await screen.findByTestId("quick-chat-message-msg-user");
const firstAssistantBubble = await screen.findByTestId("quick-chat-message-msg-assistant-1");
const secondAssistantBubble = await screen.findByTestId("quick-chat-message-msg-assistant-2");
expect(within(userBubble).queryByTestId("quick-chat-message-render-toggle")).toBeNull();
const toggles = screen.getAllByTestId("quick-chat-message-render-toggle");
expect(toggles).toHaveLength(2);
expect(toggles[0]).not.toHaveClass("btn");
expect(toggles[0]).not.toHaveClass("btn-icon");
expect(toggles[0].querySelector("svg")).toBeInTheDocument();
expect(within(firstAssistantBubble).getByText("Bold", { selector: "strong" })).toBeInTheDocument();
expect(within(secondAssistantBubble).getByText("Bold", { selector: "strong" })).toBeInTheDocument();
fireEvent.click(toggles[0]);
expect(toggles[0]).toHaveClass("quick-chat-message-render-toggle--plain");
expect(within(firstAssistantBubble).getByText(/\*\*Bold\*\* one/)).toBeInTheDocument();
expect(within(firstAssistantBubble).queryByText("Bold", { selector: "strong" })).toBeNull();
expect(within(secondAssistantBubble).getByText("Bold", { selector: "strong" })).toBeInTheDocument();
fireEvent.click(toggles[0]);
expect(toggles[0]).not.toHaveClass("quick-chat-message-render-toggle--plain");
expect(within(firstAssistantBubble).getByText("Bold", { selector: "strong" })).toBeInTheDocument();
expect(within(userBubble).getByText(/\*\*User\*\* plain/)).toBeInTheDocument();
});
it("uses the streaming sentinel toggle without affecting persisted received messages", async () => {
mockFetchChatMessages.mockResolvedValueOnce({
messages: [
{
id: "msg-assistant",
sessionId: "session-001",
role: "assistant",
content: "**Persisted** message",
thinkingOutput: null,
metadata: null,
createdAt: new Date().toISOString(),
},
],
});
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
handlers.onText?.("**Live** stream");
return {
close: vi.fn(),
isConnected: vi.fn(() => true),
};
});
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(mockFetchResumeChatSession).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Show stream" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
const streamingText = await screen.findByTestId("quick-chat-streaming-text");
const persistedBubble = await screen.findByTestId("quick-chat-message-msg-assistant");
const [persistedToggle, streamingToggle] = screen.getAllByTestId("quick-chat-message-render-toggle");
expect(within(streamingText).getByText("Live", { selector: "strong" })).toBeInTheDocument();
expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument();
fireEvent.click(streamingToggle);
expect(within(streamingText).getByText(/\*\*Live\*\* stream/)).toBeInTheDocument();
expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument();
fireEvent.click(persistedToggle);
expect(within(persistedBubble).getByText(/\*\*Persisted\*\* message/)).toBeInTheDocument();
expect(within(streamingText).getByText(/\*\*Live\*\* stream/)).toBeInTheDocument();
});
it("renders the model dropdown when panel is open in model mode", async () => {
render(<QuickChatFAB addToast={addToast} />);
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(<QuickChatFAB addToast={addToast} />);
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(<QuickChatFAB addToast={addToast} />);
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(<QuickChatFAB addToast={addToast} />);
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(<QuickChatFAB addToast={addToast} />);
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(<QuickChatFAB addToast={addToast} />);
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(<QuickChatFAB addToast={addToast} />);
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(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await selectModelOption("Claude Sonnet 4.5");
await waitFor(() => {
expect(mockCreateChatSession).toHaveBeenCalledWith(
{
agentId: "__fn_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(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
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: "__fn_agent__",
modelProvider: "openai",
modelId: "gpt-4o",
},
"proj-123",
);
});
});
it("renders header toggle controls inside header actions with icon-only new chat button", async () => {
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const headerActions = await waitFor(() => document.querySelector(".quick-chat-panel-header-actions"));
expect(headerActions).toBeTruthy();
if (!headerActions) return;
const scoped = within(headerActions);
expect(scoped.getByTestId("quick-chat-mode-toggle")).toBeDefined();
const agentModeBtn = scoped.getByTestId("quick-chat-mode-agent");
const modelModeBtn = scoped.getByTestId("quick-chat-mode-model");
expect(agentModeBtn).toHaveTextContent("A");
expect(modelModeBtn).toHaveTextContent("M");
expect(agentModeBtn).toHaveAttribute("aria-label", "Switch to agent mode");
expect(modelModeBtn).toHaveAttribute("aria-label", "Switch to model mode");
const newThreadBtn = scoped.getByTestId("quick-chat-new-thread");
expect(within(newThreadBtn).queryByText("New chat")).toBeNull();
expect(newThreadBtn.querySelector("svg")).toBeTruthy();
expect(scoped.getByTestId("quick-chat-close")).toBeDefined();
});
it("new chat action creates a fresh model thread without changing the selected model", async () => {
const existingModelSession: ChatSession = {
id: "session-model-001",
agentId: "__fn_agent__",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
title: null,
projectId: null,
status: "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const freshModelSession: ChatSession = {
id: "session-model-002",
agentId: "__fn_agent__",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
title: null,
projectId: null,
status: "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
mockAgentsHook([]);
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingModelSession });
mockCreateChatSession.mockResolvedValueOnce({ session: freshModelSession });
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-model-tag")).toHaveTextContent("Claude Sonnet 4.5");
expect(mockCreateChatSession).not.toHaveBeenCalled();
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-model-001", { limit: 50 }, "proj-123");
});
fireEvent.click(screen.getByTestId("quick-chat-new-thread"));
await waitFor(() => {
expect(mockCreateChatSession).toHaveBeenCalledTimes(1);
expect(mockCreateChatSession).toHaveBeenCalledWith(
{
agentId: "__fn_agent__",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
},
"proj-123",
);
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-model-002", { limit: 50 }, "proj-123");
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "fresh thread message" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
await waitFor(() => {
expect(mockStreamChatResponse).toHaveBeenCalledWith(
"session-model-002",
"fresh thread message",
expect.any(Object),
[],
"proj-123",
);
});
});
it("switching from model mode to agent mode creates session with only agentId", async () => {
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
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: "__fn_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(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
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: "__fn_agent__",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
},
"proj-123",
);
});
});
it("sending a message calls streamChatResponse API with expected params", async () => {
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Wait for session initialization
await waitFor(() => {
expect(mockFetchResumeChatSession).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),
onToolStart: expect.any(Function),
onToolEnd: 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("shows stop button during streaming and keeps input enabled", async () => {
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
handlers.onConnectionStateChange?.("connected");
handlers.onText?.("Streaming...");
return {
close: vi.fn(),
isConnected: vi.fn(() => true),
};
});
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(mockFetchResumeChatSession).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Hello" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-stop")).toBeInTheDocument();
});
expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled();
});
it("shows Thinking waiting indicator while streaming before first text chunk", async () => {
mockStreamChatResponse.mockImplementation((_sessionId, _content, _handlers) => ({
close: vi.fn(),
isConnected: vi.fn(() => true),
}));
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(mockFetchResumeChatSession).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Hello" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-waiting")).toHaveTextContent("Thinking…");
});
expect(screen.getByTestId("quick-chat-waiting")).not.toHaveTextContent("Connecting…");
});
it("clicking stop button cancels streaming", async () => {
const closeFn = vi.fn();
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
handlers.onConnectionStateChange?.("connected");
handlers.onText?.("Streaming...");
return {
close: closeFn,
isConnected: vi.fn(() => true),
};
});
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(mockFetchResumeChatSession).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Hello" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-stop")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("quick-chat-stop"));
await waitFor(() => {
expect(closeFn).toHaveBeenCalled();
expect(mockCancelChatResponse).toHaveBeenCalledWith("session-001", "proj-123");
});
});
it("renders pending message indicator when a message is queued", async () => {
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
handlers.onConnectionStateChange?.("connected");
handlers.onText?.("Streaming...");
return {
close: vi.fn(),
isConnected: vi.fn(() => true),
};
});
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(mockFetchResumeChatSession).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Hello" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-stop")).toBeInTheDocument();
});
fireEvent.change(input, { target: { value: "Queued follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => {
expect(screen.getByTestId("chat-pending-indicator")).toHaveTextContent("Queued: Queued follow-up");
});
});
it("user can type while streaming", async () => {
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Wait for session initialization
await waitFor(() => {
expect(mockFetchResumeChatSession).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(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Wait for session initialization
await waitFor(() => {
expect(mockFetchResumeChatSession).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("renders tool calls in quick chat messages", async () => {
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
setTimeout(() => {
handlers.onText?.("Used read tool");
handlers.onToolStart?.({ toolName: "read", args: { path: "foo.ts" } });
handlers.onToolEnd?.({ toolName: "read", isError: false, result: "contents" });
handlers.onDone?.({ messageId: "msg-tool" });
}, 0);
return {
close: vi.fn(),
isConnected: vi.fn(() => true),
};
});
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(mockFetchResumeChatSession).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Show tools" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
await waitFor(() => {
expect(screen.getByText("read")).toBeInTheDocument();
expect(screen.getByText("Tool calls")).toBeInTheDocument();
});
const preview = document.querySelector(".chat-tool-call-preview");
expect(preview).toHaveTextContent("result: contents");
});
it("renders multiple tool calls collapsed in quick chat", async () => {
mockFetchChatMessages.mockResolvedValueOnce({
messages: [
{
id: "msg-tools",
sessionId: "session-001",
role: "assistant",
content: "Used tools",
toolCalls: [
{ toolName: "read", status: "completed", isError: false, result: "contents" },
{ toolName: "grep", status: "completed", isError: false, result: "matches" },
],
metadata: {
toolCalls: [
{ toolName: "read", status: "completed", isError: false, result: "contents" },
{ toolName: "grep", status: "completed", isError: false, result: "matches" },
],
},
createdAt: new Date().toISOString(),
},
],
});
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(screen.getByText("2 tool calls")).toBeInTheDocument();
});
const toolCallsContainer = document.querySelector(".chat-tool-calls") as HTMLElement | null;
expect(toolCallsContainer).toHaveClass("chat-tool-calls--compact");
expect(screen.getByText("read, grep")).toBeInTheDocument();
});
it("compact group class applied in quick chat", async () => {
mockFetchChatMessages.mockResolvedValueOnce({
messages: [
{
id: "msg-tools",
sessionId: "session-001",
role: "assistant",
content: "Used tools",
toolCalls: [
{ toolName: "read", status: "completed", isError: false, result: "contents" },
{ toolName: "grep", status: "completed", isError: false, result: "matches" },
],
metadata: {
toolCalls: [
{ toolName: "read", status: "completed", isError: false, result: "contents" },
{ toolName: "grep", status: "completed", isError: false, result: "matches" },
],
},
createdAt: new Date().toISOString(),
},
],
});
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const group = (await waitFor(() => screen.getByTestId("chat-tool-calls-group"))) as HTMLDetailsElement;
expect(group).toHaveClass("chat-tool-calls-group--compact");
});
it("expands grouped tool calls to reveal individual quick chat tool items", async () => {
mockFetchChatMessages.mockResolvedValueOnce({
messages: [
{
id: "msg-tools",
sessionId: "session-001",
role: "assistant",
content: "Used tools",
toolCalls: [
{ toolName: "read", status: "completed", isError: false, result: "contents" },
{ toolName: "grep", status: "completed", isError: false, result: "matches" },
],
metadata: {
toolCalls: [
{ toolName: "read", status: "completed", isError: false, result: "contents" },
{ toolName: "grep", status: "completed", isError: false, result: "matches" },
],
},
createdAt: new Date().toISOString(),
},
],
});
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const group = (await waitFor(() => screen.getByTestId("chat-tool-calls-group"))) as HTMLDetailsElement;
expect(group.open).toBe(false);
const summary = group.querySelector(".chat-tool-calls-group-summary") as HTMLElement;
fireEvent.click(summary);
expect(group.open).toBe(true);
expect(screen.getByText("read")).toBeInTheDocument();
expect(screen.getByText("grep")).toBeInTheDocument();
});
it("auto-opens group for running tool calls in quick chat", async () => {
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
handlers.onText?.("Still working");
handlers.onToolStart?.({ toolName: "read", args: { path: "foo.ts" } });
handlers.onToolEnd?.({ toolName: "read", isError: false, result: "contents" });
handlers.onToolStart?.({ toolName: "grep", args: { pattern: "foo" } });
return {
close: vi.fn(),
isConnected: vi.fn(() => true),
};
});
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(mockFetchResumeChatSession).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Stream tools" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
await waitFor(() => {
const streamingMessage = screen.getByTestId("quick-chat-streaming-message");
expect(within(streamingMessage).getByText("2 tool calls")).toBeInTheDocument();
expect(within(streamingMessage).getByText("(1 running)")).toBeInTheDocument();
const group = within(streamingMessage).getByTestId("chat-tool-calls-group") as HTMLDetailsElement;
expect(group).toBeTruthy();
expect(group.open).toBe(true);
expect(streamingMessage.querySelector(".chat-tool-call--running")).toBeTruthy();
});
});
it("preserves user message after assistant reply completes", async () => {
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Wait for session initialization
await waitFor(() => {
expect(mockFetchResumeChatSession).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
mockFetchResumeChatSession.mockResolvedValueOnce({ session: mockSession });
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Wait for initial session to be created
await waitFor(() => {
expect(mockFetchResumeChatSession).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",
title: null,
projectId: null,
modelProvider: null,
modelId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const sessionForAgent2: ChatSession = {
id: "session-agent-002",
agentId: "agent-002",
status: "active",
title: null,
projectId: null,
modelProvider: null,
modelId: null,
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",
thinkingOutput: null,
metadata: null,
createdAt: new Date().toISOString(),
},
{
id: "msg-002",
sessionId: "session-agent-001",
role: "assistant" as const,
content: "Hello from agent 1 assistant",
thinkingOutput: null,
metadata: null,
createdAt: new Date().toISOString(),
},
];
const agent2Messages = [
{
id: "msg-003",
sessionId: "session-agent-002",
role: "user" as const,
content: "Hello from agent 2",
thinkingOutput: null,
metadata: null,
createdAt: new Date().toISOString(),
},
{
id: "msg-004",
sessionId: "session-agent-002",
role: "assistant" as const,
content: "Agent 2 response",
thinkingOutput: null,
metadata: null,
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 });
mockFetchResumeChatSession
// Initial load: agent-001's existing session found
.mockResolvedValueOnce({ session: sessionForAgent1 })
// Switch to agent-002: no session found → will create new
.mockResolvedValueOnce({ session: null })
// Switch back to agent-001: should find the existing session
.mockResolvedValueOnce({ session: sessionForAgent1 });
// Per-call message mocks
mockFetchChatMessages
.mockResolvedValueOnce({ messages: agent1Messages })
.mockResolvedValueOnce({ messages: agent2Messages })
.mockResolvedValueOnce({ messages: agent1Messages });
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Step 1: Open chat with agent-001 (existing session found)
await waitFor(() => {
expect(mockFetchResumeChatSession).toHaveBeenCalledWith(
{
agentId: "agent-001",
modelProvider: undefined,
modelId: undefined,
},
"proj-123",
);
});
// 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 targeted resume lookup was called with the selected agent on each switch
expect(mockFetchResumeChatSession.mock.calls).toEqual([
[{ agentId: "agent-001", modelProvider: undefined, modelId: undefined }, "proj-123"],
[{ agentId: "agent-002", modelProvider: undefined, modelId: undefined }, "proj-123"],
[{ agentId: "agent-001", modelProvider: undefined, modelId: undefined }, "proj-123"],
]);
// 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(<QuickChatFAB addToast={addToast} />);
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(<QuickChatFAB addToast={addToast} />);
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(<QuickChatFAB addToast={addToast} />);
// 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.mouseDown(trigger);
// Click inside the model selector region; panel should stay open
fireEvent.mouseDown(trigger);
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(<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 after model loading completes
const trigger = screen.getByRole("button", { name: "Select model override" });
await waitFor(() => {
expect(trigger).not.toBeDisabled();
});
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 after model loading completes
const trigger = screen.getByRole("button", { name: "Select model override" });
await waitFor(() => {
expect(trigger).not.toBeDisabled();
});
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 after model loading completes
const trigger = screen.getByRole("button", { name: "Select model override" });
await waitFor(() => {
expect(trigger).not.toBeDisabled();
});
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} />);
expect(screen.queryByTestId("quick-chat-fab")).toBeNull();
});
it("still opens panel programmatically when showFAB is false with controlled open prop", async () => {
render(<QuickChatFAB addToast={addToast} showFAB={false} open={true} />);
expect(screen.queryByTestId("quick-chat-fab")).toBeNull();
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
});
it("controlled open prop opens panel without clicking FAB", () => {
render(<QuickChatFAB addToast={addToast} open={true} />);
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
});
it("controlled open prop defaults to closed when not set", () => {
render(<QuickChatFAB addToast={addToast} />);
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(<QuickChatFAB addToast={addToast} open={false} onOpenChange={onOpenChange} />);
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(<QuickChatFAB addToast={addToast} open={true} onOpenChange={onOpenChange} />);
// 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(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Wait for session initialization
await waitFor(() => {
expect(mockFetchResumeChatSession).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 get response", "error");
});
});
describe("panel resizing", () => {
const localStorageMock = {
store: {} as Record<string, string>,
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 = {}; }),
};
let originalInnerWidth: number;
let originalInnerHeight: number;
beforeEach(() => {
originalInnerWidth = window.innerWidth;
originalInnerHeight = window.innerHeight;
Object.defineProperty(window, "innerWidth", { value: 1200, writable: true });
Object.defineProperty(window, "innerHeight", { value: 900, writable: true });
vi.stubGlobal("localStorage", localStorageMock);
localStorageMock.store = {};
localStorageMock.getItem.mockClear();
localStorageMock.setItem.mockClear();
});
afterEach(() => {
Object.defineProperty(window, "innerWidth", { value: originalInnerWidth, writable: true });
Object.defineProperty(window, "innerHeight", { value: originalInnerHeight, writable: true });
vi.unstubAllGlobals();
});
it("renders default desktop panel dimensions", () => {
render(<QuickChatFAB addToast={addToast} projectId="proj-123" open={true} />);
const panel = screen.getByTestId("quick-chat-panel");
expect(panel.style.width).toBe("320px");
expect(panel.style.height).toBe("400px");
});
it("persists panel size when a resize handle is dragged", () => {
render(<QuickChatFAB addToast={addToast} projectId="proj-123" open={true} />);
const panel = screen.getByTestId("quick-chat-panel");
const leftHandle = panel.querySelector('[data-resize-direction="w"]');
expect(leftHandle).not.toBeNull();
fireEvent.pointerDown(leftHandle!, {
clientX: 400,
clientY: 250,
pointerId: 7,
button: 0,
});
fireEvent.pointerMove(document, {
clientX: 320,
clientY: 250,
pointerId: 7,
});
fireEvent.pointerUp(document, {
clientX: 320,
clientY: 250,
pointerId: 7,
});
expect(localStorageMock.setItem).toHaveBeenCalledWith(
"fusion:quick-chat-size-proj-123",
expect.stringContaining('"width":'),
);
expect(parseFloat(panel.style.width)).toBeGreaterThan(320);
expect(panel.style.height).toBe("400px");
});
it("restores panel size from localStorage on desktop mount", () => {
localStorageMock.store["fusion:quick-chat-size-proj-123"] = JSON.stringify({ width: 470, height: 520 });
render(<QuickChatFAB addToast={addToast} projectId="proj-123" open={true} />);
const panel = screen.getByTestId("quick-chat-panel");
expect(panel.style.width).toBe("470px");
expect(panel.style.height).toBe("520px");
});
it("does not render resize handles on mobile viewport", () => {
Object.defineProperty(window, "innerWidth", { value: 640, writable: true });
render(<QuickChatFAB addToast={addToast} projectId="proj-123" open={true} />);
const panel = screen.getByTestId("quick-chat-panel");
expect(panel.querySelector('[data-resize-direction="n"]')).toBeNull();
expect(panel.querySelector('[data-resize-direction="w"]')).toBeNull();
expect(panel.querySelector('[data-resize-direction="nw"]')).toBeNull();
expect(panel.style.width).toBe("");
expect(panel.style.height).toBe("");
});
it("clamps resized panel dimensions to min and viewport max bounds", () => {
Object.defineProperty(window, "innerWidth", { value: 900, writable: true });
Object.defineProperty(window, "innerHeight", { value: 700, writable: true });
render(<QuickChatFAB addToast={addToast} projectId="proj-123" open={true} />);
const panel = screen.getByTestId("quick-chat-panel");
const cornerHandle = panel.querySelector('[data-resize-direction="nw"]');
expect(cornerHandle).not.toBeNull();
fireEvent.pointerDown(cornerHandle!, {
clientX: 450,
clientY: 320,
pointerId: 9,
button: 0,
});
// Drag down-right to force min clamp
fireEvent.pointerMove(document, {
clientX: 1200,
clientY: 1200,
pointerId: 9,
});
expect(panel.style.width).toBe("280px");
expect(panel.style.height).toBe("260px");
// Drag up-left to force viewport-max clamp
fireEvent.pointerMove(document, {
clientX: -1200,
clientY: -1200,
pointerId: 9,
});
fireEvent.pointerUp(document, {
clientX: -1200,
clientY: -1200,
pointerId: 9,
});
const maxWidth = 900 - 24 - 8;
const maxHeight = 700 - (24 + 60) - 8;
expect(panel.style.width).toBe(`${maxWidth}px`);
expect(panel.style.height).toBe(`${maxHeight}px`);
});
it("resizing the panel does not change FAB drag position", () => {
render(<QuickChatFAB addToast={addToast} projectId="proj-123" open={true} />);
const fab = screen.getByTestId("quick-chat-fab");
const panel = screen.getByTestId("quick-chat-panel");
const initialFabRight = fab.style.right;
const initialFabBottom = fab.style.bottom;
const leftHandle = panel.querySelector('[data-resize-direction="w"]');
expect(leftHandle).not.toBeNull();
fireEvent.pointerDown(leftHandle!, {
clientX: 400,
clientY: 280,
pointerId: 11,
button: 0,
});
fireEvent.pointerMove(document, {
clientX: 300,
clientY: 280,
pointerId: 11,
});
fireEvent.pointerUp(document, {
clientX: 300,
clientY: 280,
pointerId: 11,
});
expect(fab.style.right).toBe(initialFabRight);
expect(fab.style.bottom).toBe(initialFabBottom);
expect(parseFloat(panel.style.width)).toBeGreaterThan(320);
});
});
describe("mobile keyboard overlap", () => {
let savedVisualViewport: typeof window.visualViewport;
let savedInnerWidth: number;
let savedInnerHeight: number;
let savedOntouchstart: typeof window.ontouchstart;
beforeEach(() => {
savedVisualViewport = window.visualViewport;
savedInnerWidth = window.innerWidth;
savedInnerHeight = window.innerHeight;
savedOntouchstart = window.ontouchstart;
});
afterEach(() => {
Object.defineProperty(window, "visualViewport", {
value: savedVisualViewport,
writable: true,
configurable: true,
});
Object.defineProperty(window, "innerWidth", {
value: savedInnerWidth,
writable: true,
configurable: true,
});
Object.defineProperty(window, "innerHeight", {
value: savedInnerHeight,
writable: true,
configurable: true,
});
Object.defineProperty(window, "ontouchstart", {
value: savedOntouchstart,
writable: true,
configurable: true,
});
});
function mockMobileVisualViewport({
innerHeight,
vvHeight,
}: {
innerHeight: number;
vvHeight: number;
}) {
(window as any).ontouchstart = null;
Object.defineProperty(window, "innerWidth", {
value: 375,
writable: true,
configurable: true,
});
Object.defineProperty(window, "innerHeight", {
value: innerHeight,
writable: true,
configurable: true,
});
const listeners: Record<string, Array<() => void>> = {
resize: [],
scroll: [],
};
const mockVV = {
width: 375,
height: vvHeight,
offsetTop: 0,
offsetLeft: 0,
addEventListener: vi.fn((event: string, cb: () => void) => {
listeners[event]?.push(cb);
}),
removeEventListener: vi.fn(),
};
Object.defineProperty(window, "visualViewport", {
value: mockVV,
writable: true,
configurable: true,
});
return { listeners, mockVV };
}
it("sets keyboard overlap CSS variable when mobile viewport shrinks", async () => {
const { listeners, mockVV } = mockMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
});
render(<QuickChatFAB addToast={addToast} open={true} onOpenChange={vi.fn()} />);
const panel = await screen.findByTestId("quick-chat-panel");
expect(panel.style.getPropertyValue("--keyboard-overlap")).toBe("");
Object.defineProperty(window, "innerHeight", {
value: 560,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "height", {
value: 560,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(panel.style.getPropertyValue("--keyboard-overlap")).toBe("284px");
expect(panel.style.getPropertyValue("--vv-height")).toBe("560px");
});
});
it("clears keyboard overlap CSS variable when keyboard closes", async () => {
const { listeners, mockVV } = mockMobileVisualViewport({
innerHeight: 800,
vvHeight: 600,
});
render(<QuickChatFAB addToast={addToast} open={true} onOpenChange={vi.fn()} />);
const panel = await screen.findByTestId("quick-chat-panel");
await waitFor(() => {
expect(panel.style.getPropertyValue("--keyboard-overlap")).toBe("200px");
});
Object.defineProperty(mockVV, "height", {
value: 800,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(panel.style.getPropertyValue("--keyboard-overlap")).toBe("");
expect(panel.style.getPropertyValue("--vv-height")).toBe("");
});
});
it("does not subscribe to keyboard tracking while panel is closed", async () => {
const { mockVV } = mockMobileVisualViewport({
innerHeight: 800,
vvHeight: 600,
});
render(<QuickChatFAB addToast={addToast} open={false} onOpenChange={vi.fn()} />);
await waitFor(() => {
expect(screen.queryByTestId("quick-chat-panel")).toBeNull();
});
expect(mockVV.addEventListener).not.toHaveBeenCalled();
});
});
// Drag-related tests
describe("draggable behavior", () => {
const localStorageMock = {
store: {} as Record<string, string>,
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(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
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(<QuickChatFAB addToast={addToast} open={false} onOpenChange={onOpenChange} projectId="proj-123" />);
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 FAB position to localStorage (was a click, not a drag)
expect(localStorageMock.setItem).not.toHaveBeenCalledWith(
"fusion-quick-chat-position-proj-123",
expect.any(String),
);
});
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(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
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(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
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 positionCall = localStorageMock.setItem.mock.calls.find(
([key]) => key === "fusion-quick-chat-position-proj-123",
);
const savedPosition = JSON.parse(positionCall?.[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(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
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 positionCall = localStorageMock.setItem.mock.calls.find(
([key]) => key === "fusion-quick-chat-position-proj-123",
);
const savedPosition = JSON.parse(positionCall?.[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(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
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 positionCall = localStorageMock.setItem.mock.calls.find(
([key]) => key === "fusion-quick-chat-position-proj-123",
);
const savedPosition = JSON.parse(positionCall?.[1] || "{}");
expect(savedPosition.x).toBeGreaterThanOrEqual(8);
expect(savedPosition.y).toBeGreaterThanOrEqual(8);
});
it("touch events work for dragging", async () => {
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
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(<QuickChatFAB addToast={addToast} projectId="proj-123" open={true} />);
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);
});
});
describe("attachments", () => {
it("shows paperclip button and triggers hidden file input click", async () => {
render(<QuickChatFAB addToast={addToast} />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const attachBtn = await screen.findByTestId("quick-chat-attach-btn");
const clickSpy = vi.spyOn(HTMLInputElement.prototype, "click");
fireEvent.click(attachBtn);
expect(clickSpy).toHaveBeenCalled();
clickSpy.mockRestore();
});
it("keeps paperclip trigger and message input in the same composer row", async () => {
render(<QuickChatFAB addToast={addToast} />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const composerRow = await screen.findByTestId("quick-chat-input-row");
const input = screen.getByTestId("quick-chat-input");
const attachBtn = screen.getByTestId("quick-chat-attach-btn");
const sendBtn = screen.getByTestId("quick-chat-send");
expect(composerRow).toContainElement(attachBtn);
expect(composerRow).toContainElement(input);
expect(composerRow).toContainElement(sendBtn);
const fileInput = document.querySelector(".quick-chat-attachment-input") as HTMLInputElement;
const imageFile = new File(["img"], "photo.png", { type: "image/png" });
fireEvent.change(fileInput, { target: { files: [imageFile] } });
await waitFor(() => {
expect(screen.getByTestId("quick-chat-attachment-preview-0")).toBeInTheDocument();
expect(screen.getByTestId("quick-chat-send")).not.toBeDisabled();
});
});
it("adds/removes previews for selected attachments and filters unsupported files", async () => {
render(<QuickChatFAB addToast={addToast} />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const fileInput = document.querySelector(".quick-chat-attachment-input") as HTMLInputElement;
const imageFile = new File(["img"], "photo.png", { type: "image/png" });
const textFile = new File(["{}"], "data.json", { type: "application/json" });
const invalidFile = new File(["bin"], "payload.bin", { type: "application/octet-stream" });
fireEvent.change(fileInput, { target: { files: [imageFile, textFile, invalidFile] } });
await waitFor(() => {
expect(screen.getByTestId("quick-chat-attachment-preview-0")).toBeInTheDocument();
expect(screen.getByTestId("quick-chat-attachment-preview-1")).toBeInTheDocument();
expect(screen.queryByTestId("quick-chat-attachment-preview-2")).toBeNull();
});
fireEvent.click(screen.getByTestId("quick-chat-attachment-remove-0"));
await waitFor(() => {
expect(screen.queryByTestId("quick-chat-attachment-preview-1")).toBeNull();
});
});
it("supports paste and drag-drop attachment capture", async () => {
render(<QuickChatFAB addToast={addToast} />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const input = await screen.findByTestId("quick-chat-input");
const pastedFile = new File(["image"], "paste.webp", { type: "image/webp" });
fireEvent.paste(input, { clipboardData: { files: [pastedFile] } });
const wrapper = input.closest(".quick-chat-input-wrapper") as HTMLElement;
const droppedFile = new File(["drop"], "drop.png", { type: "image/png" });
fireEvent.drop(wrapper, { dataTransfer: { files: [droppedFile] } });
await waitFor(() => {
expect(screen.getByTestId("quick-chat-attachment-preview-0")).toBeInTheDocument();
expect(screen.getByTestId("quick-chat-attachment-preview-1")).toBeInTheDocument();
});
});
it("sends attachments and clears previews only after success", async () => {
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers, attachments) => {
setTimeout(() => {
handlers.onDone?.({ messageId: "msg-success" });
}, 0);
return { close: vi.fn(), isConnected: vi.fn(() => true) };
});
render(<QuickChatFAB addToast={addToast} />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const input = await screen.findByTestId("quick-chat-input");
const fileInput = document.querySelector(".quick-chat-attachment-input") as HTMLInputElement;
const imageFile = new File(["img"], "photo.png", { type: "image/png" });
fireEvent.change(fileInput, { target: { files: [imageFile] } });
await waitFor(() => {
expect(screen.getByTestId("quick-chat-attachment-preview-0")).toBeInTheDocument();
});
expect(screen.getByTestId("quick-chat-send")).not.toBeDisabled();
fireEvent.click(screen.getByTestId("quick-chat-send"));
await waitFor(() => {
expect(mockStreamChatResponse).toHaveBeenCalled();
});
const call = mockStreamChatResponse.mock.calls.at(-1);
expect(call?.[3]).toEqual([imageFile]);
await waitFor(() => {
expect(screen.queryByTestId("quick-chat-attachment-preview-0")).toBeNull();
});
expect((input as HTMLInputElement).value).toBe("");
});
it("preserves attachments when send fails", async () => {
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
setTimeout(() => {
handlers.onError?.("failed");
}, 0);
return { close: vi.fn(), isConnected: vi.fn(() => true) };
});
render(<QuickChatFAB addToast={addToast} />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const fileInput = document.querySelector(".quick-chat-attachment-input") as HTMLInputElement;
const imageFile = new File(["img"], "photo.png", { type: "image/png" });
fireEvent.change(fileInput, { target: { files: [imageFile] } });
await waitFor(() => {
expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled();
});
fireEvent.click(screen.getByTestId("quick-chat-send"));
await waitFor(() => {
expect(mockStreamChatResponse).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getByTestId("quick-chat-attachment-preview-0")).toBeInTheDocument();
});
});
});
});