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:
Fusion
2026-04-17 03:47:21 -07:00
committed by gsxdsm
parent c91ff5fbf6
commit ec3e0220fe
4 changed files with 655 additions and 71 deletions

View File

@@ -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} />

View File

@@ -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" />);

View 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");
});
});
});

View File

@@ -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,
]);
}