feat(FN-1993): enable model-targeted quick chat sessions
- Add KB agent fallback support in useQuickChat and allow switching sessions by agent plus model override - Introduce session target key matching so same target reloads messages while different model selections create distinct sessions - Update QuickChatFAB to lazy-load models, show model override UI, and keep quick chat usable for model-only conversations - Add and expand hook/component tests to cover model selection flows, KB session creation, and default-model reset behavior
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { MessageSquare, Send, X } from "lucide-react";
|
||||
import type { Agent } from "../api";
|
||||
import { useQuickChat, type ChatMessageInfo } from "../hooks/useQuickChat";
|
||||
import { fetchModels, type Agent, type ModelInfo } from "../api";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { KB_AGENT_ID, useQuickChat, type ChatMessageInfo } from "../hooks/useQuickChat";
|
||||
import { useAgents } from "../hooks/useAgents";
|
||||
|
||||
interface QuickChatFABProps {
|
||||
@@ -15,11 +16,85 @@ interface QuickChatFABProps {
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface ParsedModelSelection {
|
||||
modelProvider: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
const modelMetaTextStyle = {
|
||||
marginTop: "var(--space-xs)",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: "12px",
|
||||
lineHeight: "1.4",
|
||||
} as const;
|
||||
|
||||
const modelTagStyle = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
maxWidth: "180px",
|
||||
padding: "var(--space-xs) var(--space-sm)",
|
||||
borderRadius: "var(--radius-pill)",
|
||||
border: "1px solid color-mix(in srgb, var(--todo) 35%, var(--border))",
|
||||
background: "color-mix(in srgb, var(--todo) 14%, transparent)",
|
||||
color: "var(--text)",
|
||||
fontSize: "11px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
} as const;
|
||||
|
||||
const headerTitleWrapStyle = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "var(--space-sm)",
|
||||
minWidth: 0,
|
||||
} as const;
|
||||
|
||||
const emptyAgentLabelStyle = {
|
||||
width: "100%",
|
||||
border: "1px dashed var(--border)",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
background: "color-mix(in srgb, var(--surface) 90%, var(--bg))",
|
||||
color: "var(--text-muted)",
|
||||
padding: "var(--space-sm) var(--space-md)",
|
||||
fontSize: "12px",
|
||||
} as const;
|
||||
|
||||
function getAgentLabel(agent: Agent): string {
|
||||
const base = agent.name?.trim() || agent.id;
|
||||
return `${base} (${agent.role})`;
|
||||
}
|
||||
|
||||
function parseModelSelection(selectedModel: string): ParsedModelSelection | null {
|
||||
const value = selectedModel.trim();
|
||||
const slashIndex = value.indexOf("/");
|
||||
|
||||
if (!value || slashIndex <= 0 || slashIndex >= value.length - 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
modelProvider: value.slice(0, slashIndex),
|
||||
modelId: value.slice(slashIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function formatModelTagName(modelInfo: ModelInfo | null, parsedSelection: ParsedModelSelection | null): string | null {
|
||||
if (!parsedSelection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (modelInfo?.name?.trim()) {
|
||||
return modelInfo.name.trim();
|
||||
}
|
||||
|
||||
return parsedSelection.modelId
|
||||
.replace(/[-_]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/^\w/, (letter) => letter.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Position type for FAB positioning (right and bottom offsets from viewport edges) */
|
||||
interface Position {
|
||||
x: number;
|
||||
@@ -212,11 +287,17 @@ export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpen
|
||||
}
|
||||
}
|
||||
: setInternalOpen;
|
||||
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
const [messageInput, setMessageInput] = useState("");
|
||||
|
||||
// Track if we just finished a drag (to prevent click from firing after drag)
|
||||
const didDragRef = useRef(false);
|
||||
const modelsRequestedRef = useRef(false);
|
||||
const prevSessionTargetRef = useRef("");
|
||||
|
||||
// Draggable hook for FAB positioning
|
||||
const {
|
||||
@@ -229,6 +310,7 @@ export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpen
|
||||
|
||||
// Chat session hook
|
||||
const {
|
||||
activeSession,
|
||||
messages,
|
||||
isStreaming,
|
||||
streamingText,
|
||||
@@ -237,14 +319,35 @@ export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpen
|
||||
messagesLoading,
|
||||
sendMessage,
|
||||
switchSession,
|
||||
startModelChat,
|
||||
} = useQuickChat(projectId, addToast);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const fabRef = useRef<HTMLButtonElement | null>(null);
|
||||
const messagesRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Track the previous agent ID to detect changes
|
||||
const prevAgentIdRef = useRef<string>("");
|
||||
const parsedModelSelection = useMemo(() => parseModelSelection(selectedModel), [selectedModel]);
|
||||
const selectedModelInfo = useMemo(
|
||||
() => models.find((model) => `${model.provider}/${model.id}` === selectedModel) ?? null,
|
||||
[models, selectedModel],
|
||||
);
|
||||
const selectedModelTag = useMemo(
|
||||
() => formatModelTagName(selectedModelInfo, parsedModelSelection),
|
||||
[selectedModelInfo, parsedModelSelection],
|
||||
);
|
||||
|
||||
const sessionTargetKey = useMemo(() => {
|
||||
if (parsedModelSelection) {
|
||||
const targetAgentId = selectedAgentId || KB_AGENT_ID;
|
||||
return `${targetAgentId}::${parsedModelSelection.modelProvider}/${parsedModelSelection.modelId}`;
|
||||
}
|
||||
if (selectedAgentId) {
|
||||
return `${selectedAgentId}::`;
|
||||
}
|
||||
return "";
|
||||
}, [parsedModelSelection, selectedAgentId]);
|
||||
|
||||
const hasChatTarget = Boolean(selectedAgentId || parsedModelSelection);
|
||||
|
||||
useEffect(() => {
|
||||
if (agents.length === 0) {
|
||||
@@ -258,24 +361,64 @@ export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpen
|
||||
}
|
||||
}, [agents, selectedAgentId]);
|
||||
|
||||
// Initialize session when an agent is selected and panel opens
|
||||
// Lazy-load models on first panel open.
|
||||
useEffect(() => {
|
||||
if (!isOpen || !selectedAgentId) return;
|
||||
if (selectedAgentId !== prevAgentIdRef.current) {
|
||||
prevAgentIdRef.current = selectedAgentId;
|
||||
void switchSession(selectedAgentId);
|
||||
if (!isOpen || modelsRequestedRef.current) {
|
||||
return;
|
||||
}
|
||||
}, [isOpen, selectedAgentId, switchSession]);
|
||||
|
||||
// Handle agent selector changes
|
||||
const handleAgentChange = useCallback(
|
||||
(agentId: string) => {
|
||||
setSelectedAgentId(agentId);
|
||||
prevAgentIdRef.current = agentId;
|
||||
void switchSession(agentId);
|
||||
},
|
||||
[switchSession],
|
||||
);
|
||||
modelsRequestedRef.current = true;
|
||||
setModelsLoading(true);
|
||||
|
||||
fetchModels()
|
||||
.then((response) => {
|
||||
setModels(response.models ?? []);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("[QuickChatFAB] Failed to load models:", error);
|
||||
setModels([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setModelsLoading(false);
|
||||
});
|
||||
}, [isOpen]);
|
||||
|
||||
// Initialize/switch quick chat session whenever the selected target changes.
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionTargetKey) {
|
||||
prevSessionTargetRef.current = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionTargetKey === prevSessionTargetRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
prevSessionTargetRef.current = sessionTargetKey;
|
||||
|
||||
if (parsedModelSelection) {
|
||||
if (selectedAgentId) {
|
||||
void switchSession(selectedAgentId, parsedModelSelection.modelProvider, parsedModelSelection.modelId);
|
||||
} else {
|
||||
void startModelChat(parsedModelSelection.modelProvider, parsedModelSelection.modelId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
void switchSession(selectedAgentId);
|
||||
}, [isOpen, parsedModelSelection, selectedAgentId, sessionTargetKey, startModelChat, switchSession]);
|
||||
|
||||
const handleAgentChange = useCallback((agentId: string) => {
|
||||
setSelectedAgentId(agentId);
|
||||
}, []);
|
||||
|
||||
const handleModelChange = useCallback((value: string) => {
|
||||
setSelectedModel(value);
|
||||
}, []);
|
||||
|
||||
const selectedAgent = useMemo(
|
||||
() => agents.find((agent) => agent.id === selectedAgentId) ?? null,
|
||||
@@ -306,7 +449,7 @@ export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpen
|
||||
document.removeEventListener("mousedown", handleDocumentClick);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [isOpen]);
|
||||
}, [isOpen, setIsOpen]);
|
||||
|
||||
// Auto-scroll messages
|
||||
useEffect(() => {
|
||||
@@ -316,13 +459,25 @@ export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpen
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}, [messages, streamingText, streamingThinking, isOpen]);
|
||||
|
||||
const inputPlaceholder = useMemo(() => {
|
||||
if (selectedAgent) {
|
||||
return `Message ${selectedAgent.name || selectedAgent.id}`;
|
||||
}
|
||||
if (selectedModelTag) {
|
||||
return `Message ${selectedModelTag}`;
|
||||
}
|
||||
return "Select a model to start chatting";
|
||||
}, [selectedAgent, selectedModelTag]);
|
||||
|
||||
const inputDisabled = !hasChatTarget || !activeSession || sessionsLoading || isStreaming;
|
||||
|
||||
const handleSendMessage = useCallback(async () => {
|
||||
const trimmed = messageInput.trim();
|
||||
if (!selectedAgentId || !trimmed || isStreaming) return;
|
||||
if (!trimmed || inputDisabled) return;
|
||||
|
||||
setMessageInput("");
|
||||
await sendMessage(trimmed);
|
||||
}, [sendMessage, isStreaming, messageInput, selectedAgentId]);
|
||||
}, [sendMessage, inputDisabled, messageInput]);
|
||||
|
||||
const handleInputKeyDown = useCallback((event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== "Enter" || event.shiftKey) return;
|
||||
@@ -339,11 +494,7 @@ export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpen
|
||||
return;
|
||||
}
|
||||
setIsOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
if (agents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
}, [setIsOpen]);
|
||||
|
||||
// Calculate panel position: 60px above the FAB (FAB is 48px tall + 12px gap)
|
||||
const panelY = position.y + 60;
|
||||
@@ -376,7 +527,14 @@ export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpen
|
||||
style={{ right: position.x, bottom: panelY }}
|
||||
>
|
||||
<div className="quick-chat-panel-header">
|
||||
<h3>Quick Chat</h3>
|
||||
<div style={headerTitleWrapStyle}>
|
||||
<h3>Quick Chat</h3>
|
||||
{selectedModelTag && (
|
||||
<span style={modelTagStyle} data-testid="quick-chat-model-tag" title={selectedModelTag}>
|
||||
{selectedModelTag}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
@@ -389,19 +547,45 @@ export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpen
|
||||
</div>
|
||||
|
||||
<div className="quick-chat-panel-agent-select">
|
||||
<label htmlFor="quick-chat-agent-select" className="visually-hidden">Select agent</label>
|
||||
<select
|
||||
id="quick-chat-agent-select"
|
||||
value={selectedAgentId}
|
||||
onChange={(event) => handleAgentChange(event.target.value)}
|
||||
data-testid="quick-chat-agent-select"
|
||||
>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{getAgentLabel(agent)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{agents.length > 0 ? (
|
||||
<>
|
||||
<label htmlFor="quick-chat-agent-select" className="visually-hidden">Select agent</label>
|
||||
<select
|
||||
id="quick-chat-agent-select"
|
||||
value={selectedAgentId}
|
||||
onChange={(event) => handleAgentChange(event.target.value)}
|
||||
data-testid="quick-chat-agent-select"
|
||||
>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{getAgentLabel(agent)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
) : (
|
||||
<div style={emptyAgentLabelStyle} data-testid="quick-chat-agent-empty">New model chat</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="quick-chat-panel-agent-select" data-testid="quick-chat-model-select">
|
||||
<label htmlFor="quick-chat-model-override" className="visually-hidden">Select model override</label>
|
||||
<CustomModelDropdown
|
||||
id="quick-chat-model-override"
|
||||
models={models}
|
||||
value={selectedModel}
|
||||
onChange={handleModelChange}
|
||||
label="Select model override"
|
||||
placeholder={modelsLoading ? "Loading models…" : "Use agent's model"}
|
||||
disabled={modelsLoading || models.length === 0}
|
||||
/>
|
||||
<p style={modelMetaTextStyle} data-testid="quick-chat-model-helper">
|
||||
{modelsLoading
|
||||
? "Loading models…"
|
||||
: models.length > 0
|
||||
? "Use default to use agent's model."
|
||||
: "No models available."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="quick-chat-panel-messages" ref={messagesRef} data-testid="quick-chat-messages">
|
||||
@@ -449,14 +633,14 @@ export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpen
|
||||
value={messageInput}
|
||||
onChange={(event) => setMessageInput(event.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={selectedAgent ? `Message ${selectedAgent.name || selectedAgent.id}` : "Type a message"}
|
||||
disabled={!selectedAgentId || isStreaming}
|
||||
placeholder={inputPlaceholder}
|
||||
disabled={inputDisabled}
|
||||
data-testid="quick-chat-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSendMessage()}
|
||||
disabled={!selectedAgentId || messageInput.trim().length === 0 || isStreaming}
|
||||
disabled={inputDisabled || messageInput.trim().length === 0}
|
||||
data-testid="quick-chat-send"
|
||||
>
|
||||
<Send size={16} />
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock("../../api", () => ({
|
||||
createChatSession: vi.fn(),
|
||||
fetchChatMessages: vi.fn(),
|
||||
streamChatResponse: vi.fn(),
|
||||
fetchModels: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useAgents", () => ({
|
||||
@@ -20,6 +21,7 @@ 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[] = [
|
||||
@@ -51,6 +53,23 @@ const mockSession: ChatSession = {
|
||||
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,
|
||||
@@ -103,6 +122,20 @@ function createMockStreamResponse() {
|
||||
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 optionLabel = await screen.findByText(optionName);
|
||||
const option = optionLabel.closest('[role="option"]') ?? optionLabel;
|
||||
fireEvent.click(option);
|
||||
}
|
||||
|
||||
describe("QuickChatFAB", () => {
|
||||
const addToast = vi.fn();
|
||||
|
||||
@@ -112,15 +145,27 @@ describe("QuickChatFAB", () => {
|
||||
mockFetchChatSessions.mockResolvedValue({ sessions: [] });
|
||||
mockCreateChatSession.mockResolvedValue({ session: mockSession });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
mockFetchModels.mockResolvedValue({
|
||||
models: mockModels,
|
||||
favoriteProviders: [],
|
||||
favoriteModels: [],
|
||||
});
|
||||
createMockStreamResponse();
|
||||
});
|
||||
|
||||
it("renders nothing when no agents exist", () => {
|
||||
it("keeps FAB visible when no agents exist so model chats can start", async () => {
|
||||
mockAgentsHook([]);
|
||||
|
||||
render(<QuickChatFAB addToast={addToast} />);
|
||||
|
||||
expect(screen.queryByTestId("quick-chat-fab")).toBeNull();
|
||||
expect(screen.getByTestId("quick-chat-fab")).toBeDefined();
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-chat-agent-empty")).toBeDefined();
|
||||
expect(screen.getByText("New model chat")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders FAB button when agents exist", () => {
|
||||
@@ -139,6 +184,18 @@ describe("QuickChatFAB", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the model dropdown when panel is open", async () => {
|
||||
render(<QuickChatFAB addToast={addToast} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-chat-model-select")).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Select model override" })).toBeDefined();
|
||||
expect(mockFetchModels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("closes panel via close button and Escape key", async () => {
|
||||
render(<QuickChatFAB addToast={addToast} />);
|
||||
|
||||
@@ -174,6 +231,85 @@ describe("QuickChatFAB", () => {
|
||||
expect(screen.getByRole("option", { name: "Agent Two (reviewer)" })).toBeDefined();
|
||||
});
|
||||
|
||||
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: "__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("selecting both an agent and a model creates session with both parameters", async () => {
|
||||
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
await selectModelOption("GPT-4o");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateChatSession).toHaveBeenCalledWith(
|
||||
{
|
||||
agentId: "agent-001",
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
},
|
||||
"proj-123",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("clearing model selection uses the agent default model", async () => {
|
||||
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
await selectModelOption("GPT-4o");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateChatSession).toHaveBeenCalledWith(
|
||||
{
|
||||
agentId: "agent-001",
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
},
|
||||
"proj-123",
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Select model override" }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: "Use default" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateChatSession).toHaveBeenCalledWith({ agentId: "agent-001" }, "proj-123");
|
||||
});
|
||||
});
|
||||
|
||||
it("sending a message calls streamChatResponse API with expected params", async () => {
|
||||
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
|
||||
|
||||
|
||||
163
packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts
Normal file
163
packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatSession } from "@fusion/core";
|
||||
import * as apiModule from "../../api";
|
||||
import { KB_AGENT_ID, useQuickChat } from "../useQuickChat";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchChatSessions: vi.fn(),
|
||||
createChatSession: vi.fn(),
|
||||
fetchChatMessages: vi.fn(),
|
||||
streamChatResponse: 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);
|
||||
|
||||
function makeSession(overrides: Partial<ChatSession> & Pick<ChatSession, "id" | "agentId">): ChatSession {
|
||||
return {
|
||||
id: overrides.id,
|
||||
agentId: overrides.agentId,
|
||||
title: overrides.title ?? null,
|
||||
status: overrides.status ?? "active",
|
||||
projectId: overrides.projectId ?? null,
|
||||
modelProvider: overrides.modelProvider ?? null,
|
||||
modelId: overrides.modelId ?? null,
|
||||
createdAt: overrides.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: overrides.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("useQuickChat", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchChatSessions.mockResolvedValue({ sessions: [] });
|
||||
mockCreateChatSession.mockResolvedValue({
|
||||
session: makeSession({ id: "session-001", agentId: "agent-001" }),
|
||||
});
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||
});
|
||||
|
||||
it("startModelChat creates a KB session with provider/model override", async () => {
|
||||
const { result } = renderHook(() => useQuickChat("proj-123"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startModelChat("anthropic", "claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateChatSession).toHaveBeenCalledWith(
|
||||
{
|
||||
agentId: KB_AGENT_ID,
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
},
|
||||
"proj-123",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("switchSession falls back to KB agent when no explicit agent is provided", async () => {
|
||||
const { result } = renderHook(() => useQuickChat("proj-123"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchSession("", "openai", "gpt-4o");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateChatSession).toHaveBeenCalledWith(
|
||||
{
|
||||
agentId: KB_AGENT_ID,
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
},
|
||||
"proj-123",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("switchSession with different model selections creates distinct sessions", async () => {
|
||||
const modelASession = makeSession({
|
||||
id: "session-model-a",
|
||||
agentId: "agent-001",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
mockCreateChatSession
|
||||
.mockResolvedValueOnce({ session: modelASession })
|
||||
.mockResolvedValueOnce({
|
||||
session: makeSession({
|
||||
id: "session-model-b",
|
||||
agentId: "agent-001",
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
}),
|
||||
});
|
||||
|
||||
mockFetchChatSessions
|
||||
.mockResolvedValueOnce({ sessions: [] })
|
||||
.mockResolvedValueOnce({ sessions: [modelASession] });
|
||||
|
||||
const { result } = renderHook(() => useQuickChat("proj-123"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchSession("agent-001", "anthropic", "claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchSession("agent-001", "openai", "gpt-4o");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateChatSession).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{
|
||||
agentId: "agent-001",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
},
|
||||
"proj-123",
|
||||
);
|
||||
|
||||
expect(mockCreateChatSession).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{
|
||||
agentId: "agent-001",
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
},
|
||||
"proj-123",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("switchSession with the same target reloads messages instead of creating a new session", async () => {
|
||||
const existingSession = makeSession({
|
||||
id: "session-existing",
|
||||
agentId: "agent-001",
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
});
|
||||
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [existingSession] });
|
||||
|
||||
const { result } = renderHook(() => useQuickChat("proj-123"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchSession("agent-001", "openai", "gpt-4o");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchSession("agent-001", "openai", "gpt-4o");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateChatSession).not.toHaveBeenCalled();
|
||||
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-existing", { limit: 50 }, "proj-123");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ChatSession } from "@fusion/core";
|
||||
import {
|
||||
fetchChatSessions,
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
streamChatResponse,
|
||||
} from "../api";
|
||||
|
||||
export const KB_AGENT_ID = "__kb_agent__";
|
||||
|
||||
export interface ChatMessageInfo {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
@@ -16,6 +18,17 @@ export interface ChatMessageInfo {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ModelSelection {
|
||||
modelProvider?: string;
|
||||
modelId?: string;
|
||||
}
|
||||
|
||||
interface SessionTarget {
|
||||
agentId: string;
|
||||
modelProvider?: string;
|
||||
modelId?: string;
|
||||
}
|
||||
|
||||
export interface UseQuickChatReturn {
|
||||
// Session state
|
||||
activeSession: ChatSession | null;
|
||||
@@ -30,11 +43,63 @@ export interface UseQuickChatReturn {
|
||||
|
||||
// Operations
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
switchSession: (agentId: string) => Promise<void>;
|
||||
switchSession: (agentId: string, modelProvider?: string, modelId?: string) => Promise<void>;
|
||||
startModelChat: (modelProvider: string, modelId: string) => Promise<void>;
|
||||
loadMessages: () => Promise<void>;
|
||||
reloadMessages: () => Promise<void>;
|
||||
}
|
||||
|
||||
function normalizeModelSelection(modelProvider?: string, modelId?: string): ModelSelection {
|
||||
const provider = typeof modelProvider === "string" ? modelProvider.trim() : "";
|
||||
const id = typeof modelId === "string" ? modelId.trim() : "";
|
||||
|
||||
if (!provider || !id) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return { modelProvider: provider, modelId: id };
|
||||
}
|
||||
|
||||
function resolveSessionTarget(agentId: string, modelProvider?: string, modelId?: string): SessionTarget | null {
|
||||
const normalizedAgentId = typeof agentId === "string" ? agentId.trim() : "";
|
||||
const normalizedModel = normalizeModelSelection(modelProvider, modelId);
|
||||
|
||||
const targetAgentId = normalizedAgentId || (normalizedModel.modelProvider && normalizedModel.modelId ? KB_AGENT_ID : "");
|
||||
if (!targetAgentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
agentId: targetAgentId,
|
||||
...normalizedModel,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSessionKey(agentId: string, modelProvider?: string, modelId?: string): string {
|
||||
const normalizedModel = normalizeModelSelection(modelProvider, modelId);
|
||||
const provider = normalizedModel.modelProvider ?? "";
|
||||
const id = normalizedModel.modelId ?? "";
|
||||
return `${agentId}::${provider}/${id}`;
|
||||
}
|
||||
|
||||
function findMatchingSession(sessions: ChatSession[], target: SessionTarget): ChatSession | undefined {
|
||||
const candidateSessions = sessions.filter((session) => session.agentId === target.agentId);
|
||||
if (candidateSessions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (target.modelProvider && target.modelId) {
|
||||
return candidateSessions.find(
|
||||
(session) => session.modelProvider === target.modelProvider && session.modelId === target.modelId,
|
||||
);
|
||||
}
|
||||
|
||||
// Prefer sessions without explicit model data when available,
|
||||
// then fall back to the first session for this agent to preserve
|
||||
// existing behavior.
|
||||
return candidateSessions.find((session) => !session.modelProvider && !session.modelId) ?? candidateSessions[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for the QuickChatFAB component.
|
||||
* Provides chat session management and SSE streaming for real-time AI responses.
|
||||
@@ -57,28 +122,38 @@ export function useQuickChat(
|
||||
// Stream connection ref for cleanup
|
||||
const streamRef = useRef<{ close: () => void } | null>(null);
|
||||
|
||||
// Track the current selected agent ID for session management
|
||||
const currentAgentIdRef = useRef<string>("");
|
||||
// Track the current selected chat target for session management
|
||||
const currentSessionKeyRef = useRef<string>("");
|
||||
|
||||
// Fetch existing sessions and find/create one for the given agent
|
||||
// Fetch existing sessions and find/create one for the given target
|
||||
const initializeSession = useCallback(
|
||||
async (agentId: string) => {
|
||||
if (!agentId) return;
|
||||
async (agentId: string, modelProvider?: string, modelId?: string) => {
|
||||
const target = resolveSessionTarget(agentId, modelProvider, modelId);
|
||||
if (!target) return;
|
||||
|
||||
const sessionKey = buildSessionKey(target.agentId, target.modelProvider, target.modelId);
|
||||
|
||||
setSessionsLoading(true);
|
||||
try {
|
||||
const data = await fetchChatSessions(projectId, "active");
|
||||
// Find existing session for this agent
|
||||
const existingSession = data.sessions.find((s) => s.agentId === agentId);
|
||||
const existingSession = findMatchingSession(data.sessions, target);
|
||||
|
||||
if (existingSession) {
|
||||
setActiveSession(existingSession);
|
||||
currentAgentIdRef.current = agentId;
|
||||
currentSessionKeyRef.current = sessionKey;
|
||||
} else {
|
||||
// Create a new session for this agent
|
||||
const newSession = await createChatSession({ agentId }, projectId);
|
||||
const newSessionInput: { agentId: string; modelProvider?: string; modelId?: string } = {
|
||||
agentId: target.agentId,
|
||||
};
|
||||
|
||||
if (target.modelProvider && target.modelId) {
|
||||
newSessionInput.modelProvider = target.modelProvider;
|
||||
newSessionInput.modelId = target.modelId;
|
||||
}
|
||||
|
||||
const newSession = await createChatSession(newSessionInput, projectId);
|
||||
setActiveSession(newSession.session);
|
||||
currentAgentIdRef.current = agentId;
|
||||
currentSessionKeyRef.current = sessionKey;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[useQuickChat] Failed to initialize session:", err);
|
||||
@@ -115,7 +190,7 @@ export function useQuickChat(
|
||||
}
|
||||
}, [activeSession, loadMessages]);
|
||||
|
||||
// Reload messages from server (for same-agent revisit)
|
||||
// Reload messages from server (for same-session revisit)
|
||||
const reloadMessages = useCallback(async () => {
|
||||
if (!activeSession) return;
|
||||
setMessagesLoading(true);
|
||||
@@ -129,9 +204,14 @@ export function useQuickChat(
|
||||
}
|
||||
}, [activeSession, projectId]);
|
||||
|
||||
// Switch to a different agent's session
|
||||
// Switch to a different chat target session
|
||||
const switchSession = useCallback(
|
||||
async (agentId: string) => {
|
||||
async (agentId: string, modelProvider?: string, modelId?: string) => {
|
||||
const target = resolveSessionTarget(agentId, modelProvider, modelId);
|
||||
if (!target) return;
|
||||
|
||||
const targetSessionKey = buildSessionKey(target.agentId, target.modelProvider, target.modelId);
|
||||
|
||||
// Close any existing stream
|
||||
if (streamRef.current) {
|
||||
streamRef.current.close();
|
||||
@@ -143,21 +223,28 @@ export function useQuickChat(
|
||||
setStreamingThinking("");
|
||||
setIsStreaming(false);
|
||||
|
||||
if (agentId === currentAgentIdRef.current) {
|
||||
// Same agent — just reload messages from server
|
||||
if (targetSessionKey === currentSessionKeyRef.current && activeSession) {
|
||||
// Same chat target — just reload messages from server
|
||||
await reloadMessages();
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear old messages immediately so stale conversation doesn't briefly flash
|
||||
// while the new agent's session loads
|
||||
// while the new session loads
|
||||
setMessages([]);
|
||||
|
||||
// New agent — initialize session
|
||||
currentAgentIdRef.current = agentId;
|
||||
await initializeSession(agentId);
|
||||
// New chat target — initialize session
|
||||
currentSessionKeyRef.current = targetSessionKey;
|
||||
await initializeSession(target.agentId, target.modelProvider, target.modelId);
|
||||
},
|
||||
[initializeSession, reloadMessages],
|
||||
[initializeSession, reloadMessages, activeSession],
|
||||
);
|
||||
|
||||
const startModelChat = useCallback(
|
||||
async (modelProvider: string, modelId: string) => {
|
||||
await switchSession(KB_AGENT_ID, modelProvider, modelId);
|
||||
},
|
||||
[switchSession],
|
||||
);
|
||||
|
||||
// Send a message using SSE streaming
|
||||
@@ -245,7 +332,7 @@ export function useQuickChat(
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
return useMemo(() => ({
|
||||
activeSession,
|
||||
sessionsLoading,
|
||||
messages,
|
||||
@@ -255,7 +342,21 @@ export function useQuickChat(
|
||||
streamingThinking,
|
||||
sendMessage,
|
||||
switchSession,
|
||||
startModelChat,
|
||||
loadMessages,
|
||||
reloadMessages,
|
||||
};
|
||||
}), [
|
||||
activeSession,
|
||||
sessionsLoading,
|
||||
messages,
|
||||
messagesLoading,
|
||||
isStreaming,
|
||||
streamingText,
|
||||
streamingThinking,
|
||||
sendMessage,
|
||||
switchSession,
|
||||
startModelChat,
|
||||
loadMessages,
|
||||
reloadMessages,
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user