feat(FN-1549): simplify chat creation with agent-only selection and auto title

- Remove task selection from chat creation flow
- Add agent-only chat creation with automatic title generation
- Introduce useChat hook for unified chat state management
- Update ChatView with simplified creation UI
- Add CSS styles for chat creation interface
- Add comprehensive tests for chat manager and routes
- Update routes to support agent-only chat creation
This commit is contained in:
Fusion
2026-04-15 01:49:41 -07:00
committed by gsxdsm
parent f93e343648
commit b9264670e2
9 changed files with 412 additions and 119 deletions

View File

@@ -11,9 +11,8 @@ import {
} from "lucide-react";
import { useChat } from "../hooks/useChat";
import { useViewportMode } from "./Header";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { fetchModels } from "../api";
import type { ModelInfo } from "../api";
import { fetchAgents } from "../api";
import type { Agent } from "@fusion/core";
export interface ChatViewProps {
projectId?: string;
@@ -45,45 +44,34 @@ const KB_AGENT_ID = "__kb_agent__";
interface NewChatDialogProps {
onClose: () => void;
onCreate: (input: { agentId: string; modelProvider?: string; modelId?: string }) => void;
onCreate: (input: { agentId: string }) => void;
}
function NewChatDialog({ onClose, onCreate }: NewChatDialogProps) {
// Model selection state (single combined value: "provider/modelId" or "" for default)
const [modelValue, setModelValue] = useState("");
const [models, setModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(true);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [agentsLoading, setAgentsLoading] = useState(true);
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
// Load models on mount
// Load agents on mount
useEffect(() => {
setModelsLoading(true);
fetchModels()
setAgentsLoading(true);
fetchAgents()
.then((response) => {
setModels(response.models);
setFavoriteProviders(response.favoriteProviders);
setFavoriteModels(response.favoriteModels);
setAgents(response);
})
.catch(() => {
// Silently fail - dropdown will show empty list
setModels([]);
// Silently fail - show empty list
setAgents([]);
})
.finally(() => {
setModelsLoading(false);
setAgentsLoading(false);
});
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Parse modelValue into provider and modelId
const parsed = parseModelValue(modelValue);
// Always use the kb agent - agentId is metadata only
onCreate({
agentId: KB_AGENT_ID,
modelProvider: parsed.provider,
modelId: parsed.modelId,
});
if (!selectedAgentId) return;
onCreate({ agentId: selectedAgentId });
};
return (
@@ -92,37 +80,38 @@ function NewChatDialog({ onClose, onCreate }: NewChatDialogProps) {
<h3>New Chat</h3>
<form onSubmit={handleSubmit}>
<label className="chat-new-dialog-model-label">
Model
<CustomModelDropdown
models={models}
value={modelValue}
onChange={setModelValue}
placeholder={modelsLoading ? "Loading models..." : "Select a model…"}
disabled={modelsLoading}
label="Chat model"
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={(provider) => {
setFavoriteProviders((prev) =>
prev.includes(provider)
? prev.filter((p) => p !== provider)
: [provider, ...prev]
);
}}
onToggleModelFavorite={(modelId) => {
setFavoriteModels((prev) =>
prev.includes(modelId)
? prev.filter((m) => m !== modelId)
: [modelId, ...prev]
);
}}
/>
Agent
{agentsLoading ? (
<div className="chat-new-dialog-loading">Loading agents...</div>
) : agents.length === 0 ? (
<div className="chat-new-dialog-empty">No agents available</div>
) : (
<div className="chat-new-dialog-agent-list">
{agents.map((agent) => (
<button
key={agent.id}
type="button"
className={`chat-new-dialog-agent-item${selectedAgentId === agent.id ? " chat-new-dialog-agent-item--selected" : ""}`}
onClick={() => setSelectedAgentId(agent.id)}
data-testid={`agent-option-${agent.id}`}
>
<Bot size={16} />
<span className="chat-new-dialog-agent-name">{agent.name}</span>
<span className="chat-new-dialog-agent-role">{agent.role}</span>
</button>
))}
</div>
)}
</label>
<div className="chat-new-dialog-actions">
<button type="button" className="btn btn-sm" onClick={onClose}>
Cancel
</button>
<button type="submit" className="btn btn-sm btn-primary">
<button
type="submit"
className="btn btn-sm btn-primary"
disabled={!selectedAgentId}
>
Create
</button>
</div>
@@ -132,19 +121,7 @@ function NewChatDialog({ onClose, onCreate }: NewChatDialogProps) {
);
}
/**
* Parse a combined model value ("provider/modelId") into its components.
* Returns undefined for both fields if the value is empty or malformed.
*/
function parseModelValue(value: string): { provider?: string; modelId?: string } {
if (!value) return {};
const slashIdx = value.indexOf("/");
if (slashIdx === -1) return {};
return {
provider: value.slice(0, slashIdx),
modelId: value.slice(slashIdx + 1),
};
}
export function ChatView({ projectId, addToast }: ChatViewProps) {
const {
@@ -170,6 +147,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [sidebarVisible, setSidebarVisible] = useState(true);
const [agentsMap, setAgentsMap] = useState<Map<string, Agent>>(new Map());
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
@@ -191,9 +169,24 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
}
}, [contextMenu]);
// Fetch agents on mount for name resolution
useEffect(() => {
fetchAgents()
.then((agents) => {
const map = new Map<string, Agent>();
for (const agent of agents) {
map.set(agent.id, agent);
}
setAgentsMap(map);
})
.catch(() => {
// Silently fail - keep empty map
});
}, []);
// Handle create session
const handleCreateSession = useCallback(
async (input: { agentId: string; modelProvider?: string; modelId?: string }) => {
async (input: { agentId: string }) => {
try {
await createSession(input);
setShowNewDialog(false);
@@ -357,7 +350,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
{session.lastMessagePreview || "No messages"}
</div>
<div className="chat-session-meta">
<span>{session.agentId === KB_AGENT_ID ? "AI Assistant" : session.agentId.slice(0, 30)}</span>
<span>{agentsMap.get(session.agentId)?.name || (session.agentId === KB_AGENT_ID ? "AI Assistant" : session.agentId.slice(0, 30))}</span>
<span>{session.updatedAt ? formatRelativeTime(session.updatedAt) : ""}</span>
</div>
</div>
@@ -427,7 +420,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
)}
<Bot size={16} />
<span className="chat-thread-header-title">
{activeSession?.title || activeSession?.agentId || "Chat"}
{activeSession?.title || agentsMap.get(activeSession?.agentId ?? "")?.name || activeSession?.agentId || "Chat"}
</span>
</div>

View File

@@ -35,7 +35,7 @@ vi.mock("lucide-react", async (importOriginal) => {
};
});
// Mock CustomModelDropdown as a simple test double
// Mock CustomModelDropdown - no longer used but kept for other tests
vi.mock("../CustomModelDropdown", () => ({
CustomModelDropdown: ({
value,
@@ -59,7 +59,7 @@ vi.mock("../CustomModelDropdown", () => ({
),
}));
// Mock fetchModels
// Mock fetchAgents for new chat dialog
vi.mock("../../api", () => ({
fetchModels: vi.fn().mockResolvedValue({
models: [
@@ -69,6 +69,10 @@ vi.mock("../../api", () => ({
favoriteProviders: [],
favoriteModels: [],
}),
fetchAgents: vi.fn().mockResolvedValue([
{ id: "agent-001", name: "Alpha", role: "executor", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
{ id: "agent-002", name: "Beta", role: "reviewer", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
]),
}));
const defaultChatState = {
@@ -173,12 +177,12 @@ describe("ChatView", () => {
// Dialog should be open - check for dialog content
const dialog = document.querySelector(".chat-new-dialog");
expect(dialog).toBeInTheDocument();
// Should show Model label (not Agent)
expect(within(dialog!).getByText("Model")).toBeInTheDocument();
// Should show Agent label
expect(within(dialog!).getByText("Agent")).toBeInTheDocument();
});
it("creates session without model selection (uses default)", async () => {
const createSession = vi.fn().mockResolvedValue({ id: "session-new", agentId: "__kb_agent__" });
const createSession = vi.fn().mockResolvedValue({ id: "session-new", agentId: "agent-001" });
setupMockChat({ sessions: [], filteredSessions: [], createSession });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
@@ -187,23 +191,27 @@ describe("ChatView", () => {
const dialog = document.querySelector(".chat-new-dialog");
// Select "Use default" (the first option)
const select = within(dialog!).getByTestId("mock-model-dropdown") as HTMLSelectElement;
expect(select.value).toBe("");
// Create button should be disabled initially (no agent selected)
const createBtn = within(dialog!).getByText("Create") as HTMLButtonElement;
expect(createBtn).toBeDisabled();
// Click on an agent to select it
await userEvent.click(within(dialog!).getByTestId("agent-option-agent-001"));
// Create button should now be enabled
expect(createBtn).not.toBeDisabled();
await userEvent.click(within(dialog!).getByText("Create"));
await waitFor(() => {
expect(createSession).toHaveBeenCalledWith({
agentId: "__kb_agent__",
modelProvider: undefined,
modelId: undefined,
agentId: "agent-001",
});
});
});
it("creates session with model selection", async () => {
const createSession = vi.fn().mockResolvedValue({ id: "session-new", agentId: "__kb_agent__" });
it("creates session with agent selection", async () => {
const createSession = vi.fn().mockResolvedValue({ id: "session-new", agentId: "agent-002" });
setupMockChat({ sessions: [], filteredSessions: [], createSession });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
@@ -211,16 +219,15 @@ describe("ChatView", () => {
await userEvent.click(screen.getByTestId("chat-new-btn"));
const dialog = document.querySelector(".chat-new-dialog");
const select = within(dialog!).getByTestId("mock-model-dropdown") as HTMLSelectElement;
await userEvent.selectOptions(select, "anthropic/claude-sonnet-4-5");
// Click on a different agent
await userEvent.click(within(dialog!).getByTestId("agent-option-agent-002"));
await userEvent.click(within(dialog!).getByText("Create"));
await waitFor(() => {
expect(createSession).toHaveBeenCalledWith({
agentId: "__kb_agent__",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
agentId: "agent-002",
});
});
});