Files
fusion/packages/dashboard/app/components/ChatView.tsx
Fusion b9264670e2 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
2026-04-15 01:49:41 -07:00

522 lines
17 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import {
MessageSquare,
Send,
Plus,
Search,
Trash2,
Archive,
ChevronLeft,
Bot,
} from "lucide-react";
import { useChat } from "../hooks/useChat";
import { useViewportMode } from "./Header";
import { fetchAgents } from "../api";
import type { Agent } from "@fusion/core";
export interface ChatViewProps {
projectId?: string;
addToast: (msg: string, type?: "success" | "error") => void;
}
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
/**
* Constant agent ID for the built-in kb agent.
* The chat system always uses createKbAgent with CHAT_SYSTEM_PROMPT regardless
* of the agentId stored on the session. This ID serves as metadata only.
*/
const KB_AGENT_ID = "__kb_agent__";
interface NewChatDialogProps {
onClose: () => void;
onCreate: (input: { agentId: string }) => void;
}
function NewChatDialog({ onClose, onCreate }: NewChatDialogProps) {
const [agents, setAgents] = useState<Agent[]>([]);
const [agentsLoading, setAgentsLoading] = useState(true);
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
// Load agents on mount
useEffect(() => {
setAgentsLoading(true);
fetchAgents()
.then((response) => {
setAgents(response);
})
.catch(() => {
// Silently fail - show empty list
setAgents([]);
})
.finally(() => {
setAgentsLoading(false);
});
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedAgentId) return;
onCreate({ agentId: selectedAgentId });
};
return (
<div className="chat-new-dialog-backdrop" onClick={onClose}>
<div className="chat-new-dialog" onClick={(e) => e.stopPropagation()}>
<h3>New Chat</h3>
<form onSubmit={handleSubmit}>
<label className="chat-new-dialog-model-label">
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"
disabled={!selectedAgentId}
>
Create
</button>
</div>
</form>
</div>
</div>
);
}
export function ChatView({ projectId, addToast }: ChatViewProps) {
const {
activeSession,
sessionsLoading,
messages,
messagesLoading,
isStreaming,
streamingText,
streamingThinking,
selectSession,
createSession,
archiveSession,
deleteSession,
sendMessage,
searchQuery,
setSearchQuery,
filteredSessions,
} = useChat(projectId);
const [showNewDialog, setShowNewDialog] = useState(false);
const [messageInput, setMessageInput] = useState("");
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);
const inputRef = useRef<HTMLTextAreaElement>(null);
const mode = useViewportMode();
const isMobile = mode === "mobile";
// Scroll to bottom on new messages or streaming
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, streamingText]);
// Close context menu on outside click
useEffect(() => {
const handleClick = () => setContextMenu(null);
if (contextMenu) {
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}
}, [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 }) => {
try {
await createSession(input);
setShowNewDialog(false);
// On mobile, hide sidebar after selecting
if (isMobile) setSidebarVisible(false);
} catch {
addToast("Failed to create chat session", "error");
}
},
[createSession, addToast, isMobile],
);
// Handle send message
const handleSend = useCallback(async () => {
const trimmed = messageInput.trim();
if (!trimmed || isStreaming || !activeSession) return;
setMessageInput("");
try {
await sendMessage(trimmed);
} catch {
addToast("Failed to send message", "error");
}
}, [messageInput, isStreaming, activeSession, sendMessage, addToast]);
// Handle input key down
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
},
[handleSend],
);
// Handle textarea resize
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const textarea = e.target;
setMessageInput(textarea.value);
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`;
}, []);
// Handle archive
const handleArchive = useCallback(
async (id: string) => {
setContextMenu(null);
try {
await archiveSession(id);
addToast("Conversation archived", "success");
} catch {
addToast("Failed to archive conversation", "error");
}
},
[archiveSession, addToast],
);
// Handle delete
const handleDelete = useCallback(
async (id: string) => {
setConfirmDelete(null);
setContextMenu(null);
try {
await deleteSession(id);
addToast("Conversation deleted", "success");
} catch {
addToast("Failed to delete conversation", "error");
}
},
[deleteSession, addToast],
);
// Handle session click
const handleSessionClick = useCallback(
(id: string) => {
selectSession(id);
if (isMobile) setSidebarVisible(false);
},
[selectSession, isMobile],
);
// Handle back to sidebar (mobile)
const handleBack = useCallback(() => {
selectSession("");
setSidebarVisible(true);
}, [selectSession]);
// Render empty state (no active session)
const renderEmptyState = () => {
if (showNewDialog) {
return (
<NewChatDialog
onClose={() => setShowNewDialog(false)}
onCreate={handleCreateSession}
/>
);
}
return (
<div className="chat-empty-state">
<MessageSquare size={48} strokeWidth={1.5} />
<h2>Start a new conversation</h2>
<button className="btn btn-primary" onClick={() => setShowNewDialog(true)}>
<Plus size={16} />
New Chat
</button>
</div>
);
};
return (
<div className="chat-view">
{/* Sidebar */}
<div className={`chat-sidebar${!sidebarVisible ? " chat-sidebar--hidden" : ""}`}>
<div className="chat-sidebar-header">
<button
className="btn btn-sm chat-new-btn"
onClick={() => setShowNewDialog(true)}
data-testid="chat-new-btn"
>
<Plus size={14} />
New Chat
</button>
</div>
<div style={{ padding: "0 12px 8px" }}>
<div className="chat-sidebar-search-wrapper">
<Search size={14} className="chat-sidebar-search-icon" />
<input
type="text"
className="chat-sidebar-search"
placeholder="Search conversations..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
data-testid="chat-search-input"
/>
</div>
</div>
<div className="chat-session-list">
{sessionsLoading ? (
<div style={{ padding: "12px", color: "var(--text-secondary)", fontSize: "13px" }}>
Loading...
</div>
) : filteredSessions.length === 0 ? (
<div style={{ padding: "12px", color: "var(--text-secondary)", fontSize: "13px" }}>
No conversations yet
</div>
) : (
filteredSessions.map((session) => (
<div
key={session.id}
className={`chat-session-item${activeSession?.id === session.id ? " chat-session-item--active" : ""}`}
onClick={() => handleSessionClick(session.id)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
}}
data-testid={`chat-session-${session.id}`}
>
<div className="chat-session-title">{session.title || "Untitled"}</div>
<div className="chat-session-preview">
{session.lastMessagePreview || "No messages"}
</div>
<div className="chat-session-meta">
<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>
))
)}
</div>
</div>
{/* Context Menu */}
{contextMenu && (
<div
className="chat-session-context-menu"
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => handleArchive(contextMenu.sessionId)}
data-testid="chat-context-archive"
>
<Archive size={14} />
Archive
</button>
<button
onClick={() => {
setContextMenu(null);
setConfirmDelete(contextMenu.sessionId);
}}
data-testid="chat-context-delete"
>
<Trash2 size={14} />
Delete
</button>
</div>
)}
{/* Confirm Delete Dialog */}
{confirmDelete && (
<div className="chat-new-dialog-backdrop" onClick={() => setConfirmDelete(null)}>
<div className="chat-new-dialog" onClick={(e) => e.stopPropagation()}>
<h3>Delete Conversation?</h3>
<p style={{ fontSize: "14px", color: "var(--text-secondary)", marginBottom: "16px" }}>
This action cannot be undone. All messages in this conversation will be permanently deleted.
</p>
<div className="chat-new-dialog-actions">
<button className="btn btn-sm" onClick={() => setConfirmDelete(null)}>
Cancel
</button>
<button
className="btn btn-sm btn-danger"
onClick={() => void handleDelete(confirmDelete)}
>
Delete
</button>
</div>
</div>
</div>
)}
{/* Thread */}
<div className="chat-thread">
{/* Header */}
<div className="chat-thread-header">
{isMobile && (
<button className="btn-icon" onClick={handleBack} data-testid="chat-back-btn">
<ChevronLeft size={16} />
</button>
)}
<Bot size={16} />
<span className="chat-thread-header-title">
{activeSession?.title || agentsMap.get(activeSession?.agentId ?? "")?.name || activeSession?.agentId || "Chat"}
</span>
</div>
{/* Messages */}
<div className="chat-messages" ref={messagesContainerRef}>
{messagesLoading ? (
<div style={{ color: "var(--text-secondary)", fontSize: "13px" }}>Loading messages...</div>
) : messages.length === 0 && !activeSession ? (
renderEmptyState()
) : messages.length === 0 && activeSession ? (
<div style={{ color: "var(--text-secondary)", fontSize: "13px" }}>
No messages yet. Start the conversation!
</div>
) : (
<>
{messages.map((message) => (
<div
key={message.id}
className={`chat-message chat-message--${message.role}`}
data-testid={`chat-message-${message.id}`}
>
{message.role === "assistant" && (
<div className="chat-message-avatar">
<Bot size={14} />
<span>Assistant</span>
</div>
)}
<div className="chat-message-content">{message.content}</div>
{message.thinkingOutput && (
<details className="chat-message-thinking">
<summary>Thinking</summary>
<pre className="chat-message-thinking-content">{message.thinkingOutput}</pre>
</details>
)}
<div className="chat-message-time">{formatRelativeTime(message.createdAt)}</div>
</div>
))}
{isStreaming && streamingText && (
<div className="chat-message chat-message--assistant chat-message--streaming">
<div className="chat-message-avatar">
<Bot size={14} />
<span>Assistant</span>
</div>
<div className="chat-message-content">{streamingText}</div>
{streamingThinking && (
<details className="chat-message-thinking">
<summary>Thinking</summary>
<pre className="chat-message-thinking-content">{streamingThinking}</pre>
</details>
)}
<div className="chat-typing-indicator">
<span />
<span />
<span />
</div>
</div>
)}
</>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
{activeSession && (
<div className="chat-input-area">
<textarea
ref={inputRef}
className="chat-input-textarea"
placeholder="Type a message..."
value={messageInput}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
disabled={isStreaming}
rows={1}
data-testid="chat-input"
/>
<button
className="chat-input-send"
onClick={() => void handleSend()}
disabled={!messageInput.trim() || isStreaming}
data-testid="chat-send-btn"
>
<Send size={16} />
</button>
</div>
)}
</div>
{/* New Chat Dialog (rendered at root level) */}
{showNewDialog && (
<NewChatDialog
onClose={() => setShowNewDialog(false)}
onCreate={handleCreateSession}
/>
)}
</div>
);
}